nx_tictactoe_render.nx source
↩ module page · 310 lines · 12025 B
1// nx_tictactoe_render.nx -- bits-up HTML rendering + event dispatch for /tictactoe.
2//
3// STATUS 2026-05-20: ARCHITECTURE-COMPLETE BUT BLOCKED ON SUBSTRATE GAPS.
4//
5// This file describes the intended bits-up rendering architecture; it
6// compiles to RISC-V asm (qemu green path) but **does NOT yet compile to
7// WAT** due to two substrate-side gaps:
8//
9// 1. nxc2 --target wat: "unhandled opcode 30 in emit_instr" -- the
10// WAT backend does not yet lower string-literal data into WASM
11// data segments. Same gap blocks nx_html_emit compilation to WAT.
12// Fix: extend nxc2/wasm.c emit_instr switch + add data-segment
13// synthesis from string-literal IR nodes.
14//
15// 2. @ifdef TARGET_X86_64 / @endif blocks in nx_syscalls.nx report
16// "2 unclosed @ifdef/@ifndef block(s)" under --target wat as of
17// 2026-05-20 background changes. Macro expansion regression.
18//
19// Until both close, the /tictactoe page uses minimal bespoke JS for DOM
20// rendering with NishiLang-compiled WASM for game logic + AI. When the
21// gaps close, this file gets compiled into the WASM module and the bespoke
22// JS gets replaced by dist/_nishi/nishi-host.js calling nx_ttt_render +
23// nx_ttt_action via the sentinel-fd-99 convention.
24//
25// Per [[feedback-ui-rendering-bits-up-not-just-game-logic]]: UI emission +
26// click-to-state mapping live in NishiLang. Only the IRREDUCIBLE
27// dist/_nishi/nishi-host.js (~120 LOC: WASM-instantiate + syscall imports +
28// innerHTML setter + addEventListener) is JS.
29//
30// HTML is emitted by writing bytes to fd 99 (sentinel); the JS host captures
31// those bytes into a buffer and sets innerHTML. Same fd-99 convention is
32// reusable across every Nishi game.
33//
34// Exports (called by nishi-host.js):
35// nx_ttt_init() -> *i64 -- allocates + returns initial state
36// nx_ttt_render(s) -> i64 -- emits full HTML to fd 99
37// nx_ttt_action(s, act, a0, a1, a2, a3) -> i64
38// -- handles a click/change event;
39// returns 1 if state changed (host
40// re-renders), 0 otherwise.
41//
42// Action IDs (data-action="<id>"):
43// 1 = click cell, arg0 = cell index 0..8
44// 2 = reset game
45
46import "nx_syscalls.nx"
47import "nx_tier.nx"
48import "nx_tictactoe.nx"
49const NX_MAGIC_1000003: i64 = 1000003
50
51const NX_TTT_RENDER_FD: i64 = 99
52
53const NX_TTT_ACTION_CLICK_CELL: i64 = 1
54const NX_TTT_ACTION_RESET: i64 = 2
55const NX_TTT_ACTION_SET_OPPONENT: i64 = 3
56const NX_TTT_ACTION_SET_PLAYER: i64 = 4
57
58// ===== Byte writers =================================================
59//
60// Inline strlen-equivalent + single-digit integer write. Avoids importing
61// nx_runtime (which currently trips the WAT backend's opcode-0x30 handler
62// when called from this nesting depth). Bug logged as substrate-gap for
63// the nx_wat_compiler.nx milestone in ยง22.5.
64
65func _ttt_strlen(s: *u8) -> i64 {
66 var n: i64 = 0
67 while s[n] != 0 { n = n + 1 }
68 return n
69}
70
71func _ttt_w(s: *u8) -> i64 {
72 return sys_write(NX_TTT_RENDER_FD, s, _ttt_strlen(s))
73}
74
75// Write a single decimal digit (0..9). For tic-tac-toe cell indices 0..8
76// this is the only integer-to-string we need.
77func _ttt_wd(d: i64) -> i64 {
78 let buf: *u8 = sys_mmap(8)
79 buf[0] = (48 + d) as u8
80 return sys_write(NX_TTT_RENDER_FD, buf, 1)
81}
82
83// ===== Init + reset =================================================
84
85func nx_ttt_init() -> *i64 {
86 return nx_ttt_new(NX_TTT_X)
87}
88
89// Reset clears the game cells (board + turn + outcome + winner) but PRESERVES
90// the UI-config cells (player_side + opponent) so changing difficulty / side
91// doesn't snap the operator back to defaults.
92func nx_ttt_reset_in_place(s: *i64) {
93 var i: i64 = 0
94 while i < 9 { s[i] = NX_TTT_EMPTY; i = i + 1 }
95 s[NX_TTT_OFF_TURN] = NX_TTT_X
96 s[NX_TTT_OFF_OUTCOME] = NX_TTT_ONGOING
97 s[NX_TTT_OFF_WINNER] = 0
98}
99
100// ===== Render to fd 99 ===============================================
101
102func _ttt_emit_status(s: *i64) -> i64 {
103 _ttt_w("<div class=\"ttt-status\" aria-live=\"polite\">" as *u8)
104 let outcome: i64 = nx_ttt_outcome(s)
105 let player_side: i64 = s[NX_TTT_OFF_PLAYER_SIDE]
106 let opponent: i64 = s[NX_TTT_OFF_OPPONENT]
107 let is_human_vs_human: i64 = 0
108 var is_h: i64 = 0
109 if opponent < 0 { is_h = 1 }
110 if outcome == NX_TTT_WIN {
111 let w: i64 = nx_ttt_winner(s)
112 if is_h == 1 {
113 if w == NX_TTT_X { _ttt_w("X wins." as *u8) }
114 if w == NX_TTT_O { _ttt_w("O wins." as *u8) }
115 }
116 if is_h == 0 {
117 if w == player_side { _ttt_w("You win." as *u8) }
118 if w != player_side { _ttt_w("AI wins." as *u8) }
119 }
120 }
121 if outcome == NX_TTT_DRAW { _ttt_w("Draw." as *u8) }
122 if outcome == NX_TTT_ONGOING {
123 let turn: i64 = nx_ttt_turn(s)
124 if is_h == 1 {
125 if turn == NX_TTT_X { _ttt_w("X to move." as *u8) }
126 if turn == NX_TTT_O { _ttt_w("O to move." as *u8) }
127 }
128 if is_h == 0 {
129 if turn == player_side {
130 if player_side == NX_TTT_X { _ttt_w("Your move (X)." as *u8) }
131 if player_side == NX_TTT_O { _ttt_w("Your move (O)." as *u8) }
132 }
133 if turn != player_side { _ttt_w("AI thinking..." as *u8) }
134 }
135 }
136 _ttt_w("</div>" as *u8)
137 return 0
138}
139
140// Helper: emit one <option> tag with selected attribute when the option's
141// stored numeric value matches the current setting.
142func _ttt_emit_opt(value: i64, is_selected: i64, label: *u8) -> i64 {
143 _ttt_w("<option value=\"" as *u8)
144 // value can be negative (-1 for human); emit as text. Range is -1..2,
145 // so a tiny inline emitter is enough.
146 if value == -1 { _ttt_w("-1" as *u8) }
147 if value == 0 { _ttt_w("0" as *u8) }
148 if value == 1 { _ttt_w("1" as *u8) }
149 if value == 2 { _ttt_w("2" as *u8) }
150 _ttt_w("\"" as *u8)
151 if is_selected == 1 { _ttt_w(" selected" as *u8) }
152 _ttt_w(">" as *u8)
153 _ttt_w(label)
154 _ttt_w("</option>" as *u8)
155 return 0
156}
157
158func _ttt_emit_controls(s: *i64) -> i64 {
159 let player_side: i64 = s[NX_TTT_OFF_PLAYER_SIDE]
160 let opponent: i64 = s[NX_TTT_OFF_OPPONENT]
161 _ttt_w("<div class=\"controls\">" as *u8)
162 _ttt_w("<button type=\"button\" data-action=\"2\">New game</button>" as *u8)
163 _ttt_w("<label>Opponent: <select data-action=\"3\">" as *u8)
164 var sel: i64 = 0
165 if opponent == -1 { sel = 1 }
166 _ttt_emit_opt(-1 as i64, sel, "Another human" as *u8)
167 sel = 0
168 if opponent == NX_TTT_AI_EASY { sel = 1 }
169 _ttt_emit_opt(NX_TTT_AI_EASY, sel, "AI - easy (random)" as *u8)
170 sel = 0
171 if opponent == NX_TTT_AI_MEDIUM { sel = 1 }
172 _ttt_emit_opt(NX_TTT_AI_MEDIUM, sel, "AI - medium (1-ply)" as *u8)
173 sel = 0
174 if opponent == NX_TTT_AI_PERFECT { sel = 1 }
175 _ttt_emit_opt(NX_TTT_AI_PERFECT, sel, "AI - perfect (minimax)" as *u8)
176 _ttt_w("</select></label>" as *u8)
177
178 _ttt_w("<label>I play: <select data-action=\"4\">" as *u8)
179 sel = 0
180 if player_side == NX_TTT_X { sel = 1 }
181 _ttt_emit_opt(NX_TTT_X, sel, "X (first)" as *u8)
182 sel = 0
183 if player_side == NX_TTT_O { sel = 1 }
184 _ttt_emit_opt(NX_TTT_O, sel, "O (second)" as *u8)
185 _ttt_w("</select></label>" as *u8)
186 _ttt_w("</div>" as *u8)
187 return 0
188}
189
190func _ttt_emit_cell(s: *i64, idx: i64) -> i64 {
191 let v: i64 = nx_ttt_cell(s, idx)
192 let outcome: i64 = nx_ttt_outcome(s)
193 _ttt_w("<button type=\"button\" class=\"cell" as *u8)
194 if v == NX_TTT_X { _ttt_w(" cell-x" as *u8) }
195 if v == NX_TTT_O { _ttt_w(" cell-o" as *u8) }
196 _ttt_w("\" role=\"gridcell\" data-cell-idx=\"" as *u8)
197 _ttt_wd(idx)
198 _ttt_w("\" data-action=\"1\" data-arg0=\"" as *u8)
199 _ttt_wd(idx)
200 _ttt_w("\"" as *u8)
201 if v != NX_TTT_EMPTY { _ttt_w(" disabled" as *u8) }
202 if outcome != NX_TTT_ONGOING { _ttt_w(" disabled" as *u8) }
203 _ttt_w(">" as *u8)
204 if v == NX_TTT_X { _ttt_w("X" as *u8) }
205 if v == NX_TTT_O { _ttt_w("O" as *u8) }
206 _ttt_w("</button>" as *u8)
207 return 0
208}
209
210// Sovereign version banner. Emits a label + an `<a href="?v=1">` link to
211// the previous snapshot. Pure browser navigation -- no JS dispatch. The
212// current source-of-truth is v2 (this build). See
213// [[project-sovereign-version-pinning]].
214func _ttt_emit_version_banner() -> i64 {
215 _ttt_w("<div class=\"version-banner\" style=\"font-size:.8rem;color:#888;margin-bottom:.4rem;\">" as *u8)
216 _ttt_w("Version 2 (current) · <a href=\"?v=1\">try v1</a> · <a href=\"./\">latest</a>" as *u8)
217 _ttt_w("</div>" as *u8)
218 return 0
219}
220
221func nx_ttt_render(s: *i64) -> i64 {
222 _ttt_w("<div class=\"ttt-shell\">" as *u8)
223 _ttt_emit_version_banner()
224 _ttt_emit_status(s)
225 _ttt_w("<div class=\"board\" role=\"grid\" aria-label=\"Tic-tac-toe board\">" as *u8)
226 var i: i64 = 0
227 while i < 9 {
228 _ttt_emit_cell(s, i)
229 i = i + 1
230 }
231 _ttt_w("</div>" as *u8)
232 _ttt_emit_controls(s)
233 _ttt_w("</div>" as *u8)
234 return 0
235}
236
237// ===== Action dispatcher ============================================
238
239// Dispatcher: select the AI move per the stored opponent tier. side = the
240// side the AI is playing (which is the side that's NOT the player's).
241// Returns the chosen cell (-1 if no legal move).
242func _ttt_ai_pick(s: *i64, side: i64) -> i64 {
243 let opp: i64 = s[NX_TTT_OFF_OPPONENT]
244 if opp == NX_TTT_AI_PERFECT { return nx_ttt_pick_perfect(s, side) }
245 if opp == NX_TTT_AI_MEDIUM { return nx_ttt_pick_medium(s, side) }
246 // Easy (random) tier needs a PRNG state. For host-driven environments
247 // the host owns the PRNG; the renderer needs a small stable one. Use
248 // the state's plies/winner cells as a seed-mixer so behavior is
249 // deterministic per state without external entropy.
250 if opp == NX_TTT_AI_EASY {
251 let mix: i64 = (s[NX_TTT_OFF_TURN] * NX_MAGIC_1000003) ^ (nx_ttt_state_hash(s) as i64)
252 var prng_state: i64 = mix
253 if prng_state == 0 { prng_state = 1 }
254 let p: *i64 = (&prng_state) as *i64
255 return nx_ttt_pick_easy(s, p)
256 }
257 // Default fallback: perfect.
258 return nx_ttt_pick_perfect(s, side)
259}
260
261func nx_ttt_action(s: *i64, action: i64, arg0: i64, arg1: i64,
262 arg2: i64, arg3: i64) -> i64 {
263 if action == NX_TTT_ACTION_CLICK_CELL {
264 // Player plays at arg0; if game still ongoing AND opponent is AI,
265 // AI responds with the configured tier.
266 let applied: i64 = nx_ttt_try_apply(s, arg0)
267 if applied != 1 { return 0 }
268 let opp: i64 = s[NX_TTT_OFF_OPPONENT]
269 if opp >= 0 {
270 if nx_ttt_outcome(s) == NX_TTT_ONGOING {
271 let ai_side: i64 = nx_ttt_other(s[NX_TTT_OFF_PLAYER_SIDE])
272 let mv: i64 = _ttt_ai_pick(s, ai_side)
273 if mv >= 0 { nx_ttt_try_apply(s, mv) }
274 }
275 }
276 return 1
277 }
278 if action == NX_TTT_ACTION_RESET {
279 nx_ttt_reset_in_place(s)
280 return 1
281 }
282 if action == NX_TTT_ACTION_SET_OPPONENT {
283 s[NX_TTT_OFF_OPPONENT] = arg0
284 nx_ttt_reset_in_place(s)
285 // If AI plays X (opponent != human AND player_side != X), AI moves first.
286 let opp_after: i64 = s[NX_TTT_OFF_OPPONENT]
287 let ps: i64 = s[NX_TTT_OFF_PLAYER_SIDE]
288 if opp_after >= 0 {
289 if ps != NX_TTT_X {
290 let mv: i64 = _ttt_ai_pick(s, NX_TTT_X)
291 if mv >= 0 { nx_ttt_try_apply(s, mv) }
292 }
293 }
294 return 1
295 }
296 if action == NX_TTT_ACTION_SET_PLAYER {
297 s[NX_TTT_OFF_PLAYER_SIDE] = arg0
298 nx_ttt_reset_in_place(s)
299 // If player is O, AI moves first as X.
300 let opp_after2: i64 = s[NX_TTT_OFF_OPPONENT]
301 if opp_after2 >= 0 {
302 if arg0 != NX_TTT_X {
303 let mv: i64 = _ttt_ai_pick(s, NX_TTT_X)
304 if mv >= 0 { nx_ttt_try_apply(s, mv) }
305 }
306 }
307 return 1
308 }
309 return 0
310}