nx_campaign.nx source
↩ module page · 212 lines · 8259 B
1// nx_campaign.nx -- WRITING arc, rung W-CAMP-1: the D&D CAMPAIGN structure model.
2// Operator intent 2026-08-04: generate dnd campaigns -- the campaign/GM mode the
3// writing-hub census lists as a GAP. A campaign script is a HEADER (setting/party/
4// quest) + SESSION -> ENCOUNTER blocks, each encounter a small key:value record
5// (kind/time/location/npcs/goal/transit). This organ counts sessions+encounters,
6// validates the record set, emits the per-encounter GM BRIEF the generation seat
7// expands + the image-prompt seed (same image-gen ingestion as manga/scene), and
8// makes the CAMPAIGN-CONTINUITY law MECHANICAL per
9// feedback-arcs-as-dnd-campaign-multiverse-continuity:
10// - time moves monotonically forward (cg_time_check counts regressions);
11// - a location change without a transit beat is a teleport (cg_transit_check).
12// The campaign TEXT is operator/lane DATA (the seam: engine structures, seat
13// authors). DRY: markers via nx_manga mg_line_is/mg_count; fields via the
14// nx_scenario whole-key parser on encounter byte sub-ranges. Pure integer,
15// NO syscalls, caller owns buffers (the nx_note shape).
16// license_tier: ORIGINAL
17// module: nishi-core.write.campaign
18// depends: nishi-core.write.manga nishi-core.write.scenario
19// capability: WRITE_CAMPAIGN
20import "nx_manga.nx"
21import "nx_scenario.nx"
22
23// null-terminated string equality (1 equal / 0 not)
24func cg_streq(a: *u8, b: *u8) -> i64 {
25 var i: i64 = 0
26 while a[i] != (0 as u8) {
27 if a[i] != b[i] { return 0 }
28 i = i + 1
29 }
30 if b[i] != (0 as u8) { return 0 }
31 return 1
32}
33
34// offset of the first SESSION marker line (the campaign header ends there), or n
35func cg_header_end(buf: *u8, n: i64) -> i64 {
36 var i: i64 = 0
37 while i < n {
38 if mg_line_is(buf, n, i, "SESSION\x00" as *u8) == 1 { return i }
39 i = sc_next_line(buf, n, i)
40 }
41 return n
42}
43
44func cg_count_sessions(buf: *u8, n: i64) -> i64 { return mg_count(buf, n, "SESSION\x00" as *u8) }
45func cg_count_encounters(buf: *u8, n: i64) -> i64 { return mg_count(buf, n, "ENCOUNTER\x00" as *u8) }
46
47// byte offset of the idx-th ENCOUNTER marker line (0-based), or -1
48func cg_enc_start(buf: *u8, n: i64, idx: i64) -> i64 {
49 var i: i64 = 0
50 var seen: i64 = 0
51 var res: i64 = 0 - 1
52 var go: i64 = 1
53 while go == 1 {
54 if i >= n { go = 0 }
55 else {
56 if mg_line_is(buf, n, i, "ENCOUNTER\x00" as *u8) == 1 {
57 if seen == idx { res = i; go = 0 }
58 else { seen = seen + 1; i = sc_next_line(buf, n, i) }
59 } else { i = sc_next_line(buf, n, i) }
60 }
61 }
62 return res
63}
64
65// end of the encounter starting at start = next ENCOUNTER or SESSION marker, or n
66func cg_enc_end(buf: *u8, n: i64, start: i64) -> i64 {
67 var i: i64 = sc_next_line(buf, n, start)
68 var e: i64 = n
69 var go: i64 = 1
70 while go == 1 {
71 if i >= n { e = n; go = 0 }
72 else {
73 if mg_line_is(buf, n, i, "ENCOUNTER\x00" as *u8) == 1 { e = i; go = 0 }
74 else {
75 if mg_line_is(buf, n, i, "SESSION\x00" as *u8) == 1 { e = i; go = 0 }
76 else { i = sc_next_line(buf, n, i) }
77 }
78 }
79 }
80 return e
81}
82
83// completeness: count of REQUIRED keys missing. Header requires setting/party/
84// quest (checked ONLY in the header range so an encounter field can never
85// satisfy a header key); every encounter requires kind + goal (a GM cannot run
86// an encounter without them). 0 = runnable campaign.
87func cg_validate(buf: *u8, n: i64) -> i64 {
88 var miss: i64 = 0
89 let he: i64 = cg_header_end(buf, n)
90 if sc_has(buf, he, "setting\x00" as *u8) == 0 { miss = miss + 1 }
91 if sc_has(buf, he, "party\x00" as *u8) == 0 { miss = miss + 1 }
92 if sc_has(buf, he, "quest\x00" as *u8) == 0 { miss = miss + 1 }
93 let ne: i64 = cg_count_encounters(buf, n)
94 var idx: i64 = 0
95 while idx < ne {
96 let s: i64 = cg_enc_start(buf, n, idx)
97 let e: i64 = cg_enc_end(buf, n, s)
98 let sub: *u8 = (buf as i64 + s) as *u8
99 if sc_has(sub, e - s, "kind\x00" as *u8) == 0 { miss = miss + 1 }
100 if sc_has(sub, e - s, "goal\x00" as *u8) == 0 { miss = miss + 1 }
101 idx = idx + 1
102 }
103 return miss
104}
105
106// parse the non-negative integer value of key, or -1 if missing/non-numeric
107func cg_field_int(buf: *u8, n: i64, key: *u8) -> i64 {
108 let off: i64 = sc_find_field(buf, n, key)
109 if off < 0 { return 0 - 1 }
110 var v: i64 = 0
111 var i: i64 = off
112 var any: i64 = 0
113 var go: i64 = 1
114 while go == 1 {
115 if i >= n { go = 0 }
116 else {
117 let c: i64 = buf[i] as i64
118 if c >= 48 {
119 if c <= 57 { v = v * 10 + (c - 48); any = 1; i = i + 1 } else { go = 0 }
120 } else { go = 0 }
121 }
122 }
123 if any == 0 { return 0 - 1 }
124 return v
125}
126
127// CONTINUITY tooth 1: time is monotonically non-decreasing across the campaign.
128// Encounters without a numeric time are skipped (no false positives). Returns
129// the count of regressions (0 = timeline sound).
130func cg_time_check(buf: *u8, n: i64) -> i64 {
131 var viol: i64 = 0
132 var prev: i64 = 0 - 1
133 let ne: i64 = cg_count_encounters(buf, n)
134 var idx: i64 = 0
135 while idx < ne {
136 let s: i64 = cg_enc_start(buf, n, idx)
137 let e: i64 = cg_enc_end(buf, n, s)
138 let sub: *u8 = (buf as i64 + s) as *u8
139 let t: i64 = cg_field_int(sub, e - s, "time\x00" as *u8)
140 if t >= 0 {
141 if prev >= 0 { if t < prev { viol = viol + 1 } }
142 prev = t
143 }
144 idx = idx + 1
145 }
146 return viol
147}
148
149// CONTINUITY tooth 2: a location change between consecutive encounters without a
150// transit beat on the later one is a TELEPORT. Caller owns two scratch buffers
151// (sa/sb, each cap bytes). Returns the count of teleports (0 = travel sound).
152func cg_transit_check(buf: *u8, n: i64, sa: *u8, sb: *u8, cap: i64) -> i64 {
153 var viol: i64 = 0
154 let ne: i64 = cg_count_encounters(buf, n)
155 var idx: i64 = 1
156 while idx < ne {
157 let sp: i64 = cg_enc_start(buf, n, idx - 1)
158 let ep: i64 = cg_enc_end(buf, n, sp)
159 let subp: *u8 = (buf as i64 + sp) as *u8
160 let s: i64 = cg_enc_start(buf, n, idx)
161 let e: i64 = cg_enc_end(buf, n, s)
162 let sub: *u8 = (buf as i64 + s) as *u8
163 let ra: i64 = sc_copy_field(subp, ep - sp, "location\x00" as *u8, sa, cap)
164 let rb: i64 = sc_copy_field(sub, e - s, "location\x00" as *u8, sb, cap)
165 if ra > 0 {
166 if rb > 0 {
167 if cg_streq(sa, sb) == 0 {
168 if sc_has(sub, e - s, "transit\x00" as *u8) == 0 { viol = viol + 1 }
169 }
170 }
171 }
172 idx = idx + 1
173 }
174 return viol
175}
176
177// emit the per-encounter GM BRIEF the generation seat expands:
178// GM: <kind> @ <location> | npcs: <npcs> | goal: <goal>
179func cg_emit_brief(buf: *u8, n: i64, idx: i64, out: *u8, cap: i64) -> i64 {
180 let s: i64 = cg_enc_start(buf, n, idx)
181 if s < 0 { out[0] = 0 as u8; return 0 }
182 let e: i64 = cg_enc_end(buf, n, s)
183 let sub: *u8 = (buf as i64 + s) as *u8
184 let sn2: i64 = e - s
185 var p: i64 = 0
186 p = sc_app_lit(out, p, cap, "GM: \x00" as *u8)
187 p = sc_app_field(sub, sn2, "kind\x00" as *u8, out, p, cap)
188 p = sc_app_lit(out, p, cap, " @ \x00" as *u8)
189 p = sc_app_field(sub, sn2, "location\x00" as *u8, out, p, cap)
190 p = sc_app_lit(out, p, cap, " | npcs: \x00" as *u8)
191 p = sc_app_field(sub, sn2, "npcs\x00" as *u8, out, p, cap)
192 p = sc_app_lit(out, p, cap, " | goal: \x00" as *u8)
193 p = sc_app_field(sub, sn2, "goal\x00" as *u8, out, p, cap)
194 out[p] = 0 as u8
195 return p
196}
197
198// emit the per-encounter image-prompt seed (feeds the same image-gen path):
199// <location>, <npcs>
200func cg_emit_img(buf: *u8, n: i64, idx: i64, out: *u8, cap: i64) -> i64 {
201 let s: i64 = cg_enc_start(buf, n, idx)
202 if s < 0 { out[0] = 0 as u8; return 0 }
203 let e: i64 = cg_enc_end(buf, n, s)
204 let sub: *u8 = (buf as i64 + s) as *u8
205 let sn2: i64 = e - s
206 var p: i64 = 0
207 p = sc_app_field(sub, sn2, "location\x00" as *u8, out, p, cap)
208 p = sc_app_lit(out, p, cap, ", \x00" as *u8)
209 p = sc_app_field(sub, sn2, "npcs\x00" as *u8, out, p, cap)
210 out[p] = 0 as u8
211 return p
212}