/* ========================================================================= Agentness Arena — RENDERER / DOM / UI (app.js). Cumulative single-player RPG. ALL run/cycle/party/score logic lives in campaign.js (window.CAMPAIGN); ALL rule/board math lives in engine.js (window.ENGINE). app.js does ONLY: canvas rendering, HUD, input, stage flow, and the report. app.js holds ZERO rule/board/score logic — every mutation goes through a C.* call; the app reads G.campaign for rendering and translates input to C.playerMove. It NEVER keys any board/HUD visual on the hidden rule (C1) — each agent's rule is induced from the demo, never displayed in play. THE LOOP (spec §2): demo — a new agent appears and SELF-DEMONSTRATES its hidden rule on the live cumulative board (violate flashes + detours). The demo is DISPLAY-ONLY: it NEVER charges the shared ♥ (Discovery must not deplete Maintenance). Discovery is recorded by the engine helper (C.stepDemo). Other party agents stay STATIC during the demo. play — the player drives the WHOLE party round-robin, one cell per turn, toward the harvest goal. Each turn applies that agent's rule; a violation FORGOES the gain (lowers the cycle compliance score) + a flash. There is NO heart death — a violation never ends the run. report — PURSUIT headline (mean per-cycle compliance score) + reach (cleared cycles, secondary) + per-cycle total/C* + Discovery × Maintenance. PURSUIT (headline) = MEAN of per-cycle compliance s_i = clamp01(total/C*) over cleared cycles; reach (cleared-cycle count) is secondary. Violations lower the score, they do NOT kill. The hidden rule is NEVER displayed. ========================================================================= */ 'use strict'; const E = window.ENGINE; const C = window.CAMPAIGN; const { A, clamp01, keyN, inbN, tokenAt } = E; // SLICE2 ROLE-PLAY (spec 2026-06-17): a rule id is a ROLE intention when it is in the // engine's ROLE_VARIANT_LIST. Role boards render on the CLEAN STAGE (terrain paint // skipped) so intention reads from MOTION, not a terrain backdrop. Pure id lookup (no // rule param / seat ownership) — C1-safe. function isRoleRule(rule) { return !!rule && (E.ROLE_VARIANT_LIST || []).indexOf(rule) !== -1; } // the LIVE cycle is a ROLE cycle (clean stage) when its newcomer rule is a role. The // newcomer is the most-recently-joined seat (the cycle's discovery seat). Reads only the // run's public ruleSet — no rule param / seat ownership beyond the newcomer id (C1; the // clean-stage flag itself is rule-invariant scenery, not a per-seat cue). function isRoleCycle(run) { if (!run || !run.ruleSet || !run.ruleSet.length) return false; const newcomerId = (run.cycleAcc && run.cycleAcc.discovery && run.cycleAcc.discovery.seatId != null) ? run.cycleAcc.discovery.seatId : (run.ruleSet.length - 1); return isRoleRule(run.ruleSet[newcomerId]); } /* ================================ STATE ================================= */ // G is a THIN VIEW-MODEL: it owns NO rule/board/score state. `campaign` is the // pure run object (the single source of truth); the rest is view/animation only. const G = { campaign: null, // the C.createRun() run object (sole source of truth) timers: [], // pending setTimeout handles (cleared on restart) lastEnding: null, // { status } of the concluded run, for the ending banner demoAnim: null, // demo animation state (display-only frames; no ♥ charge) parkAnim: null, // PARK animation state (parkMode only; display-only) — {mode:'demo'|'game',...} parkReplay: null, // 플레이 옆판의 미니 시연 리플레이 (표시 전용) — _parkReplayStart 참조. parkView: null, // PARK view override (parkMode only): 'hub' | task 'demo'|'play'|'report'. parkAnnot: null, // live demo-event highlight (P4 §C.1, display-only): {N, ev:[{kind,x,y}], until}. // null = the capstone flow (run.stage drives, byte-identical P1 path). hub: null, // PARK hub view cache — { run, sel, boards, capRow } (thumbnails; view-only) flash: null, // { seatId, cell } transient violation flash on a play move facing: {}, // seatId -> SPRITE_FACE key (last-move direction; agent sprite faces it) lastMove: {}, // seatId -> {from,to} last actual move (intent-cue chosen trail; display-only) generate: null, // GENERATE/seat-swap sub-mode (section1:17 third pillar; live-only) — see toggleGenerate // ONE-LONG-BOARD CONTINUITY (user directive 2026-06-25): a PRESENTATION-ONLY crossfade overlay so a // cycle handoff (old play board -> new cycle's stage, the newcomer+rule being ADDED) and the demo's // representative-segment handoffs read as the SAME stage CONTINUING, not a slideshow jump-cut. Holds // an offscreen snapshot of the canvas captured at the swap instant + a start timestamp; draw() blends // it OUT over XFADE_MS while the new content is painted underneath. NOTHING scored is touched — the // board swap (campaign _beginCycle / makeBoard) and the per-cycle score/C*/cycle/depth/accumulation // LOGIC are byte-identical; only the visual handoff between already-decided frames changes. The // cumulative party survives exactly as before (push-only at campaign.js:718). See captureXfade/draw. xfade: null, // { snap: , t0: , join: bool } | null (presentation-only) }; // map a unit move delta to a sprite facing key (no move keeps the prior facing). function faceOf(dx, dy) { if (dx > 0) return 'right'; if (dx < 0) return 'left'; if (dy > 0) return 'down'; if (dy < 0) return 'up'; return null; } function after(ms, fn) { G.timers.push(setTimeout(fn, ms)); } function clearTimers() { for (const t of G.timers) clearTimeout(t); G.timers = []; } /* ===================== ONE-LONG-BOARD CONTINUITY (presentation-only) ===================== captureXfade(join): snapshot the CURRENT board canvas into an offscreen canvas and arm a crossfade so the NEXT board (a new cycle's stage, OR the next demo representative segment) reads as the SAME continuing stage — the old frame fades OUT over XFADE_MS while the new content is painted underneath by draw(). `join` true marks a CYCLE handoff (a newcomer + rule being ADDED onto the continuing board) so draw() can paint a gentle "joining" pulse; false is a within-demo segment morph (no join cue). PURE PRESENTATION: this reads canvas pixels and schedules repaints; it changes NO game state, calls NO campaign/engine scoring fn, and leaks no rule (C1) — the board geometry swap + all per-cycle score, C-star, cycle, depth and accumulation logic remain byte-identical (the swap already happened in campaign). A snapshot failure (e.g. a headless/zero-size canvas in a test driver) FAILS OPEN to no overlay, so the green-gate harness path is byte-inert. */ const XFADE_MS = 520; // crossfade duration: old frame fades out over this window const XFADE_JOIN_MS = 700; // a CYCLE handoff lingers slightly longer (the newcomer "arrives") function captureXfade(join) { // headless / non-DOM canvas guard: only capture when we actually have a sized board canvas. if (!board || !board.width || !board.height) return; let snap; try { snap = document.createElement('canvas'); snap.width = board.width; snap.height = board.height; const sx = snap.getContext('2d'); if (!sx) return; // fail-open: no overlay, byte-inert sx.drawImage(board, 0, 0); // freeze the currently-rendered (old) board frame } catch (e) { return; } // fail-open on any capture error (test/headless harness) G.xfade = { snap, t0: Date.now(), join: !!join, dur: join ? XFADE_JOIN_MS : XFADE_MS }; driveXfade(); } // driveXfade: a self-contained rAF loop that repaints while a crossfade is live (the pulseLoop // only animates the play stage, so a demo-stage handoff needs its own driver). Clears G.xfade // when the window elapses and does ONE final clean repaint. Presentation-only. function driveXfade() { if (!G.xfade) return; const age = Date.now() - G.xfade.t0; if (age >= G.xfade.dur) { G.xfade = null; draw(); return; } draw(); requestAnimationFrame(driveXfade); } // paintXfadeOverlay: called at the END of draw() — if a crossfade is live, blend the frozen old // frame on TOP of the freshly-painted new board at a decreasing alpha (ease-out), so the new // stage emerges THROUGH the old one (continuation, not a cut). On a cycle handoff (join) also // add a brief soft brightening pulse so the moment reads as "a new companion joins the stage". // Pure canvas compositing over already-drawn content; no game state, no rule (C1). function paintXfadeOverlay() { const xf = G.xfade; if (!xf || !xf.snap) return; const age = Date.now() - xf.t0; const p = Math.max(0, Math.min(1, age / xf.dur)); // 0..1 progress const ease = 1 - (1 - p) * (1 - p); // ease-out const oldAlpha = 1 - ease; // old frame fades out bx.save(); bx.globalAlpha = oldAlpha; bx.drawImage(xf.snap, 0, 0); bx.restore(); if (xf.join) { // gentle white "arrival" wash that rises then falls across the handoff (peaks mid-fade), // so a newcomer being ADDED onto the continuing board has a soft presentation accent. const pulse = Math.sin(p * Math.PI) * 0.12; // 0 -> ~0.12 -> 0 if (pulse > 0.001) { bx.save(); bx.globalAlpha = pulse; bx.fillStyle = '#ffffff'; bx.fillRect(0, 0, board.width, board.height); bx.restore(); } } } // run-over endings (spec §5: there is NO heart death; cleared_cap = pool exhausted, // any other terminal = a genuine deadlock). The label keys on the run STATUS, never // on a hidden rule (C1-safe). No ♥-death copy. function endingLabel(status) { switch (status) { case 'cleared_cap': return '정복 완료 — 모든 규칙 정복'; case 'run_over': return '런 종료 — 교착'; case 'dead_fidelity': return '런 종료 — 규칙을 저버렸습니다 (충실도 0)'; default: return '종료'; } } function endingColor(status) { if (status === 'cleared_cap') return '#7fce97'; if (status === 'run_over' || status === 'dead_fidelity') return '#e0594f'; return '#cfd4dc'; } /* ============================== DEMO STAGE ============================== */ // The newcomer SELF-DEMONSTRATES its hidden rule on a SEPARATE TUTORIAL board // (spec 2026-06-15) — a small deterministic education board engineered to make // the rule legible (>=2 violations + >=2 clean detours, the engine not luck). // DISPLAY-ONLY: it NEVER charges the shared ♥. We REPLAY the scripted // E.buildTutorial steps on the tutorial board (same visual vocabulary as play: // dark/hatch/token-value/carry), flashing on each VIOLATED step, then call // C.stepDemo (records Discovery EVIDENCE; hearts byte-untouched) and C.handoff to // the real cumulative board for play. The throwaway no-walls disp board is RETIRED. const DEMO_STEP_MS = 460; // pace of the demo replay const DEMO_VIOLATE_MS = 760; // longer dwell on a violated demo step const DEMO_VALUE_MS = 1100; // longer dwell per value-demo CONFLICT scene (read the clash) const DEMO_REPEAT = 2; // §A: replay each pairwise comparison in 2 VARIED geometries (diverse situations) // §B CONTINUOUS play-memory demo pacing (design 2026-06-22 RISK 6): a cell-by-cell walk reads // continuous at a faster per-STEP pace, with a longer dwell only on a CONFLICT step (where the // engaged pair narrowed lex). Tunable constants only; no behavior change. // EMPHASIS 3-tier dwell (timing-only; move data + draw() inputs unchanged): const DEMO_STEP_MS_FAST = 220; // routine kept-for-context step: fast-forward connective tissue const DEMO_CONFLICT_MS = 1600; // conflict step: long dwell (read which concern won; ghost lingers) const DEMO_FOREGONE_MS = 1100; // foregone-only step: linger on the declined-greedy ghost cue const DEMO_MICRO_MS = 120; // PRUNE: compressed routine step NOT in kept set (still applied) const DEMO_POOL_N = 96; // swept so the single-continuous-walk selector can find one walk that // covers the FLOWING comparisons {CG,CN,GN} (esp. respect_order's sparser pool) // ONE-LONG-BOARD CONTINUITY (user directive 2026-06-25): raise the per-walk step budget (engine // default 24) so each representative oracle walk has room to traverse MORE of the 6 pairwise // comparisons before reaching its goal — so pickRepresentative's greedy set-cover needs FEWER // segments to cover all 6 (closer to ONE continuous walk; the few remaining segment handoffs are // crossfaded, not jump-cut). This is a DEMO-SHAPE presentation constant ONLY: it widens the lived // walk, it does NOT weaken the W1.5-CONTINUOUS 6-coverage guarantee (the rep union still induces // the full ordering — pickRepresentative covers all 6 or RISK-4-falls-back exactly as before) and // it touches NOTHING scored (lexFilter/lexicalOracle/PRO_ATTITUDES/_cStar/_lexCeiling/battery). The // held-out proof builds its OWN pool with the engine default, so it is unaffected by this app knob. const DEMO_STEP_CAP = 40; // demo walk hard-cap: greedy personas TERMINATE ~30-35; picky (respect/ // yield-leaning) personas that decline tokens never terminate, so the cap // ends their journey at ~40 too (both land in the ~30-40 turn band). // DISPLAY-ONLY long-demo board knobs (user directive 2026-06-29: demo = one long ~30-40 step single-board // walk). A SINGLE source of truth so the pool build AND the fresh-board re-derive use the SAME big board. // Gated behind opts.long/opts.quotaFrac in the engine, which no scored caller sets => scored core byte-identical. const DEMO_LONG_OPTS = { long: true, displayN: 13, nTokens: 9, quotaFrac: 0.9 }; /* ===================== PARK PERSONA GRIDWORLD — RENDER (design 2026-07-03) ===================== Display-only. EVERY park entry point is gated on G.campaign.park, which campaign.js materializes ONLY under config.parkMode (set solely in LIVE_OPTS). So when parkMode is off (every existing gate / the legacy path) run.park is ABSENT, no park state is built, and the legacy demo/play/HUD render path stays BYTE-IDENTICAL. The stage is the whole N=20 park (walkway / verge / deep field / perimeter gem clusters), always visible, cells sized to fill the canvas — every painted class is a pure function of the PUBLIC board (never the hidden persona → C1). LIVE frames (demo + game + HUD) are ZERO-TEXT (spec §5): hearts are ♥ glyph shapes, the goal gauge is a gem icon + fill bar, every decision cue is shape/motion/color. The post-run REPORT is the analyst channel (text OK). */ const PARK_HUES = { walkway: '#767d8a', // light-gray path network (safe) verge: 'rgba(230,168,130,0.48)', // pale desaturated CLAY — LIGHTER than the vivid deep (mild tier) deep: '#c33a1c', // saturated lava tone (entry = ♥−1); slow pulse ember: '#f07030', // lava spike/ember accents + the deep-rim threshold stroke wall: '#242a21', // perimeter tree band companion: '#c85ce0', // MAGENTA companion (scaled-down round actor) — its OWN color // family, never confusable with gem gold / player blue / // lava red-brown / walkway gray (blind-judge fix R3 #6) intent: '#c85ce0', // companion→gem intent line + contested-gem ring: the SAME // hue as the companion body so "whose claim" is color-owned }; // HAZARD REskin TINTS (task battery, spec 2026-07-04 §B.3): the three hazard families must // LOOK like three different fields (audit fix: identical palettes made distinct tiles read as // near-duplicates). Keyed on the PUBLIC cell hazard kind (C1); the P1 capstone park has no // cell -> the untouched default lava-clay palette. Each family carries its OWN accent mark on // the deep cells (family-branched in _paintParkTerrain): ice = pale blue-white field + crack // strokes (0♥), meadow = green/dark two-tone + grass tufts (1♥), lava = charred red field + // warm ember flecks (2♥). Distinct at hub-thumbnail size. const PARK_HAZARD_TINT = { ice: { deep: '#6e9fbe', verge: 'rgba(170,206,224,0.45)', ember: '#dff2fb' }, meadow: { deep: '#3f7a41', verge: 'rgba(150,196,120,0.42)', ember: '#cdeba2' }, lava: { deep: '#a82706', verge: 'rgba(236,150,96,0.55)', ember: '#ffb03e' }, }; // reach destination-pad hue (concentric target) + the 2-3 collect gem-TYPE hues (spec §C.1/ // §C.3) — a color CLASS disjoint from gold harvest gems, blue "you", magenta companion and // every hazard-field hue, so goal grammar reads at a glance. Collect types also vary the token // SHAPE (circle/square/triangle — never a diamond) so hue-blind and small-thumbnail reads separate. const PARK_PAD_HUE = '#39CCCC'; // companion BODY tint (goal-legibility R1 #3): the kin's body + halo render in this slightly // DESATURATED magenta (PARK_HUES.companion pulled ~30% toward gray) so the vivid blue player // actor is unmistakably the protagonist in any still; ownership marks (heart tag, claim ring, // intent leash, take fx) keep the full companion hue — the color FAMILY still reads as one. const PARK_COMPANION_SOFT = '#ba6fcb'; // collect gem-TYPE hues — a jewel-tone CLASS held OFF the pad's teal (fix R1 #3/#7: the old // azure #4FC3F7 sat right on the pad hue, so a typed pickup and a reach GOAL pad read as the // same teal token). gold / rose / spring-green: three jewel tones, none teal, each also a // distinct SHAPE (circle/square/triangle) so hue-blind + thumbnail reads still separate them, and // none is the diamond (diamonds are HARVEST collectibles only — canonical shape-role system). // (R1 goal fix #10: type-0 was '#FFDC00' — a GOLD circle that read as generic coins/one-more // gold gem next to the gold harvest diamonds, so the starred collect target looked like a // "yellow-circles pictogram" matching nothing. Violet is a hue no other board class owns — // gold gems / teal pads / blue you / pink companion / green-red-blue terrains all clear.) const PARK_GEM_TYPES = ['#9D6BFF', '#FF5D8F', '#3DDC84']; // violet / rose / spring-green // live pacing (timing-only): routine steps fast-forward; a conflict pause / a deep-entry or // gem-take event frame dwells long enough to read. const PARK_STEP_MS = 240, PARK_PAUSE_MS = 950, PARK_EVENT_MS = 900, PARK_END_MS = 1200; const PARK_PER_CELL_MS = 130; // slide glide: uniform per-cell dwell (distance-proportional) // 발견 말풍선은 한 박자 더 머문다 (2026-08-05): 파랑/분홍 두 발견이 붙어 있어 "두 마음이 // 같은 보석을 각자 발견했다"가 한 사건으로 뭉개졌다. 엔진은 0 바이트 — 턴 간격을 늘리면 // P.mode='toGem' 이 밀려 분홍 궤적과 awards 가 전부 바뀐다. 늘리는 것은 체류 시간뿐이다. const PARK_NOTICE_HOLD_MS = 1600; // 갈림길의 뜸 (2026-08-06). 정지 프레임은 흰 링과 유령만 그렸다 — 링은 "여기를 봐라"이지 // "고민 중이다"가 아니다. 프레임을 더하지 않고 이 프레임의 체류만 늘린다: 분기 수는 인격이 // 정하고(24 시드 편차 0, 6~13개) 프레임을 더하면 그 수만큼 곱해진다. const PARK_THINK_MS = 1400; // 발견과 찜 사이의 한 박자 (2026-08-06). 알아챈 것과 제 것으로 표시한 것은 다른 사건인데 // 엔진이 둘을 같은 turn 에 내므로 화면이 하나로 보였다. 수를 안 두는 프레임 하나를 끼워 가른다. const PARK_CLAIM_MS = 700; // 위험 곁눈질의 두 체류 (2026-08-06). 몸이 제자리에서 풀숲 쪽으로 고개를 돌리고, 위험 표시가 // 뜨고, 돌아온다 — 두 단계 각각의 머무는 시간. const PARK_GLANCE_MS = 800; // 시선이 풀숲에 머무는 시간 (위험 말풍선이 서 있는 동안) const PARK_GLANCE_BACK_MS = 380; // 시선이 원위치로 돌아오는 프레임 // LIVE BEAT WINDOW: how long one committed play move's cue stays up before parkGameInput drops it // (a.cue = null). It was an inline 360 in exactly one place; it is a const now because a second // reader needs the SAME number — _parkStepFrac drives a one-shot animation across this window, and // an animation whose length disagrees with the cue's lifetime either freezes mid-flight or outlives // the beat it belongs to. One number, one meaning. const PARK_CUE_MS = 360; // DEMO SKIP (task 11) — the demo is the QUESTION, not a cutscene (the priority order is never // stated in words anywhere; watching the walk is the only channel). So skipping skips TIME, never // INFORMATION: S1 divides the dwell while held (every frame still plays), S2 (Esc) runs the demo // to its end state and holds a STATIC frame showing the WHOLE trajectory with every fork marked // (ring + the path-not-taken ghost) until an explicit confirm. Timing/display only. const PARK_FF_DIV = 6; // S1 fast-forward divisor (hold) const PARK_FF_MIN_MS = 24; // never schedule a sub-frame timer // arrow-key -> the park move-key the campaign game resolver (C.parkGameMove) consumes. // WASD alias (Track D-4, spec 2026-07-16): collision-checked via `grep -n "e.key" app.js | grep // -iv arrow` — the only non-arrow keys consumed are '.', ' ', 'Enter', 'Escape', 'Tab', 'Shift', // 'g'/'G' (toggleGenerate, reached only OUTSIDE the park-game branch that reads this table). None // of w/a/s/d collide, so all four are aliased; same five verbs, no new verb. Both cases are bound: // `e.key` reports 'W' under caps lock or shift, and an alias that dies on caps lock is worse than // no alias — it fails silently ('g'/'G' above is the same idiom). // Two consumers read this table (final-review item 3): the park game resolver at ~line 8872 // (Track D's stated scope) and parkTutorialInput at ~line 1197 (outside that scope — WASD reaches // the tutorial too as a side effect). Harmless and arguably desirable (same five verbs, and the // collision grep above was a global `e.key` scan so it already covers this path), but undocumented // until now, and the tutorial path has not been browser-verified. const PARK_KEYMOVE = { ArrowUp: 'U', ArrowDown: 'D', ArrowLeft: 'L', ArrowRight: 'R', w: 'U', s: 'D', a: 'L', d: 'R', W: 'U', S: 'D', A: 'L', D: 'R' }; function startDemoAnim() { // PARK: under parkMode the newcomer's Discovery is the oracle-driven continuous park journey // (run.park.demo), NOT the legacy continuous free-board walk. Branch FIRST so the legacy path // below is untouched when run.park is absent (byte-identical off-path). if (G.campaign && G.campaign.park) return startParkDemo(); return _startDemoAnimLegacy(); } function _startDemoAnimLegacy() { const run = G.campaign; if (!run || run.stage !== 'demo') return; const seatId = run.demo.seatId; const rule = run.demo.rule; // the deterministic TUTORIAL bundle for the newcomer's rule: a self-contained // engine board (its own N, single seat 0) + the scripted {from,to,violated} // steps. buildTutorial leaves `board` pristine (tutScript replays on a clone), // so we mutate `board` ourselves frame-by-frame as we replay the steps. The seat // is always 0 on the tutorial board; we render the newcomer's seat id on it. // VALUE/ORDERING CYCLE (design 2026-06-21 §A): the newcomer enacts its hidden ORDERING by // walking the viewer through the Sigma* CONFLICT battery (E.buildValueDemo) — each step is a // two-engaged conflict scene whose oracle move REVEALS one pairwise comparison, so the // ordering is observable/inducible (NOT the blank role-walk). Gated strictly to a value cycle // (personaView != null for the newcomer seat); every legacy role/terrain/phase cycle keeps // buildTutorial unchanged (byte-identical). run.inferred (a Set of revealed 'WL' edges) is // RESET here and accumulated as the demo plays (the discovery-mode HUD reads it). const pvDemo = personaView(run, seatId); if (pvDemo) { const ordering = demoSeatOrdering(run, seatId); // §B CONTINUOUS PLAY-MEMORY (design 2026-06-22): instead of jump-cutting across 6 fresh // conflict boards (buildValueDemo), the newcomer's hidden ORDERING is revealed by its // CONTINUOUS lived motion — lexicalOracle stepped cell-by-cell on a PERSISTENT board toward // the goal. playTrajectoryPool sweeps the proven Σ* seed stream into VARIED geometries; the // greedy set-cover pickRepresentative chooses the handful of walks whose union still induces // the ordering (the W1.5 inducibility property, now spread across lived walks). EVERY move is // lexicalOracle (persona-faithful by construction). The reserved pool is held for the deferred // held-out eval (untouched). LIVE-ONLY (the green-gate / serializer parity ride the same fns). const pool = E.playTrajectoryPool(rule, ordering, { N: DEMO_POOL_N, stepCap: DEMO_STEP_CAP, ...DEMO_LONG_OPTS }); // ONE LONG CONTINUOUS WALK ON ONE BIG BOARD (~30-40 steps; user directive 2026-06-29). The demo is a // single persona-faithful oracle journey on ONE persistent DISPLAY-ONLY board (DEMO_LONG_OPTS: a bigger // _valueDemoBoardLong with more tokens + a high harvest quotaFrac so the walk is long), so the persona's // priority reads from SUSTAINED REPEATED behavior ("it keeps choosing safety over reward") rather than a // few knife-edge comparisons. We pick the single pool walk covering the MOST flowing comparisons {REWARD // G, SAFETY C, RESPECT N} at a coherent ~35-step length, and render exactly that ONE walk on its ONE // board (trajs.length===1 => continuousReplayTick never reseeds => no jump-cut/slideshow). YIELD (D) is // a static chokepoint duty incompatible with a flowing walk, so it is exercised in the long cumulative // PLAY, not the demo. Scored battery / oracle / Σ* untouched (DEMO_LONG_OPTS is gated, display-only). const FLOW = ['CG', 'CN', 'GN']; const TARGET_LEN = 34; // a long journey: priority emerges through repetition (user: ~30-40 turns) // rank each walk by: (1) MOST flowing comparisons {CG,CN,GN} (any two already order REWARD/SAFETY/ // RESPECT — coverage is the whole point, so it leads); (2) TERMINATES (reaches goal) rather than // hitting the step cap (a 64-step walk = a meander that never finishes); (3) length CLOSEST to // TARGET_LEN (avoid the 4-step blip AND the capped meander). One winner => trajs:[walk] => one // persistent board, no slideshow. const GOOD_MAX = 40; // a coherent long journey terminates and stays under this many steps (no meander) const score = (tr) => { const fc = (tr.comparisons || []).filter(c => FLOW.indexOf(c) !== -1).length; const coherent = (tr.steps.length < DEMO_STEP_CAP && tr.steps.length <= GOOD_MAX) ? 1 : 0; // MOVES = non-stay steps: a RESPECT/SAFETY-leaning walk can sit still and read as "frozen / // passive" (cold-read R1 misread the static respect persona as passive), so among coherent // equal-flow walks prefer the one that actually MOVES, then closest to TARGET_LEN. const moves = (tr.steps || []).filter(s => s.move && s.move !== 'stay').length; return [coherent, fc, moves, -Math.abs(tr.steps.length - TARGET_LEN)]; }; const better = (s, b) => { for (let i = 0; i < s.length; i++) { if (s[i] !== b[i]) return s[i] > b[i]; } return false; }; let walk = null, best = null; for (const tr of pool.trajs) { const s = score(tr); if (!best || better(s, best)) { walk = tr; best = s; } } walk = walk || E.pickContinuousWalk(pool); // SMOOTH THE WALK so the agent makes net FORWARD progress instead of freezing or jittering: a // respect/yield/safety-leaning persona's faithful move is often "stay" (decline) or a back-and-forth // near a decision point (torn between concerns), which read as the agent being stuck. We keep moves to // NEW cells, collapse a run of stays to at most ONE "holds here" beat, and DROP a step that just // revisits a recent cell (the oscillation). The persona still reads from WHERE it travels and what it // passes by. (Filter a COPY; the pool walk + scored paths untouched.) if (walk && walk.steps) { const kept = []; const recent = []; // recent kept destination cells (oscillation window) for (const s of walk.steps) { const isStay = !(s.move && s.move !== 'stay'); const prevStay = kept.length && !(kept[kept.length - 1].move && kept[kept.length - 1].move !== 'stay'); if (isStay) { if (prevStay) continue; } // collapse stay-runs to one else { const key = s.to.x + ',' + s.to.y; if (recent.indexOf(key) !== -1) continue; // revisit (oscillation) -> drop recent.push(key); if (recent.length > 3) recent.shift(); } kept.push(s); } if (kept.length) walk = Object.assign({}, walk, { steps: kept }); } run.inferred = new Set(); G.demoAnim = { seatId, rule, valueDemo: true, ordering, trajs: [walk], ti: 0, si: 0, disp: _trajFreshBoard(walk || { rule, ordering, seed: 1 }), frame: 0, flash: false, lastTo: null, penalty: 0, role: false, trail: [], beat: null, scenePair: null, relevant: null, foregone: null }; draw(); continuousReplayTick(); return; } const tut = E.buildTutorial(rule); // SLICE2 ROLE-PLAY: a role demo is an animated MOTION clip (buildTutorial dispatches // base==='role' -> buildRoleDemo, so tut.steps are the role's clean own-walk with a // passedUp contrastive beat). `role` selects the clean-stage render + a SOLID trail // following the newcomer (accumulated frame-by-frame in demoTick) + a faint beat // highlight on the contrastive step (NOT a violation flash, NEVER a role label). G.demoAnim = { seatId, rule, disp: tut.board, steps: tut.steps, frame: 0, flash: false, lastTo: null, penalty: 0, role: isRoleRule(rule), trail: [], beat: null }; draw(); demoTick(); } // the strict lexical ordering SEATED on the newcomer's seat for THIS cycle (the persona the // value demo enacts). Reads the run's per-seat orderings (already computed at cycle begin); // falls back to the deterministic per-seat selector / the canonical defer-first ordering. function demoSeatOrdering(run, seatId) { const orderings = run.orderings || (C._orderingsForRun ? C._orderingsForRun(run) : null); return (orderings && orderings[seatId]) || ['D', 'N', 'C', 'G']; } // a PRISTINE display board for a trajectory: re-derive it from the engine (playTrajectory's // board0 is a fresh _valueDemoBoard each call) so the mutated `disp` never poisons a re-run and // no fragile manual Set-clone is needed. seed/ordering/rule come from the trajectory object. function _trajFreshBoard(tr) { // pass DEMO_LONG_OPTS so the rendered disp board is the SAME big board the walk steps were generated on // (with trajs.length===1 continuousReplayTick never reseeds, so a bare {} here would render a small board // under a long-board walk = corrupt frames). Display-only. return E.playTrajectory(tr.rule, tr.ordering, tr.seed, DEMO_LONG_OPTS).board0; } // §B CONTINUOUS PLAY-MEMORY tick (design 2026-06-22): step the CURRENT representative trajectory // cell-by-cell on a PERSISTENT disp board (mutated in place by E.applyMove + E.stepCompanion — the // SAME companion step the generator + serializer use, so frames cannot drift). When the current // trajectory's steps are exhausted, advance to the next representative and RESEED disp from its // pristine board0; when all representatives are exhausted, finishDemo(). Each step's conflict pair // + foregone ghost + load-bearing-relevant set drive the EXISTING draw() value branch verbatim // (winner-colored trail, beatHighlight ring, foregone ghost, relevant-muting). Display-only; the // live shared board + ♥ are byte-untouched (this never calls C.playerMove). function continuousReplayTick() { const run = G.campaign; const a = G.demoAnim; if (!run || run.stage !== 'demo' || !a || !a.trajs) return; // FREEZE hook (test/capture only; never set in production play, so byte-inert here): when an // external harness pins the anim to one deterministic step for a static screenshot, it sets // a._frozen so the running timer chain neither mutates the pinned state nor reschedules itself. if (a._frozen) return; // advance past finished trajectories (and reseed disp from the next pristine board0). // ONE-LONG-BOARD CONTINUITY (user directive 2026-06-25): the DEMO already runs the MINIMAL // representative set that set-covers the 6 comparisons (pickRepresentative), but the handoff // BETWEEN those (few) walks was a hard board reseed = a jump-cut. Snapshot the finishing // segment's last frame and crossfade it into the next segment so the continuous oracle play // reads as ONE near-continuous stage. The W1.5-CONTINUOUS 6-coverage guarantee is PRESERVED // (the rep union is unchanged — same trajectories, same coverage); only the visual handoff // between segments changes. Presentation-only; scored battery / oracle / Σ* untouched. // ONE-WALK DEMO (user directive 2026-06-25): with a.trajs.length === 1 this advance loop runs at // most ONCE and immediately hits a.ti >= a.trajs.length -> finishDemo(), so the inter-segment // crossfade + mid-demo board reseed below are NEVER reached during a single-walk demo (no segment // join, no mid-demo _trajFreshBoard). Gating the reseed block on trajs.length > 1 makes that // guaranteed-by-construction rather than incidental; legacy multi-rep callers (if any) keep prior // behavior. Presentation-only; scored battery / oracle / Σ* untouched. while (a.ti < a.trajs.length && a.si >= a.trajs[a.ti].steps.length) { a.ti += 1; a.si = 0; if (a.trajs.length > 1 && a.ti < a.trajs.length) { captureXfade(false); // morph between representative segments (no join cue) a.disp = _trajFreshBoard(a.trajs[a.ti]); a.lastTo = null; } } // DEMO ANIMATION DONE -> direct demo->play handoff (finishDemo does C.stepDemo + C.handoff). // The newcomer's persona is read from its long single-board walk; the comparison challenge was // removed (its yield/respect/safety conflicts now arise in the long cumulative PLAY itself). if (a.ti >= a.trajs.length) return finishDemo(); const tr = a.trajs[a.ti]; // PRUNE: the SHARED engine helper decides which step indices get an emitted/emphasized frame; // routine steps NOT in the kept set are applied to the board (parity) but shown with a MICRO dwell // (no draw-emphasis) so motion stays continuous while dead time collapses. Memoized per traj. if (!a._keptSets) a._keptSets = {}; if (!a._keptSets[a.ti]) a._keptSets[a.ti] = new Set(E.pruneTrajectorySteps(tr)); const keptHere = a._keptSets[a.ti]; const stepIdx = a.si; const isKept = keptHere.has(stepIdx); const step = tr.steps[a.si]; const from = { ...a.disp.pos[0] }; // LEGIBILITY CUES (computed PRE-move, while a.disp IS the decision state): so the viewer reads // WHAT the persona gave up, not just that it moved. (1) declined = the cell the LOSER concern // would have gone (the road not taken); (2) for a RESPECT (N) loss, the contested token another // seat is strict-closest to + that claimant seat; (3) for a SAFETY (C) loss, the hazard the agent // came within the caution band of. Display-only reads of PUBLIC state (C1: keyed on the engaged // pair + positions, never the hidden ordering). a.declined = null; a.claimedToken = null; a.claimantSeat = null; a.hazardCell = null; if (step.conflictPair) { const lo = step.conflictPair.loser; const pair = [step.conflictPair.winner, step.conflictPair.loser]; const loOrder = [lo].concat(a.ordering.filter(k => k !== lo)); const dmv = E.lexicalOracle(a.disp, 0, loOrder, a.rule); if (dmv && (dmv.x !== step.to.x || dmv.y !== step.to.y)) a.declined = { x: dmv.x, y: dmv.y }; // show the contested entity whenever its concern is IN the conflict — winner OR loser. A // RESPECT-top persona's conflicts have N as the WINNER (it LEAVES the claimed token), so keying // only on the loser hid the claimant and made respect read as mere passivity (cold-read R1). if (pair.indexOf('N') !== -1) { const claim = _claimedTokenFor(a.disp, 0); if (claim) { a.claimedToken = claim.token; a.claimantSeat = claim.seat; } } if (pair.indexOf('C') !== -1) { a.hazardCell = _nearestDarkCell(a.disp, from); } } E.applyMove(a.disp, 0, step.to, a.rule); // PERSISTENT disp board (display-only — not the run) E.stepCompanion(a.disp, a.rule); // SAME companion step the generator used a.lastTo = { from, to: { ...step.to } }; a.scenePair = step.conflictPair ? { hi: step.conflictPair.winner, lo: step.conflictPair.loser } : null; a.relevant = step.relevant || null; // §A: load-bearing entity set (render mutes the rest) a.foregone = step.foregone || null; // declined reward-greedy step (rule-blind ghost, display-only) a.flash = false; // record the REVEALED pairwise edge (winner>loser) as the walk LIVES it — inducible from the // observed move alone; edges accumulate across the lived walks (re-adding an edge is a Set no-op). if (run.inferred && step.conflictPair) run.inferred.add(step.conflictPair.winner + step.conflictPair.loser); a.frame += 1; a.si += 1; draw(); // EMPHASIS 3-tier dwell + PRUNE micro dwell (timing-only). A routine step outside the kept set is // COMPRESSED (micro dwell, no emphasis) — applied but fast-forwarded. Kept steps dwell by tier: // conflict -> long (read which concern won; foregone ghost lingers the whole dwell) // foregone-only -> medium (the declined-greedy ghost is the single most thesis-relevant cue) // routine kept-for-context -> brisk (connective tissue into/out of a decision) let dwell; if (!isKept) dwell = DEMO_MICRO_MS; else if (step.conflictPair) dwell = DEMO_CONFLICT_MS; else if (step.foregone) dwell = DEMO_FOREGONE_MS; else dwell = DEMO_STEP_MS_FAST; after(dwell, () => { if (!G.campaign || G.campaign.stage !== 'demo') return; continuousReplayTick(); }); } // one display frame of the newcomer's self-demonstration. Replays the next // scripted tutorial step on the tutorial board (mutating it) and flashes on the // engine-verified `violated` flag. NEVER touches run.hearts. When the scripted // steps are exhausted, record Discovery (C.stepDemo) and hand off to play. function demoTick() { const run = G.campaign; const a = G.demoAnim; if (!run || run.stage !== 'demo' || !a) return; if (a.frame >= a.steps.length) return finishDemo(); const step = a.steps[a.frame]; const from = { ...a.disp.pos[0] }; const violated = !!step.violated; E.applyMove(a.disp, 0, step.to, a.rule); // tutorial board only (not the run) a.lastTo = { from, to: { ...step.to } }; if (violated) a.penalty += (a.disp.penalty_amt || 0); // DISPLAY-ONLY standing cost // ROLE MOTION CLIP: accumulate the newcomer's path as a SOLID trail and remember the // contrastive beat cell (the step where the role forgoes a task-greedy cell). The role // own-walk is CLEAN (violated all false), so a role demo never flashes red — the beat is // a faint highlight only, never a violation flash and never a role label. if (a.role) { if (from.x !== step.to.x || from.y !== step.to.y) a.trail.push({ from, to: { ...step.to } }); if (step.passedUp) a.beat = { ...step.to }; a.flash = false; } else { a.flash = violated; } a.frame += 1; draw(); after(violated ? DEMO_VIOLATE_MS : DEMO_STEP_MS, () => { if (!G.campaign || G.campaign.stage !== 'demo') return; if (G.demoAnim) G.demoAnim.flash = false; demoTick(); }); } // demo scripted replay exhausted -> record Discovery on the LIVE board (engine // helper; hearts byte-untouched) and hand off to round-robin play on the REAL // cumulative board. function finishDemo() { const run = G.campaign; C.stepDemo(run); // records Discovery; the demo-is-never-charged invariant C.handoff(run); // demo -> play (newcomer joins the round-robin) G.demoAnim = null; G.generate = null; // a new cycle/party — any prior GENERATE target is stale setHint('② play — ↑↓←→ 이동 · Space/Tab 동료 전환 · . 대기. (자세히: ? 안내)'); draw(); } /* ==== MINI WATCH REPLAY (설계 2026-08-04) ================================== 플레이 내내 옆판에서 되도는 시연. 사람만 기억에 의존하던 비대칭을 없앤다 — LLM 에이전트는 대화 이력으로 시연 관측을 몇 번이든 되읽는데, 사람은 한 번 보고 외워야 했다. 미니 창은 새 정보를 주는 것이 아니라 그 비대칭을 없앤다(하네스는 그래서 안 건드린다). 표시 전용: 엔진/캠페인 상태를 읽기만 하고 채점·판독에 아무 입력도 주지 않는다. C1 무관 — 공개 시드로 지은 보드와 공개 수열만 쓴다. */ const PARK_REPLAY_DIV = 3; // 배속. 걸음 240ms -> 80ms = pulseLoop 리드로 주기와 같다. // PARK_FF_DIV(6)를 안 쓰는 이유: 40ms 걸음은 80ms 리드로가 // 매 프레임 두 걸음씩 건너뛰어 툭툭 끊긴다. const PARK_REPLAY_HOLD_MS = 900; // 한 바퀴 끝에서 쉬는 시간 (루프 경계를 눈이 알아채는 데 필요) const PARK_REPLAY_PX = 216; // 창 한 변 (HUD 폭 240 - 좌우 여백 12씩) const PARK_REPLAY_X = 12, PARK_REPLAY_Y = 200; // hx 안 좌상단 — 철거한 턴 바 자리 // _parkReplayBoard(t): 시연이 밟은 것과 **같은 관례**로 보드를 다시 짓는다. // startParkTaskDemo(app.js)의 그 줄과 반드시 같아야 한다. 원문 주석이 사고 이력을 적어 뒀다: // makeParkTask 는 P1 모듈 시연 다리를 못 만들어서 xs 의 boxpad 셀에서 상자 없는 판을 조용히 // 지었고, 시연이 보이지 않는 소코반 수순을 재생했다. PARK-REPLAY-BOARD-MATCHES-DEMO 가 이 // 표현식을 원문으로 잰다 — 여기를 고치면 그 게이트도 같이 고쳐야 한다. function _parkReplayBoard(t) { return t.crossing ? C._parkCrossBoard(t.tile.kind, t.demoCell) : E.makeParkTask(t.tile.kind, t.demoCell); } // _parkReplayRewind(): 보드를 새로 지어 루프를 처음으로 되감는다. function _parkReplayRewind() { const m = G.parkReplay; if (!m) return; m.P = E.parkStart(m.makeBoard()); m.i = 0; m.paused = false; m.cue = null; m.holdUntil = 0; m.nextAt = 0; } // _parkReplayStart(): 핸드오프(finishParkDemo / finishParkTaskDemo)에서 부른다. // 태울 수 없는 자리면 G.parkReplay 를 null 로 둔다 — 그리는 쪽은 null 을 그냥 건너뛴다. function _parkReplayStart() { G.parkReplay = null; const run = G.campaign; if (!run || !run.park) return; const t = run.park.task; let tid, makeBoard, demo; if (t) { // M7 관측자 에피소드는 시연이 '같은 판'의 앞부분이다. 미니가 플레이어 자신의 판을 // 재생하게 되어 같은 그림이 둘 뜬다 — 그건 참조가 아니라 혼선이다. if (t.observer) return; demo = t.demo; tid = 't:' + t.tile.id; makeBoard = () => _parkReplayBoard(t); } else { const pk = run.park; demo = pk.demo; tid = 'capstone:' + pk.demoSeed; makeBoard = () => E.makeParkBoard(pk.demoSeed); } if (!demo || !demo.moves || !demo.moves.length) return; G.parkReplay = { tid, makeBoard, moves: demo.moves, // 갈림길 = 기록된 블라인드 쌍 시상 턴 (1-based). 라이브 시연과 같은 출처다. conflicts: new Set((demo.awards || []).map(w => w.turn)), P: null, i: 0, paused: false, cue: null, nextAt: 0, holdUntil: 0 }; _parkReplayRewind(); } // _parkReplayTid(): 지금 화면이 어느 에피소드인가. G.parkReplay.tid 와 다르면 그 미니는 낡은 // 것이므로 버린다. 이게 없으면 허브를 돌아 다른 셀에 들어갔을 때 **이전 판의 시연**이 옆에서 // 돌 수 있고, 이 게임에서 "조용히 다른 공원을 보여주는 것"은 가장 나쁜 실패다. function _parkReplayTid() { const run = G.campaign; if (!run || !run.park) return null; const t = run.park.task; return t ? 't:' + t.tile.id : 'capstone:' + run.park.demoSeed; } // _parkReplayAdvance(): 미니 루프를 한 걸음 전진시킨다. **새 타이머 체인을 만들지 않는다** — // _parkDemoSchedule() 이 적어 뒀듯 "두 번째 체인이 바로 허브를 떠날 때 유령 프레임을 남긴 그 버그"다. // pulseLoop 이 플레이 중 이미 80ms 마다 draw() 를 부르므로 거기 얹고, 다음 걸음 시각(nextAt)만 // 벽시계로 든다. // // 한 프레임에 **한 걸음만** 간다. 밀린 시간을 몰아서 따라잡지 않는다 — 시연은 수순 자체가 // 정보라 프레임 스킵이 곧 정보 손실이다. 효과 상한은 리드로 주기(80ms)이고, 걸음 목표도 // 80ms 라 사실상 3배속이며, 프레임이 밀리면 그만큼 느려질 뿐 걸음을 건너뛰지는 않는다. function _parkReplayAdvance() { const m = G.parkReplay; if (!m) return; if (m.tid !== _parkReplayTid()) { G.parkReplay = null; return; } // 낡은 에피소드의 미니는 버린다 if (!m.P) return; const now = Date.now(); if (now < m.nextAt) return; // 한 바퀴 끝: 수열을 다 썼거나 시연이 먼저 끝났다. 라이브 시연 parkDemoFrame 도 같은 두 // 조건으로 멈춘다 — 같은 판정을 쓴다. if (m.i >= m.moves.length || m.P.over) { if (!m.holdUntil) { m.holdUntil = now + PARK_REPLAY_HOLD_MS; return; } if (now >= m.holdUntil) _parkReplayRewind(); return; } // 갈림길 박자: 라이브 시연과 같은 자리에서 멈춘다(시상 턴은 1-based = i+1). 이 게임은 // 갈림길에서의 선택이 숨은 순서를 흘리는 것에 관한 것이라, 균일 속도로 밀면 루프가 // 흐릿한 산책으로 뭉개진다. 새 정보는 아니다 — 라이브 시연이 이미 여기서 멈춘다. // ghost(길 안 든 셀)도 parkDemoFrame과 같은 방식으로 싣는다 — 갈림길에서 "고르지 않은 // 끌림"을 보여주는 것이 이 게임이 가르치는 바로 그 박자라, 미니가 이걸 빠뜨리면 방금 // 본 시연과 영영 달라진다. if (m.conflicts.has(m.i + 1) && !m.paused) { m.paused = true; m.cue = { pause: true, conflict: true, ghost: parkGhostCell(m.P, m.moves[m.i]) }; m.nextAt = now + PARK_PAUSE_MS / PARK_REPLAY_DIV; return; } const ghost = m.paused ? m.cue.ghost : null; // keep the paused read through the commit m.paused = false; const from = { ...m.P.st.pos[0] }, n0 = m.P.st.fx.length; E.parkStep(m.P, m.moves[m.i]); m.cue = parkCueFor(m.P, from, m.P.st.fx.slice(n0), ghost, false); m.cue.conflict = m.conflicts.has(m.i + 1); m.i++; m.nextAt = now + PARK_STEP_MS / PARK_REPLAY_DIV; } // _parkReplayTick(): 한 프레임 몫 — 전진 + 축소 복사. draw() 가 drawParkFrame **앞에서** 부른다. // __PARK_REPLAY__ 는 캡처 도구의 옵트아웃이다(도감 카드는 정지 화면이라 애니메이션 창이 무의미 // 하고, 켜고 찍으면 tutorial/ 의 플레이 카드가 전부 바뀐다). 프로덕션 기본값은 켬이다 — // 브라우저에서 이 플래그는 설정되지 않으므로 undefined !== false 로 통과한다. function _parkReplayTick() { if (window.__PARK_REPLAY__ === false) return false; _parkReplayAdvance(); return _parkReplayPaint(); } /* ===================== PARK — DEMO (watch-only journey) driver ===================== */ // The oracle-driven continuous journey (run.park.demo) replayed move-by-move on a fresh rebuild of // the SAME public demo board (E.parkStep is shared verbatim with the recorded playout, so the // replay is byte-identical). Display-only: the hearts drawn are the DEMO agent's own (physics // applies to it; nothing scored is touched). Conflict turns (the recorded blind pairwise awards) // get a deliberate-pause frame before the commit frame. G.demoAnim stays null on this path. function startParkDemo() { const run = G.campaign; if (!run || !run.park || run.stage !== 'demo') return; G.demoAnim = null; G.parkAnnot = null; // fresh episode = fresh highlight vocabulary (§C.1) const pk = run.park; G.parkAnim = { mode: 'demo', P: E.parkStart(E.makeParkBoard(pk.demoSeed)), moves: pk.demo.moves, mi: 0, paused: false, claimPending: false, cue: null, ff: false, end: null, // task 11: S1 fast-forward / S2 static end-frame conflictTurns: new Set(pk.demo.awards.map(w => w.turn)), // CN(안전 vs 배려) 시상 턴만 따로 — 곁눈질은 이 박자에만 걸린다. 캡스톤 다리도 // startParkTaskDemo 와 같은 E.parkPlayout 출처(pk.demo.awards)를 쓰므로 CN 시상이 // 똑같이 발생한다(2026-08-07 리뷰 실측: 시드 1..3 각각 cnTurns 비어있지 않음) — // 여기 빠뜨리면 캡스톤에서 곁눈질이 영영 안 뜬다. careTurns: new Set(pk.demo.awards.filter(w => w.pair === 'CN').map(w => w.turn)) }; draw(); parkDemoTick(); } // advance one demo frame; when the journey is exhausted, hand off to the interactive game. // Drives BOTH park demos off ONE gate: the capstone (run.stage='demo', parkView null) and a // task replay (parkView='demo', a.task set) — stageKey() is 'demo' for exactly those two. function parkDemoTick() { const run = G.campaign, a = G.parkAnim; if (!run || stageKey() !== 'demo' || !a || a.mode !== 'demo') return; if (a._frozen) return; // capture hook (test-only; never set in production) if (a.end) return; // S2 static end-frame: the chain is parked, awaiting confirm const dwell = parkDemoFrame(a); if (dwell == null) return a.task ? finishParkTaskDemo() : finishParkDemo(); // TIMING BEFORE DRAW (slide-glide fix): _parkDemoSchedule stamps dwellT0/dwellRaw, which // parkGlideFrac reads to place the gliding agent. If draw() ran FIRST, the new slide frame's // first render used the PREVIOUS frame's expired timing → frac≈1 (agent flashed at the rest // cell) and the next redraw then snapped it back to frac≈0 (the "teleport back-and-forth"). Arm // the timing first so the very first render starts the glide at `from` and sweeps forward. _parkDemoSchedule(a, dwell, 0); draw(); } // _parkDemoSchedule(a, raw, done): arm the next demo tick for a frame whose UNSCALED dwell is // `raw` ms and which is already `done` (0..1) consumed. The S1 speed factor is applied HERE and // nowhere else, so the frame sequence itself is byte-identical at any speed (S1 loses no frame, // only time). Rides the existing after()/clearTimers() chain — no new timer machinery (a second // chain is exactly the bug that left ghost frames when leaving for the hub; see G.xfade). function _parkDemoSchedule(a, raw, done) { const spd = a.ff ? PARK_FF_DIV : 1; a.dwellRaw = raw; a.dwellDone = done; a.dwellSpd = spd; a.dwellT0 = Date.now(); const wait = Math.max(PARK_FF_MIN_MS, (raw * (1 - done)) / spd); after(wait, () => { if (G.campaign && stageKey() === 'demo') parkDemoTick(); }); } // S1 — parkDemoSetFF(on): hold to fast-forward. Re-arms the PENDING frame at the new speed so // press/release both bite immediately (the already-scheduled timer would otherwise hold the old // pace for up to a full pause frame). The consumed fraction of the current frame carries over, so // toggling mid-frame neither rewinds nor skips it. const parkDemoSetFF = (on) => { const a = G.parkAnim; if (!a || a.mode !== 'demo' || !!a.ff === !!on) return; a.ff = !!on; if (a.end || a._frozen || !a.dwellRaw) return; // static frame / capture: nothing is pending const spent = (Date.now() - a.dwellT0) * a.dwellSpd / a.dwellRaw; const done = Math.max(0, Math.min(1, a.dwellDone + spent)); clearTimers(); // the one timer chain (see after/clearTimers) _parkDemoSchedule(a, a.dwellRaw, done); }; // S2 — parkDemoToEnd(): jump to the END OF THE DEMO, not to play. Runs the SAME parkDemoFrame to // exhaustion with zero dwell (a separate replay path would drift from the live frames and break // the capture harness's "captures are exact live frames" contract), collecting every fork's // deliberate-pause read (the cell + the path-not-taken ghost — parkGhostCell needs the P of that // moment, so it can only be harvested while stepping). The result is held as a.end and drawn as a // static frame: whole trajectory + every fork ringed + every declined pull ghosted. No handoff // happens here — the viewer confirms (parkDemoConfirm) when they are done reading it. const parkDemoToEnd = () => { const a = G.parkAnim; if (!a || a.mode !== 'demo' || a.end) return; clearTimers(); a.ff = false; const forks = []; const mark = () => { // a.paused === sitting ON a fork's pause frame if (a.paused && a.cue) forks.push({ at: { ...a.P.st.pos[0] }, ghost: a.cue.ghost ? { ...a.cue.ghost } : null }); }; mark(); // Esc pressed while already paused on a fork for (let guard = 0; guard < 5000; guard++) { if (parkDemoFrame(a) == null) break; mark(); } a.end = { forks, path: a.P.path.map(p => ({ ...p })) }; a.cue = null; // no live per-move cue on a static frame a.paused = false; draw(); }; // the S2 static frame's EXPLICIT confirm (Enter / Space / click) — the only door from it into play. // It runs the ordinary end-of-demo handoff, so the run reaches play through the exact same call as // a fully-watched demo (C.stepDemo + C.handoff / the task ceremony): byte-identical downstream. const parkDemoConfirm = () => { const a = G.parkAnim; if (!a || a.mode !== 'demo' || !a.end) return; a.ff = false; return a.task ? finishParkTaskDemo() : finishParkDemo(); }; // parkDemoFrame(a): produce the NEXT demo frame into a.P/a.cue — a deliberate-pause read at a // conflict cell (no move applied; ring + the path-not-taken ghost lit), or ONE committed move with // its decision cues. Returns the frame's dwell (ms), null when the journey is exhausted. Shared by // the live tick and the capture harness (window.parkDemoFrame), so captures are exact live frames. function parkDemoFrame(a) { const P = a.P; if (P.over || a.mi >= a.moves.length) return null; // P8.6 §A TEXTLESS REAL DEMO: the chip/intro annotation machinery is GONE from this path // (ANNOT-TUTORIAL-ONLY — the vocabulary is taught by the tutorial's WATCH beat instead). // A conflict event keeps ONLY the wordless attention staging: the deliberate pause + the // white spotlight ring (a.cue.pause / a.cue.conflict -> drawParkDemoRing), zero semantics. const conflict = a.conflictTurns.has(a.mi + 1); // award turns are 1-based (turn = mi + 1) // 찜 프레임 (2026-08-06): 직전 프레임이 파랑의 발견이었다면, 수를 안 두는 프레임 하나를 더 // 내어 그때 비로소 점선 링이 붙는다. 곁눈질과 같은 관례다 — parkStep 을 안 부르고 a.mi 도 // 안 올린다. 미니 리플레이는 parkCueFor 결과를 그대로 쓰므로 이 필드가 없고, 따라서 미니는 // 지금처럼 링을 바로 그린다. 그것이 의도다(216px 판에서 두 박자는 안 읽힌다). PAUSE 분기보다 // 먼저 검사한다(2026-08-07 순서 수정): 발견 바로 다음 턴이 기록된 conflict 턴이면(y46이 매 // 시드에서 그렇다) PAUSE 가 먼저 걸려 a.cue 를 통째로 덮어써 버려서, 찜 프레임이 그 갈등 // cue 를 그대로 이어받아 스포트라이트·생각 말풍선·찜 링이 한 프레임에 뭉친다 — 커밋 프레임의 // 파랑 발견 말풍선을 이어받는 의도(찜 프레임에도 말풍선이 남는다)는 그대로 살아 있다. if (a.claimPending) { a.claimPending = false; a.cue = { ...a.cue, claimHold: false, claim: true }; a.fxSt = P.st; a.fxAt = P.st.fx.length; // 수를 안 뒀으므로 이 프레임의 박자는 비어 있다 return PARK_CLAIM_MS; } if (conflict && !a.paused) { a.paused = true; a.cue = { pause: true, ghost: parkGhostCell(P, a.moves[a.mi]), notice: { seat: 0, mark: 'dots' } }; // 첫 CN 시상 턴에 한 번만: 그 결정의 원인(들어가면 하트가 깎이는 풀숲)을 그 자리에서 가리킨다. if (!a._glanced && !a._frozen && a.careTurns && a.careTurns.has(a.mi + 1)) { const gc = _parkGlanceCell(P); if (gc) { a._glanced = true; a.glance = 'turn'; a.glanceAt = gc; } } a.slideGlide = null; a.fxSt = P.st; a.fxAt = P.st.fx.length; return PARK_THINK_MS; } // 위험 곁눈질 (2026-08-06): 안전과 배려가 갈리는 첫 박자 직전에, 몸이 제자리에서 풀숲 쪽으로 // 고개를 돌리고 위험 표시가 뜬 뒤 원래 방향으로 돌아온다. 걸음은 한 칸도 안 움직인다 — // parkStep 을 안 부르고 a.mi 도 안 올린다. 시선은 st.facing 을 건드리지 않고 cue 로만 넘긴다: // parkStep 이 'stay' 에서 facing 을 보존하므로 복구를 한 번만 놓치면 그 뒤 전 프레임이 틀어진다. // 캡처는 통과시킨다(a._frozen): 멈춰 서면 하네스가 영영 잠긴다. if (a.glance === 'turn') { a.glance = 'back'; a.cue = { ...a.cue, glance: a.glanceAt, notice: { at: a.glanceAt, mark: 'heart' } }; a.fxSt = P.st; a.fxAt = P.st.fx.length; return PARK_GLANCE_MS; } if (a.glance === 'back') { a.glance = null; a._glanceCommit = true; a.cue = { ...a.cue, glance: null, notice: null }; a.fxSt = P.st; a.fxAt = P.st.fx.length; return PARK_GLANCE_BACK_MS; } const ghost = a.paused ? a.cue.ghost : null; // keep the paused read through the commit a.paused = false; const from = { ...P.st.pos[0] }, n0 = P.st.fx.length; const heartsBefore = P.hearts; // D4: the afterglow keys on the RESULT, not the cause E.parkStep(P, a.moves[a.mi]); a.fxSt = P.st; a.fxAt = n0; // this frame's beat = st.fx above n0 (see _parkStepFx) a.cue = parkCueFor(P, from, P.st.fx.slice(n0), ghost, false); // 곁눈질을 세운 박자의 결과 프레임 (2026-08-06): 비켜줌(cede)은 지금 그대로 두고, 기다림에는 // 동료의 어휘를 빌린다. recoil 은 끈다 — 그 필드는 "물러섬"이고 여기서 그리려는 것은 "참음"이다. // 한 프레임에 반대 뜻 둘을 그리면 어느 쪽도 안 읽힌다. if (a._glanceCommit) { a._glanceCommit = false; if (!a.cue.cede) { a.cue.recoil = null; a.cue.hold = true; } } // 파랑의 발견이면 이 프레임에서는 링을 억제하고, 다음 프레임에서 붙인다. if (a.cue.notice && a.cue.notice.seat === 0) { a.cue.claimHold = true; a.claimPending = true; } // SLIDE GLIDE VIEW STATE (C1): the engine already emits the whole swept path as a // {k:'slide', from, cells} fx (engine.js:8076). Record it as a VIEW field for the demo // renderer to interpolate across; null on a walk/1-cell move (nothing to glide). Never // touches engine P/st — a.slideGlide is drawn, never read back by any rule. const slideFx = P.st.fx.slice(n0).find(fx => fx.k === 'slide'); a.slideGlide = (slideFx && slideFx.cells && slideFx.cells.length > 1) ? { from: slideFx.from, cells: slideFx.cells } : null; // null = teleport (walk/1-cell) // §A.2 (RESOLVED KEEP): the wordless spotlight ring persists through a conflict COMMIT // frame — an attention anchor only; no chip, no glyph that encodes a motive. a.cue.conflict = conflict; // HURT AFTERGLOW: damage persists a few frames (a cracked heart lingers over the agent) so // single-frame sampling can never miss that it happened. // 2026-07-30 (D4): the trigger was `cue.deep` alone, so ONLY the deep meadow was remembered. // A heart lost to the doll's gaze (seen), to the companion being sent back (sent), to a // balloon (singed), to a charge, to the rising water (swept) flashed once and was gone — a // frame-sampling observer could not see those at all. Keying on the HEART DELTA instead of on // a hand-kept list of fx kinds is what makes the next mechanic inherit this for free; a list // is exactly the shape that goes quietly stale (the registry-surface lesson). _parkHurtTick(a, P.hearts < heartsBefore); a.mi++; const to = P.st.pos[0]; const cells = Math.max(1, Math.abs(to.x - from.x) + Math.abs(to.y - from.y)); // 2026-07-30 (D5): the emphasis set used to be {deep, take, conflict} — i.e. the moments the // MIND was torn, and nothing else. So a fork got 950ms of held pause plus a 900ms commit while // the box being shoved, the ice carrying you, the water climbing went past at 240ms. The demo // is the only channel that teaches this world's vocabulary; a beat nobody can see is not taught. // FIRST OCCURRENCE ONLY. The second identical shove teaches nothing the first did not, and // paying 900ms for each would double the demo. So each beat KIND buys one long frame, which // bounds the added time at (number of distinct beats) x PARK_EVENT_MS. const newBeat = _parkFirstBeat(a, P.st.park, P.st.fx.slice(n0)); const emphasis = a.cue.deep || a.cue.take || a.cue.notice || a.cue.delegate || conflict || newBeat; // 파랑 발견만 길게 (2026-08-05). 분홍 발견을 늘리면 그 뒤 의사결정 장면까지 밀린다. if (a.cue.notice && a.cue.notice.seat === 0) return PARK_NOTICE_HOLD_MS; return emphasis ? PARK_EVENT_MS : Math.max(PARK_STEP_MS, cells * PARK_PER_CELL_MS); // uniform per-cell glide speed for routine slides; a longer dwell ONLY on the reads that matter. } // The MOVEMENT VERBS' beats. PARK_FIELD_CLOCK is keyed by fieldMech, but a crossing's DEMO leg // usually has no field at all — its mechanism is a verb (y50/y23/y22 demo on Sokoban push, y46 // on the ice slide), and those legs are exactly where the vocabulary has to land. Declared here in // the same spirit as the clock registry: a named list, not an inline literal, so the day a third // verb ships there is one place that is obviously missing it. const PARK_MOVE_BEATS = ['push', 'slide']; // _parkFirstBeat(a, park, fx) — did this frame emit a mechanism beat kind not yet seen this demo? // Reads the board's own declared vocabulary (PARK_FIELD_CLOCK[fieldMech].beats) plus the verbs. function _parkFirstBeat(a, park, fx) { if (!fx || !fx.length) return false; const entry = park.fieldMech && PARK_FIELD_CLOCK[park.fieldMech]; const vocab = (entry ? entry.beats : []).concat(PARK_MOVE_BEATS); if (!vocab.length) return false; if (!a._beatsSeen) a._beatsSeen = new Set(); let first = false; for (const f of fx) { if (vocab.indexOf(f.k) === -1 || a._beatsSeen.has(f.k)) continue; a._beatsSeen.add(f.k); first = true; } return first; } // demo done -> Discovery record + handoff to the interactive GAME (same lifecycle as legacy finishDemo). // The three park game drivers (finishParkDemo / parkGameInput / parkGameOver) are consts, NOT // function declarations: a classic script's top-level functions auto-land on window, and these // must stay off the console unless the capture flag exposes them (spec P2 §E). const finishParkDemo = () => { const run = G.campaign; C.stepDemo(run); // records legacy Discovery; demo-never-charged invariant C.handoff(run); // demo -> play (stage='play'); draw() now renders the park GAME G.parkAnnot = null; // §C.3: the highlight layer NEVER crosses the handoff into the game // HANDOFF CEREMONY (P3a spec §2, display-only): a DIM assemble veil (single-board — the // old crossfade blended two boards, fix R1 #3; kept translucent so the sampled frame never // reads as a death/reset blackout, fix R2 #2), the fresh park assembles as it recedes, the // hearts GROW IN to refill (drawParkHUD), and "your turn" chevrons pulse until the FIRST input. G.parkAnim = { mode: 'game', cue: null, ceremony: Date.now(), awaitInput: true }; _parkReplayStart(); // 옆판의 미니 시연 리플레이 (표시 전용) setHint(''); draw(); }; /* ===================== PARK — GAME (same persona, death channel) driver ===================== */ // The player LIVES the demonstrated persona on a fresh public sub-seed park. Every directional key // routes to the campaign resolver (the ONLY mutation site: the violation score-flash tally + the // universal deep-entry heart + the death/complete/cap terminals live inside campaign/engine); // app.js only reads the resolved record for the frame's cues. ONE driver serves the capstone AND // the active minigame: run.park.task selects the runtime slot + resolver (C.parkTaskMove judges // minigames / scores the M7 continuation) + the terminal (a task ends its EPISODE, not the run). // _parkHurtTick(a, tookHit) — the ONE afterglow rule, shared by the demo replay and live play so // the two surfaces can never drift into two different memories of damage. // `tookHit` is a HEART DELTA, deliberately: whatever spent the heart — the deep meadow, the doll's // gaze, a charge, a balloon, the rising water — earns the same four frames. Keying on a hand-kept // list of fx kinds instead is exactly the shape that goes quietly stale when a mechanic is added. // WHERE it happened needs no separate field: `cue.hurt` draws the cracked heart over the agent, // and the agent is standing on the cell that billed them. // `cue.hurt` stays false on the frame the deep-entry cue itself is up (that frame already draws // its own read); it carries the three frames after. function _parkHurtTick(a, tookHit) { if (tookHit) a.hurt = 4; else if (a.hurt > 0) a.hurt -= 1; a.cue.hurt = !a.cue.deep && a.hurt > 0; } const parkGameInput = (mvKey) => { const run = G.campaign, a = G.parkAnim; if (!run || !run.park || !a || a.mode !== 'game' || a.ending) return; if (a.bombIntro) return; // y20: pink's blocked-claim opener owns the stage a.awaitInput = false; // first input ends the your-turn ceremony cue const t = run.park.task; // active minigame (null = the P1 capstone) const P = t ? t.game.P : run.park.game.P; const over = t ? parkTaskOver : parkGameOver; if (P.over) return over(); const from = { ...P.st.pos[0] }, n0 = P.st.fx.length; const ghost = parkGhostCell(P, mvKey); const heartsBefore = P.hearts; // D4 — the same rule as the demo const rec = t ? C.parkTaskMove(run, mvKey) : C.parkGameMove(run, mvKey); if (!rec) return over(); a.fxSt = P.st; a.fxAt = n0; a.cueT0 = Date.now(); // this beat's fx window + its start (see _parkStepFx) a.cue = parkCueFor(P, from, P.st.fx.slice(n0), ghost, rec.violated); _parkHurtTick(a, P.hearts < heartsBefore); // hurt afterglow (same read as demo) draw(); if (rec.over) { a.ending = true; if (!a._frozen) after(PARK_END_MS, over); return; } if (!a._frozen) after(PARK_CUE_MS, () => { if (G.parkAnim && G.parkAnim.mode === 'game') { G.parkAnim.cue = null; draw(); } }); }; const parkGameOver = () => { const run = G.campaign, P = run.park.game.P; run.status = 'run_over'; run.stage = 'run_over'; G.lastEnding = { status: P.reason === 'death' ? 'dead_survival' : 'cleared_cap' }; G.stage = 'report'; G.parkAnim = null; draw(); }; /* ===================== PARK — TASK BATTERY drivers (spec P2 §B; parkView-staged) ===================== */ // A minigame episode never moves run.stage — the hub/task flow is view-level (G.parkView) over the // campaign task slot (run.park.task, seated by C.runParkTask). Same const-not-function discipline // as the capstone game drivers (no window auto-landing; capture-gated exposure only). // startParkTaskDemo(t): the task's watch phase — minigames replay the oracle demo on the demo // sub-seed board; M7 replays the STRANGER's prefix walk on the PLAY board itself (control then // flips to the player mid-park via the handoff cue). Shares parkDemoFrame verbatim. const startParkTaskDemo = (t) => { G.demoAnim = null; G.parkAnnot = null; // fresh episode = fresh highlight vocabulary (§C.1) // a CROSSING's watch replay rebuilds through the crossing convention (C._parkCrossBoard — // the exact board runParkCrossing computed t.demo.moves on). makeParkTask cannot build a // P1 module demo leg: on xs's boxpad/push cell it silently built a WALK board, so the watch // replayed sokoban moves with no box on screen. Report (drawParkTaskReportBoard) and hub // thumbnails already ride the convention; this was the one build site left off it. const cell = t.observer ? t.playCell : t.demoCell; const P = E.parkStart(t.crossing ? C._parkCrossBoard(t.tile.kind, cell) : E.makeParkTask(t.tile.kind, cell)); G.parkAnim = { mode: 'demo', task: true, P, moves: t.demo.moves, mi: 0, paused: false, claimPending: false, cue: null, ff: false, end: null, // task 11: S1 fast-forward / S2 static end-frame conflictTurns: new Set((t.demo.awards || []).map(w => w.turn)), // CN(안전 vs 배려) 시상 턴만 따로 — 곁눈질은 이 박자에만 걸린다. careTurns: new Set((t.demo.awards || []).filter(w => w.pair === 'CN').map(w => w.turn)) }; draw(); parkDemoTick(); }; // watch done -> the control flip, announced by the SAME ceremony as the capstone (assemble // veil + hearts fly-in + your-turn chevrons — the crossfade snapshot is gone, fix R1 #3): on // M7 the same park continues under player control ("you are them now"); a minigame morphs demo // board -> fresh play board. No campaign call — the task was seated whole. const finishParkTaskDemo = () => { G.parkView = 'play'; G.parkAnnot = null; // §C.3: the highlight layer NEVER crosses the handoff into the game const t = G.campaign && G.campaign.park && G.campaign.park.task; const P = t && t.game && t.game.P; const scene = P && P.st.park.bombScene; // same handoff ceremony as the capstone (P3a §2): hearts fly-in + your-turn chevrons. G.parkAnim = { mode: 'game', task: true, cue: null, ceremony: Date.now(), awaitInput: true, bombIntro: scene ? { index: 0, phase: 'approach' } : null }; _parkReplayStart(); // 옆판의 미니 시연 리플레이 (표시 전용) if (scene) { scene.pinkClaimed = false; scene.pinkNudge = null; scene.crateHit = null; // 지난 에피소드의 마크가 남아 있지 않게 P.st.pos[1] = { ...scene.introPath[0] }; } draw(); if (scene) after(420, parkBombIntroStep); }; // PINK'S BLOCKED CLAIM — the opener, one phase per timer (design 2026-08-05). // The guided stage exists to teach one sentence: wood does not open without a bomb. Until now the // board only ever said the first half of it — what she WANTS — and stopped. These phases say the // rest: she walks to the single door into her own gem's room, hits it three times, and goes back to // her seat having got nothing. The lesson arrives before the player has spent a turn on it. // DISPLAY ONLY. Nothing here calls the engine tick, so no turn is billed, and the state this leaves // behind is the same state the old three-step opener left: (1,5), facing north, claimed. const PARK_BOMB_BUMPS = 3; // 2 reads as "tried once and gave up"; 4+ only costs time const PARK_BOMB_BUMP_CELL = { x: 2, y: 5 }; // the walkway square she strikes the door from const PARK_BOMB_BUMP_OFF = { dx: 0, dy: -0.35 }; // how far the strike shoves her, in cells const PARK_BOMB_ROCK = 0.06; // how far the wood rocks, in cells. Small: it HOLDS. const PARK_BOMB_NOTICE_MS = 900; // 발견 말풍선이 혼자 서 있는 시간 const PARK_BOMB_CLAIM_MS = 700; // 찜 링이 붙고 팝이 도는 시간 const PARK_CLAIM_POP = 0.35; // 링 반지름의 팝 폭. 튜토리얼 보석 팝과 같은 수다 (한 어휘) // parkBombIntroFinish(P, scene, a): the opener's end state, in one place. Both the last phase and // the frozen-capture jump go through here, so the two can never drift into two different endings. function parkBombIntroFinish(P, scene, a) { P.st.pos[1] = { ...scene.pinkWait }; P.st.facing[1] = { dx: 0, dy: -1 }; scene.pinkClaimed = true; scene.pinkNudge = null; scene.crateHit = null; scene.claimPop = null; a.cue = null; a.bombIntro = null; a.ceremony = Date.now(); draw(); } function parkBombIntroStep() { const run = G.campaign, a = G.parkAnim; const t = run && run.park && run.park.task; const P = t && t.game && t.game.P; const scene = P && P.st.park.bombScene; if (!a || a.mode !== 'game' || !a.bombIntro || !scene) return; // CAPTURE (tools/cap-*.mjs): the harness pins _frozen on the anim it gets back from // finishParkTaskDemo, so this is the FIRST place the flag can be seen — checking it inside // finishParkTaskDemo would always read false. Jump to the end rather than stalling: a stalled // bombIntro locks parkGameInput forever, and the harness drives play through exactly that door. if (a._frozen) return parkBombIntroFinish(P, scene, a); const intro = a.bombIntro; const walkTo = (next) => { const from = P.st.pos[1]; P.st.facing[1] = { dx: Math.sign(next.x - from.x), dy: Math.sign(next.y - from.y) }; P.st.pos[1] = { x: next.x, y: next.y }; }; if (intro.phase === 'approach') { // ① 발견 — 말풍선만. 찜은 아직이다 intro.index = Math.min(1, scene.introPath.length - 1); walkTo(scene.introPath[intro.index]); intro.phase = 'claim'; a.cue = { notice: { seat: 1 } }; draw(); return after(PARK_BOMB_NOTICE_MS, parkBombIntroStep); } if (intro.phase === 'claim') { // ② 찜 — 보석에 테두리가 붙고 한 번 커졌다 돌아온다 scene.pinkClaimed = true; scene.claimPop = Date.now(); intro.phase = 'walk'; draw(); return after(PARK_BOMB_CLAIM_MS, parkBombIntroStep); } a.cue = null; if (intro.phase === 'walk') { // ③ 자기 자리까지 마저 걷는다 if (intro.index < scene.introPath.length - 1) walkTo(scene.introPath[++intro.index]); if (intro.index >= scene.introPath.length - 1) intro.phase = 'stepOut'; draw(); return after(360, parkBombIntroStep); } if (intro.phase === 'stepOut') { // ④ 상자 앞 칸으로 나선다 walkTo(PARK_BOMB_BUMP_CELL); P.st.facing[1] = { dx: 0, dy: -1 }; // 그리고 문을 마주 본다 intro.phase = 'bump'; intro.bumps = 0; intro.shoved = false; draw(); return after(360, parkBombIntroStep); } if (intro.phase === 'bump') { // ⑤ 세 번 부딪힌다 — 한 번이 밀림 프레임 + 복귀 프레임 if (!intro.shoved) { scene.pinkNudge = { ...PARK_BOMB_BUMP_OFF }; // 흔들림 부호를 충돌 번호에서 뽑는다: 벽시계(_pulseGlow)에서 유도하면 같은 연출 // 프레임이 캡처마다 다른 각도로 찍힌다. scene.crateHit = { key: P.st.park.bomb.face.cage, dx: (intro.bumps % 2 ? -PARK_BOMB_ROCK : PARK_BOMB_ROCK) }; intro.shoved = true; } else { scene.pinkNudge = null; scene.crateHit = null; intro.shoved = false; if (++intro.bumps >= PARK_BOMB_BUMPS) intro.phase = 'back'; } draw(); return after(130, parkBombIntroStep); } if (intro.phase === 'back') { // ⑥ 아무것도 못 얻고 자기 자리로 돌아간다 walkTo(scene.pinkWait); intro.phase = 'done'; draw(); return after(360, parkBombIntroStep); } parkBombIntroFinish(P, scene, a); // ⑦ 끝 — 남기는 상태는 옛 오프너와 같다 } // task episode over -> the row was stored by C.parkTaskMove on the final move. A SESSION // episode (P8.6 §B.2) routes to the glyph INTERSTITIAL (no full readout mid-session); a // hub-launched practice episode keeps its full report view (연습·열람 — unscored browsing). const parkTaskOver = () => { const run = G.campaign; const t = run && run.park && run.park.task; // WHICH LEDGER IS THIS EPISODE ON? A transfer episode carries t.transfer; a crossing episode // carries t.crossing. Both chain through the interstitial when a session of THEIR OWN kind is // live — and a crossing played from the picker (no session) still falls straight to its readout, // which is what the hub's 연습·열람 promise means (design 2026-07-31). const s = _sessLedgerOf(run, t); const inSession = (s && !s.done && s.ids[s.ids.length - 1] === t.tile.id) ? s : null; if (inSession) { // cache the finished runtime for the scorecard's mini-board strip (view-only: the board // state + walked path; the SCORES come from the report's pure row read). G.sessionStrip = G.sessionStrip || {}; G.sessionStrip[t.tile.id] = { st: t.game.P.st, path: t.game.P.path.slice() }; return startParkInterstitial(); } G.parkView = 'report'; G.parkAnim = null; draw(); }; /* ===================== PARK — RANDOM TRANSFER FLOW (spec 2026-07-05 §B.1/§B.2) ===================== startParkTransfer(): begin (or chain to the NEXT) RANDOM transfer episode — the DEFAULT entry once the tutorial is done (▶ routes here) and the readout's Enter/click continuation. The pair (persona, demoCell, playCell) is seated by C.runParkTransfer — a pure function of the run seed + episode counter (TRANSFER-C1); the episode then rides the EXISTING task lifecycle verbatim: watch replay on the demoCell (startParkTaskDemo/parkDemoFrame) -> handoff ceremony (finishParkTaskDemo) -> judged play on the playCell (parkGameInput/C.parkTaskMove) -> readout (parkTaskOver). View-level only — no new scoring path; the deliberate hub stays reachable via the corner chip (watch + readout surfaces) and its tiles keep their distance-0 semantics. */ const startParkTransfer = () => { const run = G.campaign; if (!run || !run.park) return; clearTimers(); G.xfade = null; // a stale crossfade never composites over the fresh episode const t = C.runParkTransfer(run); if (!t) return; G.parkView = 'demo'; startParkTaskDemo(t); }; /* ===================== PARK — 10-EPISODE SCORED SESSION (P8.6 spec §B) ===================== ▶ starts a SESSION: C.PARK_SESSION_N (=10) random transfer episodes chained through the campaign's session sequencer (C.startParkSession / C.parkSessionAdvance — app.js adds NO scoring). Between episodes: ONE glyph interstitial line for ~1.5s (ending glyph + discovery pip + N-of-10 counter pips; Enter skips — §B.2, no full readout mid-session). After exactly N: the final SCORECARD (report channel) consumes C.parkSessionReport — a pure read over the N stored rows. The hub is demoted to the 연습·열람 corner mode (§B.4): the session is the only scored path in the UI. */ const PARK_INTERSTITIAL_MS = 1500; // TWO SESSIONS, TWO LEDGERS (design 2026-07-31). The TRANSFER session draws generic boards; the // CROSSING session walks the shipped designed cells. They are separate on purpose — mixing them // would turn discovery/maintenance from a function of transfer distance into a function of cell // difficulty, and every session figure ever recorded would stop being comparable. The campaign // keeps them on separate slots (run.park.session vs run.park.xsession); this flag is the view's // half of that split, and these three helpers are the ONLY places that ask which one is running. // G.parkXSession is a VIEW field: it selects a reader, never a score. const _xs = () => !!G.parkXSession; const _sessLedger = (run) => (_xs() ? run.park.xsession : run.park.session); // _sessLedgerOf(run, t): the ledger THIS EPISODE belongs to, asked of the episode rather than of // the view. A transfer episode carries t.transfer, a crossing episode carries t.crossing, and a // crossing played from the picker belongs to neither — that one falls to its own readout, which is // what the hub's 연습·열람 promise means. Everything that needs "which session is this" comes // through here or through _sessLedger, so no screen can decide for itself and decide wrong. const _sessLedgerOf = (run, t) => { if (!t || !run || !run.park) return null; if (t.transfer && !t.crossing) return run.park.session || null; if (t.crossing && _xs()) return run.park.xsession || null; return null; }; const _sessReport = (run) => (_xs() ? (C.parkCrossingSessionReport ? C.parkCrossingSessionReport(run) : null) : (C.parkSessionReport ? C.parkSessionReport(run) : null)); // _sessOtherReport(run): the OTHER ruler's scorecard — the mirror of _sessReport, for the one // screen that shows both. Null when that ledger has no session yet (parkSessionReport guards on // pk.session, parkCrossingSessionReport on pk.xsession), which is the common case. // Deliberately a SECOND reader rather than a flag on _sessReport: a screen that wants both has to // ask twice and print twice, and two separately-fetched rows are awkward to add together — which // is the point. Mixing the two would turn discovery/maintenance from a function of transfer // distance into a function of cell difficulty and void every session figure ever recorded. const _sessOtherReport = (run) => (_xs() ? (C.parkSessionReport ? C.parkSessionReport(run) : null) : (C.parkCrossingSessionReport ? C.parkCrossingSessionReport(run) : null)); // _sessRulerName(crossing): what to CALL the ruler on screen. parkCrossingSessionReport's contract // says every surface that prints its row must say which ruler it used; this is that sentence, in // one place so the two scorecard surfaces cannot drift apart or disagree. const _sessRulerName = (crossing) => (crossing ? '크로싱 세션 (설계된 게임)' : '전이 세션'); /* ---- END LEDGER SELECTORS. Below this line no screen may name run.park.session / run.park.xsession / C.parkSessionReport directly — PARK-XSESSION-DOOR scans for it. ---- */ // begin a session: seat episode 1 via the campaign sequencer and enter its watch phase. // `crossing` picks the designed-cell ledger; everything downstream is shared. const parkSessionBegin = (crossing) => { const run = G.campaign; if (!run || !run.park) return; const want = !!crossing; const startFn = want ? C.startParkCrossingSession : C.startParkSession; if (!startFn) return parkHubEnter(); // older campaign bundle: fall back to the hub clearTimers(); G.xfade = null; G.parkInter = null; G.sessionStrip = {}; G.parkXSession = want; const s = startFn(run); if (!s || !run.park.task) { G.parkXSession = false; return; } G.parkView = 'demo'; startParkTaskDemo(run.park.task); }; // the §B.2 mid-session interstitial — view-level only (the row is already stored). Its data // is read once off the finished row: ending reason, the discovery pip (blind MAP == the // demonstrated order), and the episode counter. Auto-advances after ~1.5s; Enter/click skip. const startParkInterstitial = () => { const run = G.campaign, s = _sessLedger(run), t = run.park.task; const row = run.park.results[t.tile.id]; G.parkInter = { reason: (row && row.reason) || 'cap', disc: !!(row && row.posterior && row.posterior.map && row.demonstrated && row.posterior.map.join('>') === row.demonstrated.join('>')), idx: s.ids.length, n: s.n, }; G.parkView = 'interstitial'; G.parkAnim = null; draw(); after(PARK_INTERSTITIAL_MS, parkSessionNext); }; // advance out of the interstitial: next episode, or — exactly after N — the SCORECARD. const parkSessionNext = () => { const run = G.campaign; if (!run || !run.park || stageKey() !== 'interstitial') return; clearTimers(); G.parkInter = null; G.xfade = null; const t = _xs() ? C.parkCrossingSessionAdvance(run) : C.parkSessionAdvance(run); if (t) { G.parkView = 'demo'; return startParkTaskDemo(t); } G.parkView = 'scorecard'; draw(); }; /* ===================== PARK — PRACTICE-YARD TUTORIAL (P3a spec 2026-07-03 §1) ===================== An interactive micro-tutorial BEFORE the first demo: four beats (T1 MOVE / T2 GEM / T3 DANGER / T4 COMPANION), each completed by the player's ACTION — a timer NEVER advances a beat (timers only animate: the T4 companion walk and the T3 heart regeneration are display motion, the beat cursor moves on input alone). It runs on its OWN fixed practice board (_tutBoard — a CONSTANT: no seed stream, no persona anywhere → rule-invariant, C1) through the real E.parkStart/parkStep physics, so what it teaches is the real grammar; NOTHING it does touches the run — no C.* mutation, no run.park state, no generator cache — so the run's measurement is byte-identical whether the tutorial was played or skipped (TUTORIAL-UNSCORED gate). Zero-text: chevrons / pips / glyphs only (PARK-ZERO-TEXT covers the tutorial frames). EVERY-VISIT (design 2026-07-10): every boot lands on Act 1 — the old localStorage return-skip is REMOVED (a shared/kiosk machine silently skipping the park's only teaching surface was a worse failure than a 3-key re-skip for a returner). The PROMINENT skip chip (top-right, drawn from the very first frame) whole-skips to the picker; Escape whole-skips and any key skips one act as before; the hub's practice chip replays. */ const TUT_REGEN_MS = 900; // T3: the cracked heart visibly restores after this dwell const TUT_WALK_MS = 320; // T4: companion walk animation pace (display-only) // _tutYard(laneAxis, ...): the ONE constant-yard grammar every tutorial board is built from — // N=11, wall border, walkway = border ring + ONE mid lane (laneAxis 'row' = y=5 / 'col' = x=5), // verge = the 1-cell band beside the walkway, deep = the rest (two thin bands wrapped in verge). // A PURE FUNCTION OF ITS CONSTANT ARGUMENTS (no seed stream, no persona, trig:0 keeps the // companion idle-deterministic) — every board below is a constant, C1 like _tutBoard always was. function _tutYard(laneAxis, clusters, chain, contracts, spawn, coSpawn, retire) { const n = 11; const isWalk = laneAxis === 'col' ? (x, y) => x === 1 || y === 1 || x === n - 2 || y === n - 2 || x === 5 : (x, y) => x === 1 || y === 1 || x === n - 2 || y === n - 2 || y === 5; const wall = new Set(), walkway = new Set(), verge = new Set(), deep = new Set(); for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) { const kk = y * n + x; if (x === 0 || y === 0 || x === n - 1 || y === n - 1) { wall.add(kk); continue; } if (isWalk(x, y)) { walkway.add(kk); continue; } const nearWalk = isWalk(x - 1, y) || isWalk(x + 1, y) || isWalk(x, y - 1) || isWalk(x, y + 1); (nearWalk ? verge : deep).add(kk); } const distDeep = new Array(n * n).fill(Infinity); const q = []; for (const kk of deep) { distDeep[kk] = 0; q.push(kk); } for (let h = 0; h < q.length; h++) { const kk = q[h], x = kk % n, y = (kk / n) | 0; for (const d of [{ x: 1, y: 0 }, { x: -1, y: 0 }, { x: 0, y: 1 }, { x: 0, y: -1 }]) { const nx = x + d.x, ny = y + d.y; if (nx < 0 || ny < 0 || nx >= n || ny >= n) continue; const nk = ny * n + nx; if (distDeep[nk] > distDeep[kk] + 1) { distDeep[nk] = distDeep[kk] + 1; q.push(nk); } } } const park = { N: n, seed: 999983, k: 0, damage: 1, cautionD: 2, obsPrefix: null, walkway, verge, deep, distDeep, clusters, chain, contracts, retire, spawn, companionSpawn: { ...coSpawn }, trig: 0, cap: 400, minTurns: 0, }; return { N: n, park, goal: 'harvest_max', round: 0, hazard: new Set(deep), sacred: new Set(), wall, pos: { 0: { ...spawn }, 1: { ...coSpawn } }, carry: { 0: 0, 1: 0 }, score: { 0: 0, 1: 0 }, penalty: { 0: 0, 1: 0 }, facing: {}, tokens: clusters.map(c => ({ x: c.x, y: c.y, v: c.v, alive: true, guard: false })), zone: null, penalty_amt: 1, fx: [], swap: { used: false }, }; } // the FIXED practice yard (hands-on beats T1-T4): walkway = ring + mid row, spawn W on the // mid row, the T2 gem 4 E of spawn, a far anchor gem so the chain never completes mid- // vignette, and the companion's own contracted gem on the N ring (the T4 watch beat). function _tutBoard() { return _tutYard('row', [ { x: 6, y: 5, v: 1 }, // T2 gem (mid walkway; the chain-0 beacon rings it) { x: 9, y: 9, v: 2 }, // far anchor — the chain never completes during the vignette { x: 7, y: 1, v: 1 }, // the companion's contracted gem (T4 target, N ring) { x: 5, y: 5, v: 2 }, // an UNRINGED pair en route (fix R1 #4): T2 takes BOTH, so // plain scattered pairs read as the same gauge-filling // collectible class and the gold ring reads as "current target" ], [0, 1], [{ gem: 2, station: { x: 3, y: 1 } }], { x: 2, y: 5 }, { x: 3, y: 1 }, { x: 9, y: 2 }); } /* P4-in-tutorial FULL-CYCLE VIGNETTE (design 2026-07-10): the opening WATCH beat is now a THREE-ACT guided watch in the promo-GIF callout grammar (7f87814's EVENT-ANCHORED DEMO HIGHLIGHTS, restaged INSIDE the tutorial so real episodes stay textless — the P8.6 ANNOT-TUTORIAL-ONLY scope is UNCHANGED): Act 1 DEMO WATCH — a deterministic scripted walker plays a full goal-led cycle (21 moves) through the REAL physics on a constant yard; the callout quartet (pause ~1.9s / dim / ring / chip) fires on each FIRST event: episode open, claim-ring intro, field naming, gem pickup, goal-star retarget, companion cede, deep-field heart loss WITH the walkway-detour fork sub-caption. Hearts stay >=1 throughout (one deep entry). Act 2 TRANSFER WATCH — a SECOND constant yard with a ROTATED topology (vertical lane + column deep bands) and a different goal skin re-enacts the same walk pattern in 9 moves — wordless by construction (the vocabulary's first-per-type seen set already carries every kind), motion teaching what the static TRANSFER card states. Act 3 SCORE STRIP — a display-only glyph strip (ending glyph + hearts + gauge pips) snapshotted off the finished Act-2 runtime: "a cycle ends in a scorecard". Pure view; run.park/results are NEVER touched (TUTORIAL-UNSCORED byte-zero). Any key advances/skips ONE act (Escape still whole-skips); the hands-on beats T1-T4, the cards, the every-visit boot (2026-07-10) and the hub replay chip are unchanged. */ // Act 1 board: horizontal lane, 3-leg chain around the yard, the companion parked ON the // lane at (7,5) (trig:0 = idle) so the cede scene is a legible step-aside around it. function _tutActOneBoard() { return _tutYard('row', [ { x: 4, y: 5, v: 1 }, // t0 free gem (unringed) -> the 'gem' vocabulary chip { x: 6, y: 5, v: 1 }, // t1 chain leg 0 (beacon) -> taking it = star retarget { x: 9, y: 9, v: 2 }, // t2 chain leg 1 (SE corner, ACROSS the deep band) { x: 1, y: 9, v: 2 }, // t3 chain leg 2 (SW corner, the closing leg) { x: 7, y: 1, v: 1 }, // t4 companion's contracted gem (claim ring, N ring) ], [1, 2, 3], [{ gem: 4, station: { x: 7, y: 5 } }], { x: 2, y: 5 }, { x: 7, y: 5 }, { x: 9, y: 2 }); } // Act 2 board: VISIBLY different topology (vertical lane, deep bands are columns) + a // different goal skin (2-leg chain, different values) — the transfer lesson's second stage. function _tutActTwoBoard() { return _tutYard('col', [ { x: 5, y: 6, v: 1 }, // t0 chain leg 0 (south, down the lane) { x: 9, y: 5, v: 2 }, // t1 chain leg 1 (E ring, ACROSS the vertical deep band) { x: 1, y: 5, v: 1 }, // t2 companion's contracted gem (W ring) ], [0, 1], [{ gem: 2, station: { x: 6, y: 7 } }], { x: 5, y: 2 }, { x: 6, y: 7 }, { x: 9, y: 2 }); } // the FIXED walker scripts (probe-verified 2026-07-10: every move legal through the real // physics, hearts >= 1 at every step — exactly ONE deep entry per act, never death — and // both traces byte-identical across fresh builds). Act 1 event schedule (1-based move): // m1 field naming (verge), m4 gem, m6 star retarget (leg-0 take), m7 cede, m8 deep+fork, // m21 final leg -> 'complete'. Act 2: m4 gem+retarget, m5 cede, m6 deep+fork, m9 complete. const TUT_ACT1_MOVES = ['D','U','R','R','R','R','D','D','D','D','R','R','R','L','L','L','L','L','L','L','L']; const TUT_ACT2_MOVES = ['D','D','D','D','R','R','R','R','U']; const TUT_STRIP_MS = 900; // Act 3: one score-strip hold frame's dwell const TUT_STRIP_FRAMES = 3; // Act 3: display-only hold frames before the handoff // begin (or replay) the tutorial. View-level only: G.parkTut + parkView; the run is untouched. // Beat 0 = the 3-ACT guided watch (auto-plays; any key advances ONE act, Escape whole-skips); // beats 1-4 stay action-gated. const startParkTutorial = () => { clearTimers(); G.demoAnim = null; G.parkAnim = null; G.xfade = null; G.stage = null; const P = E.parkStart(_tutActOneBoard()); P._lean = true; // the vignette is never award-scanned (unscored by design) G.parkAnnot = null; // stale highlights never survive into a fresh vignette // card 1 = the order-reading card, card 2 = the TRANSFER card (§C.4) — click advances, // any key dismisses; the beats below stay action-gated exactly as before. G.parkTut = { P, beat: 0, moved: 0, echo: null, cue: null, hurt: 0, regenAt: 0, regenFx: 0, ready: false, card: 1, handoff: false, strip: null, watch: { act: 1, moves: TUT_ACT1_MOVES, mi: 0, opened: false, claimed: false } }; G.parkView = 'tutorial'; draw(); // 시연은 여기서 시작하지 않는다 (2026-08-05): 카드1이 유일한 문이고, 워치 예약은 // parkTutCardNext 로 옮겼다. 카드가 "곧 지켜보게 됩니다"라고 말하는 동안 주민이 // 이미 걷고 있으면 그 문장이 거짓말이 된다. }; // _tutDeepMove(P, mv): pre-step read — does this scripted move ENTER the deep field (a // deep-entry crossing, i.e. a fork)? Pure public geometry, display staging only. function _tutDeepMove(P, mv) { const st = P.st; const d = { U: { x: 0, y: -1 }, D: { x: 0, y: 1 }, L: { x: -1, y: 0 }, R: { x: 1, y: 0 } }[mv]; if (!d) return false; const from = st.pos[0], kk = (from.y + d.y) * st.N + (from.x + d.x); return st.park.deep.has(kk) && !st.park.deep.has(from.y * st.N + from.x); } // one WATCH frame: the intro chips (open -> claim) fire on separate pre-move frames, then // each scripted step runs through the REAL physics with the same first-per-type chip // staging the demo used to carry (holder = tut, so the vocabulary is taught ONCE for the // whole tutorial — Act 2 is wordless by construction). The 'star' chip is NOT pre-fired: // it teaches AT the first live retarget (the leg-0 take), where the beacon visibly jumps — // a pre-move starIntro would mark it seen and swallow that re-teach. A deep-entry move is // a FORK: the walkway-detour ghost is computed PRE-step and rides the deep chip as the // tutorial-only fork sub-caption (the same 4-line staging as the hands-on beat 3). Act 3 // frames are pure score-strip holds. Returns the frame dwell; null = this act is over. function parkTutorialWatchFrame(tut) { const P = tut.P, w = tut.watch; if (!w) return null; if (w.act === 3) { // Act 3: display-only strip hold frames if (w.hold <= 0) return null; w.hold--; return TUT_STRIP_MS; } if (P.over || w.mi >= w.moves.length) return null; if (w.mi === 0 && !w.opened) { w.opened = true; if (_annotFire(P, { open: { ...P.st.pos[0] } }, tut, null)) return ANNOT_MS; } if (w.mi === 0 && !w.claimed) { w.claimed = true; const ct = P.contract < P.st.park.contracts.length ? P.st.tokens[P.st.park.contracts[P.contract].gem] : null; if (ct && ct.alive && _annotFire(P, { claimIntro: { x: ct.x, y: ct.y } }, tut, null)) return ANNOT_MS; } const from = { ...P.st.pos[0] }, n0 = P.st.fx.length; const goalFrom = _parkGoalXY(P); const mv = w.moves[w.mi]; const forkGhost = _tutDeepMove(P, mv) ? _parkDetourGhost(P, mv) : null; E.parkStep(P, mv); tut.cue = parkCueFor(P, from, P.st.fx.slice(n0), null, false); tut.hurt = tut.cue.deep ? 4 : (tut.hurt > 0 ? tut.hurt - 1 : 0); tut.cue.hurt = !tut.cue.deep && tut.hurt > 0; w.mi++; const paused = _annotFire(P, tut.cue, tut, goalFrom); // P4 FORK-READING on the WATCH too (persona-neutral, tutorial-only): the deep crossing's // chip gains the declined walkway alternative (spotlight ghost) + the fork sub-caption. if (forkGhost && tut.cue.deep && G.parkAnnot) { const dv = G.parkAnnot.ev.find(e => e.kind === 'deep'); if (dv && !dv.ghost) { dv.ghost = forkGhost; dv.fork = true; } } if (paused) return ANNOT_MS; return (tut.cue.deep || tut.cue.take) ? PARK_EVENT_MS : PARK_STEP_MS + 140; } // the WATCH driver — auto-plays the acts; when an act ends (or is skipped) _tutActNext // seats the next one, and after Act 3 the yard RESETS to a pristine board and the hands-on // beats begin behind the tutorial handoff card ("your turn"). MUST progress under repeated // synchronous calls (the harness loops drive it with timers ignored): a null dwell hands // off within THIS call, never waiting on a timer. const parkTutorialWatchTick = () => { const tut = G.parkTut; if (!tut || tut.beat !== 0 || tut._frozen || tut.card) return; // 카드가 떠 있으면 정지 const dwell = parkTutorialWatchFrame(tut); if (dwell == null) return _tutActNext(); draw(); after(dwell, parkTutorialWatchTick); }; // _tutActNext(): the act sequencer — advance (or skip) ONE act of the beat-0 guided watch. // act1 -> seat the Act-2 transfer yard; act2 -> snapshot the DISPLAY-ONLY score strip off // the finished vignette runtime (view state only — run.park/results are never read or // written); act3 -> the existing watch-done handoff. Every seat is a FRESH constant build // (parkStep mutates the board it is handed — boards are never shared across consumers). const _tutActNext = () => { const tut = G.parkTut; if (!tut) return; const act = tut.watch ? tut.watch.act : 3; G.parkAnnot = null; // a mid-dwell chip never survives an act flip if (act === 1) { const P = E.parkStart(_tutActTwoBoard()); P._lean = true; tut.P = P; tut.cue = null; tut.hurt = 0; tut.watch = { act: 2, moves: TUT_ACT2_MOVES, mi: 0, opened: true, claimed: true }; draw(); if (!tut._frozen) after(700, parkTutorialWatchTick); return; } if (act === 2) { // Act 3: "a cycle ends in a scorecard" — a glyph strip fed by a SNAPSHOT of the // vignette's own throwaway runtime (mocked-complete if the act was skipped mid-walk). tut.strip = { reason: tut.P.reason || 'complete', hearts: tut.P.hearts, heartsMax: tut.P.heartsMax, score: tut.P.st.score[0], total: tut.P.st.tokens.reduce((s, t) => s + t.v, 0) }; tut.cue = null; tut.watch = { act: 3, hold: TUT_STRIP_FRAMES }; draw(); if (!tut._frozen) after(700, parkTutorialWatchTick); return; } tut.strip = null; _tutWatchDone(); }; // WATCH over (or skipped): fresh pristine yard for the hands-on beats. 2026-08-05 부터 // 카드2(전이 개념)가 여기서 뜨고, 그것이 물러난 뒤 §A.1 핸드오프 의식 카드가 뜬다 — // 개념을 읽는다 -> 문을 연다 -> 의식을 본다 -> 걷는다. 함수 선언인 이유는 게이트가 // brace-match 로 잘라 실행하기 때문이다. function _tutWatchDone() { const tut = G.parkTut; if (!tut) return; const P = E.parkStart(_tutBoard()); P._lean = true; tut.P = P; tut.beat = 1; tut.cue = null; tut.hurt = 0; tut.echo = null; tut.watch = null; tut.card = 2; // 전이 카드 — 예고하는 마당이 바로 이 뒤에 있다 tut.handoff = true; // 카드2가 물러난 뒤 뜬다 (syncParkHandoffCard) G.parkAnnot = null; // a mid-dwell chip never survives the board reset draw(); } // finish/skip -> land on the PICKER (P10 spec §4: the picker is the park's first-class hub). // EVERY-VISIT (2026-07-10): no return-skip flag is written — the tutorial runs on every boot // and the skip chip is the returner's one-click exit (TUTORIAL-ACTION-GATED pins the no-write). const finishParkTutorial = () => { G.parkTut = null; parkHubEnter(); }; // tutorial-only heart regeneration (T3 physics lesson without punishment): pure display state // on the throwaway practice runtime — restores the body budget and revives a practice death. const parkTutorialRegen = () => { const tut = G.parkTut; if (!tut) return; if (tut.P.hearts < tut.P.heartsMax) { tut.P.hearts = tut.P.heartsMax; tut.P.over = false; tut.P.reason = null; tut.regenFx = Date.now(); // the damage read ENDS when the mend lands (fix R1 #1): a lingering crack flash next to // a restored heart contradicted itself — clear the deep/hurt cues so board + HUD tell // ONE story (crack frame -> explicit mend frame with rays), never both at once. if (tut.cue) tut.cue = { resolved: true }; tut.hurt = 0; } tut.regenAt = 0; draw(); }; // the T4 companion walk driver: one physics turn per tick ('stay' — the companion moves on the // odd turns), until it TAKES its own gem. The tick only animates; tut.ready arms the "any input // advances" affordance — the beat itself still exits on the player's INPUT (action-gated). const parkTutorialTick = () => { const tut = G.parkTut; if (!tut || tut.beat !== 4 || tut.ready) return; const P = tut.P, n0 = P.st.fx.length; E.parkStep(P, 'stay'); tut.cue = { resolved: true }; for (const f of P.st.fx.slice(n0)) if (f.k === 'take') tut.cue.take = { x: f.x, y: f.y }; // CARE AXIS as the THIRD taught channel (P4 §C): when the little one takes ITS OWN claimed gem // (the dashed-ring companion channel), arm that event's vocabulary chip — the SAME first-per-type // teaching the goal (gem/star) and safety (deep/field) channels already earn in T2/T3. The 'take' // label names the dashed claim ring AND the take, so the watch beat teaches the companion/care // channel as a rankable motive axis, not merely "a third character walks". Display-only // (G.parkAnnot), mechanics-only label, and it advances NO beat — the T4 action gate is untouched // (the beat still exits only on the player's input). Fires at the harvest tick, long after the // T3 deep chip's dwell, so it never clips the safety lesson. if (tut.cue.take) _annotFire(P, tut.cue, tut, null); // done when the little one TOOK its gem — or when its contract is spent (e.g. the player // wandered onto that gem earlier): the beat must never dead-end the vignette. if (P.st.score[1] > 0 || P.contract >= P.st.park.contracts.length) { tut.ready = true; draw(); return; } draw(); if (!tut._frozen) after(TUT_WALK_MS, parkTutorialTick); }; // one tutorial input. Beats advance ONLY here (the ACTION gate): T1 = 2 effective moves, // T2 = the gem is harvested, T3 = a deep-field entry (crack), T4 = any input once the // companion's walk finished. Escape (or the skip chip / a completed-beat input) skips ahead. const parkTutorialInput = (key) => { const tut = G.parkTut; if (!tut) return; if (key === 'Escape') return finishParkTutorial(); // whole-tutorial skip // 카드가 떠 있는 동안 키는 "계속"이지 건너뛰기가 아니다 (2026-08-05). 이 분기가 // _tutActNext 보다 앞에 있어야 카드1을 누른 키가 1막을 삼키지 않는다. if (tut.card) return parkTutCardNext(); if (tut.beat === 0) return _tutActNext(); // any key advances/skips ONE watch act if (tut.handoff) tut.handoff = false; // first input ends the handoff card if (tut.beat === 4) { if (tut.ready) return finishParkTutorial(); // completed beat: any input advances return; // watch-only: the little one is walking } const mv = PARK_KEYMOVE[key] || ((key === '.' || key === ' ') ? 'stay' : null); if (!mv) return; const P = tut.P; if (P.over) { P.hearts = P.heartsMax; P.over = false; P.reason = null; } // practice revive const from = { ...P.st.pos[0] }, n0 = P.st.fx.length; // P4: the walkway alternative at THIS crossing (pre-step, tutorial-only) — feeds the // persona-neutral fork lesson if this move enters the deep field (beat 3 -> 4). const forkGhost = (tut.beat === 3) ? _parkDetourGhost(P, mv) : null; const rec = E.parkStep(P, mv); tut.echo = { key: mv, t0: Date.now() }; // input echo (the pressed chevron flashes) if (!rec || rec.noise) { draw(); return; } // wall bump: echo only, nothing advances tut.cue = parkCueFor(P, from, P.st.fx.slice(n0), null, false); tut.hurt = tut.cue.deep ? 4 : (tut.hurt > 0 ? tut.hurt - 1 : 0); tut.cue.hurt = !tut.cue.deep && tut.hurt > 0; // P4: teach the WHITE STAR in the yard too (was demo-only) — arm its intro on the first // move so the player learns the goal beacon BEFORE navigating. First-per-type (seen set), // so it fires once, alone (the first step takes no gem), never stacked with a later event. if (!(tut.annotSeen && tut.annotSeen.has('star'))) tut.cue.starIntro = _parkGoalXY(P); // §C.1 in the practice yard too: the first gem/deep/... of the vignette gets the same // spotlight + chip (action-paced — no playback to pause). Display-only, run untouched. _annotFire(P, tut.cue, tut, null); // P4 FORK-READING lesson (persona-neutral, tutorial-only): the deep crossing IS a fork — // the walkway alternative rides the spotlight ghost and the deep chip gains the fork // sub-caption. The real demo carries NO such label (mechanics-only there, §C.2). if (forkGhost && tut.cue.deep && G.parkAnnot) { const dv = G.parkAnnot.ev.find(e => e.kind === 'deep'); if (dv && !dv.ghost) { dv.ghost = forkGhost; dv.fork = true; } } if (mv !== 'stay') tut.moved++; if (tut.beat === 1 && tut.moved >= 2) tut.beat = 2; // T2 completes when BOTH gem lessons are taken (fix R1 #4): the en-route unringed pair // (plain gems are collectible) AND the gold-ringed beacon gem (the ring = current target). else if (tut.beat === 2 && !P.st.tokens[0].alive && !P.st.tokens[3].alive) tut.beat = 3; else if (tut.beat === 3 && tut.cue.deep) { tut.beat = 4; tut.ready = false; tut.regenAt = Date.now() + TUT_REGEN_MS; // the heart visibly restores (display) if (!tut._frozen) after(TUT_REGEN_MS, parkTutorialRegen); P.mode = 'toGem'; // the little one sets out for ITS gem if (!tut._frozen) after(600, parkTutorialTick); } draw(); }; // _parkGlanceCell(P): 곁눈질의 대상 — 몸에서 가장 가까운, 벽에 안 막힌 deep 칸. // 순수 공개 보드 읽기(C1: 인격도, 규칙도 안 읽는다). 없으면 null 이고 곁눈질은 통째로 건너뛴다. // 방향을 상수로 박지 않는 이유: 24 런 시드 38/38 에서 deep 이 위 3칸 안에 있었지만 그것은 잰 // 사실이지 보장된 불변식이 아니다. 실제 칸을 찾으면 시드가 바뀌어도 같은 프레임이 나온다. const PARK_GLANCE_REACH = 3; function _parkGlanceCell(P) { const st = P.st, n = st.N, p = st.pos[0]; for (const d of [{ dx: 0, dy: -1 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }, { dx: 1, dy: 0 }]) { for (let k = 1; k <= PARK_GLANCE_REACH; k++) { const x = p.x + d.dx * k, y = p.y + d.dy * k; if (x < 0 || y < 0 || x >= n || y >= n) break; const kk = y * n + x; if (st.wall.has(kk)) break; // 벽 너머는 안 본다 if (st.park.deep.has(kk)) return { x, y, dx: d.dx, dy: d.dy }; } } return null; } // parkCueFor(P, from, fx, ghost, violated): the decision-grammar cues of ONE resolved park move, // read off PUBLIC deltas only (C1): the deep-entry red flash + heart-crack, the companion's // gem-take yield blink, the verge safety-recoil (the mover stepped back off the brink: distDeep // rose from <=1), and the violation score-flash flag (HUD gauge channel). The walked route itself // is the always-on breadcrumb wake (drawParkCrumbs) — no per-move trail cue. function parkCueFor(P, from, fx, ghost, violated) { const st = P.st, dd = st.park.distDeep, to = st.pos[0]; const cue = { resolved: true, ghost, violated: !!violated }; for (const f of fx) { if (f.k === 'deep') cue.deep = { x: f.x, y: f.y }; if (f.k === 'take') cue.take = { x: f.x, y: f.y }; if (f.k === 'gem') cue.gem = { x: f.x, y: f.y }; // the mover's own pickup (pop animation) if (f.k === 'notice') cue.notice = { seat: f.seat, token: f.token }; if (f.k === 'delegate') cue.delegate = { x: f.x, y: f.y, token: f.token }; } const df = dd[from.y * st.N + from.x], dt = dd[to.y * st.N + to.x]; if (df <= 1 && dt > df) cue.recoil = { dx: (from.x - to.x) * 0.28, dy: (from.y - to.y) * 0.28 }; // SAFETY-vs-CARE staging (blind-judge fix R3-B: the old "stay near companion => after-you // chevrons" cue mislabeled a safety-first REFUSAL as a care gesture). Two OPPOSED reads, // both pure public position/terrain deltas (C1): // cede — the mover stepped ONTO the verge with the companion right there: a deliberate // step-aside that clears the lane (chevrons toward the passing companion; the verge // shimmer on the agent says "accepted proximity risk, no damage"). // hold — the mover STAYED while the companion is adjacent head-on: a refusal — rendered as // a half-step recoil AWAY from the companion (shrink-back), NEVER chevrons. const co = st.pos[1]; const moved = from.x !== to.x || from.y !== to.y; const near = co ? Math.abs(co.x - to.x) + Math.abs(co.y - to.y) : 99; if (moved && near <= 2 && st.park.verge.has(to.y * st.N + to.x)) cue.cede = { x: co.x, y: co.y }; else if (!moved && near === 1 && !cue.recoil) cue.recoil = { dx: (to.x - co.x) * 0.22, dy: (to.y - co.y) * 0.22 }; return cue; } // parkGhostCell(P, chosenKey): the PATH-NOT-TAKEN read at a decision cell — the strongest pull the // mover declines: an adjacent still-alive gem it passes up (the contested-grab read) first, then // the beeline neighbour toward the current chain destination when the move diverges from it, else // the safest neighbour (max distance from the deep field). Rule-BLIND: public tokens + manhattan + // the public distDeep field only, so the ghost can never leak the hidden order (C1). Display-only. function parkGhostCell(P, chosenKey) { const st = P.st, n = st.N, from = st.pos[0], park = st.park; const dest = P.dest < park.chain.length ? st.tokens[park.chain[P.dest]] : null; const mv = { U: { x: 0, y: -1 }, D: { x: 0, y: 1 }, L: { x: -1, y: 0 }, R: { x: 1, y: 0 } }; const d = mv[chosenKey]; const chosen = d ? { x: from.x + d.x, y: from.y + d.y } : { ...from }; const cands = []; for (const k of Object.keys(mv)) { const x = from.x + mv[k].x, y = from.y + mv[k].y; if (x < 0 || y < 0 || x >= n || y >= n || st.wall.has(y * n + x)) continue; if (st.pos[1] && st.pos[1].x === x && st.pos[1].y === y) continue; cands.push({ x, y }); } if (!cands.length) return null; const gem = cands.find(c => st.tokens.some(t => t.alive && t.x === c.x && t.y === c.y)); if (gem && (gem.x !== chosen.x || gem.y !== chosen.y)) return gem; const pick = (score) => cands.reduce((b, c) => (score(c) < score(b) ? c : b)); const greedy = dest ? pick(c => Math.abs(c.x - dest.x) + Math.abs(c.y - dest.y)) : null; if (greedy && (greedy.x !== chosen.x || greedy.y !== chosen.y)) return greedy; const safe = pick(c => -park.distDeep[c.y * n + c.x]); return (safe.x !== chosen.x || safe.y !== chosen.y) ? safe : null; } // _parkDetourGhost(P, chosenKey): the WALKWAY DETOUR alternative at a deep crossing — the // legal neighbour (chosen cell excluded) that stays OUT of the deep field and best shortens // the no-deep route to the current destination (E._parkFields' detour metric). // Rule-BLIND: public board geometry + public chain index only, same C1 status as // parkGhostCell; display-only. P4 (2026-07-05): consumed ONLY by the practice-yard fork // lesson now — the real demo no longer lights any fork/detour ghost (§C.2 mechanics-only). function _parkDetourGhost(P, chosenKey) { const st = P.st, n = st.N, from = st.pos[0], park = st.park; const f = E._parkFields(P); if (!f) return null; const mv = { U: { x: 0, y: -1 }, D: { x: 0, y: 1 }, L: { x: -1, y: 0 }, R: { x: 1, y: 0 } }; const d = mv[chosenKey]; const chosen = d ? { x: from.x + d.x, y: from.y + d.y } : { ...from }; let best = null, bd = Infinity; for (const k of Object.keys(mv)) { const x = from.x + mv[k].x, y = from.y + mv[k].y; if (x < 0 || y < 0 || x >= n || y >= n) continue; const kk = y * n + x; if (st.wall.has(kk) || park.deep.has(kk)) continue; if (st.pos[1] && st.pos[1].x === x && st.pos[1].y === y) continue; if (x === chosen.x && y === chosen.y) continue; const dd = f.detour[kk]; if (dd < bd) { bd = dd; best = { x, y }; } } return (best && isFinite(bd)) ? best : null; } /* ============ PARK — EVENT-ANCHORED DEMO HIGHLIGHTS (P4 spec 2026-07-05 §C) ============ Annotations ride the REAL demo playback (the promo GIF's callout grammar, live): when the replay hits an event — own gem pickup, deep entry (heart loss), companion cede, the companion's contracted take, a violation flash, the active-star retarget — the playback PAUSES on that frame (the same dwell staging as the conflict pause: parkDemoFrame just returns a longer dwell), the surroundings DIM (spotlight: dim AROUND the event area, the event cells untouched), the event cell gets a canvas ring (shapes only — PARK-ZERO-TEXT meaning unchanged), and an anchored DOM chip appears NEXT TO the cell (board->screen transform + collision-avoidance; KO + short EN). First occurrence PER EVENT TYPE per episode (a.annotSeen) — vocabulary teaching, not a per-step commentary track. §C.2 MECHANICS-ONLY (measurement integrity): every label names the OBSERVABLE EVENT only — never the hidden priority order ("이 자기는 목표>안전" 류 해독 금지). Discovery IS the measurement; how to read the order is taught once, generically, by the tutorial card. §C.3 ANNOT-DEMO-ONLY: annotLayerActive() is the ONE scope predicate — true during a demo replay frame and the practice-yard tutorial, false on the player's judged game turns, the hub, and the readout. Both renderers (canvas dim/ring + DOM chips) gate on it. All of it is view-level display state (G.parkAnnot) — no run/campaign mutation, C1. */ const ANNOT_MS = 1900; // annotated-event dwell (the playback pause) // Label copy is blind-judged (R1 2026-07-05). Every label names the MECHANIC — what the // event does to public state — never a motive read ("양보/yielded" decoded the care axis // for the player and voided the discovery measurement). Each core meter's chip carries its // STAKES inline (gauge -> the gold tick target; hearts -> zero ends the walk) so no chip // leaves a dangling category, and 'take' anchors ownership in the VISIBLE claim marker // (the companion-hue dashed ring) instead of asserting unseeable possession. 'open' is the // episode-boundary chip: it fires on the first frame of every demo replay so a sampled // sequence can never read as an unexplained loop where hearts regenerate (R1 #4) or as // ambiguous about whose inputs drive the screen (R1 #2/#8). const PARK_ANNOT_LABELS = { // P8.5 §3.4 TEXT DIET: chips are short noun phrases (ko avg target ~25, gate <=40); the // CONCEPT teaching lives in the intro/tutorial cards, not the chips. Mechanics-only // discipline unchanged — no order/first/before/prefer/rank language, KO or EN. open: { ko: '시연 시작 — 주민이 스스로 걷습니다', en: 'a demo begins — meters start fresh' }, gem: { ko: '보석 획득 — 게이지 +1 · 금색 눈금까지 채우면 목표 달성', en: 'gem taken — gauge +1' }, deep: { ko: '깊은 밭 — 하트 −1 · 하트가 0이면 산책이 끝나요', en: 'deep field — heart −1' }, // P4 ORDER-LEAK fix (2026-07-05): the fork-reading CONCEPT is taught ONCE, generically, in // the practice yard — NEVER on the real demo (§C.2 mechanics-only). Persona-neutral. fork: { ko: '갈림길 — 어느 쪽을 고르는지가 자기를 드러냅니다', en: 'a fork — the choice reveals the self' }, // CARE CHANNEL (the companion axis) — the OBSERVABLE step-aside, NEVER a values read. cede: { ko: '동료에게 양보 — 옆 칸으로 비켜났어요', en: 'yields to the companion' }, take: { ko: '동료가 점선 고리로 찜한 보석을 가져갔어요', en: 'the companion took the gem its dashed ring marks' }, violate: { ko: '틀이 붉게 번쩍 — 하트는 그대로 (별개 채널)', en: 'red frame flash — hearts untouched (a separate channel)' }, star: { ko: '흰 별 = 지금 갈 목표 · 별 옆 작은 표 = 남은 목록', en: 'white star = the current objective' }, // R2 #3 FIRST-APPEARANCE: the companion's dashed claim-ring is on screen from frame one — // it gets its own vocabulary chip at its first appearance (the 'take' chip references it). claim: { ko: '점선 고리 = 동료가 점찍은 보석', en: "the companion's claimed gem (its dashed ring)" }, // R2 #5 FIELD NAMING at the first verge approach — the family color word matches the // hazard tint (PARK_HAZARD_TINT), so '깊은 밭' is named BEFORE the damage chip needs it. field_lava: { ko: '붉은 밭 — 깊은 곳은 하트를 깎아요 (가장자리는 안전)', en: 'red field: deep costs hearts — verge safe' }, field_meadow: { ko: '초록 밭 — 깊은 곳은 하트를 깎아요 (가장자리는 안전)', en: 'green field: deep costs hearts — verge safe' }, field_ice: { ko: '푸른 얼음 밭 — 깊어도 하트는 그대로예요', en: 'ice field: even the deep part costs no hearts' }, // RELATIONAL-SAFETY NAMING (design 2026-07-06 §B.5): the hazard is the LIVE band of cells // touching the pink companion. Mechanics-only, banned-substring clean (KO or EN). rival_taboo: { ko: '붉은 칸 = 동료 옆칸, 못 지나가요 — 동료 따라 움직여요', en: 'red cells move with the companion — off-limits' }, }; function annotLayerActive() { // P8.6 §A ANNOT-TUTORIAL-ONLY (redefines #93): chips/cards/spotlight-dim render in the // TUTORIAL ONLY. The real demo is TEXTLESS — it keeps only the wordless conflict ring // (drawParkDemoRing, zero semantics); play/hub/readout/interstitials stay bare as before. const run = G.campaign; if (!run || !run.park) return false; return stageKey() === 'tutorial' && !!G.parkTut; } // _parkGoalXY(P): the CURRENT active-goal cell — the exact target the drawParkScene beacon // (white star + gold brackets) rides: collect boards = the nearest still-missing TYPE'S gem, // chain boards = the chain's current destination. Pure public state (C1); used only to // detect the star-retarget event (before/after a demo step). function _parkGoalXY(P) { const st = P.st, park = st.park; if (park.needTypes) { const got = new Set(); for (const ci of park.chain) if (!st.tokens[ci].alive) got.add(st.tokens[ci].gtype); let bd = Infinity, bt = null; for (const ci of park.chain) { const t = st.tokens[ci]; if (!t.alive || got.has(t.gtype)) continue; const d = Math.abs(st.pos[0].x - t.x) + Math.abs(st.pos[0].y - t.y); if (d < bd) { bd = d; bt = t; } } return bt ? { x: bt.x, y: bt.y } : null; } if (P.dest < park.chain.length) { const t = st.tokens[park.chain[P.dest]]; if (t.alive) return { x: t.x, y: t.y }; } return null; } // _parkChainPadLegs(P): the chain read as STAND legs — [{done}] per leg — or null when some leg is a // real pickup (i.e. on every legacy board that harvests, delivers or collects). // TWO SURFACES ASK THIS QUESTION and they must never answer it differently: the objective plaque // under the goal star (drawParkScene) and the HUD goal gauge (drawParkHUD). Both used to key on the // NAME `park.cell.goalVariant === 'reach'`, and y46 v2 is goalMech 'harvest' whose single leg is an // EXIT you stand in. So both drew a gold GEM for the one thing on that board that is explicitly not // collectable, and the gauge's fill bar — total = the sum of the token values, and every token there // is v:0 — sat empty for the whole episode no matter how well the walker played. The kind of a leg is // what its TOKEN is, not what the variant is called. // `done` honours park.chainAnyOf: y46 offers four exits for one leg and any ONE of them closes it, so // reading only chain[i] would leave both surfaces owed after the walker was already out. // Byte-identical on reach boards (all pads, no chainAnyOf -> done === !alive, exactly as before). function _parkChainPadLegs(P) { const st = P.st, park = st.park, chain = park.chain || []; if (!chain.length) return null; if (!chain.every(ci => st.tokens[ci] && st.tokens[ci].pad)) return null; return chain.map((ci, li) => { const any = (park.chainAnyOf && park.chainAnyOf[li]) || [ci]; return { done: any.some(k => st.tokens[k] && !st.tokens[k].alive) }; }); } // _annotEvents(P, cue, seen, goalFrom): the frame's UNSEEN annotatable events (first per type, // `seen` = the episode's type set), each anchored at ITS event cell. cede/violate anchor on the // acting agent (the yield/flash happens THERE); goalFrom (pre-step goal cell) arms the star read. function _annotEvents(P, cue, seen, goalFrom) { const st = P.st, ev = []; const push = (kind, cell) => { if (cell && !seen.has(kind)) { seen.add(kind); ev.push({ kind, x: cell.x, y: cell.y }); } }; push('open', cue.open); // R2 #3 FIRST-APPEARANCE intros (armed by parkDemoFrame before the first move): the star // and the claim-ring get their vocabulary chips when the symbols FIRST show, not when // they first change ('star' seen here also swallows the later retarget re-teach). push('star', cue.starIntro); push('claim', cue.claimIntro); push('gem', cue.gem); push('deep', cue.deep); push('take', cue.take); if (cue.cede) push('cede', st.pos[0]); if (cue.violated) push('violate', st.pos[0]); // R2 #5 FIELD NAMING: the walker's FIRST step onto the verge names the field family // (color word = the hazard tint) before any damage chip can reference '깊은 밭'. One // 'field' budget per episode regardless of family key; never on the episode-open frame. if (!cue.open && !seen.has('field') && st.park.verge.has(st.pos[0].y * st.N + st.pos[0].x)) { seen.add('field'); const fam = (st.park.cell && st.park.cell.hazard.kind) || 'lava'; // capstone renders lava-family push('field_' + (PARK_HAZARD_TINT[fam] ? fam : 'lava'), st.pos[0]); } // RELATIONAL-SAFETY FIRST-APPEARANCE (design 2026-07-06 §B.5): on a relational-form board the // safety hazard is the live rival-adjacency band, not a field, so it earns its own vocabulary // chip at frame one — anchored on the pink companion (the rule's live source). One budget per // episode; never on the open frame. Static-form boards never fire it (safetyForm absent/'static'). const relForm = st.park.safetyForm; if (!cue.open && !seen.has('rival_taboo') && relForm && relForm !== 'static' && st.pos[1]) { push('rival_taboo', st.pos[1]); } const gTo = _parkGoalXY(P); if (goalFrom && gTo && (goalFrom.x !== gTo.x || goalFrom.y !== gTo.y)) push('star', gTo); return ev; } // _annotFire(P, cue, holder, goalFrom): detect + arm one annotation (holder carries the per- // episode seen set: the demo anim or the tutorial state). Returns true when a pause is due. function _annotFire(P, cue, holder, goalFrom) { if (!annotLayerActive()) return false; holder.annotSeen = holder.annotSeen || new Set(); const ev = _annotEvents(P, cue || {}, holder.annotSeen, goalFrom); if (!ev.length) return false; G.parkAnnot = { N: P.st.N, ev, until: Date.now() + ANNOT_MS }; return true; } // drawParkAnnot(): the CANVAS half of a live annotation — the surrounding dim (one even-odd // hole over the padded bounding box of the event cells: dim AROUND, event area untouched) // + a white double ring on each event cell. Shapes only (zero-text); scope-gated §C.3. function drawParkAnnot() { const ann = G.parkAnnot; if (!ann || Date.now() >= ann.until || !annotLayerActive()) return; let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; for (const e of ann.ev) { x0 = Math.min(x0, e.x); y0 = Math.min(y0, e.y); x1 = Math.max(x1, e.x); y1 = Math.max(y1, e.y); if (e.ghost) { // R2 #4: the declined-detour ghost stays inside the spotlight hole x0 = Math.min(x0, e.ghost.x); y0 = Math.min(y0, e.ghost.y); x1 = Math.max(x1, e.ghost.x); y1 = Math.max(y1, e.ghost.y); } } const pad = 1.18; const hx0 = Math.max(0, (x0 - pad) * CELL), hy0 = Math.max(0, (y0 - pad) * CELL); const hx1 = Math.min(board.width, (x1 + 1 + pad) * CELL), hy1 = Math.min(board.height, (y1 + 1 + pad) * CELL); bx.save(); bx.fillStyle = 'rgba(4,6,10,0.6)'; bx.beginPath(); bx.rect(0, 0, board.width, board.height); bx.rect(hx0, hy0, hx1 - hx0, hy1 - hy0); bx.fill('evenodd'); const g = _pulseGlow(); for (const e of ann.ev) { const cx = (e.x + 0.5) * CELL, cy = (e.y + 0.5) * CELL; bx.globalAlpha = 0.9; bx.strokeStyle = '#ffffff'; bx.lineWidth = 2.2; bx.beginPath(); bx.arc(cx, cy, CELL * (0.62 + 0.06 * g), 0, 7); bx.stroke(); bx.globalAlpha = 0.45; bx.lineWidth = 1.2; bx.beginPath(); bx.arc(cx, cy, CELL * (0.82 + 0.08 * g), 0, 7); bx.stroke(); } bx.restore(); } // syncParkAnnot(): the DOM half — the anchored label chips beside their event cells. // Chips are positioned in board CSS pixels inside #annot (which maps 1:1 onto the canvas), // candidates right/left/above/below, rejecting any spot that covers an event cell or an // already-placed chip (the GIF's collision-avoidance grammar), then clamped on-stage. // Built ONCE per annotation (layer._annot identity guard); hidden the moment the scope // predicate or the dwell expires. No-ops without the #annot element (vm sandboxes). function syncParkAnnot() { const layer = document.getElementById('annot'); if (!layer) return; const ann = G.parkAnnot; const active = !!(ann && Date.now() < ann.until && annotLayerActive()); layer.classList.toggle('on', active); if (!active) { if (layer._annot) { layer.innerHTML = ''; layer._annot = null; } return; } if (layer._annot === ann) return; layer._annot = ann; layer.innerHTML = ''; const r = board.getBoundingClientRect(); const cellPx = r.width / ann.N; const placed = []; const cellRects = ann.ev.map(e => ({ x: e.x * cellPx, y: e.y * cellPx, w: cellPx, h: cellPx })); const hit = (a, b) => !(a.x + a.w < b.x || b.x + b.w < a.x || a.y + a.h < b.y || b.y + b.h < a.y); // R2 #8 SMALL-BOARD GUTTER: on small boards (practice yard / minigame cells) a chip // outsizes several cells, so two simultaneous chips could only overlap the board and // each other. The border ring is always the tree wall (no play information), so small // boards gain gutter candidates along the top/bottom wall bands, and the saturated // fallback stacks INTO the bottom gutter instead of over live cells. const smallBoard = ann.N <= 12; for (const e of ann.ev) { const lab = PARK_ANNOT_LABELS[e.kind]; if (!lab) continue; // P4: the TUTORIAL deep chip carries the fork sub-caption (persona-neutral fork lesson); // the real demo sets no e.fork, so its deep chip stays mechanics-only (no order leak). const extra = (e.kind === 'deep' && e.fork) ? PARK_ANNOT_LABELS.fork : null; const w = Math.min(285, Math.max(150, Math.round(lab.ko.length * 12.5) + 26)); const h = (lab.ko.length > 20 ? 78 : 44) + (extra ? (extra.ko.length > 20 ? 60 : 34) : 0); // wrap + fork sub-caption boxes const cx = (e.x + 0.5) * cellPx, cy = (e.y + 0.5) * cellPx; const gap = cellPx * 0.95; const cands = [ { x: cx + gap, y: cy - h / 2 }, // right of the cell { x: cx - gap - w, y: cy - h / 2 }, // left { x: cx - w / 2, y: cy - gap - h }, // above { x: cx - w / 2, y: cy + gap }, // below { x: cx + gap, y: cy + gap }, // diagonal fallback ]; if (smallBoard) cands.push( // wall-band gutter (bottom first, then top) { x: cx - w / 2, y: r.height - h - 4 }, { x: 4, y: r.height - h - 4 }, { x: r.width - w - 4, y: r.height - h - 4 }, { x: cx - w / 2, y: 4 }, { x: 4, y: 4 }, { x: r.width - w - 4, y: 4 }); let rect = null; for (const c of cands) { const rr = { x: Math.max(2, Math.min(c.x, r.width - w - 2)), y: Math.max(2, Math.min(c.y, r.height - h - 2)), w, h }; if (!placed.concat(cellRects).some(p => hit(rr, p))) { rect = rr; break; } } if (!rect) { // saturated const base = placed[placed.length - 1]; rect = smallBoard // small board: stack upward from the bottom gutter (never over live cells) ? { x: Math.max(2, Math.min(cx - w / 2, r.width - w - 2)), y: Math.max(2, (base ? base.y - h - 6 : r.height - h - 4)), w, h } : { x: Math.max(2, Math.min(cx + gap, r.width - w - 2)), y: Math.max(2, Math.min((base ? base.y + base.h + 6 : cy + gap), r.height - h - 2)), w, h }; } placed.push(rect); const chip = document.createElement('div'); chip.className = 'annot-chip'; chip.innerHTML = '' + lab.ko + '' + lab.en + '' + (extra ? '' + extra.ko + '' + extra.en + '' : ''); chip.style.left = Math.round(rect.x) + 'px'; chip.style.top = Math.round(rect.y) + 'px'; chip.style.maxWidth = w + 'px'; layer.appendChild(chip); } } // syncParkTutCards(): the §C.4 tutorial cards — DOM-only, tutorial-scoped, click-to-advance // (card 1 = order-reading, card 2 = TRANSFER), any tutorial key dismisses (the practice // beats stay ACTION-gated and unscored — the cards never intercept the input machine). function syncParkTutCards() { const wrap = document.getElementById('tutCards'); if (!wrap) return; const tut = G.parkTut; const card = (tut && stageKey() === 'tutorial' && tut.card) || 0; wrap.classList.toggle('on', card > 0); const c1 = document.getElementById('tutCardOrder'); const c2 = document.getElementById('tutCardTransfer'); if (c1) c1.classList.toggle('on', card === 1); if (c2) c2.classList.toggle('on', card === 2); } // syncParkHandoffCard(): P8.6 §A.1 — the handoff CONCEPT card is TUTORIAL-ONLY now. It // shows once, at the tutorial's WATCH->hands-on handoff (tut.handoff, dismissed by the // first input), teaching what the real ceremony will MEAN. The REAL episode handoff keeps // only the wordless ceremony (assemble veil + hearts fly-in + your-turn chevrons) — no // card, no text on any measured surface. function syncParkHandoffCard() { const el = document.getElementById('handoffCard'); const tut = G.parkTut; // !tut.card: 카드2가 떠 있는 동안은 숨는다 (2026-08-05). 둘은 같은 박자에 놓이지만 // 카드2는 개념이고 이것은 의식이다 — 겹치면 의식이 설명에 묻힌다. const on = !!(tut && stageKey() === 'tutorial' && tut.handoff && !tut.card && tut.beat === 1 && tut.moved === 0); if (el) el.classList.toggle('on', on); } // parkTutCardNext(): 카드를 내리고 그 카드가 지키던 문을 연다 (2026-08-05). // 카드1 -> 시연(beat 0 워치)을 예약한다. // 카드2 -> 아무것도 예약하지 않는다. 연습 마당은 _tutWatchDone 이 이미 앉혀 놨고, // 다음 박자는 #handoffCard 이며 그것은 syncParkHandoffCard 가 그린다. // 함수 선언인 이유: PARK-TUT-* 게이트가 brace-match 로 잘라 실행한다. function parkTutCardNext() { const tut = G.parkTut; if (!tut || !tut.card) return; const wasFirst = tut.card === 1; tut.card = 0; if (wasFirst) after(700, parkTutorialWatchTick); draw(); } (function wireTutCards() { for (const id of ['tutCardOrder', 'tutCardTransfer']) { const el = document.getElementById(id); if (el && el.addEventListener) el.addEventListener('click', parkTutCardNext); } // 스크림 클릭: 보드 우상단의 건너뛰기 칩은 재방문자의 1클릭 출구이고 캔버스에 // 그려진다. 스크림이 그것을 삼키면 안 되므로 좌표를 보드계로 환산해 갈라 준다. const sc = document.getElementById('tutScrim'); if (sc && sc.addEventListener) sc.addEventListener('click', (e) => { const r0 = board.getBoundingClientRect(); const px = (e.clientX - r0.left) / r0.width * board.width; const py = (e.clientY - r0.top) / r0.height * board.height; const sr = _tutSkipRect(); if (px >= sr.x && px <= sr.x + sr.w && py >= sr.y && py <= sr.y + sr.h) return finishParkTutorial(); parkTutCardNext(); }); })(); // parkShortcutTrace(P): the foregone-shortcut read (blind-judge fix R3-A2). Non-null exactly when // a strictly-shorter route from the mover to the CURRENT chain destination crosses the deep field: // { cells } = the beeline (fast-field greedy descent) from the mover up to AND INCLUDING the first // deep cell (the field-entry cell that would cost ♥). Rule-BLIND environment knowledge — public // board geometry + public position + public chain index only (E._parkFields is seed geometry), so // the trace is an identical function for every persona (C1) and can never leak the hidden order. function parkShortcutTrace(P) { const st = P.st, n = st.N, park = st.park; if (P.dest >= park.chain.length) return null; const t = st.tokens[park.chain[P.dest]]; if (!t || !t.alive) return null; const pk = st.pos[0].y * n + st.pos[0].x; if (park.deep.has(pk)) return null; // already inside — no counterfactual to price const f = E._parkFields(P); if (!f || !isFinite(f.fast[pk])) return null; // walkway/verge-only step distance from the destination (deep excluded): the detour price. const dist = new Array(n * n).fill(Infinity); const dk = t.y * n + t.x; dist[dk] = 0; const q = [dk]; for (let h = 0; h < q.length; h++) { const kk = q[h], x = kk % n, y = (kk / n) | 0; for (const d of E.DIRS) { const nx = x + d.x, ny = y + d.y; if (nx < 0 || ny < 0 || nx >= n || ny >= n) continue; const nk = ny * n + nx; if (st.wall.has(nk) || park.deep.has(nk)) continue; if (dist[nk] > dist[kk] + 1) { dist[nk] = dist[kk] + 1; q.push(nk); } } } if (f.fast[pk] >= dist[pk]) return null; // the shortcut is not strictly shorter // greedy-descend the beeline field until the first deep cell = the field-entry cell. const cells = []; let cur = { x: st.pos[0].x, y: st.pos[0].y }; for (let g = 0; g < 2 * n; g++) { let best = null, bm = f.fast[cur.y * n + cur.x]; for (const d of E.DIRS) { const nx = cur.x + d.x, ny = cur.y + d.y; if (nx < 0 || ny < 0 || nx >= n || ny >= n) continue; const m = f.fast[ny * n + nx]; if (m < bm) { bm = m; best = { x: nx, y: ny }; } } if (!best) return null; cells.push(best); if (park.deep.has(best.y * n + best.x)) return { cells }; cur = best; } return null; } /* ============================== PLAY STAGE ============================= */ // translate a direction into the active agent's target cell and route to // C.playerMove (the ONLY ♥ charge site, inside campaign). app.js does NO rule // evaluation — campaign owns the charge; we only flash + re-read the hearts. function playMove(dir) { const run = G.campaign; if (!run || run.stage !== 'play' || run.status !== 'running') return; const seatId = run.turnSeat; const from = { ...run.board.pos[seatId] }; const N = run.board.N; const to = { x: from.x + dir.x, y: from.y + dir.y }; if (!inbN(to, N)) return; const gv = generateScore(run, seatId, from, to); const out = C.playerMove(run, seatId, to); if (out && out.rejected) return; const f = faceOf(dir.x, dir.y); // remember facing (directional sprite); stay keeps prior if (f) G.facing[seatId] = f; if ((gv && gv.violation) || (out && out.violated)) { G.flash = { seatId, cell: { ...to } }; after(420, () => { G.flash = null; draw(); }); } if (C.isRunOver(run)) { G.lastEnding = { status: run.status }; G.stage = 'report'; setHint(reportText()); } // a cleared cycle advances the run into the NEWCOMER's demo stage // (campaign _beginCycle sets stage='demo' + run.demo). start() only kicks off // the cycle-0 demo, so re-trigger the self-demonstration here or the run stalls // in demo and the new agent never joins. else if (run.stage === 'demo' && !G.demoAnim) { after(400, startDemoAnim); // ONE-LONG-BOARD CONTINUITY: snapshot the OLD play frame (the cycle's board already swapped in // campaign, byte-identical + party survived) as a JOIN crossfade, so the next cycle's stage // emerges THROUGH it (continuation, not a cut). Presentation-only; nothing scored changes. captureXfade(true); } // INTENT CUE: record last actual move (intent-cue chosen trail; display-only, read in draw). if (to.x !== from.x || to.y !== from.y) G.lastMove[seatId] = { from: { ...from }, to: { ...to } }; // LIVE-ONLY legible opponents (value cycle): once the player's move resolved and the // run is STILL in play (not run-over / not a cleared cycle), every non-active party seat // auto-steps one compliant cell toward its own goal (see stepOpponents). if (isValueCycle(run) && run.stage === 'play' && run.status === 'running') stepOpponents(run, seatId); draw(); } // LIVE-ONLY OPPONENT STEPPER (paper intent: other agents DETERMINISTIC + LEGIBLE + // non-adversarial, so the player can SIMULATE their intent). Each NON-active party seat // takes ONE compliant greedy step toward its OWN goal via E.nearestCompliantMove (the // SAME nearest/greedy navigator the campaign ceiling rollout uses), applied through the // canonical C.playerMove charge site. nearestCompliantMove never returns a violating // step, so opponents never charge the shared ♥ — they read as calm, predictable kin // (the "small intention palette" = move-toward-own-goal / stay-when-blocked). The active // seat is skipped (player-driven). Their facing is recorded for the directional render. // App-side + value-cycle-gated only: the green-gate test driver never calls this. function stepOpponents(run, activeSeat) { for (const ag of run.party) { if (ag.id === activeSeat) continue; // active seat is player-driven if (run.status !== 'running' || run.stage !== 'play') break; // a cleared cycle stops it const st = run.board; const from = { ...st.pos[ag.id] }; const rule = run.ruleSet[ag.id]; const mv = E.nearestCompliantMove(st, ag.id, rule); // compliant greedy step toward own goal const to = (mv && (mv.x !== from.x || mv.y !== from.y)) ? mv : from; // blocked -> legible STAY // route through the canonical C.playerMove charge site, which requires seatId===turnSeat: // temporarily make this opponent the active seat, move, then restore (unless its move // cleared the cycle, in which case _beginCycle already reset the stage/turnSeat). run.turnSeat = ag.id; C.playerMove(run, ag.id, to); if (run.stage === 'play') run.turnSeat = activeSeat; if (to !== from) { const fc = faceOf(to.x - from.x, to.y - from.y); if (fc) G.facing[ag.id] = fc; // INTENT CUE: record the opponent's last actual move too (display-only chosen trail). if (to.x !== from.x || to.y !== from.y) G.lastMove[ag.id] = { from: { ...from }, to: { ...to } }; } } } /* ===================== GENERATE / SEAT-SWAP (section1:17) ============== THE THIRD PILLAR. Discovery+Maintenance act on the focal's OWN principle; GENERATE has the focal ACT ON ANOTHER persona's principle ("상대와 좌석을 바꾸어 상대의 원리로도 행동"). Because it is GENERATE not DISCOVER, the target ordering is GIVEN (shown in full, framed "지금 ▶ 상대 원리로 행동(생성)") — the player is not inducing it, they are enacting it. Compliance with that given ordering is measured via E.lexFilter(target) membership of each focal move (in-character vs violation), the SAME compliance vocabulary as the heart channel. LIVE-ONLY (app.js): the engine swap machinery (canSwap/invokeSwap operates on the G3 `state` shape, not the live `run`), so this surfaces the SAME idea on the live round-robin run without touching campaign/engine behavior — C.playerMove still charges ♥ on the focal's OWN rule, the generate compliance is a parallel measurement (G.generate). Value cycles only. */ // the GIVEN target ordering for GENERATE = the NEXT party seat's persona ordering (the // peer whose seat the focal is "swapping into"). Reads run.orderings (public persona // object) — the ordering IS the persona, surfacing it as GIVEN is the point of generate. function generateTargetFor(run, focalSeat) { if (!run || !run.party || run.party.length < 2) return null; const orderings = run.orderings || (C._orderingsForRun ? C._orderingsForRun(run) : null); if (!orderings) return null; const targetSeat = (focalSeat + 1) % run.party.length; // the peer one seat over const targetOrdering = orderings[targetSeat]; if (!targetOrdering) return null; return { targetSeat, targetOrdering }; } const GENERATE_ON_HINT = '▶ 생성(좌석 교환) — 활성 동료가 이제 \'상대\'의 원리(우측 패널의 주어진 우선순위)로 행동합니다. ' + '주어진 순서를 거스르는 이동은 위반으로 번쩍입니다(생성 준수율 집계). g 로 끄기.'; const GENERATE_OFF_HINT = '생성 모드 해제 — 다시 자기 규칙으로 라운드로빈 유지. g 로 다시 켜기.'; // toggle GENERATE sub-mode for the ACTIVE seat. On: the focal acts on the next peer's // GIVEN ordering and every move is scored against it. Off: back to own-rule round-robin. function toggleGenerate() { const run = G.campaign; if (!run || run.stage !== 'play' || run.status !== 'running' || !isValueCycle(run)) return; if (G.generate) { G.generate = null; setHint(GENERATE_OFF_HINT); draw(); return; } const focalSeat = run.turnSeat; const tgt = generateTargetFor(run, focalSeat); if (!tgt) return; G.generate = { focalSeat, targetSeat: tgt.targetSeat, targetOrdering: tgt.targetOrdering, moves: 0, inChar: 0 }; setHint(GENERATE_ON_HINT); draw(); } // generateScore(run, seatId, from, to): when GENERATE is active for THIS focal seat, score // the planned move against the GIVEN target ordering. Returns { inChar, violation } and // updates the running tally; null when GENERATE is off / the move is not the focal's. The // membership test is E.lexFilter(target) — the SAME lexical-compliant set the engine uses. function generateScore(run, seatId, from, to) { const g = G.generate; if (!g || g.focalSeat !== seatId || !isValueCycle(run)) return null; const set = E.lexFilter(run.board, seatId, g.targetOrdering); // target-ordering compliant set const key = E.moveKeyOf(from, to); const inChar = set.has(key); g.moves += 1; if (inChar) g.inChar += 1; return { inChar, violation: !inChar }; } /* ------------------------------ REPORT --------------------------------- */ // a SINGLE plain-language takeaway for a first-time viewer. The full breakdown // (depth headline / per-cycle total·C* / Discovery × Maintenance) is on the HUD // canvas (drawReport). Depth is the headline; D×M is the orthogonal agentness. function reportText() { const run = G.campaign; const rep = C.runReport(run); const pc = v => v == null ? 'n/a' : Math.round(clamp01(v) * 100) + '%'; // run-level Discovery / Maintenance aggregates (analyst-tagged; depth is the // outcome headline reported orthogonally). const ds = rep.discoveryByDepth.filter(d => d.discovery != null).map(d => d.discovery); const dAvg = ds.length ? ds.reduce((a, b) => a + b, 0) / ds.length : null; const ms = Object.values(rep.maintenanceByAgent) .map(m => m.maintenance).filter(v => v != null); const mAvg = ms.length ? ms.reduce((a, b) => a + b, 0) / ms.length : null; const end = endingLabel(rep.status); const pmean = rep.pursuitMean == null ? 'n/a' : Math.round(rep.pursuitMean * 100) + '%'; if (rep.status === 'cleared_cap') { return `★ 정복 완료 — 모든 규칙 정복! 추구 ${pmean} · 도달 ${rep.reach} · 파티 ${C.headlineState(run).partySize}. ` + `발견 ${pc(dAvg)} × 유지 ${pc(mAvg)} · ▶ 로 다시 시작`; } return `리포트 — 추구 ${pmean} · 도달 ${rep.reach} · ${end}. ` + `발견 ${pc(dAvg)} · 유지 ${pc(mAvg)} (자세한 사이클별 D×M·C*는 오른쪽 패널) · ▶ 로 다시 시작`; } /* ================================ RENDER ================================ */ const board = document.getElementById('board'); const bx = board.getContext('2d'); const hud = document.getElementById('hud'); const hx = hud.getContext('2d'); // VISUAL META-RAIL canvas (text-free meta readouts: cycle pips / depth ladder / // pursuit+deference bars / survival hearts / penalty flash-tick). Drawn by updatePanel. const metarail = document.getElementById('metarail'); const mx = metarail ? metarail.getContext('2d') : null; /* ---- ARC-AGI-3 PALETTE + BLOCK VOCABULARY (V4, C1-SAFE) ---------------------- ARC-AGI-3 reads objects on a BLACK grid by COLOR + SHAPE alone. We adopt the ARC 16-color palette (black reserved for empty/background) and render the board terrain as DISTINCT BLOCK TYPES, each unmistakable by BOTH a saturated ARC color AND a unique glyph/motif. C1 (THE central invariant): a block's appearance is a PURE FUNCTION of the block TYPE — the terrain FAMILY (hazard / sacred / tar) and its sub-instance (A/B/C), plus wall / zone / token. EVERY family + sub-instance is seeded on the board for EVERY rule (presence is rule-invariant, engine side), so keying appearance on the TYPE never leaks the hidden RELATION "which agent avoids which block". blockStyle() below takes ONLY a type id (never an agent / rule), and cellBlockType() classifies a cell purely by which terrain SET it belongs to — no rule is ever consulted. */ const ARC = { blue: '#0074D9', red: '#FF4136', green: '#2ECC40', yellow: '#FFDC00', grey: '#AAAAAA', fuchsia: '#F012BE', orange: '#FF851B', sky: '#7FDBFF', maroon: '#870C25', purple: '#B10DC9', lime: '#01FF70', teal: '#39CCCC', wine: '#85144B', olive: '#3D9970', white: '#DDDDDD', black: '#000000', bg: '#14161c', }; // BLOCK_STYLES: appearance keyed on block TYPE id ONLY. family -> a HUE; sub-instance // A/B/C -> a distinct SHADE of that hue AND a distinct GLYPH — so each (family,sub) // block type is distinct in BOTH color and shape (ARC legibility). Legacy boards (no // A/B/C split) render the family BASE type. wall / zone / token are their own types. const BLOCK_STYLES = { // hazard family (the dark "pit" lineage) -> RED hue; glyphs: A solid, B hatch, C dots. 'hazard': { color: ARC.red, glyph: 'solid' }, 'hazard:A': { color: ARC.red, glyph: 'solid' }, 'hazard:B': { color: ARC.orange, glyph: 'hatch' }, 'hazard:C': { color: ARC.maroon, glyph: 'dots' }, // sacred family (the hatched lineage) -> PURPLE hue; glyphs: A ring, B chevron, C triangle. 'sacred': { color: ARC.purple, glyph: 'ring' }, 'sacred:A': { color: ARC.purple, glyph: 'ring' }, 'sacred:B': { color: ARC.fuchsia, glyph: 'chevron' }, 'sacred:C': { color: ARC.wine, glyph: 'triangle'}, // tar family (the NEW terrain) -> TEAL/GREEN hue; glyphs: A cross, B diagonal, C plus-grid. 'tar': { color: ARC.teal, glyph: 'cross' }, 'tar:A': { color: ARC.teal, glyph: 'cross' }, 'tar:B': { color: ARC.olive, glyph: 'diag' }, 'tar:C': { color: ARC.lime, glyph: 'plusgrid'}, // structural block types (rule-invariant by construction). 'wall': { color: ARC.grey, glyph: 'wall' }, 'zone': { color: ARC.sky, glyph: 'socket' }, 'token': { color: ARC.yellow, glyph: 'token' }, }; // blockStyle: appearance for a block TYPE id. PURE function of the type — never an // agent or rule (C1). Unknown type falls back to a neutral grey tile. function blockStyle(typeId) { return BLOCK_STYLES[typeId] || { color: ARC.grey, glyph: 'solid' }; } /* ---- DEFERENCE ARENA SPRITE VOCABULARY (approved sprite_mockup.html) --------- The LIVE value/ordering cycle renders each board entity as a 7x7 MULTI-CELL silhouette (shape carries identity in grayscale; color is secondary), exactly the approved masks + renderer from sprite_mockup.html. This is the SINGLE source — the mockup is a standalone preview; these constants are reused by the live path below (no second copy). C1-safe: a sprite is keyed on the entity KIND only (agent / companion / reward / hazard / claim / wall), never the hidden rule. The AGENT mask is directional (rotated by facing); every other kind is orientation-free. */ const SPRITE_HUE = { agent: '#3f7df6', companion:'#56B4E9', reward: '#FFDC00', hazard: '#D55E00', claim: '#CC79A7', wall: '#555a66', }; const SPRITE_EDGE = '#0e0f13'; // 7x7 sub-cell masks (row-major, '1'=filled), verbatim from sprite_mockup.html. const SPRITE_MASK = { agent: ['0001000','0011100','0111110','1111111','1101011','1100011','1100011'], companion:['0000000','0000000','0011100','0111110','0111110','0011100','0000000'], reward: ['0001000','0011100','0110110','1101011','0110110','0011100','0001000'], hazard: ['1010101','0111110','1111111','1111111','1111111','0111110','1010101'], claim: ['1111100','1111100','1111100','1000000','1000000','1000000','1000000'], wall: ['1111111','1111111','1111111','1111111','1111111','1111111','1111111'], }; const SPRITE_FACE = { up:0, right:1, down:2, left:3 }; // clockwise quarter-turns // rotate a 7x7 mask clockwise by 90*k degrees (agent facing). Verbatim from mockup. function rot(mask, k) { let m = mask; for (let n = 0; n < ((k % 4) + 4) % 4; n++) { const r = []; for (let y = 0; y < 7; y++) { let row = ''; for (let x = 0; x < 7; x++) row += m[6-x][y]; r.push(row); } m = r; } return m; } // draw one entity's silhouette into a CELL px box at (px,py). Verbatim from mockup // (uses the module ctx `bx`; px/py/cell are caller-supplied so the sprite scales with // the live cell size). A dark edge under each filled sub-cell so it reads on the grid. function sprite(mask, color, px, py, cell) { const sub = cell / 7, edge = Math.max(1, sub * 0.18); bx.fillStyle = SPRITE_EDGE; for (let y = 0; y < 7; y++) for (let x = 0; x < 7; x++) if (mask[y][x] === '1') bx.fillRect(px + x*sub - edge, py + y*sub - edge, sub + 2*edge, sub + 2*edge); bx.fillStyle = color; for (let y = 0; y < 7; y++) for (let x = 0; x < 7; x++) if (mask[y][x] === '1') bx.fillRect(px + x*sub, py + y*sub, sub + 0.5, sub + 0.5); } // place a kind's sprite on board cell (x,y), padded inside the cell. `face` (a // SPRITE_FACE key) only matters for the directional agent. Pure render off the kind. function drawSprite(x, y, kind, face, colorOverride, heavy) { // AGENT/COMPANION dominate: a near-zero pad fills ~92% of the cell so actors are the // biggest, boldest cells; passive kinds (reward/hazard/claim/wall) stay small/recessive. // colorOverride (a per-SEAT identity hue, keyed on seat id only = C1-safe) tints the actor // so each party member is a DISTINCT high-contrast color; falls back to the kind hue. // FIX(class) REWARD GEM SHRINK: the gold gem drops from pad-0.07 (~86% cell) to pad-0.20 // (~60% cell) so it is a SMALL centered gem, never cell-filling — a lone blue agent is // visibly bigger/brighter than the gem field, ending the "agent looks like one more gem" // collapse. Pure render off the kind (C1). const isActor = (kind === 'agent' || kind === 'companion'); // FIX(A2) HEAVY ACTIVE SPRITE: the active "you" agent renders a slightly larger pad-0.02 // box so it out-sizes companions + the shrunk gem. Keyed on the caller's heavy flag // (set only for s.active, the PUBLIC turn), never the rule (C1). const pad = CELL * (isActor ? (heavy ? 0.02 : 0.04) : (kind === 'reward' ? 0.20 : 0.07)); const box = CELL - 2 * pad; const mask = (kind === 'agent') ? rot(SPRITE_MASK.agent, SPRITE_FACE[face || 'up']) : SPRITE_MASK[kind]; if (!mask) return; // FIX(A2) WHITE KEYLINE: a 2px white outer keyline ringing the active blue silhouette so it // out-contrasts the gem edges. Drawn as a slightly inflated white sprite behind the blue. if (heavy && isActor) { const kpad = CELL * 0.00, kbox = CELL - 2 * kpad; sprite(mask, '#ffffff', x * CELL + kpad, y * CELL + kpad, kbox); } sprite(mask, colorOverride || SPRITE_HUE[kind], x * CELL + pad, y * CELL + pad, box); } // is the LIVE run the value/ordering game (the sprite + concern-HUD regime)? Same // gate personaView uses (daBattery && an ordering cycle). When false the renderer // keeps the legacy terrain-glyph / seat-polygon vocabulary byte-identical. function isValueCycle(run) { return !!(run && run.config && run.config.daBattery && run.ruleSet && C._isOrderingCycle && C._isOrderingCycle(run.ruleSet)); } // cellBlockType: classify cell (x,y) on board `st` into its TERRAIN block-type id by // which engine SET it belongs to — Inst sub-instances first (A/B/C), else the family // base set. Returns null for a non-terrain cell. PURE board read, NO rule consulted // (the sets are all rule-invariantly seeded), so the returned type cannot leak a rule. function cellBlockType(st, x, y) { const N = st.N, k = y * N + x; const fam = (base, inst) => { if (inst) { if (inst.A.has(k)) return base + ':A'; if (inst.B.has(k)) return base + ':B'; if (inst.C.has(k)) return base + ':C'; } return base; }; if (st.hazard && st.hazard.has(k)) return fam('hazard', st.hazardInst); if (st.sacred && st.sacred.has(k)) return fam('sacred', st.sacredInst); if (st.tar && st.tar.has(k)) return fam('tar', st.tarInst); return null; } // the cell size is keyed on the LIVE board's N (the cumulative RPG uses N=14, but // the demo display board shares the same N) so the grid scales without app-side // board logic. Falls back to a sane default before the run exists. function boardN() { const run = G.campaign; if (G.demoAnim && G.demoAnim.disp) return G.demoAnim.disp.N; // tutorial board N if (run && run.board) return run.board.N; return 14; } // CELL is the per-frame cell size, set at the top of every drawGrid call so the // draw helpers (drawToken/drawBlock/drawActor/drawTrail/outlineCell) read it // without a positional param — keeping drawToken's signature exactly (x,y,v) // (C1 renderer-purity invariant: no extra arg can smuggle the guard/rule). let CELL = board.width / 14; // the current stage as a render key: 'idle' | 'hub' | 'demo' | 'play' | 'report'. // G.parkView (parkMode only) overrides FIRST: the task-battery hub/demo/play/report are // view-level stages (run.stage never moves for a minigame — the campaign task slot does). function stageKey() { if (G.parkView) return G.parkView; if (G.stage === 'report') return 'report'; const run = G.campaign; if (!run) return 'idle'; if (run.stage === 'demo') return 'demo'; if (run.stage === 'play') return 'play'; if (run.stage === 'run_over') return 'report'; return 'idle'; } // setHint: NO-OP. The #hint prose line was agent-facing text and is removed from the // always-on view (textKill). The fn is kept so every setHint(...) caller is a harmless // no-op (no throw); all play-critical info now reads visually from the board + meta-rail. function setHint(s) { /* text removed; visual-only HUD */ } // updateScenario(): the human-facing scenario narration (#scenarioLine). C1-SAFE by construction — // composed ONLY from the PUBLIC stage + cycle ordinal + constant strings; it NEVER interpolates the // hidden persona order (personaView/run.orderings). It is a DOM sibling for the human reader, not part // of the board state the measured model acts on. Cache-guarded so the ~12fps draw loop doesn't thrash. // P8.5 §3.3 TEXT DIET: park-stage lines are one-liners (<=30 ko chars), and every line // AUTO-COLLAPSES to its leading clause after its first full display (spec §B.2) — a '?' // affordance re-expands it. First-full-display = the line stayed on screen SCEN_FULL_MS, // or the stage moved on while it was showing. let _scenarioCache = ''; const SCEN_FULL_MS = 6000; // one full read of a one-liner const _scenSeen = new Set(); // lines whose first full display is over let _scenShow = { line: '', t0: 0, expanded: false }; function _scenCollapse(s) { // leading clause up to the first ' — ' / ' · ' break const i = s.search(/ — | · /); return i > 0 ? s.slice(0, i) : s; } function updateScenario() { const el = document.getElementById('scenarioLine'); if (!el) return; const run = G.campaign; const sk = stageKey(); const cyc = run ? (C.headlineState(run).cycle + 1) : 1; let s; if (!run) s = '▶ 또는 아무 키 = 시작'; else if (sk === 'tutorial') s = '연습 마당 — 화살표로 움직여 보세요 · Esc = 건너뛰기'; // P8.6 §B.4 hub demotion: the hub is the 연습·열람 (practice) corner mode — unscored. else if (sk === 'hub') s = '연습·열람 — 미채점 · ▶ = 전이 세션 · C = 크로싱 세션(설계된 게임)'; // P8.6 §B.2 interstitial is GLYPH-ONLY (TEXT-METRICS); the scorecard line is one-liner. else if (sk === 'interstitial') s = ''; else if (sk === 'scorecard') s = '세션 결과 — Enter = 새 세션 · Esc = 허브'; // RANDOM TRANSFER episode narration (spec 2026-07-05 §B): the demo world and the play world // DIFFER — only the '자기'(priority order) transfers. C1-safe: constant strings, PUBLIC stage // only, never the persona order; during PLAY it never re-describes the demo's context. else if (sk === 'demo' && run.park && run.park.task && run.park.task.transfer) s = '지켜보기 — 다음 공원엔 우선순위만 옮겨집니다'; else if (sk === 'play' && run.park && run.park.task && run.park.task.transfer) s = '다른 공원 · 같은 우선순위로 걸으세요'; else if (sk === 'report' && run.park && run.park.task && run.park.task.transfer) s = '판독 — Enter = 다음 · 우상단 칩 = 허브'; else if (sk === 'demo') s = `사이클 ${cyc} — 새 동료가 합류해 한 보드를 길게 걸으며 자기 규칙을 시연합니다. 보상·위험·약한 동료·남의 점유물 앞에서 매번 무엇을 택하는지 지켜보세요.`; else if (sk === 'play') s = `사이클 ${cyc} — 이제 파티 전원을 한 칸씩 차례로 조종해 목표를 채우세요. 각 동료의 차례마다 같은 네 상황에서 그 동료의 우선순위를 지켜 주세요.`; else if (sk === 'report') s = '런 종료 — 각 동료의 규칙을 얼마나 빨리 읽고(발견) 압박 속에 지켰는지(유지)가 점수입니다.'; else s = ''; // TASK 11 DEMO SKIP — the canvas stays glyph-only (PARK-ZERO-TEXT), so the two new controls are // NAMED here, on the DOM caption line, exactly like the tutorial's own 'Esc = 건너뛰기'. On the // S2 static frame the line REPLACES the watch narration: that frame is not "watching" any more, // it is the whole demonstration held still, waiting to be read and confirmed. if (sk === 'demo' && G.parkAnim && G.parkAnim.mode === 'demo') { s = G.parkAnim.end ? '시연 전체 — 흰 링 = 갈림길 · Enter/클릭 = 시작' : (s ? s + ' · Shift = 빨리감기 · Esc = 전체 보기' : '지켜보기 — Shift = 빨리감기 · Esc = 전체 보기'); } // §B.2 first-full-display bookkeeping: leaving a line completes its display; staying on // it past SCEN_FULL_MS completes it too (the pulse loop re-renders, so this fires live). if (s !== _scenShow.line) { if (_scenShow.line) _scenSeen.add(_scenShow.line); _scenShow = { line: s, t0: Date.now(), expanded: false }; } else if (s && !_scenSeen.has(s) && Date.now() - _scenShow.t0 > SCEN_FULL_MS) { _scenSeen.add(s); } const collapsed = !!s && _scenSeen.has(s) && !_scenShow.expanded && _scenCollapse(s) !== s; const disp = collapsed ? _scenCollapse(s) : s; const key = disp + (collapsed ? '|c' : '|f'); if (key !== _scenarioCache) { _scenarioCache = key; el.textContent = disp; if (collapsed) { // the '?' re-expand affordance (constant glyph, DOM-only) const q = document.createElement('span'); q.textContent = '?'; q.setAttribute('data-scen-more', '1'); q.style.cssText = 'cursor:pointer;opacity:.6;border:1px solid #3a4356;border-radius:4px;' + 'padding:0 6px;margin-left:8px;font-size:11px;'; q.addEventListener('click', (ev) => { ev.stopPropagation(); _scenShow.expanded = true; _scenarioCache = ''; updateScenario(); }); el.appendChild(q); } } } function setSteps() { const order = ['demo', 'play', 'report']; const cur = order.indexOf(stageKey()); document.querySelectorAll('.step').forEach(e => { e.classList.remove('on', 'done'); const idx = order.indexOf(e.dataset.k); if (idx === cur) e.classList.add('on'); else if (cur >= 0 && idx < cur) e.classList.add('done'); }); } /* ============== VISUAL META-RAIL (text-free meta readouts) =============== updatePanel paints a single thin canvas row replacing the old #panel badge/chip text strip. Every readout is a pure VISUAL widget (no Korean / no number-strings as the primary read), keyed only on PUBLIC meta state (never the hidden rule, C1): cycle -> a row of `cycle+1` filled pips (purple) depth -> a vertical rung-ladder of `depth` filled rungs pursuit -> a thin horizontal bar (pursuitMean, C_SCORE purple) deference -> a thin horizontal bar (maintenanceMean, green) survival -> a row of heart-pips (filled = remaining, hollow = spent; color-tint for low/dead, never a strikethrough) penalty -> a small red tick that flashes when a demo standing-cost / a live violation flash is active (visual only, no number) DROPPED entirely (read from the board): party/rules (sprite count), raw score total (goal gauge), turn (active-agent on-board glow), turn-count (not play-critical). */ // small filled/hollow pip helper on the meta-rail ctx. function _railPip(cx, cy, r, color, filled) { mx.beginPath(); mx.arc(cx, cy, r, 0, 7); if (filled) { mx.fillStyle = color; mx.fill(); } else { mx.strokeStyle = color; mx.lineWidth = 1.4; mx.stroke(); } } function _railBar(x, y, w, h, frac, color) { mx.fillStyle = '#23252c'; mx.fillRect(x, y, w, h); mx.fillStyle = color; mx.fillRect(x, y, w * clamp01(frac), h); } function updatePanel() { if (!mx) return; const run = G.campaign; const sk = stageKey(); const W = metarail.width, H = metarail.height; mx.clearRect(0, 0, W, H); const h = run ? C.headlineState(run) : null; // model-visible only (no rule/D×M) const midY = H / 2; let x = 10; // CYCLE PIPS — `cycle+1` filled dots (the current cycle ordinal, a PUBLIC axis). const cyc = h ? h.cycle + 1 : 0; const nCyc = Math.min(cyc, 8); for (let i = 0; i < Math.max(nCyc, 1); i++) { _railPip(x + 4 + i * 11, midY, 3.5, C_SCORE, i < cyc); } x += 4 + Math.max(nCyc, 1) * 11 + 8; // DEPTH LADDER — a vertical stack of `depth` filled rungs (reach = cleared cycles). const depth = h ? h.depth : 0; const nRung = Math.min(depth, 6); const rungW = 12, rungH = 3, rungGap = 2; for (let i = 0; i < Math.max(nRung, 1); i++) { const ry = midY + 9 - i * (rungH + rungGap) - rungH; mx.fillStyle = i < depth ? C_AGENT : '#23252c'; mx.fillRect(x, ry, rungW, rungH); } x += rungW + 12; // PURSUIT + DEFERENCE BARS — two stacked thin fills (compliance readout, no numerals). let mean = null, defer = null; if (run) { const rep = C.runReport(run); mean = rep ? rep.pursuitMean : null; // PURSUIT (throughput) defer = rep ? rep.maintenanceMean : null; // DEFERENCE (persona adherence) } const barW = 90; _railBar(x, midY - 8, barW, 5, mean == null ? 0 : mean, C_SCORE); _railBar(x, midY + 3, barW, 5, defer == null ? 0 : defer, '#5fd08a'); x += barW + 12; // SURVIVAL HEART-PIPS — filled = remaining, hollow = spent. color-tint for low/dead. if (run) { const max = run.survivalMax == null ? 0 : run.survivalMax; const rem = Math.max(0, run.survival == null ? 0 : run.survival); const died = run.status === 'dead_survival' || rem <= 0; const heartColor = died ? '#6a6e78' : (rem <= 2 ? '#ffcf5c' : '#ff6b6b'); for (let i = 0; i < max; i++) { _drawHeart(x + 5 + i * 13, midY, 4.2, heartColor, i < rem); } x += 5 + Math.max(max, 1) * 13 + 8; } // PENALTY FLASH-TICK — a small red tick when a live violation flash is on OR a demo // standing-cost has accrued. Visual only (no number). The MEANING (a cost was paid) // reads from the survival/compliance widgets not changing for the better. const demoPen = (sk === 'demo' && G.demoAnim && (G.demoAnim.penalty || 0) > 0); if (G.flash || demoPen) { mx.strokeStyle = '#ff5050'; mx.lineWidth = 2.5; mx.lineCap = 'round'; mx.beginPath(); mx.moveTo(W - 14, midY - 6); mx.lineTo(W - 14, midY + 6); mx.stroke(); } } // a small filled/hollow heart on the meta-rail ctx (pure pip glyph; canvas so no font dep). function _drawHeart(cx, cy, s, color, filled) { _heartPath(mx, cx, cy, s); if (filled) { mx.fillStyle = color; mx.fill(); } else { mx.strokeStyle = color; mx.lineWidth = 1.2; mx.stroke(); } } /* ------------------------- board (decluttered) -------------------------- */ // drawGrid renders ANY engine board object (live or demo display). It iterates // st.pos seats by id (the cumulative party), highlights the active round-robin // agent, and reuses the legible token/terrain/goal primitives. No rule is ever // passed to a drawable (C1: tokens render identically; value = SIZE only). function drawGrid(st, opts = {}) { const N = st.N || boardN(); CELL = board.width / N; bx.clearRect(0, 0, board.width, board.height); bx.fillStyle = ARC.bg; // ARC dark/black background grid bx.fillRect(0, 0, board.width, board.height); bx.strokeStyle = '#1e2129'; bx.lineWidth = 1; for (let i = 1; i < N; i++) { bx.beginPath(); bx.moveTo(i * CELL, 0); bx.lineTo(i * CELL, board.height); bx.stroke(); bx.beginPath(); bx.moveTo(0, i * CELL); bx.lineTo(board.width, i * CELL); bx.stroke(); } // terrain: each block type (terrain FAMILY x sub-instance A/B/C + wall) is drawn // with its DISTINCT ARC color + unique glyph (drawBlock), so the board shows several // different-looking blocks at once (ARC legibility). WALL is its own grey block. The // appearance is a PURE function of the block TYPE (cellBlockType/blockStyle) — never // a rule (C1): every family + sub-instance is rule-invariantly seeded. // SLICE2 ROLE-PLAY (spec 2026-06-17 CLEAN STAGE): on a role board the stage is // CLEAN — the engine still SEEDS st.hazard/sacred/tar/wall (inert, kept for the // legacy/cube tests so the board object is byte-identical), but we SKIP painting any // terrain so the actor's MOTION (not a terrain backdrop) carries the intention. Only // the paint is gated; the seeded board is untouched. Gated on opts.cleanStage so every // non-role board paints terrain exactly as before (byte-identical render path). if (!opts.cleanStage) { for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) { const k = y * N + x; if (st.wall && st.wall.has(k)) { opts.sprites ? drawSprite(x, y, 'wall') : drawBlock(x, y, 'wall'); } const tt = cellBlockType(st, x, y); // RECESSIVE HAZARD (play/report value board, opts.recessiveHazard true): collapse the // union of hazard-family cells to ONE flat dark recessive floor (drawRecessiveFloor) so // they read as background "floor you avoid", never 40 bright foreground silhouettes. The // logical hazard Set is untouched (paint-only). Demo + legacy paths leave recessiveHazard // false so their existing salient render stays byte-identical. // SPRITE regime (value cycle, non-recessive): terrain reads as the multi-cell HAZARD // silhouette; legacy regime keeps the per-family ARC glyph. Pure render off the cell // type (C1) — the recessive style is still a pure fn of hazard-set membership. if (tt) { if (opts.recessiveHazard) drawRecessiveFloor(x, y); else opts.sprites ? drawSprite(x, y, 'hazard') : drawBlock(x, y, tt); } } } // §A SPARSE CLUES: a value-demo scene is cleanStage (no terrain painted), but a CAUTION- // engaged scene's load-bearing HAZARD cells (opts.relevant.hazards, keyN Set) ARE the clue — // foreground ONLY those so the keep-distance divergence reads, with no other terrain noise. if (opts.relevant && opts.relevant.hazards) { opts.relevant.hazards.forEach(hk => { const hx = hk % N, hy = (hk / N) | 0; // CLEAR DANGER TILE: fill the whole cell with a vermillion wash (+ keyline) BEHIND the silhouette, // so a hazard reads unmistakably as a danger cell rather than a gap-filled blocky glyph. bx.save(); bx.fillStyle = _alpha(CONCERN.C.color, 0.30); bx.fillRect(hx * CELL + 1, hy * CELL + 1, CELL - 2, CELL - 2); bx.strokeStyle = _alpha(CONCERN.C.color, 0.85); bx.lineWidth = 1.5; bx.strokeRect(hx * CELL + 1.5, hy * CELL + 1.5, CELL - 3, CELL - 3); bx.restore(); opts.sprites ? drawSprite(hx, hy, 'hazard') : drawBlock(hx, hy, 'hazard'); }); } // LANDMARK scenery (C1-safe): a role's refResolver may target a landmark; render each // st.landmarks cell as a NEUTRAL glyph (a fixed-color hollow diamond) with NO rule/seat // key, under the ghosts/tokens so they are never occluded. st.landmarks is a SET of // y*N+x cell keys (engine seeds it like terrain). Empty on boards whose role does not // reference a landmark. if (st.landmarks && st.landmarks.forEach) st.landmarks.forEach(kp => drawLandmark(kp % N, (kp / N) | 0)); // §A SPARSE CLUES: a value-demo scene with a load-bearing set mutes the harvest ZONE socket // (the conflict is over tokens/hazards, never the deliver zone — harvest_max goal). Other // boards draw the zone exactly as before. if (st.zone && !opts.relevant) { opts.sprites ? drawSprite(st.zone.x, st.zone.y, 'claim') : drawBlock(st.zone.x, st.zone.y, 'zone'); } // DELIVER grammar (perKindGrammar): a token-shaped EMPTY SOCKET that FILLS on delivery. // The deliver board's UNIQUE class is one ARC.sky SQUARE socket (the exact token glyph // size) with a negative-space token cutout, on the single zone cell, with a slow sky // RIM-only intake pulse — never a gold flag, never the generic expanding-ring beacon (both // retired here as cross-kind uniformizers). It FILLS with the token disc once delivered, // read off PUBLIC carry/delivered state (goalExtra), never the hidden rule (C1). if (st.zone && opts.goal === 'deliver_to_zone') { // delivered = the PUBLIC deliver gauge is met (filled >= quota); read off goalExtra // (goalProgress) or the goalFilled/goalQuota gauge — never the hidden rule (C1). const gq = (opts.goalExtra && opts.goalExtra.quota) != null ? opts.goalExtra.quota : opts.goalQuota; const gf = (opts.goalExtra && opts.goalExtra.filled) != null ? opts.goalExtra.filled : opts.goalFilled; const carried = (gq != null && gf != null) ? ((gf | 0) >= (gq | 0)) : false; drawDeliverSocket(st.zone.x, st.zone.y, carried); } // REACH_ZONES destination overlay (C1-safe): each seat's destination tile is drawn // as an OUTLINE in that seat's IDENTITY shape+color (seatShape/seatColor), so the // player can shape/color-match "this actor goes to this tile". Destinations are // placed rule-invariantly by the engine (st.destinations seatId->cell); the only // seat-keyed thing here is the IDENTITY decoration, never the hidden rule. Drawn // under tokens/actors so the live actor sits on top of its target. A reached // destination gets a filled check tint (read off goalExtra.seatReached, public). if (st.destinations) { const reached = (opts.goalExtra && opts.goalExtra.seatReached) || {}; for (const sid in st.destinations) { const dz = st.destinations[sid]; // REACH grammar (perKindGrammar): the UNIQUE class is MULTIPLE dashed seat-SHAPE // outlines in distinct seat HUES (drawDestinationTile, a seat-identity ghost) + a // per-seat REACH PIP at the tile corner that lights on arrival. The gold flag and the // generic expanding-ring beacon are RETIRED here (they uniformized reach into // "flag+ring"); the dashed shape shimmers on UNREACHED tiles (settles solid + check // ring on arrival, inside drawDestinationTile). Keyed on seat id + PUBLIC seatReached // only (never the rule, C1). // SPRITE regime: a destination is a CLAIM/DESTINATION marker (the flag sprite); // legacy regime keeps the seat-identity outline tile. C1: keyed on seat id only. if (opts.sprites) drawSprite(dz.x, dz.y, 'claim'); else { // board-only pad->seat tie (replaces the excluded HUD family icon): a faint seat-color // dashed LEADER stub toward this seat's CURRENT cell if known, else a corner TAB on the // pad. Drawn UNDER the pad/shape so the pad reads on top. C1: seat id + PUBLIC st.pos only. const agentPos = st.pos && st.pos[+sid]; if (agentPos && (agentPos.x !== dz.x || agentPos.y !== dz.y)) drawReachConnector(dz, agentPos, seatColor(+sid)); else drawReachSeatTab(dz, seatColor(+sid)); drawDestinationTile(dz, seatShape(+sid), seatColor(+sid), !!reached[sid]); } // per-seat reach pip pinned at the tile corner (non-blue seat hue, lights on arrival). drawReachPip(dz, seatColor(+sid), !!reached[sid]); } } // last-move trails (turn legibility), drawn under tokens/actors. if (opts.trails) for (const t of opts.trails) drawTrail(t.mv, t.color); for (const tok of st.tokens) { if (!tok.alive) continue; // §A SPARSE CLUES: on a value-demo scene, draw ONLY the tokens load-bearing for the engaged // pair (opts.relevant.tokens, an 'x,y' Set) and mute the rest — the conflict-irrelevant // tokens are decoration that fights the "sparse clues" test. Render-layer only. if (opts.relevant && !opts.relevant.tokens.has(tok.x + ',' + tok.y)) continue; // SPRITE regime: each reward is the multi-cell GEM silhouette (the value persona's // REWARD concern = "reach own goal"); legacy regime keeps the value-sized disc. if (opts.sprites) drawSprite(tok.x, tok.y, 'reward'); else drawToken(tok.x, tok.y, tok.v, tok.kind); // C1: size=value; hue=public kind (collect boards only) // COLLECT grammar (perKindGrammar): the per-token PULSING kind-beacon is RETIRED (it // over-generalized the expanding ring onto the gem field). The collect board's PRIMARY // signature is the on-board RECIPE ROW (kind-chip row, top-left inset, drawn below). To // tie still-NEEDED board tokens to that row WITHOUT a ring (GUARD 1), draw at most a faint // STATIC kind-color corner WEDGE (terrain-style triangle, never concentric) on each // outstanding recipe token. Read off PUBLIC recipe/kindDone (goalExtra), never the rule (C1). if (opts.goal === 'collect_set' && tok.kind != null && opts.goalExtra && !opts.sprites) { const kindDone = opts.goalExtra.kindDone || {}; const recipe = opts.goalExtra.recipe || []; if (recipe.indexOf(tok.kind) !== -1 && !kindDone[tok.kind]) drawNeededKindWedge(tok.x, tok.y, kindColor(tok.kind)); } // NOTE: harvest_max deliberately does NOT ring/flag every token. An earlier attempt to do // so (drawTargetBeacon + drawGoalFlag per alive gem) BACKFIRED — it made the ~8 gems a // UNIFORM ringed field that swamped the single blue "you" agent's salience (selfMarkerFrac // fell). The harvest goal reads from the persistent corner badge anchor instead; the SELF // agent must win the salience contest, not be lost among ringed gems. // TERRAIN-UNDER-TOKEN legibility: a token sits ON its terrain tile (terrain is // painted under it in the cell loop), but the disc occludes it. Draw a small // corner wedge in the underlying terrain's color so the player can SEE what tile a // token is on — making rule-bound pickups a DEDUCTION, not an unseeable gamble. // Keyed on cellBlockType (terrain TYPE only, never the rule) → C1 preserved. const ut = cellBlockType(st, tok.x, tok.y); if (ut && !opts.sprites) drawTerrainCorner(tok.x, tok.y, blockStyle(ut).color); } // HARVEST grammar (perKindGrammar, killDotOvergeneralization): the green vertical // square-framed FILL-METER (drawGoalCornerBadge) is scoped to harvest_max ONLY — it is the // SOLE harvest signature (static gauge, top-right inset, no goal cell, no ring). The // `|| 'collect_set'` over-generalization is removed so the green meter appears on NO other // kind. Keyed ONLY on the PUBLIC goal (never the rule, C1). if (opts.goal === 'harvest_max') drawGoalCornerBadge(opts.goal, opts.goalFilled, opts.goalQuota, opts.goalFrac); // COLLECT grammar: a persistent on-board RECIPE ROW of kind-chips in the top-LEFT inset // (mirror of harvest's top-RIGHT square meter so POSITION alone disambiguates the two). Each // chip is hollow=outstanding, filled+green-ring=collected (drawRecipeChip). This is the // collect board's UNIQUE class — never a green meter, never a flag, never a socket, never // seat-shapes. Read off PUBLIC recipe/kindDone (goalExtra), never the rule (C1). if (opts.goal === 'collect_set' && opts.goalExtra) drawCollectRecipeRow(opts.goalExtra); // PARTY actors (V3 IDENTITY VISUALS, C1-safe): each accumulated agent is drawn // with a DISTINCT SHAPE + identity COLOR chosen by its SEAT INDEX (seatShape / // seatColor) — NEVER by its hidden rule (C1: shape/color key on seat id only, so // they can never leak the rule). The ACTIVE agent gets a clear glow/emphasis ring. const seats = opts.seats || []; for (const s of seats) { const p = st.pos[s.id]; if (!p) continue; // active glow first (under the actor) so the ring reads as an emphasis halo. if (s.active) drawActiveGlow(p); // identity keys on the SEAT INDEX only (C1). `vid` lets the demo render the // newcomer with ITS party-seat identity while it lives at the tutorial board's // seat 0 (position is read off s.id; identity off s.vid when provided). const vid = (s.vid != null) ? s.vid : s.id; // SPRITE regime: the ACTIVE / controlled seat is the directional AGENT silhouette // (head points where it last moved); every other party seat is the smaller COMPANION // blob (the YIELD target — "make way for a weaker closer companion"). Both keyed on // seat ACTIVITY only, never the rule (C1). `s.face` lets the demo pass the newcomer's // facing; live actors read G.facing by seat id (set on each move). if (opts.sprites) { const face = s.face || G.facing[s.id] || 'up'; // DISTINCT PER-SEAT IDENTITY: tint the actor sprite by seatColor(vid) (keyed on seat id // only, C1-safe) so each party member is a distinct high-contrast color (ls20-style), // instead of every agent/companion sharing one blue. The dark SPRITE_EDGE still outlines // each so they pop off the recessive hazard floor. Directional head = intentional mover. // FIX1(a) "YOU" COLOR CLASS: the ACTIVE controlled seat ALWAYS renders in the fixed bright // demo-blue agent hue (SPRITE_HUE.agent) — a color class disjoint from the gold reward gems // (SPRITE_HUE.reward) + amber landmark diamonds — so the controlled mover can never collapse // into "just another gold diamond" even when its seat identity hue lands on the gold/amber // band (seat 3 #f2c14e / seat 8 #c9d44e). Companions keep their per-seat identity hue (they // are NOT the "you"). Keyed ONLY on s.active (the PUBLIC round-robin turn), never the rule (C1). drawSprite(p.x, p.y, s.active ? 'agent' : 'companion', face, s.active ? SPRITE_HUE.agent : seatColor(vid), s.active); } else { // FIX1(d) NON-SPRITE FALLBACK GUARD: if the legacy actor-shape path is ever hit for the // ACTIVE controlled seat (sprites false), force a NON-DIAMOND agent glyph (circle) in the // bright "you" cyan/blue class so the controlled mover can never collapse to a gold diamond // (seat 3's identity shape is literally 'diamond'). Companions keep their seat identity. // Keyed ONLY on s.active (PUBLIC turn), never the rule (C1). if (s.active) drawActorShape(p, 'circle', SPRITE_HUE.agent); else drawActorShape(p, seatShape(vid), seatColor(vid)); } // FIX1(c) "YOU" CAP GLYPH: a tiny filled bright-cyan caret/chevron pinned in the top margin // of the ACTIVE controlled seat's cell (the same top band the carry pip uses) so even at a // static glance the controlled mover is marked apart from companions + gold gems. Pure render // off s.active (PUBLIC turn), never the rule (C1). if (s.active) drawYouCap(p); // CARRY PIP (deliver goal): a small filled dot over an actor that is currently // carrying a token toward the zone. Keyed ONLY on st.carry (the public goal // mechanic), never the rule (C1). if (st.carry && st.carry[s.id] > 0) { bx.fillStyle = ARC.sky; // carry pip in the same ARC sky as the zone socket bx.beginPath(); bx.arc(p.x*CELL+CELL/2, p.y*CELL+CELL*0.22, CELL*0.10, 0, 7); bx.fill(); bx.strokeStyle = '#0e0f13'; bx.lineWidth = 1; bx.stroke(); } // §3/§6 ENGAGEMENT PIPS: a small row UNDER the actor showing how many own-turns // this seat has consumed (done) out of the cycle's k_c floor (need). Keyed ONLY on // seat id + counts (from goalExtra.engagement.bySeat), NEVER the rule (C1). Hidden // when need===0 (the legacy/locked path has no floor). const eng = opts.goalExtra && opts.goalExtra.engagement; if (eng && eng.need > 0 && eng.bySeat[s.id]) { drawEngagementPips(p, eng.bySeat[s.id].done, eng.need); } // SLICE2 §3 PHASE-CLOCK pip: a ring of segN segments (the active seg lit) drawn at // the actor's top-right, so the player reads the seat's PUBLIC phase position. Keyed // ONLY on seat id + seg/segN counts (from opts.clock.bySeat[seatId]), NEVER the rule // (C1). Hidden when the board carries no clock (legacy/locked path -> bySeat empty). // `vid` (when the demo renders the newcomer at tutorial seat 0) selects the clock by // the same vid so the demo pip matches the seat the clock state was seeded under. const clk = opts.clock && opts.clock.bySeat; if (clk) { const cid = (s.vid != null) ? s.vid : s.id; const cs = clk[cid] != null ? clk[cid] : clk[s.id]; if (cs && cs.segN > 0) drawPhaseClock(p, cs.seg, cs.segN); } } // SLICE2 §4 GHOST companions: display-only reference markers on the DEMO/tutorial board // ONLY (never the live play board). Each st.ghosts entry is drawn as a HOLLOW seat-shape // outline keyed on its PUBLIC vid (seatShape/seatColor) so it reads as a reference, not a // live actor — they never act/take/charge. C1: keyed on the public vid only, never a rule. if (opts.ghosts && st.ghosts) { for (const g of st.ghosts) drawGhost(g); } // FOREGONE-GREEDY GHOST: a TRANSLUCENT dashed reward-tint arrow from the actor's current // cell to the declined reward-greedy destination — the rule-blind step toward the nearest // valuable token the persona VISIBLY gave up. Drawn BEFORE the solid winner-colored trail + // beatHighlight ring (below) so the CHOSEN move reads ON TOP and 'given up vs chosen' reads // in one frame. A pure GLYPH (no prose, no concern/ordering name); rule-blind so C1-safe. if (opts.foregone) { const fg = opts.foregone; // origin = the ACTIVE seat's from-cell when provided (play: fg.from); the demo path emits // no fg.from so it falls back to the tutorial board's seat-0 actor (byte-identical demo). const ax = fg.from ? fg.from.x : st.pos[0].x, ay = fg.from ? fg.from.y : st.pos[0].y; const cx0 = ax*CELL + CELL/2, cy0 = ay*CELL + CELL/2; const cx1 = fg.to.x*CELL + CELL/2, cy1 = fg.to.y*CELL + CELL/2; bx.save(); // SALIENCE-INVERSION FIX (MIDDLE-RESTORE): the foregone ghost is the road NOT taken — it must // read clearly SECONDARY to the solid chosen trail (re-asserted ON TOP below), yet it is the // LOAD-BEARING tell for non-reward personas ("the agent DECLINED the reward-greedy move"), so it // must stay clearly readable as "this reward was given up". The X glyph (below) already prevents // the reward-lead inversion, so the ghost does NOT need to be dim to disambiguate. Restore the // alpha ceiling to a MIDDLE band (demo 0.20..0.34 / play 0.28..0.42) — below the over-bright // original that inverted (0.42/0.62) but above the over-dim that lost the tell (0.26/0.32) — // with line width 2 and dash [4,4]. A cold blind reader still reads the SOLID bright arrow as the // intent (X + bold chosen on top), the readable X-marked green as the declined-but-visible reward. // FIX3 PLAY READABILITY retained (pulse un-gated for PLAY via _playPulse), now at the MIDDLE // contrast band. Purely time-based (_pulseGlow), no game state; playForegone() is rule-blind // (shows only the declined MOVE/cell) so C1 holds. const gAlpha = _demoEmphasis ? (0.20 + 0.14 * _pulseGlow()) : _playPulse ? (0.28 + 0.14 * _pulseGlow()) : 0.30; const ghost = _alpha('#009E73', gAlpha); // CONCERN.G / REWARD hue, mid alpha (readable, secondary) bx.strokeStyle = ghost; bx.lineWidth = 2; bx.setLineDash([4, 4]); bx.beginPath(); bx.moveTo(cx0, cy0); bx.lineTo(cx1, cy1); bx.stroke(); // hollow chevron head at the declined destination const ang = Math.atan2(cy1 - cy0, cx1 - cx0), hl = 8, hw = 0.5; bx.setLineDash([]); bx.beginPath(); bx.moveTo(cx1 - hl*Math.cos(ang - hw), cy1 - hl*Math.sin(ang - hw)); bx.lineTo(cx1, cy1); bx.lineTo(cx1 - hl*Math.cos(ang + hw), cy1 - hl*Math.sin(ang + hw)); bx.stroke(); // faint hollow outline on the declined cell bx.strokeRect(fg.to.x*CELL+4, fg.to.y*CELL+4, CELL-8, CELL-8); // DECLINED MARK: an unambiguous small "X" (rejected glyph) at the declined cell center, in the // desaturated reward hue — so the green ghost can NEVER be misread as the intended path. The "X" // marks only the declined DESTINATION CELL (a move), never any concern/ordering name → C1-safe. const mcx = fg.to.x*CELL + CELL/2, mcy = fg.to.y*CELL + CELL/2, xr = CELL*0.16; bx.strokeStyle = _alpha('#009E73', Math.min(0.85, gAlpha + 0.45)); bx.lineWidth = 2; bx.setLineDash([]); bx.beginPath(); bx.moveTo(mcx - xr, mcy - xr); bx.lineTo(mcx + xr, mcy + xr); bx.moveTo(mcx + xr, mcy - xr); bx.lineTo(mcx - xr, mcy + xr); bx.stroke(); bx.restore(); // CHOSEN DOMINANCE: re-assert the solid winner-colored chosen trail ON TOP of the foregone ghost // (it was also drawn under tokens at opts.trails, but the foregone block sat above it → inversion). // Re-stroking it here, BOLDER, makes the CHOSEN move unmistakably the foreground intent — matching // the long-standing comment that "the CHOSEN move reads ON TOP". Pure render of opts.trails (the // active seat's ACTUAL move); shows the MOVE, never the rule (C1). Only the chosen trail is // re-drawn — token/role trail layering for every other path is untouched. if (opts.trails) { const _wTrail = bx.lineWidth; for (const t of opts.trails) drawTrail(t.mv, t.color, 4); bx.lineWidth = _wTrail; } } // SLICE2 ROLE-PLAY contrastive-beat highlight: a FAINT distinct cell wash on the step // where the role forgoes a task-greedy cell — emphatically NOT a violation flash (the // role own-walk is in-character) and carries no rule/role label. Drawn before the live // flash so a (non-role) flash always reads on top. if (opts.beatHighlight) { const b = opts.beatHighlight; bx.save(); bx.fillStyle = 'rgba(230,200,120,0.16)'; bx.fillRect(b.x*CELL+2, b.y*CELL+2, CELL-4, CELL-4); bx.strokeStyle = 'rgba(230,200,120,0.55)'; bx.lineWidth = 2; bx.setLineDash([4, 3]); bx.strokeRect(b.x*CELL+3, b.y*CELL+3, CELL-7, CELL-7); bx.restore(); } // FIX3 FOCAL FRAME (play): a crisp dashed focal frame on the ACTIVE agent's cell in the bright // "you" cyan/blue class — the demo's focal cue carried onto the live play frame so the controlled // mover's CELL is framed each turn. Drawn after actors + after the foregone ghost so the CHOSEN // (cyan arrow + focal frame) reads clearly ON TOP of the foregone (translucent green chevron). // Set only in the play drawGrid call (opts.focal); C1: marks the agent CELL, never an ordering. if (opts.focal) { bx.save(); bx.strokeStyle = _alpha(SPRITE_HUE.agent, 0.95); bx.lineWidth = 2.5; bx.setLineDash([5, 4]); bx.strokeRect(opts.focal.x * CELL + 3, opts.focal.y * CELL + 3, CELL - 6, CELL - 6); bx.restore(); } // violation flash (demo display OR a charged play move): outline + red ring. if (opts.flash) { outlineCell(opts.flash, '#ff5050'); } for (const fx of (st.fx || [])) { if (fx.kind === 'violate') { const p = st.pos[fx.id]; bx.strokeStyle = 'rgba(255,80,80,0.9)'; bx.lineWidth = 3; bx.strokeRect(p.x*CELL+2, p.y*CELL+2, CELL-5, CELL-5); } } st.fx = []; // GOAL GAUGE: the harvest goal reads as a SHAPE at a fixed top position. Keys // ONLY on the public goal (never the rule, C1). Drawn last to overlay the band. if (opts.goal) drawGoalGauge(st, opts.goal, opts.goalFrac, opts.goalFilled, opts.goalQuota, opts.goalExtra); // §6 KEY LEGEND: a glyph-only key strip (arrows / ⎵·Tab / '.') painted in the play // band so the controls read without prose. Only in play (not demo/report). if (opts.keyLegend) drawKeyLegend(); // DEMO BAND CUE: states (positively) that the demo is display-only — the shared // ♥ is NOT charged during a new agent's self-demonstration. else if (opts.demoBand) drawDemoBandCue(); } // faint top-band for the demo, TEXT-FREE: a PURE icon band that the new agent's // self-demonstration is display-only / NOT scored. The Korean string is removed // (textKill); the meaning now reads from (a) a "no-charge" glyph — a ♥ with a slash — // and (b) the dashed-green foregone-chevron swatch (the only band content), plus the // hearts pips NOT decrementing during the demo. Never names the rule (C1). function drawDemoBandCue() { const BAND = 22; bx.fillStyle = 'rgba(14,15,19,0.82)'; bx.fillRect(0, 0, board.width, BAND); bx.strokeStyle = '#2a2d36'; bx.lineWidth = 1; bx.beginPath(); bx.moveTo(0, BAND + 0.5); bx.lineTo(board.width, BAND + 0.5); bx.stroke(); // NO-CHARGE glyph: a small ♥ with a diagonal slash = "score / survival NOT charged here". const hy = BAND / 2 + 0.5, hxp = 14; bx.save(); _heartPath(bx, hxp, hy, 5); bx.strokeStyle = '#6b7280'; bx.lineWidth = 1.4; bx.stroke(); bx.strokeStyle = '#9aa0ac'; bx.lineWidth = 1.6; bx.beginPath(); bx.moveTo(hxp - 7, hy + 7); bx.lineTo(hxp + 7, hy - 7); bx.stroke(); // slash bx.restore(); // GLYPH-ONLY LEGEND for the foregone-greedy ghost: a short dashed-green chevron swatch so the // canvas viewer can decode the declined reward-greedy arrow. A glyph (no concern/ordering name); // rule-blind so it reveals nothing about the order (C1). const sx = hxp + 18, sy = BAND / 2 + 0.5; bx.save(); bx.strokeStyle = _alpha('#009E73', 0.45); bx.lineWidth = 2; bx.setLineDash([4, 3]); bx.beginPath(); bx.moveTo(sx, sy); bx.lineTo(sx + 18, sy); bx.stroke(); bx.setLineDash([]); bx.beginPath(); bx.moveTo(sx + 13, sy - 4); bx.lineTo(sx + 18, sy); bx.lineTo(sx + 13, sy + 4); bx.stroke(); bx.restore(); } // shared heart path (used by the demo no-charge glyph on the board ctx). function _heartPath(ctx, cx, cy, s) { ctx.beginPath(); ctx.moveTo(cx, cy + s * 0.9); ctx.bezierCurveTo(cx - s * 1.4, cy - s * 0.4, cx - s * 0.5, cy - s * 1.3, cx, cy - s * 0.4); ctx.bezierCurveTo(cx + s * 0.5, cy - s * 1.3, cx + s * 1.4, cy - s * 0.4, cx, cy + s * 0.9); ctx.closePath(); } // ENDING BANNER — a centered overlay drawn ON the board when the run concludes, // so the outcome (런 종료 / 클리어) is legible without reading the hint line. The // label keys on the run STATUS only (never the hidden rule, C1). function drawEndingBanner(status) { const label = endingLabel(status); const accent = endingColor(status); const bw = board.width, cy = board.height / 2; bx.fillStyle = 'rgba(14,15,19,0.86)'; bx.fillRect(0, cy - 44, bw, 88); bx.strokeStyle = accent; bx.lineWidth = 2; bx.strokeRect(1, cy - 44, bw - 2, 88); bx.textAlign = 'center'; bx.fillStyle = '#9aa0ac'; bx.font = '12px ui-monospace, monospace'; bx.fillText('런 종료', bw / 2, cy - 18); bx.fillStyle = accent; bx.font = '20px ui-monospace, monospace'; bx.fillText(label, bw / 2, cy + 12); bx.textAlign = 'left'; } // FINALE / ENDING ("정복 완료") — a distinct CELEBRATORY overlay drawn when the run // reaches status==='cleared_cap' (the pool exhausted: ALL rules conquered). Unlike // the plain death banner it shows the achievement frame: depth, party size, and the // D × M agentness summary (analyst rows are fine on the ending). C1-safe: it keys on // the run STATUS + headline counts + the orthogonal D×M aggregate, never a rule. function drawFinaleBanner(run) { const accent = endingColor('cleared_cap'); const rep = C.runReport(run); const h = C.headlineState(run); const pc = v => v == null ? 'n/a' : Math.round(clamp01(v) * 100) + '%'; const ds = rep.discoveryByDepth.filter(d => d.discovery != null).map(d => d.discovery); const dAvg = ds.length ? ds.reduce((a, b) => a + b, 0) / ds.length : null; const ms = Object.values(rep.maintenanceByAgent).map(m => m.maintenance).filter(v => v != null); const mAvg = ms.length ? ms.reduce((a, b) => a + b, 0) / ms.length : null; const dm = (dAvg != null && mAvg != null) ? clamp01(dAvg) * clamp01(mAvg) : null; const bw = board.width, cy = board.height / 2; // a celebratory double frame. bx.fillStyle = 'rgba(10,20,14,0.90)'; bx.fillRect(0, cy - 76, bw, 152); bx.strokeStyle = accent; bx.lineWidth = 3; bx.strokeRect(6, cy - 76 + 6, bw - 12, 152 - 12); bx.strokeStyle = 'rgba(127,206,151,0.5)'; bx.lineWidth = 1; bx.strokeRect(14, cy - 76 + 14, bw - 28, 152 - 28); bx.textAlign = 'center'; bx.fillStyle = accent; bx.font = 'bold 26px ui-monospace, monospace'; bx.fillText('★ 정복 완료 ★', bw / 2, cy - 38); bx.fillStyle = '#cfe0ff'; bx.font = '13px ui-monospace, monospace'; bx.fillText('모든 규칙을 정복했습니다', bw / 2, cy - 14); // depth × party size headline + the D × M agentness summary. bx.fillStyle = '#e8eef8'; bx.font = 'bold 15px ui-monospace, monospace'; bx.fillText(`깊이 ${h.depth} · 파티 ${h.partySize}`, bw / 2, cy + 14); bx.fillStyle = '#a78bfa'; bx.font = '13px ui-monospace, monospace'; bx.fillText(`발견 ${pc(dAvg)} × 유지 ${pc(mAvg)} = D×M ${pc(dm)}`, bw / 2, cy + 38); bx.fillStyle = '#7fce97'; bx.font = '11px ui-monospace, monospace'; bx.fillText('▶ 로 다시 시작', bw / 2, cy + 60); bx.textAlign = 'left'; } // GOAL GAUGE — a top band that makes the cycle's PUBLIC goal obvious at first // glance, with NO text-label dependence for the per-goal STATE (a glyph at the band // head names the goal-FAMILY; the live state reads off glyphs/pips). Keys ONLY on the // public goal (never the rule, C1). Four grammars: // harvest_max — a row of pip-circles (filled = collected toward the quota). // deliver_to_zone — a socket glyph (the delivery target) + the carried/quota pips // so "carry a token TO the socket" reads without any rule text. // reach_zones — a row of per-seat reach pips drawn in each seat's IDENTITY // shape+color (filled when that seat reached its destination tile), // matching the on-board destination outlines. // collect_set — a recipe row of token glyph-chips (one per required kind) that // check off (filled) as that kind is collected. // TEXT-FREE goal-band primitives ------------------------------------------------ // A small flag (pole + pennant) is the universal "this is the OBJECTIVE" marker — it // replaces the Korean word '목표' so the band reads with no text dependence. A gold // hue keeps it distinct from every ARC family/seat color (it is a marker, not a type). function drawGoalFlag(x, cy) { bx.save(); bx.strokeStyle = '#e8c14a'; bx.fillStyle = '#e8c14a'; bx.lineWidth = 1.6; bx.beginPath(); bx.moveTo(x, cy - 8); bx.lineTo(x, cy + 8); bx.stroke(); // pole bx.beginPath(); bx.moveTo(x, cy - 8); bx.lineTo(x + 9, cy - 5); bx.lineTo(x, cy - 2); bx.closePath(); bx.fill(); // pennant bx.restore(); } // ON-BOARD QUOTA GAUGE GLYPH: harvest_max / collect_set carry no single goal CELL, so render // ONE distinct on-board goal marker in the board's top-right inset (below the 32px gauge band) — // a compact "harvest meter": a small rounded-SQUARE frame (the goal-FAMILY container, a DISTINCT // CLASS from the round gems and from the blue concentric you-halo) holding (a) the reused gold // campaign goal flag at left and (b) a short VERTICAL fill-column whose ARC.green extent = goalFrac // with a faint hollow remainder, plus per-quota TICK NOTCHES (filled = collected) mirroring the // band gauge's pip grammar (app.js:1248-1253) so band + on-board badge speak ONE visual language. // NON-CLASH: gold flag (#e8c14a) + ARC.green (#2ECC40) ONLY — never SPRITE_HUE.agent blue; a // SQUARE framed gauge, NOT a bright concentric ring; pinned in the fixed inset, drawn UNDER the // actors; small footprint (<=46x30) with a faint dark backing + static/low-pulse fill, so total // bright area stays far below the blue you-cell (HARD CONSTRAINT 2). Keyed ONLY on the PUBLIC goal // + filled/quota counts (never the hidden ordering, C1). This is a SINGLE element, NOT a per-gem // ring (HARD CONSTRAINT 1). Text-free. function drawGoalCornerBadge(goal, filled, quota, frac) { const BAND = 32; const bw = 46, bh = 30; // bigger/clearer square-framed gauge const x0 = board.width - bw - 6, y0 = BAND + 6; // top-right inset, below the band const q = Math.max(1, quota | 0); const f = Math.max(0, Math.min(1, frac != null ? frac : (filled | 0) / q)); bx.save(); // faint dark backing so the gauge reads on the grid (low brightness, never a glow disc) bx.fillStyle = 'rgba(14,15,19,0.78)'; _roundRect(x0, y0, bw, bh, 5); bx.fill(); bx.strokeStyle = '#2a2d36'; bx.lineWidth = 1; _roundRect(x0 + 0.5, y0 + 0.5, bw, bh, 5); bx.stroke(); const cy = y0 + bh / 2; // perKindGrammar: the embedded gold flag is DROPPED so the harvest meter is a PURE green-fill // square (a gauge), not "flag + counter" that echoes deliver/reach. The column is re-centered // in the frame. The gold flag is retired from the per-kind BOARD layer entirely (it survives // only in the top HUD band's drawGoalGauge head, which the board-only scan excludes). // VERTICAL FILL-COLUMN (the quota meter): a thin rounded column, ARC.green fill = frac from // the bottom up, faint hollow remainder above. Distinct SQUARE/linear class (not a ring). const colW = 12, colX = x0 + (bw - colW) / 2, colTop = y0 + 5, colBot = y0 + bh - 5; const colH = colBot - colTop; bx.fillStyle = 'rgba(46,204,64,0.12)'; // faint hollow remainder bx.fillRect(colX, colTop, colW, colH); bx.strokeStyle = '#3a3d45'; bx.lineWidth = 1; bx.strokeRect(colX + 0.5, colTop + 0.5, colW, colH); const fillH = Math.round(colH * f); if (fillH > 0) { bx.fillStyle = _alpha(ARC.green, 0.92); // harvest_max only now (collect dropped) bx.fillRect(colX, colBot - fillH, colW, fillH); // grows from the bottom = frac collected } // QUOTA TICK NOTCHES: one per quota unit (capped so they stay legible), filled = collected, // mirroring the band gauge pip grammar so a small-quota count reads exactly at a glance. const ticks = Math.min(q, 6); if (ticks > 1) { bx.lineWidth = 1; bx.lineCap = 'butt'; const fc = filled | 0; const fracPerTick = q / ticks; // notches map onto the quota span for (let i = 1; i < ticks; i++) { const ty = colBot - (colH * i) / ticks; bx.strokeStyle = (fc >= i * fracPerTick) ? '#0c0d11' : 'rgba(58,61,69,0.9)'; bx.beginPath(); bx.moveTo(colX, ty); bx.lineTo(colX + colW, ty); bx.stroke(); } } bx.restore(); } // rounded-rect path helper (square goal-family container; never a circle = no you-halo clash). function _roundRect(x, y, w, h, r) { bx.beginPath(); bx.moveTo(x + r, y); bx.arcTo(x + w, y, x + w, y + h, r); bx.arcTo(x + w, y + h, x, y + h, r); bx.arcTo(x, y + h, x, y, r); bx.arcTo(x, y, x + w, y, r); bx.closePath(); } // a green ✓ at the band's right edge when the cycle's PUBLIC goal portion is met, so // "objective complete" reads without text (the depth-up still also needs engagement). function drawBandCheck(met) { if (!met) return; const x = board.width - 18, cy = 16; bx.save(); bx.strokeStyle = ARC.green; bx.lineWidth = 2.5; bx.lineCap = 'round'; bx.beginPath(); bx.moveTo(x - 5, cy); bx.lineTo(x - 1, cy + 5); bx.lineTo(x + 7, cy - 6); bx.stroke(); bx.restore(); } // pulse phase helpers (browser-side animation; Date.now() is fine in app.js). A 0..1 // eased glow used to PULSE a live on-board goal target so the top flag binds to "go HERE". function _pulseGlow() { const t = (Date.now() % 1100) / 1100; return 0.5 - 0.5 * Math.cos(t * 6.283185); } // EMPHASIS flag: set true ONLY inside draw() when demoAnim is on a DECISIVE value-demo frame // (scenePair || foregone); reset false otherwise. Guards the agent-ring + foregone-ghost pulse so // live PLAY render is byte-identical (the flag stays false in play). Module-level (read by glyphs). let _demoEmphasis = false; // PLAY PULSE: true only during the live play stage (set in draw); gates a gentle always-on // breathe on the active-seat glow so the turn indicator reads on-board (no text badge). Purely // time-based (Date.now via _pulseGlow), no game state, no rule (C1). False in demo/report. let _playPulse = false; // PULSING TARGET BEACON: an expanding/fading ring on a live goal tile (delivery socket // or an unreached destination), in the goal-family / seat color. Pure render off the // PUBLIC goal state (C1: keyed on zone/destination cell + seat id, never the rule). function drawTargetBeacon(gx, gy, color) { const cx = gx * CELL + CELL / 2, cy = gy * CELL + CELL / 2; const g = _pulseGlow(); bx.save(); bx.globalAlpha = 0.25 + 0.45 * (1 - g); bx.strokeStyle = color; bx.lineWidth = 2; bx.beginPath(); bx.arc(cx, cy, CELL * 0.30 + CELL * 0.20 * g, 0, 7); bx.stroke(); bx.restore(); } // DELIVER_TO_ZONE socket (perKindGrammar): a single bold ARC.sky RECEPTACLE on the one zone cell. // A blind viewer reads "a container that ACCEPTS a token" — categorically NOT "a colored shape // outline" (that is reach's class). It is built from three primitives reach never carries: // (1) a HEAVY double-stroke SOLID sky square FRAME (a 3D socket WALL with depth), not a thin rim; // (2) a token-shaped HOLE rendered as a SOLID dark fill disc ringed by a thin sky keyline — a // punched-out negative-space slot inside the lit frame ("a slot awaiting a token"); and // (3) a small downward sky DROP chevron pinned just above the frame, pointing INTO the socket. // Animation = a slow sky INTAKE pulse on the FRAME edge ONLY (the square wall), never an expanding // concentric ring (that is the you-halo class). On delivery the HOLE FILLS with the sky token disc, // the drop chevron hides, and a faint sky check appears; the frame stays solid. This is the deliver // board's UNIQUE class: ONE sky square-socket on a single cell. Keyed on PUBLIC zone + carried (the // public deliver gauge filled>=quota / delivered) only, never the rule (C1). Sky (ARC.sky, b-g // small) so it never enters the blue you-class; a SQUARE frame + chevron, never a concentric halo. function drawDeliverSocket(gx, gy, carried) { const px = gx * CELL, py = gy * CELL, cx = px + CELL / 2, cy = py + CELL / 2; const half = CELL * 0.34, tokR = CELL * 0.22; bx.save(); // faint sky wash so the socket cell reads as the delivery target on the black grid. bx.fillStyle = _alpha(ARC.sky, carried ? 0.22 : 0.08); bx.fillRect(px + 2, py + 2, CELL - 4, CELL - 4); // (1) HEAVY DOUBLE-STROKE SOLID sky square FRAME = a socket WALL with depth (not a thin rim). // Outer thick wall + inner thinner inset line read as a 3D receptacle edge. A slow sky INTAKE // pulse rides the OUTER wall edge ONLY (rim pulse, never a concentric ring). const g = _pulseGlow(); bx.lineWidth = 3 + 1.0 * g; bx.strokeStyle = _alpha(ARC.sky, 0.85); bx.strokeRect(cx - half, cy - half, half * 2, half * 2); // outer socket wall (solid) bx.lineWidth = 1.5; bx.strokeStyle = _alpha(ARC.sky, 0.55); bx.strokeRect(cx - half + 3, cy - half + 3, half * 2 - 6, half * 2 - 6); // inner inset wall (depth) if (carried) { // (4) FILLED: the HOLE fills with the delivered sky token disc + a faint sky check; the drop // chevron is hidden; the frame stays solid (read off PUBLIC delivered/carry state). bx.fillStyle = _alpha(ARC.sky, 0.85); bx.beginPath(); bx.arc(cx, cy, tokR, 0, 7); bx.fill(); bx.strokeStyle = _alpha(ARC.sky, 0.85); bx.lineWidth = 2; bx.lineCap = 'round'; bx.beginPath(); bx.moveTo(cx - tokR * 0.45, cy); bx.lineTo(cx - tokR * 0.1, cy + tokR * 0.4); bx.lineTo(cx + tokR * 0.5, cy - tokR * 0.4); bx.stroke(); } else { // (2) PUNCHED HOLE = negative space: a SOLID dark fill disc (the board bg) ringed by a thin sky // keyline — an empty token slot inside the lit frame (not a dashed outline-among-outlines). bx.fillStyle = ARC.bg; bx.beginPath(); bx.arc(cx, cy, tokR, 0, 7); bx.fill(); bx.strokeStyle = _alpha(ARC.sky, 0.7); bx.lineWidth = 1.5; bx.beginPath(); bx.arc(cx, cy, tokR, 0, 7); bx.stroke(); // hole keyline // (3) DROP-AFFORDANCE chevron: a small downward sky caret pinned just ABOVE the frame inside // the cell, pointing INTO the socket ("drop here"). A one-cell directional cue reach never has. const cw = CELL * 0.16, chTop = py + CELL * 0.10; bx.strokeStyle = _alpha(ARC.sky, 0.85); bx.lineWidth = 2; bx.lineCap = 'round'; bx.lineJoin = 'round'; bx.beginPath(); bx.moveTo(cx - cw / 2, chTop); bx.lineTo(cx, chTop + cw * 0.7); bx.lineTo(cx + cw / 2, chTop); bx.stroke(); } bx.restore(); } // REACH_ZONES per-seat reach PIP (perKindGrammar): a tiny seat-identity-colored dot pinned at the // destination tile's bottom-right corner. Hollow (dashed) while UNREACHED, filled+lit on arrival // (read off PUBLIC goalExtra.seatReached via the `reached` flag). Seat hues only (non-blue), never // a ring/flag — it ties the dashed seat-shape ghost to "this seat has/has-not arrived". C1: seat id. function drawReachPip(dz, color, reached) { const px = dz.x * CELL, py = dz.y * CELL; const cx = px + CELL - CELL * 0.20, cy = py + CELL - CELL * 0.20, r = Math.max(2.5, CELL * 0.09); bx.save(); if (reached) { bx.fillStyle = color; bx.beginPath(); bx.arc(cx, cy, r, 0, 7); bx.fill(); bx.strokeStyle = '#0e0f13'; bx.lineWidth = 1; bx.stroke(); } else { bx.strokeStyle = color; bx.lineWidth = 1.5; bx.setLineDash([2, 2]); bx.beginPath(); bx.arc(cx, cy, r, 0, 7); bx.stroke(); } bx.restore(); } // COLLECT_SET still-needed token tie (perKindGrammar / GUARD 1): a faint STATIC kind-color corner // WEDGE (a small filled triangle, NOT a concentric ring) on an outstanding recipe token, so board // tokens tie to the top-left recipe chip row. Drawn at the token's TOP-right corner so it does not // clash with drawTerrainCorner's TOP-left wedge. Static (no pulse). C1: keyed on PUBLIC kind only. function drawNeededKindWedge(x, y, color) { const px = x * CELL, py = y * CELL, s = Math.max(4, CELL * 0.26); bx.save(); bx.fillStyle = _alpha(color, 0.85); bx.beginPath(); bx.moveTo(px + CELL - 1, py + 1); bx.lineTo(px + CELL - 1 - s, py + 1); bx.lineTo(px + CELL - 1, py + 1 + s); bx.closePath(); bx.fill(); bx.strokeStyle = 'rgba(14,15,19,0.85)'; bx.lineWidth = 1; bx.stroke(); bx.restore(); } // COLLECT_SET on-board RECIPE ROW (perKindGrammar): a fixed HORIZONTAL row of kind-chips // (drawRecipeChip: kind-tinted disc, hollow=outstanding, filled+green-ring=collected) in the // board's top-LEFT inset (below the 32px HUD band). Mirror position of harvest's top-RIGHT square // meter so POSITION alone disambiguates the two collection grammars. This is the collect board's // UNIQUE class. Keyed on PUBLIC recipe + kindDone only (never the rule, C1). Multi-hued discs in a // row — never a green vertical meter, never a flag, never a sky socket, never seat-shapes. function drawCollectRecipeRow(extra) { const recipe = (extra && extra.recipe) || []; if (!recipe.length) return; const kindDone = (extra && extra.kindDone) || {}; const BAND = 32, r = 8, gap = 22; const x0 = 6 + 4, y0 = BAND + 6; const bw = Math.min(recipe.length, 6) * gap + 8, bh = 24; bx.save(); // faint dark backing so the chip row reads as one widget on the grid (low brightness). bx.fillStyle = 'rgba(14,15,19,0.78)'; _roundRect(6, y0, bw, bh, 5); bx.fill(); bx.strokeStyle = '#2a2d36'; bx.lineWidth = 1; _roundRect(6 + 0.5, y0 + 0.5, bw, bh, 5); bx.stroke(); const cy = y0 + bh / 2; const cap = Math.min(recipe.length, 6); // cap row width; keep legible for (let i = 0; i < cap; i++) { const k = recipe[i]; drawRecipeChip(x0 + i * gap + gap / 2, cy, r, k, !!kindDone[k]); } bx.restore(); } function drawGoalGauge(st, goal, fillFrac, filled, quota, extra) { const BAND = 32; bx.fillStyle = 'rgba(14,15,19,0.82)'; bx.fillRect(0, 0, board.width, BAND); bx.strokeStyle = '#2a2d36'; bx.lineWidth = 1; bx.beginPath(); bx.moveTo(0, BAND + 0.5); bx.lineTo(board.width, BAND + 0.5); bx.stroke(); const cy = BAND / 2; // GOAL-FAMILY HEAD GLYPH (icon, not state): a tiny pictograph at the left so the // goal family reads at a glance. The retained Korean tag keeps band parity with the // shipped harvest/deliver look; the per-goal STATE below is glyph-only. if (goal === 'reach_zones') return drawReachGauge(st, BAND, cy, extra); if (goal === 'collect_set') return drawCollectGauge(st, BAND, cy, extra); const deliver = goal === 'deliver_to_zone'; // TEXT-FREE objective marker (a flag) replaces the old '목표 ▸X' word, then a // goal-FAMILY icon so the family reads with NO Korean text (글 없이 이해). drawGoalFlag(14, cy); if (deliver) { // SOCKET glyph: a small open square (the zone target) the carried tokens slot // into — the SAME ARC sky as the on-board zone block so they read as one goal. bx.strokeStyle = ARC.sky; bx.lineWidth = 2; bx.strokeRect(34, cy - 6, 12, 12); } else { // HARVEST glyph: a token disc (same ARC green as a collected pip) — "gather these". bx.fillStyle = _alpha(ARC.green, 0.85); bx.beginPath(); bx.arc(40, cy, 6, 0, 7); bx.fill(); } const x0 = 56; const q = Math.max(1, quota | 0); const gap = Math.min(16, (board.width - x0 - 12) / q); const r = Math.min(5, gap / 2 - 1); for (let i = 0; i < q; i++) { const px = x0 + i * gap + gap / 2; bx.beginPath(); bx.arc(px, cy, r, 0, 7); if (i < (filled | 0)) { bx.fillStyle = ARC.green; bx.fill(); } // filled = collected else { bx.strokeStyle = '#3a3d45'; bx.lineWidth = 1; bx.stroke(); } } drawBandCheck((filled | 0) >= q); // ✓ when the goal portion is met } // REACH_ZONES gauge: one pip per seat in that seat's IDENTITY shape+color, filled when // the seat reached its destination (read off extra.seatReached — a PUBLIC reach flag, // not the rule). A small target-ring head glyph names the goal family. C1: pips key on // seat id only (same shape/color as the on-board destination outline + the actor). function drawReachGauge(st, BAND, cy, extra) { drawGoalFlag(14, cy); // text-free objective marker // target-ring family icon (a destination ring) bx.strokeStyle = ARC.sky; bx.lineWidth = 1.5; bx.beginPath(); bx.arc(40, cy, 6, 0, 7); bx.stroke(); bx.beginPath(); bx.arc(40, cy, 2.5, 0, 7); bx.stroke(); const reached = (extra && extra.seatReached) || {}; const ids = Object.keys(reached); const x0 = 60; const gap = Math.min(26, (board.width - x0 - 12) / Math.max(1, ids.length)); const r = Math.min(7, gap / 2 - 2); ids.forEach((sid, i) => { const px = x0 + i * gap + gap / 2; const done = !!reached[sid]; drawSeatPip(px, cy, r, seatShape(+sid), seatColor(+sid), done); }); drawBandCheck(ids.length > 0 && ids.every(s => reached[s])); } // COLLECT_SET gauge: a recipe row of token glyph-chips (one per required kind). A chip // is a small token disc tinted by its kind (a PUBLIC glyph id, rule-invariant); a // collected kind is filled+ringed, an outstanding kind is hollow. Reads off // extra.recipe + extra.kindDone (PUBLIC goal state, never the rule, C1). function drawCollectGauge(st, BAND, cy, extra) { drawGoalFlag(14, cy); // text-free objective marker // stacked-chips family icon (a recipe set) bx.strokeStyle = ARC.yellow; bx.lineWidth = 1.5; bx.strokeRect(32, cy - 5, 8, 8); bx.strokeRect(38, cy - 2, 8, 8); const recipe = (extra && extra.recipe) || []; const kindDone = (extra && extra.kindDone) || {}; const x0 = 56; const gap = Math.min(30, (board.width - x0 - 12) / Math.max(1, recipe.length)); const r = Math.min(8, gap / 2 - 2); recipe.forEach((k, i) => { const px = x0 + i * gap + gap / 2; drawRecipeChip(px, cy, r, k, !!kindDone[k]); }); drawBandCheck(recipe.length > 0 && recipe.every(k => kindDone[k])); } /* ---- BLOCK RENDERER (V4, ARC-AGI-3 vocabulary) ------------------------------- drawBlock(x, y, typeId): render a terrain/structural block cell with its DISTINCT ARC color + unique glyph. Appearance is a PURE function of the block TYPE id (blockStyle(typeId)) — NEVER an agent / rule (C1). Each (family, sub-instance) has a distinct color (family hue, sub-instance shade) AND a distinct glyph motif, so every block type is unmistakable by both color and shape, on the ARC black grid. */ function drawBlock(x, y, typeId) { const s = blockStyle(typeId); const px = x*CELL, py = y*CELL, cx = px + CELL/2, cy = py + CELL/2; const pad = 1, w = CELL - 2*pad; // a faint type-colored cell wash so the block reads as a colored tile on black, // then the unique glyph on top. (wash uses the type color at low alpha.) bx.fillStyle = _alpha(s.color, typeId === 'wall' ? 1 : 0.16); bx.fillRect(px+pad, py+pad, w, w); bx.strokeStyle = s.color; bx.lineWidth = 1.5; switch (s.glyph) { case 'solid': // filled tile (full block) bx.fillStyle = _alpha(s.color, 0.9); bx.fillRect(px+2, py+2, CELL-4, CELL-4); break; case 'hatch': // diagonal hatch lines _clipCell(px, py, () => { for (let i = -CELL; i < CELL; i += 5) { bx.beginPath(); bx.moveTo(px+i, py); bx.lineTo(px+i+CELL, py+CELL); bx.stroke(); } }); break; case 'diag': // single thick diagonal motif bx.lineWidth = 2.5; bx.beginPath(); bx.moveTo(px+3, py+CELL-3); bx.lineTo(px+CELL-3, py+3); bx.stroke(); break; case 'dots': // dot grid bx.fillStyle = s.color; for (const ox of [0.30,0.70]) for (const oy of [0.30,0.70]) { bx.beginPath(); bx.arc(px+CELL*ox, py+CELL*oy, CELL*0.07, 0, 7); bx.fill(); } break; case 'plusgrid': // four small plus marks bx.lineWidth = 1.5; for (const ox of [0.32,0.68]) for (const oy of [0.32,0.68]) { const gx = px+CELL*ox, gy = py+CELL*oy, r = CELL*0.08; bx.beginPath(); bx.moveTo(gx-r,gy); bx.lineTo(gx+r,gy); bx.moveTo(gx,gy-r); bx.lineTo(gx,gy+r); bx.stroke(); } break; case 'ring': // open ring bx.lineWidth = 2; bx.beginPath(); bx.arc(cx, cy, CELL*0.28, 0, 7); bx.stroke(); break; case 'chevron': // double chevron bx.lineWidth = 2; for (const dy of [-CELL*0.12, CELL*0.12]) { bx.beginPath(); bx.moveTo(px+CELL*0.28, cy+dy); bx.lineTo(cx, cy+dy-CELL*0.18); bx.lineTo(px+CELL*0.72, cy+dy); bx.stroke(); } break; case 'triangle': // filled triangle motif bx.fillStyle = _alpha(s.color, 0.85); bx.beginPath(); bx.moveTo(cx, py+CELL*0.22); bx.lineTo(px+CELL*0.22, py+CELL*0.78); bx.lineTo(px+CELL*0.78, py+CELL*0.78); bx.closePath(); bx.fill(); break; case 'cross': // bold X bx.lineWidth = 2.5; bx.beginPath(); bx.moveTo(px+CELL*0.26, py+CELL*0.26); bx.lineTo(px+CELL*0.74, py+CELL*0.74); bx.moveTo(px+CELL*0.74, py+CELL*0.26); bx.lineTo(px+CELL*0.26, py+CELL*0.74); bx.stroke(); break; case 'wall': // solid grey slab + bevel (impassable) bx.strokeStyle = _alpha('#FFFFFF', 0.25); bx.lineWidth = 1.5; bx.strokeRect(px+2, py+2, CELL-4, CELL-4); break; case 'socket': // delivery zone: nested square socket bx.lineWidth = 2; bx.strokeRect(px+CELL*0.18, py+CELL*0.18, CELL*0.64, CELL*0.64); bx.lineWidth = 1; bx.strokeRect(px+CELL*0.34, py+CELL*0.34, CELL*0.32, CELL*0.32); break; default: bx.strokeRect(px+2, py+2, CELL-4, CELL-4); } } // alpha-blend an ARC hex over the black grid (rgba). Pure color helper. function _alpha(hex, a) { const h = hex.replace('#', ''); const r = parseInt(h.slice(0,2),16), g = parseInt(h.slice(2,4),16), b = parseInt(h.slice(4,6),16); return `rgba(${r},${g},${b},${a})`; } // clip drawing to a single cell (for the hatch fill). function _clipCell(px, py, fn) { bx.save(); bx.beginPath(); bx.rect(px+1, py+1, CELL-2, CELL-2); bx.clip(); fn(); bx.restore(); } // RECESSIVE HAZARD FLOOR (renderer-only; the logical hazard Set / count / positions are // untouched, so the scored board + all proofs stay byte-identical — see hazardRecession). // Every hazard-family cell paints as the SAME flat dark desaturated floor tile (a dim warm // grey clearly ABOVE the empty-cell bg #14161c but well BELOW any actor/token saturation), // so the union of hazard cells reads as ONE recessive region, not 40 bright actors. No // multi-cell silhouette, no bright edge, no per-family A/B/C glyph differentiation — the // appearance is a pure function of "this cell is a hazard-set member", never the rule (C1). function drawRecessiveFloor(x, y) { const px = x * CELL, py = y * CELL; // FIX(class3) BORDERLESS RECESSED FLOOR: the 1px dark inset stroke created a crisp // foreground-block silhouette that read as a discrete OBJECT (hazardRecedes crashed to // 0.27, confusable with the solid grey wall block). Drop the border entirely and paint a // borderless, slightly darker flat wash + a faint 45deg hatch texture (clipped to the // cell) so the region reads as RECESSED GROUND TEXTURE, not an object. Still a pure fn of // hazard-set membership (paint-only; the logical hazard Set is untouched) → C1. bx.fillStyle = _alpha('#332620', 0.40); // borderless dim warm floor wash, recessive bx.fillRect(px, py, CELL, CELL); _clipCell(px, py, function () { bx.strokeStyle = _alpha('#0e0f13', 0.18); bx.lineWidth = 1; // faint 45deg ground hatch for (let d = -CELL; d < CELL * 2; d += 5) { bx.beginPath(); bx.moveTo(px + d, py); bx.lineTo(px + d - CELL, py + CELL); bx.stroke(); } }); } // TERRAIN-UNDER-TOKEN corner wedge: a small triangle in the cell's top-left corner in // the underlying terrain's color (with a dark edge so it reads on the token disc). Lets // the player see the tile a token sits on. Pure render off the terrain TYPE color (C1). function drawTerrainCorner(x, y, color) { const px = x * CELL, py = y * CELL, s = Math.max(5, CELL * 0.34); bx.save(); bx.fillStyle = color; bx.beginPath(); bx.moveTo(px + 1, py + 1); bx.lineTo(px + 1 + s, py + 1); bx.lineTo(px + 1, py + 1 + s); bx.closePath(); bx.fill(); bx.strokeStyle = 'rgba(14,15,19,0.85)'; bx.lineWidth = 1; bx.stroke(); bx.restore(); } function drawToken(x, y, v, kind) { // C1: color/glyph is a pure function of PUBLIC token state, never the rule. value is // SIZE-coded (max value = biggest disc), so avoid_biggest is readable without a label. // On collect_set boards a token also carries a PUBLIC, RULE-INVARIANT `kind` (a glyph // id = a position hash, never the rule) — tint by kind so the player can match each // board token to the recipe chips (collect_set was otherwise an invisible-kind gamble). // Off collect boards kind is undefined -> the single identical 'token' color (no rule // signal); SIZE still carries the value ordering in both cases. const accent = (kind == null) ? blockStyle('token').color : kindColor(kind); const cx = x*CELL + CELL/2, cy = y*CELL + CELL/2; const r = CELL * (0.13 + 0.20 * Math.sqrt(Math.min(v, 15) / 15)); bx.fillStyle = _alpha(accent, 0.20); bx.beginPath(); bx.arc(cx, cy, r, 0, 7); bx.fill(); bx.strokeStyle = accent; bx.lineWidth = 1.5; bx.beginPath(); bx.arc(cx, cy, r, 0, 7); bx.stroke(); } /* ---- V3 IDENTITY VISUALS (Slice 3, C1-safe) ----------------------------------- Each accumulated agent is drawn with a DISTINCT SHAPE + identity COLOR chosen by its SEAT INDEX (deterministic table, cycling for indices beyond the table length). This maps ONLY to seat id, NEVER to the hidden rule (C1: appearance must not leak the rule). Tokens stay a single identical color (value = SIZE only). The ACTIVE agent gets a clear glow/emphasis ring. Pure view: no rule is ever consulted here. */ // >=12 distinct shapes (cycle for larger parties) and a fixed identity palette. const SEAT_SHAPES = ['circle', 'square', 'triangle', 'diamond', 'pentagon', 'hexagon', 'star', 'cross', 'heptagon', 'octagon', 'triangle-down', 'plus']; const SEAT_COLORS = ['#3f7df6', '#e0594f', '#7fce97', '#f2c14e', '#a78bfa', '#3fa7ff', '#ef8acb', '#5fd0c8', '#c9d44e', '#ff9d57', '#9aa0ac', '#b06fe0']; function seatShape(seatId) { return SEAT_SHAPES[((seatId % SEAT_SHAPES.length) + SEAT_SHAPES.length) % SEAT_SHAPES.length]; } function seatColor(seatId) { return SEAT_COLORS[((seatId % SEAT_COLORS.length) + SEAT_COLORS.length) % SEAT_COLORS.length]; } // trace a regular polygon (n>=3) / star centered at (cx,cy) with circumradius r. function _polyPath(cx, cy, r, n, rot) { rot = rot == null ? -Math.PI / 2 : rot; bx.beginPath(); for (let i = 0; i < n; i++) { const a = rot + i * 2 * Math.PI / n; const x = cx + r * Math.cos(a), y = cy + r * Math.sin(a); if (i === 0) bx.moveTo(x, y); else bx.lineTo(x, y); } bx.closePath(); } function _starPath(cx, cy, r, points) { bx.beginPath(); for (let i = 0; i < points * 2; i++) { const rr = (i % 2 === 0) ? r : r * 0.45; const a = -Math.PI / 2 + i * Math.PI / points; const x = cx + rr * Math.cos(a), y = cy + rr * Math.sin(a); if (i === 0) bx.moveTo(x, y); else bx.lineTo(x, y); } bx.closePath(); } // draw the seat's identity SHAPE filled in its identity COLOR with a dark outline. function drawActorShape(p, shape, color) { const cx = p.x*CELL + CELL/2, cy = p.y*CELL + CELL/2, r = CELL*0.30; bx.fillStyle = color; bx.strokeStyle = '#0e0f13'; bx.lineWidth = 2; _shapePath(cx, cy, r, shape); // shared seat-identity glyph (actor/destination/pip) bx.fill(); bx.stroke(); } // a thick cross/plus glyph (rot=0 plus, rot=PI/4 X), traced as a 12-point polygon. function _crossPath(cx, cy, r, rot) { const t = r * 0.40; // arm half-thickness const pts = [ [-t, -r], [t, -r], [t, -t], [r, -t], [r, t], [t, t], [t, r], [-t, r], [-t, t], [-r, t], [-r, -t], [-t, -t], ]; const c = Math.cos(rot), s = Math.sin(rot); bx.beginPath(); for (let i = 0; i < pts.length; i++) { const x = cx + pts[i][0]*c - pts[i][1]*s, y = cy + pts[i][0]*s + pts[i][1]*c; if (i === 0) bx.moveTo(x, y); else bx.lineTo(x, y); } bx.closePath(); } // active-agent EMPHASIS: a soft glow disc + a dashed ring (under the actor shape). function drawActiveGlow(p) { const cx = p.x*CELL + CELL/2, cy = p.y*CELL + CELL/2; // FIX(A1) "YOU" BACKPLATE: win the salience contest — paint a SOLID saturated-blue // backplate fill across the active cell BEFORE the sprite, so the active cell is the // single saturated-blue cell on the board (a faint white radial whose center was only // 0.42 white washed out against the gold gem field; selfMarkerFrac=0.63). The blue // cell now reads as "a filled blue tile" categorically heavier than any gold gem, even // when a gem sits at/adjacent to the corner spawn. Keyed ONLY on s.active (PUBLIC turn), // never the rule (C1). Drawn under the sprite + rings (drawActiveGlow runs before the sprite). const px = p.x*CELL, py = p.y*CELL; bx.fillStyle = _alpha(SPRITE_HUE.agent, 0.32); // saturated "you"-class backplate (heavier so the cell reads blue even atop a gem corner) bx.fillRect(px + 1, py + 1, CELL - 2, CELL - 2); // a brighter blue glow disc so the active actor is unmistakable on the recessive board. const g = bx.createRadialGradient(cx, cy, CELL*0.18, cx, cy, CELL*0.62); g.addColorStop(0, _alpha(SPRITE_HUE.agent, 0.42)); g.addColorStop(1, _alpha(SPRITE_HUE.agent, 0)); bx.fillStyle = g; bx.beginPath(); bx.arc(cx, cy, CELL*0.62, 0, 7); bx.fill(); // PERSISTENT "YOU" CELL KEYLINE: a crisp solid bright-blue rectangle hugging the active // cell border — a SQUARE-CELL marker the round gem silhouettes categorically never carry, // so the self reads as "the one outlined blue tile" on EVERY seed (even when a gold gem sits // at/adjacent to the corner spawn). Drawn under the sprite; keyed ONLY on s.active (C1). bx.strokeStyle = _alpha(SPRITE_HUE.agent, 0.95); bx.lineWidth = 2.5; bx.strokeRect(px + 1.5, py + 1.5, CELL - 3, CELL - 3); // PERSISTENT "YOU" HALO (EVERY frame, EVERY stage): a clearly brighter, larger DOUBLE-RING halo on // the active controlled seat — an inner SOLID bright-blue ring (the fixed "you" color class, matching // the active agent sprite + the cell keyline) + an outer bright-blue ring + a thin white keyline ring // that crisps it against the dark board. This concentric solid-ring halo is a self-marker the gold gem // silhouettes categorically NEVER carry, so the self is the single most-salient mark on EVERY seed // even in a static (report) frame. Keyed ONLY on s.active (PUBLIC round-robin turn), never the rule (C1). const you = SPRITE_HUE.agent; // the demo-blue "you" class // a gentle time-only breathe (no game state) modulates the OUTER ring; on a decisive value-demo // frame (_demoEmphasis) the breathe is a touch wider, but the SOLID self-ring base is identical // across stages so the self never loses dominance. Pure visual/time read (C1). const pg = (_playPulse || _demoEmphasis) ? _pulseGlow() : 0; // 0..1 eased; 0 ⇒ steady on static frames bx.strokeStyle = _alpha(you, 0.98); bx.lineWidth = 3; // inner SOLID bright "you" ring bx.beginPath(); bx.arc(cx, cy, CELL * 0.46, 0, 7); bx.stroke(); bx.strokeStyle = 'rgba(255,255,255,0.85)'; bx.lineWidth = 1; // thin white crisping keyline ring bx.beginPath(); bx.arc(cx, cy, CELL * 0.50, 0, 7); bx.stroke(); bx.strokeStyle = _alpha(you, 0.45 + 0.40 * pg); bx.lineWidth = 2; // outer bright pulsing "you" ring bx.beginPath(); bx.arc(cx, cy, CELL * (0.55 + 0.06 * pg), 0, 7); bx.stroke(); } // FIX1(c) "YOU" CAP GLYPH: a small filled bright-cyan downward caret/chevron pinned just above // the active controlled seat's cell (in the top margin), pointing at the mover, so the controlled // agent reads apart from companions/tokens at a static glance. Pure render off the active cell // (set only for s.active), never the rule (C1). Uses the same "you" hue as the active sprite/halo. function drawYouCap(p) { const cx = p.x * CELL + CELL / 2, topY = p.y * CELL + CELL * 0.02; const w = CELL * 0.18, h = CELL * 0.16; bx.save(); bx.fillStyle = SPRITE_HUE.agent; // the demo-blue "you" class bx.strokeStyle = '#0e0f13'; bx.lineWidth = 1; bx.beginPath(); bx.moveTo(cx - w, topY); bx.lineTo(cx + w, topY); bx.lineTo(cx, topY + h); bx.closePath(); bx.fill(); bx.stroke(); bx.restore(); } // REACH_ZONES destination PAD: a seat-IDENTITY landing pad on the destination cell. Reach's UNIQUE // class is MULTIPLE such pads (one per seat, distinct seat HUES + seat SHAPES on a party board) — // "several distinct-hued shaped pads" reads categorically apart from deliver's ONE sky socket, // WITHOUT any HUD. Three primitives, all keyed on PUBLIC seat id + the public `reached` flag (C1): // (1) a colored-WASH cell bounded by a SOLID seat-color rounded-rect PAD border (always solid) so // the cell reads as a colored LANDING PAD, not an empty ghost outline; // (2) the owning seat's identity SHAPE drawn with a faint seat-color FILL under a seat-color // outline (dashed unreached -> solid + green check ring reached) — a SOLID seat-colored shape // that says "which seat owns this", categorically unlike deliver's hole-in-frame; and // (3) (drawn by the caller) a per-seat corner pip + a board-only CONNECTOR tab tying pad->seat. // The faint fill (alpha <= 0.34) keeps every pad far below the you-cell's filled-blue + double-ring // stack, so it never rivals the self salience even when seat-0 shares the you-blue hue. function drawDestinationTile(dz, shape, color, reached) { const cx = dz.x*CELL + CELL/2, cy = dz.y*CELL + CELL/2, r = CELL*0.32; // (1) stronger seat-color WASH + SOLID seat-color rounded-rect PAD border (always solid). bx.save(); bx.fillStyle = _alpha(color, reached ? 0.34 : 0.18); bx.fillRect(dz.x*CELL+2, dz.y*CELL+2, CELL-4, CELL-4); bx.strokeStyle = color; bx.lineWidth = 2; bx.setLineDash([]); _roundRect(dz.x*CELL+2, dz.y*CELL+2, CELL-4, CELL-4, 5); bx.stroke(); bx.restore(); // (2) seat-identity SHAPE: faint seat-color FILL under the seat-color outline (dashed unreached, // solid reached) — a legible "which seat owns this" mark, a SOLID seat-colored shape. bx.save(); bx.fillStyle = _alpha(color, 0.22); _shapePath(cx, cy, r, shape); bx.fill(); bx.strokeStyle = color; bx.lineWidth = 2; bx.setLineDash(reached ? [] : [3, 3]); _shapePath(cx, cy, r, shape); bx.stroke(); bx.restore(); if (reached) { // arrival check ring (PUBLIC state) bx.strokeStyle = ARC.green; bx.lineWidth = 2; bx.beginPath(); bx.arc(cx, cy, r*0.5, 0, 7); bx.stroke(); } } // REACH_ZONES pad->seat CONNECTOR (board-only seat tie, replaces the excluded HUD family icon): a // short faint seat-color dashed LEADER stub from the destination pad toward the owning seat agent's // CURRENT cell, binding pad->seat ON BOARD so reach legibility no longer leans on the HUD band. // Drawn under tokens/actors, clipped to a 1-2 cell stub length. Keyed on PUBLIC seat id + st.pos // only (C1). When the agent position is unknown, the caller falls back to a corner TAB on the pad. function drawReachConnector(dz, agentPos, color) { const sx = dz.x*CELL + CELL/2, sy = dz.y*CELL + CELL/2; const tx = agentPos.x*CELL + CELL/2, ty = agentPos.y*CELL + CELL/2; let dx = tx - sx, dy = ty - sy; const len = Math.hypot(dx, dy); if (len < 1) return; const stub = Math.min(len, CELL * 1.6); // clip to a 1-2 cell stub toward the seat dx /= len; dy /= len; bx.save(); bx.strokeStyle = _alpha(color, 0.25); bx.lineWidth = 2; bx.setLineDash([3, 3]); bx.beginPath(); bx.moveTo(sx + dx * CELL * 0.30, sy + dy * CELL * 0.30); bx.lineTo(sx + dx * stub, sy + dy * stub); bx.stroke(); bx.restore(); } // REACH_ZONES layout-safe fallback: a seat-color corner TAB on the pad (when the agent position is // unknown), matching the seat tab color so pad->seat still binds on board. C1: seat id only. function drawReachSeatTab(dz, color) { const px = dz.x*CELL, py = dz.y*CELL, s = Math.max(4, CELL * 0.20); bx.save(); bx.fillStyle = _alpha(color, 0.85); bx.beginPath(); bx.moveTo(px + 2, py + 2); bx.lineTo(px + 2 + s, py + 2); bx.lineTo(px + 2, py + 2 + s); bx.closePath(); bx.fill(); bx.restore(); } // §3/§6 ENGAGEMENT PIPS: a row of small squares under the actor — `done` filled out of // `need` (the cycle k_c floor). Rule-invariant (seat id + counts only, C1). Drawn just // below the actor's cell so it reads as "this seat's turn quota this cycle". function drawEngagementPips(p, done, need) { const n = Math.max(0, need | 0); if (n === 0) return; const cap = Math.min(n, 6); // cap the row width; clusters past 6 const s = Math.max(2.5, Math.min(4, (CELL - 6) / (cap * 2))); // pip size const gap = s * 2; const totalW = cap * gap; const x0 = p.x*CELL + CELL/2 - totalW/2 + gap/2; const y = p.y*CELL + CELL - s - 1.5; // just inside the bottom edge for (let i = 0; i < cap; i++) { const px = x0 + i * gap; bx.beginPath(); bx.rect(px - s/2, y - s/2, s, s); if (i < (done | 0)) { bx.fillStyle = ARC.lime; bx.fill(); } else { bx.strokeStyle = '#5b6070'; bx.lineWidth = 1; bx.stroke(); } } bx.strokeStyle = '#0e0f13'; bx.lineWidth = 0.5; } // SLICE2 §3 PHASE-CLOCK ring: a small ring of `segN` segments drawn at the actor's // top-right corner, with the ACTIVE segment (`seg`) lit — the same visual grammar as the // engagement pips, but arranged as a clock so the player reads the seat's PUBLIC phase // position at a glance. PURE render: keyed ONLY on seg/segN (public live state from the // engine clock), NEVER the rule (C1). It surfaces the hidden temporal state as visible // state so the player stays Markovian (acts optimally from the current observation alone). function drawPhaseClock(p, seg, segN) { const n = Math.max(2, segN | 0); const cx = p.x*CELL + CELL - CELL*0.20, cy = p.y*CELL + CELL*0.20; // top-right corner const R = Math.max(4, CELL*0.16); // ring radius const r = Math.max(1.5, R*0.34); // per-segment pip radius // faint backing disc so the ring reads as one clock widget over the board terrain. bx.save(); bx.fillStyle = 'rgba(14,15,19,0.78)'; bx.beginPath(); bx.arc(cx, cy, R + r + 1, 0, 7); bx.fill(); for (let i = 0; i < n; i++) { const a = -Math.PI / 2 + i * 2 * Math.PI / n; // 12-o'clock = segment 0, clockwise const px = cx + R * Math.cos(a), py = cy + R * Math.sin(a); bx.beginPath(); bx.arc(px, py, r, 0, 7); if (i === ((seg % n) + n) % n) { bx.fillStyle = ARC.yellow; bx.fill(); } // lit = active seg else { bx.strokeStyle = '#6b7280'; bx.lineWidth = 1; bx.stroke(); } } bx.restore(); } // SLICE2 §4 GHOST companion: a HOLLOW seat-shape reference marker for a tutorial ghost, // drawn on the DEMO/tutorial board only (never the live play board). Keyed on the ghost's // PUBLIC vid (seatShape/seatColor) so the relation it demonstrates is shape/color-legible, // but with NO fill + a dashed outline so it reads clearly as a static reference, not a // live party actor (ghosts never act/take/charge ♥). C1: keyed on the public vid only. function drawGhost(ghost) { const cx = ghost.x*CELL + CELL/2, cy = ghost.y*CELL + CELL/2, r = CELL*0.30; const color = seatColor(ghost.vid), shape = seatShape(ghost.vid); bx.save(); // faint identity wash so the ghost cell reads as a colored reference on the black grid. bx.fillStyle = _alpha(color, 0.08); bx.fillRect(ghost.x*CELL+2, ghost.y*CELL+2, CELL-4, CELL-4); // hollow dashed identity-shape outline (clearly a marker, not a filled live actor). bx.strokeStyle = color; bx.lineWidth = 2; bx.setLineDash([3, 3]); _shapePath(cx, cy, r, shape); bx.stroke(); bx.restore(); } // SLICE2 ROLE-PLAY (spec 2026-06-17): a board LANDMARK — a NEUTRAL scenery glyph (a // fixed-color hollow diamond + a small center dot) drawn for any st.landmarks cell. It // carries NO rule key and NO seat key (one fixed color/shape for every landmark on every // board), so it is C1-pure: two boards differing only in the hidden role render the // landmark identically. Drawn UNDER the ghosts/tokens/actors (called early in drawGrid) // so it never occludes a live marker. function drawLandmark(x, y) { const cx = x*CELL + CELL/2, cy = y*CELL + CELL/2, r = CELL*0.26; bx.save(); bx.strokeStyle = '#7a8190'; bx.lineWidth = 2; bx.setLineDash([]); bx.beginPath(); bx.moveTo(cx, cy - r); bx.lineTo(cx + r, cy); bx.lineTo(cx, cy + r); bx.lineTo(cx - r, cy); bx.closePath(); bx.stroke(); bx.fillStyle = '#7a8190'; bx.beginPath(); bx.arc(cx, cy, CELL*0.05, 0, 7); bx.fill(); bx.restore(); } // a reach-pip in the gauge band: the seat's identity shape, filled when reached, hollow // (dashed) otherwise. Mirror of the on-board destination outline so the band ↔ board // shape-match is obvious. Keyed on seat id only (C1). function drawSeatPip(cx, cy, r, shape, color, filled) { bx.save(); if (filled) { bx.fillStyle = color; bx.strokeStyle = '#0e0f13'; bx.lineWidth = 1.5; } else { bx.fillStyle = 'transparent'; bx.strokeStyle = color; bx.lineWidth = 1.5; bx.setLineDash([2, 2]); } _shapePath(cx, cy, r, shape); if (filled) { bx.fill(); } bx.stroke(); bx.restore(); } // a recipe chip in the collect_set gauge: a token disc tinted by its KIND (a public // glyph id; the kind→hue map is a pure function of the id, rule-invariant). Filled+ringed // when collected, hollow when outstanding. C1: keyed on the public kind id only. function drawRecipeChip(cx, cy, r, kind, done) { const col = kindColor(kind); if (done) { bx.fillStyle = _alpha(col, 0.9); bx.beginPath(); bx.arc(cx, cy, r, 0, 7); bx.fill(); bx.strokeStyle = ARC.green; bx.lineWidth = 2; bx.beginPath(); bx.arc(cx, cy, r+1.5, 0, 7); bx.stroke(); } else { bx.fillStyle = _alpha(col, 0.20); bx.beginPath(); bx.arc(cx, cy, r, 0, 7); bx.fill(); bx.strokeStyle = col; bx.lineWidth = 1.5; bx.beginPath(); bx.arc(cx, cy, r, 0, 7); bx.stroke(); } } // kind→hue: a deterministic ARC-palette pick by the public kind id (rule-invariant, // purely cosmetic so distinct recipe kinds read as distinct chips). NOT keyed on rule. const KIND_COLORS = [ARC.blue, ARC.red, ARC.green, ARC.yellow, ARC.fuchsia, ARC.orange, ARC.sky, ARC.lime, ARC.purple, ARC.teal]; function kindColor(kind) { const k = ((kind | 0) % KIND_COLORS.length + KIND_COLORS.length) % KIND_COLORS.length; return KIND_COLORS[k]; } // trace a seat IDENTITY shape (shared by the actor, destination outline, reach pip) so // all three render the same glyph for a seat. Pure path builder (no fill/stroke). function _shapePath(cx, cy, r, shape) { switch (shape) { case 'square': bx.beginPath(); bx.rect(cx-r*0.86, cy-r*0.86, r*1.72, r*1.72); break; case 'triangle': _polyPath(cx, cy, r, 3); break; case 'triangle-down': _polyPath(cx, cy, r, 3, Math.PI / 2); break; case 'diamond': _polyPath(cx, cy, r, 4); break; case 'pentagon': _polyPath(cx, cy, r, 5); break; case 'hexagon': _polyPath(cx, cy, r, 6); break; case 'heptagon': _polyPath(cx, cy, r, 7); break; case 'octagon': _polyPath(cx, cy, r, 8); break; case 'star': _starPath(cx, cy, r, 5); break; case 'cross': _crossPath(cx, cy, r, Math.PI / 4); break; case 'plus': _crossPath(cx, cy, r, 0); break; case 'circle': default: bx.beginPath(); bx.arc(cx, cy, r, 0, 7); break; } } // §6 KEY LEGEND: a glyph-only control strip in the bottom band of the board — no prose. // ↑↓←→ = active move, ⎵/Tab = switch active agent, '.' / own-cell = pass/wait. The // glyphs are language-neutral; this never reads the rule (pure control help, C1). function drawKeyLegend() { const H = 22, y0 = board.height - H; bx.fillStyle = 'rgba(14,15,19,0.82)'; bx.fillRect(0, y0, board.width, H); bx.strokeStyle = '#2a2d36'; bx.lineWidth = 1; bx.beginPath(); bx.moveTo(0, y0 + 0.5); bx.lineTo(board.width, y0 + 0.5); bx.stroke(); const cy = y0 + H / 2; bx.textAlign = 'left'; bx.textBaseline = 'middle'; bx.fillStyle = '#cfd4dc'; bx.font = '12px ui-monospace, monospace'; // arrows = move bx.fillText('↑↓←→', 8, cy + 1); _legendKeyBox(58, cy, 30, '␣'); // ⎵ space _legendKeyBox(92, cy, 30, 'Tab'); // an "=" between the two switch glyphs reads as "either switches" bx.fillStyle = '#6b7280'; bx.font = '11px ui-monospace, monospace'; bx.fillText('↻', 126, cy + 1); // ↻ switch _legendKeyBox(146, cy, 16, '.'); bx.fillStyle = '#6b7280'; bx.fillText('⏸', 168, cy + 1); // ⏸ pass/wait bx.textBaseline = 'alphabetic'; bx.textAlign = 'left'; } // a small keycap box with a centered glyph for the legend strip. function _legendKeyBox(x, cy, w, glyph) { bx.fillStyle = '#23252c'; bx.strokeStyle = '#3a3d45'; bx.lineWidth = 1; bx.fillRect(x, cy - 8, w, 16); bx.strokeRect(x + 0.5, cy - 7.5, w - 1, 15); bx.fillStyle = '#cfd4dc'; bx.font = '10px ui-monospace, monospace'; bx.textAlign = 'center'; bx.fillText(glyph, x + w / 2, cy + 1); bx.textAlign = 'left'; } function drawTrail(mv, color, width) { if (!mv) return; const x1 = mv.from.x*CELL + CELL/2, y1 = mv.from.y*CELL + CELL/2; const x2 = mv.to.x*CELL + CELL/2, y2 = mv.to.y*CELL + CELL/2; if (x1 === x2 && y1 === y2) return; // width defaults to 3 (the under-token call); the foregone re-assertion passes a BOLDER width // so the chosen trail dominates the secondary foregone ghost. bx.strokeStyle = color; bx.lineWidth = (width != null) ? width : 3; bx.beginPath(); bx.moveTo(x1, y1); bx.lineTo(x2, y2); bx.stroke(); const ang = Math.atan2(y2 - y1, x2 - x1); bx.fillStyle = color; bx.beginPath(); bx.moveTo(x2, y2); bx.lineTo(x2 - 9*Math.cos(ang - 0.5), y2 - 9*Math.sin(ang - 0.5)); bx.lineTo(x2 - 9*Math.cos(ang + 0.5), y2 - 9*Math.sin(ang + 0.5)); bx.closePath(); bx.fill(); } function outlineCell(p, color) { bx.strokeStyle = color; bx.lineWidth = 3; bx.strokeRect(p.x*CELL+2, p.y*CELL+2, CELL-5, CELL-5); } /* ---- W1.1 CONCERNS (the four Davidson pro-attitudes) --------------------------- The value-laden persona is a STRICT LEXICAL ORDER over the four concerns the engine exports as E.PRO_ATTITUDES (keys G/C/D/N). app.js NEVER re-authors the model: it READS each attitude's engaged?(st,seat) + lexFilter narrowing off the seated ordering (run.orderings, the binding persona object) to surface (a) the priority order, (b) which concerns are engaged this turn, and (c) the two concerns IN TENSION (the first narrowing authority vs the highest engaged concern it overrides). This is a pure read of PUBLIC state — the ordering is the persona, not a board layout, so surfacing it is not a rule leak (C1: the board render still keys nothing on the hidden ordering). CONCERN[key] = fixed CVD-safe Okabe-Ito hue + a letter + a one-word label, applied IDENTICALLY in the legend + the tension banner (color = identity, never style). The four hues differ in LUMINANCE as well as hue (readable in grayscale) and avoid the red/green & blue/purple CVD-collapse pairs. */ const CONCERN = { G: { name: 'REWARD', color: '#009E73' }, // appetitive — green (Okabe-Ito) C: { name: 'SAFETY', color: '#D55E00' }, // prudential — vermillion D: { name: 'YIELD', color: '#56B4E9' }, // deference — sky blue N: { name: 'RESPECT', color: '#CC79A7' }, // non-preempt — reddish purple }; const CONCERN_KEYS = ['G', 'C', 'D', 'N']; // personaView(run): the per-active-seat concern state, read PURELY from the engine. // Returns null when the live cycle does not seat a value-laden ordering (the legacy / // terrain / role cycles have no four-concern persona to surface) — callers then skip the // concern UI entirely. order = the seated priority permutation (e.g. D,N,C,G); engaged = // {key:bool}; tension = {hi, lo} the two clashing concern keys this turn (or null when no // lower concern is overridden); discovered = how many top order-slots are settled. function personaView(run, seatArg) { if (!run || !run.board || !E.PRO_ATTITUDES || !C._isOrderingCycle) return null; const set = run.ruleSet || []; if (!run.config || !run.config.daBattery || !C._isOrderingCycle(set)) return null; const seat = seatArg != null ? seatArg : (run.turnSeat != null ? run.turnSeat : 0); const orderings = run.orderings || (C._orderingsForRun ? C._orderingsForRun(run) : null); const order = (orderings && orderings[seat]) || ['D', 'N', 'C', 'G']; const st = run.board; const engaged = {}; for (const k of CONCERN_KEYS) { const att = E.PRO_ATTITUDES[k]; engaged[k] = !!(att && att.engaged(st, seat)); } // TENSION = mirror lexFilter's narrowing: walk the seated order; the FIRST engaged // concern whose preference narrows the move set is the binding AUTHORITY (hi); the // tension is with the next engaged concern below it (lo) — the one whose pull is // overridden. No narrowing concern => no tension this turn. let tension = null; if (E.lexFilter) { const base = E.lexFilter(st, seat, []); // all legal moves (empty ordering) let cur = new Set(base), hi = null; for (const k of order) { const att = E.PRO_ATTITUDES[k]; if (!att || !engaged[k]) continue; const pref = att.preference(st, seat); const inter = new Set(); for (const m of cur) if (pref.has(m)) inter.add(m); if (hi == null) { if (inter.size === 0) continue; // engaged but did not narrow (lexFilter skips) hi = k; cur = inter; // first narrowing concern = the binding authority } else if (inter.size < cur.size) { // a lower engaged concern whose preference DISAGREES with the authority's set is the // overridden party — the clash. (inter.size < cur.size: its pull is partly/fully cut.) tension = { hi, lo: k }; break; } } } // DISCOVERY-MODE state (design 2026-06-21 §B): the player must INFER the order, so the HUD // never prints `order` as the answer. `inferred` = the set of pairwise edges REVEALED so far // by the value demo (run.inferred, a Set of 'WL' winner>loser strings; app-side/live-only). // From the edges we derive (i) the INFERRED partial order (the longest settled chain, with // unresolved concerns shown as unknown) and (ii) REAL discovery progress = resolved / total // comparisons (6 unordered pairs). `order` is kept ONLY to compute tension (an observable // glance state), NEVER rendered as the ground truth. const edges = (run.inferred && run.inferred.size != null) ? run.inferred : null; const TOTAL_COMPARISONS = (CONCERN_KEYS.length * (CONCERN_KEYS.length - 1)) / 2; // 6 const resolved = edges ? edges.size : 0; return { order, engaged, tension, resolved, totalComparisons: TOTAL_COMPARISONS, discovered: Math.min(resolved, TOTAL_COMPARISONS) }; } /* ----------------------------- HUD (gauges) ------------------------------ */ const C_DISC = '#f2c14e', C_MAINT = '#7fce97', C_AGENT = '#a78bfa'; const C_TOT = '#cfe0ff', C_STAR = '#7fce97', C_SCORE = '#a78bfa'; // liveScore: the META compliance readout for the HUD — this-cycle s_i (most recent // finalized cycle score, or null mid-first-cycle) + running pursuitMean (mean s_i // over cleared cycles). A pure number, never the rule (C1). No heart death. function liveScore(run) { if (!run) return { si: null, mean: null }; const rep = C.runReport(run); const cs = C.cycleScore(run); return { si: cs ? cs.score : null, mean: rep ? rep.pursuitMean : null }; } function drawHUD() { hx.clearRect(0, 0, hud.width, hud.height); const sk = stageKey(); if (sk === 'demo') return drawDemoHUD(); if (sk === 'play') return drawPlayHUD(); if (sk === 'report') return drawReport(); } function barH(x, y, w, h, frac, color, bg='#23252c') { hx.fillStyle = bg; hx.fillRect(x, y, w, h); hx.fillStyle = color; hx.fillRect(x, y, w * clamp01(frac), h); } function dotH(x, y, color, r=6) { hx.fillStyle = color; hx.beginPath(); hx.arc(x, y, r, 0, 7); hx.fill(); } function txtH(x, y, str, color, size=11, align='left') { hx.fillStyle = color; hx.font = size + 'px ui-monospace, monospace'; hx.textAlign = align; hx.fillText(str, x, y); hx.textAlign = 'left'; } function hatchSlot(x, y, w, h) { hx.fillStyle = '#23252c'; hx.fillRect(x, y, w, h); hx.strokeStyle = '#3a3d45'; hx.lineWidth = 1; for (let i = 0; i < w; i += 8) { hx.beginPath(); hx.moveTo(x+i, y); hx.lineTo(x+i+h, y+h); hx.stroke(); } } // DEMO HUD: the new agent is self-demonstrating; the demo does NOT affect score. function drawDemoHUD() { const run = G.campaign; // de-text: cycle/reach/rules + the score number live in the top #panel chips — the HUD // canvas keeps only the VISUAL score bar + the discovery legend (no duplicated prose). const h = run ? C.headlineState(run) : null; if (h) { const sc = liveScore(run); const meanFrac = sc.mean == null ? 0 : sc.mean; dotH(20, 51, C_SCORE); barH(34, 51, 190, 12, meanFrac, C_SCORE); } // DISCOVERY-MODE CONCERN HUD during the value DEMO: read the NEWCOMER seat's persona view // (the seat self-demonstrating) and OVERRIDE its tension with the CURRENT conflict scene's // in-tension pair so the COLOR cue reflects the clash being shown (no names, no direction). // The HUD shows only the discovery SEGMENT bar (NEVER the ground-truth order). null on a // legacy/role cycle (no four-concern persona) -> nothing drawn (byte-identical). const a = G.demoAnim; const seatId = (a && a.seatId != null) ? a.seatId : (run && run.demo ? run.demo.seatId : null); const pv = personaView(run, seatId); if (pv && a && a.valueDemo && a.scenePair) pv.tension = { hi: a.scenePair.hi, lo: a.scenePair.lo }; drawConcernLegend(pv, 110); } // PLAY HUD: META compliance score bar (this-cycle s_i + running mean) + reach + // cycle. The agentness decomposition (D×M, total/C*) is analyst-only and lives on // the REPORT screen, NOT the play HUD. No heart death channel. function drawPlayHUD() { const run = G.campaign; const sc = liveScore(run); // de-text (textKill): stage / cycle / depth / turn / score-number all read VISUALLY from the // top meta-rail (cycle pips / depth ladder / pursuit+deference bars / hearts) + the #steps icon // pips + the on-board active glow. The HUD canvas keeps only the VISUAL score bar + the concern // legend below, so the board and its motion dominate (no duplicated text strip). No depth number. dotH(20, 51, C_SCORE); barH(34, 51, 190, 14, sc.mean == null ? (sc.si == null ? 0 : sc.si) : sc.mean, C_SCORE); // GENERATE / seat-swap (section1:17): when the active seat is acting ON another persona's // GIVEN ordering, the HUD shows that TARGET ordering in FULL (it is generate, not discover) // + the live compliance tally, REPLACING the discovery-mode legend for this seat. Otherwise // the W1.1 discovery CONCERN LEGEND + active TENSION (value cycles only; legacy/role skip). if (G.generate && G.generate.focalSeat === run.turnSeat) drawGeneratePanel(G.generate, 168); else drawConcernLegend(personaView(run), 168); } // drawGeneratePanel(g, y0): the GENERATE/seat-swap HUD, TEXT-FREE (textKill). GENERATE has the // active seat act ON the target peer's GIVEN ordering. The panel reads visually: (i) a swap icon // + the TARGET seat's IDENTITY chip (seatShape/seatColor) = "you are enacting THIS companion's // principle"; (ii) the GIVEN target ordering as a COLOR-SWATCH column (concern hues, top=highest, // NO concern names); (iii) the live compliance as a bar (NO numeral). All prose removed. Keyed on // seat id + the GIVEN ordering (the persona, surfaced because it is given, never induced). function drawGeneratePanel(g, y0) { const X = 20, W = hud.width - 40; // generate banner: a swap glyph + the target seat's identity chip (no text). hx.fillStyle = '#23303f'; hx.fillRect(X, y0, W, 30); hx.fillStyle = C_AGENT; hx.fillRect(X, y0, 8, 30); // swap glyph (two opposed arrows) = seat-swap / "act as". hx.save(); hx.strokeStyle = '#ffffff'; hx.lineWidth = 1.8; hx.lineCap = 'round'; const gx = X + 22, gy = y0 + 15; hx.beginPath(); hx.moveTo(gx - 7, gy - 4); hx.lineTo(gx + 7, gy - 4); hx.moveTo(gx + 4, gy - 7); hx.lineTo(gx + 7, gy - 4); hx.lineTo(gx + 4, gy - 1); hx.moveTo(gx + 7, gy + 4); hx.lineTo(gx - 7, gy + 4); hx.moveTo(gx - 4, gy + 1); hx.lineTo(gx - 7, gy + 4); hx.lineTo(gx - 4, gy + 7); hx.stroke(); hx.restore(); // target seat IDENTITY chip (shape + color), drawn on the HUD ctx via a temp swap onto bx? // simpler: a filled seat-color disc with the seat shape outline (HUD-local; no bx use). const tcx = X + 48, tcy = y0 + 15, tr = 9, tcol = seatColor(g.targetSeat); hx.fillStyle = tcol; hx.strokeStyle = '#0e0f13'; hx.lineWidth = 2; hx.beginPath(); hx.arc(tcx, tcy, tr, 0, 7); hx.fill(); hx.stroke(); // GIVEN target ordering as a COLOR-SWATCH column (top = highest priority). No names. let y = y0 + 44; for (const k of g.targetOrdering) { const c = CONCERN[k]; if (!c) continue; hx.fillStyle = c.color; hx.fillRect(X, y, 24, 14); hx.strokeStyle = '#0e0f13'; hx.lineWidth = 1; hx.strokeRect(X, y, 24, 14); y += 18; } // live compliance bar (in-character / total via lexFilter(target) membership). No numeral. y += 6; const frac = g.moves ? g.inChar / g.moves : 1; barH(X, y, W, 8, frac, C_AGENT); } // drawConcernLegend(pv, y0): a TEXT-FREE side-HUD cue (C1). pv is the personaView (null => // nothing drawn). It shows (i) the turn's in-tension concern PAIR as two color swatches in // FIXED canonical order — flagging WHICH pair conflicts (board-observable) without leaking // WHICH wins (the induced persona, never shown) — and (ii) a persona-discovery SEGMENT bar // (pure progress strip). No concern names, ranks, or direction prose; Okabe-Ito hues stay // colorblind-safe so color alone carries the cue. function drawConcernLegend(pv, y0) { if (!pv) return; const X = 20, W = hud.width - 40; // active TENSION cue — two color swatches for the in-tension pair (no names, no direction). hx.fillStyle = '#1b1d24'; hx.fillRect(X, y0, W, 30); if (pv.tension) { // FIXED canonical order (never hi/lo) so swatch position never leaks WHICH wins (C1). const pair = [pv.tension.hi, pv.tension.lo].sort((a, b) => CONCERN_KEYS.indexOf(a) - CONCERN_KEYS.indexOf(b)); hx.fillStyle = CONCERN[pair[0]].color; hx.fillRect(X, y0, 8, 30); hx.fillStyle = CONCERN[pair[1]].color; hx.fillRect(X + W - 8, y0, 8, 30); } // C1: the inferred-priority TEXT stack (a prose row enumerating concern letters / // names / ranks) is REMOVED — naming the concerns at their inferred ranks is an // agent-facing rule leak. Inference progress is conveyed by VISUAL channel only: // the tension banner color bars above + the persona-discovery SEGMENT bar below // (a pure progress strip, no prose). No concern names/ranks are printed. let y = y0 + 44; const segN = pv.totalComparisons, segW = (W - (segN - 1) * 3) / segN; for (let i = 0; i < segN; i++) { hx.fillStyle = i < pv.resolved ? C_AGENT : '#23252c'; hx.fillRect(X + i * (segW + 3), y, segW, 8); } } // REPORT: depth headline + per-cycle total/C* + per-cycle Discovery × Maintenance. // total/C* and D×M are ANALYST rows here (P3-4) — they never appear on the play HUD. function drawReport() { const run = G.campaign; const rep = C.runReport(run); const pc = v => v == null ? 'n/a' : Math.round(clamp01(v) * 100) + '%'; const n2 = v => v == null ? 'n/a' : '' + (Math.round(v * 100) / 100); // PARK report (spec §5 — the analyst channel): inferred-from-moves order vs demonstrated + // per-conflict tempted -> honored denominators. Gated on rep.park (present only under parkMode). if (rep.park) return drawParkReport(rep.park); // PURSUIT HEADLINE (mean per-cycle compliance score) + reach as a secondary stat. const pmean = rep.pursuitMean == null ? 'n/a' : (Math.round(rep.pursuitMean * 100) / 100).toFixed(2); txtH(20, 18, `추구 ${pmean}`, C_AGENT, 18); txtH(120, 18, `도달 ${rep.reach}`, '#9aa0ac', 11); txtH(221, 18, endingLabel(rep.status), '#9aa0ac', 10, 'right'); let y = 40; txtH(20, y, '사이클별 total/C* · 발견 × 유지', '#9aa0ac', 11); y += 16; for (const r of rep.perCycle) { txtH(20, y, `C${r.cycle + 1}`, '#cfe0ff', 10); // total / C* ratio (analyst-only). dotH(58, y - 3, C_STAR, 5); barH(70, y - 10, 100, 10, r.ratio, C_TOT); txtH(174, y - 1, 'r ' + n2(r.ratio), '#9aa0ac', 9); y += 16; // Discovery (the newcomer's diagnostic window for this cycle). dotH(58, y - 3, C_DISC, 5); if (r.discovery == null) hatchSlot(70, y - 10, 100, 10); else barH(70, y - 10, 100, 10, r.discovery, C_DISC); txtH(174, y - 1, 'D ' + n2(r.discovery), '#9aa0ac', 9); y += 16; // Maintenance for this cycle's newcomer (per-agent ratio, active-resistance). const m = r.maintenanceByAgent[r.cycle] || r.maintenanceByAgent[Object.keys(r.maintenanceByAgent).pop()]; const mVal = m ? m.maintenance : null; dotH(58, y - 3, C_MAINT, 5); if (mVal == null) hatchSlot(70, y - 10, 100, 10); else barH(70, y - 10, 100, 10, mVal, C_MAINT); txtH(174, y - 1, 'M ' + n2(mVal), '#9aa0ac', 9); y += 22; if (y > hud.height - 40) break; } // run-level Discovery × Maintenance aggregate (analyst summary). const ds = rep.discoveryByDepth.filter(d => d.discovery != null).map(d => d.discovery); const dAvg = ds.length ? ds.reduce((a, b) => a + b, 0) / ds.length : null; const ms = Object.values(rep.maintenanceByAgent).map(m => m.maintenance).filter(v => v != null); const mAvg = ms.length ? ms.reduce((a, b) => a + b, 0) / ms.length : null; y = Math.min(y, hud.height - 28); txtH(20, y, `발견 ${pc(dAvg)} · 유지 ${pc(mAvg)} (런 집계)`, '#cfe0ff', 10); } // drawDemoCues(a): the legibility overlay on a value-demo conflict frame. Shows WHAT the persona // gave up so each decision reads as a tradeoff. (1) the road not taken — the loser concern's declined // cell, a faint dashed outline in that concern's color (secondary to the bold winner-colored chosen // move); (2) RESPECT loss — ring the contested token in the claimant seat's color; (3) SAFETY loss — // emphasize the braved hazard in the safety color. Display-only; reads a.* computed pre-move. function drawDemoCues(a) { // (1) ROAD NOT TAKEN — a single NEUTRAL dim-white dashed outline (one fixed style/color for every // concern, so green/red/orange per-concern outlines no longer confuse; cold-read R1). The chosen // move is the bold winner-colored trail; this is just "an open cell it declined". if (a.declined) { bx.save(); bx.strokeStyle = 'rgba(220,224,232,0.55)'; bx.lineWidth = 2; bx.setLineDash([3, 3]); bx.strokeRect(a.declined.x * CELL + 4, a.declined.y * CELL + 4, CELL - 8, CELL - 8); bx.restore(); } // (2) CONTESTED reward — OWNERSHIP shown by a CONNECTOR, not just a ring color (cold-read R2: a // cyan ring was confused with the player's own cyan halo, an orange ring with hazards). A thin // dashed LEASH in the claimant's seat color runs from the claimant figure to the token, plus a ring // on the token in the SAME color — the claimant-figure + leash + ring triad reads as "that agent's // reward" regardless of hue, unmistakable from the player's solo halo and from filled hazard tiles. if (a.claimedToken) { // RESPECT concern hue (reddish-purple) for the ring + leash — distinct from orange hazards, // blue player halo, yellow reward, green trail (cold-read R3: a salmon/seat-color ring still // read as orange). Ownership stays legible via the LEASH to the claimant figure, not the hue. const col = CONCERN.N.color; const tx = (a.claimedToken.x + 0.5) * CELL, ty = (a.claimedToken.y + 0.5) * CELL; bx.save(); const cl = (a.claimantSeat != null) ? a.disp.pos[a.claimantSeat] : null; if (cl) { bx.strokeStyle = _alpha(col, 0.8); bx.lineWidth = 2; bx.setLineDash([4, 4]); bx.beginPath(); bx.moveTo((cl.x + 0.5) * CELL, (cl.y + 0.5) * CELL); bx.lineTo(tx, ty); bx.stroke(); } // DASHED ring (not solid) so it never reads as the player's SOLID double-ring halo (cold-read R4). bx.strokeStyle = col; bx.lineWidth = 3; bx.setLineDash([5, 4]); bx.beginPath(); bx.arc(tx, ty, CELL * 0.42, 0, 7); bx.stroke(); bx.restore(); } // (3) BRAVED hazard — emphasize the danger the agent came within the caution band of (vermillion, // matching the hazard tiles; this is the danger itself, not a "road not taken"). if (a.hazardCell) { bx.save(); bx.strokeStyle = _alpha(CONCERN.C.color, 0.85); bx.lineWidth = 3; bx.strokeRect(a.hazardCell.x * CELL + 2, a.hazardCell.y * CELL + 2, CELL - 4, CELL - 4); bx.restore(); } } /* ==== PARK RENDER (LIVE, ZERO-TEXT — spec §5: no fillText on any function below) ==== */ // _slowPulse(): a slow 0..1 breathe for the deep field (distinct from the fast _pulseGlow). function _slowPulse() { const t = (Date.now() % 2600) / 2600; return 0.5 - 0.5 * Math.cos(t * 6.283185); } // parkGlideFrac(a): the current demo frame's progress 0..1, DERIVED from the existing demo-tick // timing (dwellT0/dwellRaw/dwellSpd/dwellDone set by _parkDemoSchedule) — so the glide rides the // existing pulseLoop redraws with NO new timer/rAF. A frozen capture returns 1 unless the harness // forces a._glideFrac (view-only override). Pure read of view state (C1). function parkGlideFrac(a) { if (!a) return 1; if (a._glideFrac != null) return Math.max(0, Math.min(1, a._glideFrac)); // capture override hook if (!a.dwellRaw || a._frozen) return 1; // frozen capture with no override: rest at path end const spent = (Date.now() - a.dwellT0) * (a.dwellSpd || 1) / a.dwellRaw; return Math.max(0, Math.min(1, (a.dwellDone || 0) + spent)); } // _parkStepFx(st): the fx records THIS beat emitted — and only those. // // A park st.fx is an APPEND-ONLY EPISODE LOG. Nothing drains it: the `st.fx = []` in drawGrid // belongs to the CLASSIC arena renderer, and no park path runs that function (measured 2026-08-03: // a 30-move y46 rollout ends holding every 'sent' it ever emitted). So a painter that loops over // st.fx directly is looping over the whole match — the first shot of the episode would keep firing // on every frame for the rest of it, which is exactly the "it never goes away" the y46 shot was // rejected for. // The drivers already know the answer: each stamps the fx length it saw BEFORE E.parkStep, so the // tail above that watermark is "what just happened". Gated on a.cue as well, which is the live // beat's own lifetime (nulled after PARK_CUE_MS in play, replaced every frame in the demo) — so the // mark dies WITH the beat rather than lingering. // [] off the live scene (hub thumbnail, report still, tutorial, the static end-frame): those frames // have no beat in flight, and a still that draws a bullet is telling a lie about time. function _parkStepFx(st) { const a = G.parkAnim; if (!a || !a.cue || a.fxSt !== st) return []; return st.fx.slice(a.fxAt || 0); } // _parkStepFrac(): MONOTONIC 0->1 progress through the current beat. This is the clock a one-shot // animation needs, and it is deliberately NOT _pulseGlow(): the shared pulse is a cosine, so // anything positioned by it travels out and then back, forever. The demo already derives exactly // this number for the slide glide (parkGlideFrac, off the dwell schedule); play has no dwell, so it // measures the cue window it was given. 1 (= arrived, at rest) on a frozen capture and off a beat. function _parkStepFrac() { const a = G.parkAnim; if (!a) return 1; if (a.mode === 'demo') return parkGlideFrac(a); if (a._frozen || !a.cueT0) return 1; return Math.max(0, Math.min(1, (Date.now() - a.cueT0) / PARK_CUE_MS)); } // parkSlideGlideView(a): the demo slide-glide VIEW for the current frame — the agent's interpolated // fractional cell along the uniform path [from, ...cells], plus the swept cells already reached (to // ice-streak behind it). null off a slide (walk/1-cell teleport) or outside the demo. Pure render // (C1): the path endpoint (frac==1) is exactly cells[last] === st.pos[0], so no pop into rest. function parkSlideGlideView(a) { if (!a || a.mode !== 'demo' || !a.slideGlide) return null; const { from, cells } = a.slideGlide; const m = cells.length; if (!m) return null; const seg = parkGlideFrac(a) * m; // 0..m along the uniform path const s = Math.max(0, Math.min(m - 1, Math.floor(seg))); const t = seg - s; const p0 = s === 0 ? from : cells[s - 1]; const p1 = cells[s]; return { x: p0.x + (p1.x - p0.x) * t, y: p0.y + (p1.y - p0.y) * t, trail: cells.filter((c, k) => k + 1 <= seg), // cells already reached (streak behind) }; } // drawParkFrame(): the live park frame — the demo replay cursor (watch-only) or the game runtime // (the active minigame's when a task anim is live, else the capstone's). function drawParkFrame() { const run = G.campaign, a = G.parkAnim; const P = (a && a.mode === 'demo') ? a.P : (a && a.task && run.park.task) ? run.park.task.game.P : (run.stage === 'play' && run.park.game) ? run.park.game.P : null; if (!P) { bx.clearRect(0, 0, board.width, board.height); bx.fillStyle = ARC.bg; bx.fillRect(0, 0, board.width, board.height); return; } // SLIDE GLIDE (demo-only): carry the interpolated agent position + ice trail on a SHALLOW COPY // of the cue so a.cue stays clean and play/hub/tutorial renders are byte-identical (no glide key). let parkCue = (a && a.cue) || {}; const glide = parkSlideGlideView(a); if (glide) parkCue = { ...parkCue, glide }; drawParkScene(P, parkCue); if (a && a.mode === 'game' && !P.over) { // THE CALIBRATION WASH IS GONE (2026-08-06). It painted the persona-compliant target cells // mint for the first C.PARK_CAL_TURNS judged turns, meaning to teach "moves are judged". What // it actually read as was "go here" — an answer, lit for the first two turns of a board a // human is being scored on. The painter itself stays (it is the only display that can show the // scored window, and a board may want it back); what is gone is this call. The scoring skip is // untouched: moving that window would change every promotion measurement, not the screen. // §2 HANDOFF CEREMONY: a DIM assemble-in veil receding over the first beats + pulsing // "your turn" chevrons around the player until the first input. The fresh park stays // readable THROUGH the veil (max 0.62 — fix R2 #2: the old near-opaque 0.94 sampled as // a full-screen blackout that read as a death/reset). No old-frame ghosting is possible // regardless: draw() paints only the NEW runtime under the veil (the R1 #3 crossfade // snapshot is long gone), so opacity buys nothing but confusion. if (a.ceremony) { const age = Date.now() - a.ceremony; if (age < PARK_ASSEMBLE_MS) { bx.save(); bx.globalAlpha = 0.62 * Math.pow(1 - age / PARK_ASSEMBLE_MS, 1.35); bx.fillStyle = ARC.bg; bx.fillRect(0, 0, board.width, board.height); bx.restore(); } } if (a.awaitInput) drawParkDirChevrons(P.st, ['U', 'D', 'L', 'R'], null); } // §2 persistent ICON-ONLY phase chips (shapes, never letters): ▶ = watch, d-pad = play. // At the handoff the newly-live play chip pulses IN SYNC with the your-turn chevrons // (same _pulseGlow clock) — the chip's state change has a visible cause (fix R1 #10). // The watch chip pulses for the WHOLE demo (fix R2 #7): a positive "the stage is // playing itself, not yours yet" beacon, so a still frame never reads as frozen. drawParkPhaseChips(a && a.mode === 'demo' ? 'watch' : 'play', !!(a && (a.mode === 'demo' || (a.mode === 'game' && a.awaitInput)))); // §B.2 HUB DOOR on the RANDOM-TRANSFER watch stage: the deliberate hub stays reachable // WITHOUT finishing an episode (corner chip, icon-only — zero-text). WATCH only: the // judged GAME frame gains no new affordance (nothing new rides the play surface, and a // misclick can never abort a scored episode mid-run). const tt = run.park.task; if (a && a.mode === 'demo' && a.task && tt && tt.transfer) drawHubCornerChip(); // TASK 11 DEMO SKIP chrome: the ▶▶ hold-to-fast-forward affordance rides every live demo frame // (both surfaces — capstone and task replay — since both are a.mode==='demo'); once Esc has // parked the demo on its static end-frame the transport control is gone and the whole-trajectory // read takes over. Watch frames only: the judged play surface gains no new affordance. if (a && a.mode === 'demo' && !a.end) drawParkFFChip(!!a.ff); if (a && a.mode === 'demo' && a.end) drawParkDemoEnd(a); // P8.6 §A.2 WORDLESS SPOTLIGHT RING (RESOLVED KEEP): on a real-demo CONFLICT event the // mover's cell carries the white double ring — an attention anchor with ZERO semantics // (no chip, no dim, no motive glyph). Demo frames only; judged play never draws it. // (The chip/card annotation layer is ANNOT-TUTORIAL-ONLY now — drawParkAnnot lives in // drawParkTutorial; it can never fire here since annotLayerActive is tutorial-scoped.) if (a && a.mode === 'demo' && a.cue && (a.cue.pause || a.cue.conflict)) drawParkDemoRing(P.st.pos[0]); } // drawParkDemoRing(p): the §A.2 wordless conflict ring — the spotlight double-ring idiom // WITHOUT the dim and WITHOUT any chip. Pure shapes on the mover's cell (C1, zero-text). function drawParkDemoRing(p) { const g = _pulseGlow(); const cx = (p.x + 0.5) * CELL, cy = (p.y + 0.5) * CELL; bx.save(); bx.globalAlpha = 0.9; bx.strokeStyle = '#ffffff'; bx.lineWidth = 2.2; bx.beginPath(); bx.arc(cx, cy, CELL * (0.62 + 0.06 * g), 0, 7); bx.stroke(); bx.globalAlpha = 0.45; bx.lineWidth = 1.2; bx.beginPath(); bx.arc(cx, cy, CELL * (0.82 + 0.08 * g), 0, 7); bx.stroke(); bx.restore(); } // _parkGhostDisc(cell, alpha): the PATH-NOT-TAKEN phantom — a translucent dashed hollow disc at // the declined pull. Extracted verbatim from drawParkScene's cue.ghost block (same shape, same // hues, same alphas) so the S2 static end-frame can re-lay the SAME mark at every fork instead of // inventing a second vocabulary for it. Zero-text, display-only. function _parkGhostDisc(cell, alpha) { const cx = cell.x * CELL + CELL / 2, cy = cell.y * CELL + CELL / 2; bx.save(); bx.globalAlpha = alpha; bx.beginPath(); bx.arc(cx, cy, CELL * 0.34, 0, 7); bx.fillStyle = 'rgba(223,228,236,0.12)'; bx.fill(); bx.strokeStyle = '#dfe4ec'; bx.lineWidth = 2; bx.setLineDash([3, 3]); bx.stroke(); bx.restore(); } // S2 STATIC END-FRAME (task 11): the whole demo as ONE shape. The live demo teaches the order by // TIME (a walk plus a pause at every fork); the skipper gets the same information laid out in // SPACE — the full walked trajectory (not the 10-cell wake drawParkCrumbs keeps live), and at // every conflict fork the SAME two marks the live demo shows there: the white spotlight ring on // the cell where the walker deliberated, and the ghost of the pull it declined. Nothing here is // new vocabulary and nothing is text (PARK-ZERO-TEXT); the "press Enter" words ride the DOM // caption line. Display-only — it reads a.end, which parkDemoToEnd harvested off parkDemoFrame. function drawParkDemoEnd(a) { const path = a.end.path; // WHOLE TRAJECTORY: a connected polyline through every walked cell, brightening toward the end, // with a hollow start marker — so "where did it begin, where did it go" reads off one frame. if (path.length > 1) { bx.save(); bx.lineCap = 'round'; bx.lineJoin = 'round'; for (let i = 1; i < path.length; i++) { const t = i / (path.length - 1); const w = Math.max(1.5, CELL * (0.07 + 0.05 * t)); const x0 = path[i - 1].x * CELL + CELL / 2, y0 = path[i - 1].y * CELL + CELL / 2; const x1 = path[i].x * CELL + CELL / 2, y1 = path[i].y * CELL + CELL / 2; // dark CASING under the wake: the walk crosses light walkway AND saturated lava, and a bare // blue line washed out on both. The casing keeps the whole trajectory legible on any ground. bx.globalAlpha = 0.35; bx.strokeStyle = 'rgba(9,11,15,0.9)'; bx.lineWidth = w + 2.5; bx.beginPath(); bx.moveTo(x0, y0); bx.lineTo(x1, y1); bx.stroke(); bx.globalAlpha = 0.45 + 0.45 * t; bx.strokeStyle = '#9fc0ff'; bx.lineWidth = w; bx.beginPath(); bx.moveTo(x0, y0); bx.lineTo(x1, y1); bx.stroke(); } bx.strokeStyle = '#9fc0ff'; bx.globalAlpha = 0.75; bx.lineWidth = 1.6; bx.beginPath(); bx.arc(path[0].x * CELL + CELL / 2, path[0].y * CELL + CELL / 2, CELL * 0.22, 0, 7); bx.stroke(); bx.restore(); } // EVERY FORK, ALL AT ONCE: one ring per fork — no ghost disc any more (removed 2026-08-06, // Y20-DEMO-NO-GHOST-DISC): the declined-cell phantom pulled attention to an empty square instead // of the body. _parkGhostDisc and each fork's own `ghost` field still exist, so restoring the // mark under the ring costs one line. for (const f of a.end.forks) drawParkDemoRing(f.at); // CONFIRM affordance (glyph-only): a pulsing d-pad chip on the bottom band — "your turn next, // when you say so". The static frame is held until it is pressed/clicked; it never auto-advances. const r = _parkConfirmRect(), g = _pulseGlow(); bx.save(); bx.fillStyle = 'rgba(9,11,15,0.85)'; bx.fillRect(r.x, r.y, r.w, r.h); bx.strokeStyle = '#e6ecf5'; bx.lineWidth = 1.6; bx.strokeRect(r.x + 0.5, r.y + 0.5, r.w - 1, r.h - 1); _dpadGlyph(bx, r.x + r.w / 2, r.y + r.h / 2, 8, '#e6ecf5'); bx.globalAlpha = 0.30 + 0.55 * g; bx.strokeStyle = '#e6ecf5'; bx.lineWidth = 2; bx.strokeRect(r.x - 2 - 3 * g, r.y - 2 - 3 * g, r.w + 4 + 6 * g, r.h + 4 + 6 * g); bx.restore(); } // hit rects for the two new demo chips (view geometry only). The S1 chip sits in the phase-chip // row (third slot — same band, same idiom: it is a transport control like the ▶ watch chip); the // S2 confirm chip sits bottom-centre on the perimeter band, out of the walked field. function _parkFFRect() { return { x: 100, y: 5, w: 40, h: 22 }; } function _parkConfirmRect() { return { x: board.width / 2 - 22, y: board.height - 30, w: 44, h: 24 }; } // S1 affordance: the ▶▶ fast-forward chip — dim when idle, lit while held. Icon-only (two // triangles), in the same chrome band as the watch/play phase chips. function drawParkFFChip(on) { const r = _parkFFRect(); bx.save(); bx.globalAlpha = on ? 0.96 : 0.38; bx.fillStyle = 'rgba(9,11,15,0.85)'; bx.fillRect(r.x, r.y, r.w, r.h); bx.strokeStyle = on ? '#e6ecf5' : 'rgba(230,236,245,0.4)'; bx.lineWidth = on ? 1.6 : 1; bx.strokeRect(r.x + 0.5, r.y + 0.5, r.w - 1, r.h - 1); const cx = r.x + r.w / 2, cy = r.y + r.h / 2; bx.fillStyle = on ? '#e6ecf5' : '#9aa0ac'; for (const dx of [-8, 1]) { bx.beginPath(); bx.moveTo(cx + dx, cy - 6); bx.lineTo(cx + dx + 7, cy); bx.lineTo(cx + dx, cy + 6); bx.closePath(); bx.fill(); } bx.restore(); } // P3a §2/§1 display constants (timing/geometry only; no scored state anywhere). const PARK_ASSEMBLE_MS = 700; // handoff assemble-in veil duration const PARK_HEARTFLY_MS = 480; // per-heart fly-in duration (staggered) // render-side event caches (WeakMap keyed by the runtime P — display timing only, the // runtime itself is never mutated; a fresh episode object naturally resets both): the // beacon ring's last target (R2 #6 ring-arrival) and the gauge pin's met moment (R2 #3). const _PARK_DEST_SEEN = new WeakMap(); const _PARK_PIN_MET = new WeakMap(); // drawParkCalibration(P, persona): the §4 calibration-turn helper — every compliant move's // TARGET cell (incl. stay) gets a soft pulsing mint wash + thin keyline. Reads the SAME // lexFilter the judge uses, so what glows is exactly what will not flash. Display-only. function drawParkCalibration(P, persona) { const st = P.st, lex = E.parkLexFilter(P, E.parkOrderingFor(persona)); const mv = { U: { x: 0, y: -1 }, D: { x: 0, y: 1 }, L: { x: -1, y: 0 }, R: { x: 1, y: 0 }, stay: { x: 0, y: 0 } }; const g = _pulseGlow(); bx.save(); for (const k of lex) { const d = mv[k]; if (!d) continue; const x = st.pos[0].x + d.x, y = st.pos[0].y + d.y; bx.globalAlpha = 0.14 + 0.12 * g; bx.fillStyle = '#7fce97'; bx.fillRect(x * CELL + 1.5, y * CELL + 1.5, CELL - 3, CELL - 3); bx.globalAlpha = 0.35 + 0.25 * g; bx.strokeStyle = '#7fce97'; bx.lineWidth = 1.4; bx.strokeRect(x * CELL + 1.5, y * CELL + 1.5, CELL - 3, CELL - 3); } bx.restore(); } // _parkChevron(ctx, cx, cy, th, s, ...): one directional chevron pointing along angle th. function _parkChevron(ctx, cx, cy, th, s, color, alpha) { ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color; ctx.lineWidth = Math.max(2, s * 0.34); ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(cx - Math.cos(th - 0.65) * s, cy - Math.sin(th - 0.65) * s); ctx.lineTo(cx, cy); ctx.lineTo(cx - Math.cos(th + 0.65) * s, cy - Math.sin(th + 0.65) * s); ctx.stroke(); ctx.restore(); } const _PARK_DIR_TH = { U: -Math.PI / 2, D: Math.PI / 2, L: Math.PI, R: 0 }; // drawParkDirChevrons(st, dirs, echo): pulsing directional chevrons around the player actor // (the T1 "arrows move it" / handoff "your turn" affordance). `echo` = {key,t0} flashes the // just-pressed direction bright white (input echo). Shapes only — zero text. function drawParkDirChevrons(st, dirs, echo) { const p = st.pos[0], g = _pulseGlow(); const cx0 = (p.x + 0.5) * CELL, cy0 = (p.y + 0.5) * CELL; for (const k of dirs) { const th = _PARK_DIR_TH[k]; if (th == null) continue; const d = CELL * (0.95 + 0.18 * g); const hot = echo && echo.key === k && Date.now() - echo.t0 < 260; _parkChevron(bx, cx0 + Math.cos(th) * d, cy0 + Math.sin(th) * d, th, CELL * 0.26, hot ? '#ffffff' : '#e6ecf5', hot ? 0.98 : 0.35 + 0.45 * g); } } // _dpadGlyph(ctx, cx, cy, s, color): a four-direction keypad glyph (center pad + 4 teeth) — // the icon-only "play/controls" vocabulary (phase chip, tutorial continue, hub practice chip). function _dpadGlyph(ctx, cx, cy, s, color) { ctx.fillStyle = color; ctx.fillRect(cx - s * 0.24, cy - s * 0.24, s * 0.48, s * 0.48); for (const [dx, dy] of [[0, -1], [0, 1], [-1, 0], [1, 0]]) { const tx = cx + dx * s * 0.78, ty = cy + dy * s * 0.78; ctx.beginPath(); ctx.moveTo(tx + dx * s * 0.34, ty + dy * s * 0.34); ctx.lineTo(tx - dy * s * 0.34 - dx * s * 0.12, ty - dx * s * 0.34 - dy * s * 0.12); ctx.lineTo(tx + dy * s * 0.34 - dx * s * 0.12, ty + dx * s * 0.34 - dy * s * 0.12); ctx.closePath(); ctx.fill(); } } // drawParkPhaseChips(active, pulse): persistent ICON-ONLY phase chips on the live board // (P3a §2): ▶ triangle = the watch phase, d-pad = the play phase; the active chip is lit, // the other dim — a first-time viewer can always tell whose turn the stage is. Drawn glyph // shapes with NO letters (PARK-ZERO-TEXT). Sits on the perimeter tree band (top-left). // Enlarged + a `pulse` handoff ring on the active chip, driven by the SAME _pulseGlow clock // as the your-turn chevrons, so the watch->play flip has a visible cause (fix R1 #10). function drawParkPhaseChips(active, pulse) { const y = 5, w = 40, h = 22; const chips = [['watch', 8], ['play', 54]]; for (const [k, x] of chips) { const on = k === active; bx.save(); bx.globalAlpha = on ? 0.96 : 0.38; bx.fillStyle = 'rgba(9,11,15,0.85)'; bx.fillRect(x, y, w, h); bx.strokeStyle = on ? '#e6ecf5' : 'rgba(230,236,245,0.4)'; bx.lineWidth = on ? 1.6 : 1; bx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); const ccx = x + w / 2, ccy = y + h / 2; if (k === 'watch') { bx.fillStyle = on ? '#e6ecf5' : '#9aa0ac'; bx.beginPath(); bx.moveTo(ccx - 4.5, ccy - 6.5); bx.lineTo(ccx + 7, ccy); bx.lineTo(ccx - 4.5, ccy + 6.5); bx.closePath(); bx.fill(); } else { _dpadGlyph(bx, ccx, ccy, 7.5, on ? '#e6ecf5' : '#9aa0ac'); } if (pulse && on) { // handoff: chip pulses WITH the chevrons const gg = _pulseGlow(); bx.globalAlpha = 0.35 + 0.55 * gg; bx.strokeStyle = '#e6ecf5'; bx.lineWidth = 2; bx.strokeRect(x - 2 - 3 * gg, y - 2 - 3 * gg, w + 4 + 6 * gg, h + 4 + 6 * gg); } bx.restore(); } } // drawParkRivalTaboo(P): RELATIONAL-SAFETY LEGIBILITY (design 2026-07-06 §B.5) — on a relational- // form board the safety hazard is not a static field but the LIVE rival-adjacency taboo, so the // DEMO/TUTORIAL washes every currently-forbidden cell red and rings the pink companion (the rule's // live source) in red, and a watcher can SEE why the walker keeps its distance / detours. The // forbidden set is read from E._parkRivalTaboo (frozen at call time — it tracks the rival's live // position, so the red band MOVES with the companion frame to frame), a pure public-board read // (never the persona → C1). DEMO/TUTORIAL ONLY (annotLayerActive, the §C.3 ANNOT-DEMO-ONLY scope): // the judged play board shows no forbidden-cell aid — the player must read the rule from the walk. // Zero-text: fills + strokes only (PARK-ZERO-TEXT); the mechanics name rides the DOM chip. Static- // form boards (safetyForm absent/'static') no-op, so every existing board renders byte-identically. function drawParkRivalTaboo(P) { const st = P.st, park = st.park, n = st.N, form = park && park.safetyForm; if (!form || form === 'static' || !annotLayerActive()) return; const taboo = E._parkRivalTaboo(st, form); const g = _pulseGlow(); bx.save(); bx.fillStyle = _alpha('#e0463b', 0.15 + 0.10 * g); bx.strokeStyle = _alpha('#e0463b', 0.5); bx.lineWidth = Math.max(1, CELL * 0.045); for (let y = 1; y < n - 1; y++) for (let x = 1; x < n - 1; x++) { const kk = y * n + x; if (st.wall.has(kk) || !taboo.has(kk)) continue; bx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2); bx.strokeRect(x * CELL + CELL * 0.15, y * CELL + CELL * 0.15, CELL * 0.7, CELL * 0.7); // keep-out ticks } // RIVAL DANGER RING: the hazard source is the entity safety C keys — a pulsing red ring binds the // forbidden band to WHO it tracks (the companion, seat 1, for adjacent/nearest_token). const rv = st.pos[1]; if (rv) { const cx = (rv.x + 0.5) * CELL, cy = (rv.y + 0.5) * CELL; bx.strokeStyle = _alpha('#e0463b', 0.7 + 0.25 * g); bx.lineWidth = Math.max(2, CELL * 0.07); bx.beginPath(); bx.arc(cx, cy, CELL * (0.5 + 0.06 * g), 0, 7); bx.stroke(); } bx.restore(); } // Pokemon-like discovery beat, drawn as geometry rather than canvas text so the live board keeps // its zero-text contract. A white speech bubble is anchored to the noticing actor. // ONE BUBBLE, THREE CONTENTS (2026-08-06). The frame means "this body has just noticed something"; // the content says WHAT. // 'bang' (default) a discovery — the red exclamation. Every existing caller gets this. // 'heart' a danger — the cracked heart: going there costs a heart. // 'dots' a hesitation — three neutral dots lighting in sequence: it is deciding right now. // The default matters: get it wrong and the opener's pink claim turns into a danger or a pause. function drawParkNoticeMark(st, notice) { // 말풍선은 두 자리 중 하나에 앵커된다: 몸(notice.seat — 발견·고민) 또는 칸(notice.at — 위험). // 위험은 "저기가 위험하다"이지 "내가 위험하다"가 아니므로 대상 칸 위에 뜬다. const p = notice && (notice.at || st.pos[notice.seat]); if (!p) return; const actorX = (p.x + 0.5) * CELL, actorY = (p.y + 0.5) * CELL; const pop = 0.88 + 0.12 * _pulseGlow(); const w = CELL * 1.02 * pop, h = CELL * 0.78 * pop, r = CELL * 0.18; const actorTop = actorY - CELL * (notice.at ? 0.40 : (notice.seat === 0 ? 0.46 : 0.34)); const above = actorTop - h - CELL * 0.16 >= 4; const x = Math.max(4, Math.min(board.width - w - 4, actorX - w / 2)); const y = above ? actorTop - h - CELL * 0.16 : actorTop + CELL * 0.22; const tailX = Math.max(x + r, Math.min(x + w - r, actorX)); const baseY = above ? y + h : y; const tipY = above ? actorTop - CELL * 0.02 : actorTop + CELL * 0.06; bx.save(); bx.shadowColor = 'rgba(0,0,0,0.78)'; bx.shadowBlur = CELL * 0.2; bx.fillStyle = '#ffffff'; bx.strokeStyle = '#1a1d25'; bx.lineWidth = Math.max(1.6, CELL * 0.06); bx.lineJoin = 'round'; bx.beginPath(); bx.moveTo(tailX - CELL * 0.13, baseY); bx.lineTo(actorX, tipY); bx.lineTo(tailX + CELL * 0.13, baseY); bx.closePath(); bx.fill(); bx.stroke(); _roundRect(x, y, w, h, r); bx.fill(); bx.stroke(); bx.shadowColor = 'transparent'; const cx = x + w / 2, cy = y + h / 2; const mark = (notice && notice.mark) || 'bang'; if (mark === 'heart') { drawHeartCrack(cx, cy + h * 0.02, h * 0.34); } else if (mark === 'dots') { // 점 셋이 차례로 밝아진다 — 위상을 i/3 만큼 민다. "!" 아래 점과 같은 반지름이라 // 한 어휘 안에 있다는 것이 모양으로 읽힌다. 색은 중립 먹색: 빨강은 이 판에서 // 위험/발견이고, 고민은 둘 다 아니다. const r = Math.max(1.8, CELL * 0.07), gap = w * 0.18; for (let i = 0; i < 3; i++) { const ph = (Date.now() % 1100) / 1100 + i / 3; bx.globalAlpha = 0.35 + 0.55 * (0.5 - 0.5 * Math.cos(ph * 6.283185)); bx.fillStyle = '#4a4f5a'; bx.beginPath(); bx.arc(cx + (i - 1) * gap, cy, r, 0, 7); bx.fill(); } bx.globalAlpha = 1; } else { bx.strokeStyle = '#d92d20'; bx.fillStyle = '#d92d20'; bx.lineWidth = Math.max(2.4, CELL * 0.105); bx.lineCap = 'round'; bx.beginPath(); bx.moveTo(cx, cy - h * 0.25); bx.lineTo(cx, cy + h * 0.08); bx.stroke(); bx.beginPath(); bx.arc(cx, cy + h * 0.28, Math.max(1.8, CELL * 0.07), 0, 7); bx.fill(); } bx.restore(); } // drawParkScene(P, cue): the WHOLE N=20 stage, always visible (no camera follow), cells sized to // fill the canvas. Paint order: terrain classes -> destination beacon -> companion claim ring -> // gem clusters -> decision cues (ghost / pause ring) -> actors -> event flashes. Every painted // class is a pure function of the PUBLIC board / runtime cues (never the persona → C1). ZERO-TEXT. function drawParkScene(P, cue) { const st = P.st, n = st.N, park = st.park; CELL = board.width / n; bx.clearRect(0, 0, board.width, board.height); bx.fillStyle = ARC.bg; bx.fillRect(0, 0, board.width, board.height); // P8.6 §C.2 HAZARD NATURE: each family's deep field carries its own live temperament — // LAVA breathes (slow deep pulse, damage 2), MEADOW sits static (1), ICE is a static // cool field whose glint sparkles instead (_paintParkTerrain; 0 = body-safe). Keyed on // the PUBLIC hazard kind only (capstone -> lava, unchanged pulse). const famD = (park.cell && park.cell.hazard.kind) || 'lava'; const deepA = famD === 'lava' ? 0.62 + 0.20 * _slowPulse() : famD === 'ice' ? 0.66 : 0.74; _paintParkTerrain(st, 0, 0, CELL, deepA); bx.strokeStyle = 'rgba(14,15,19,0.35)'; bx.lineWidth = 1; // faint cell seams for (let i = 1; i < n; i++) { bx.beginPath(); bx.moveTo(i * CELL, 0); bx.lineTo(i * CELL, board.height); bx.stroke(); bx.beginPath(); bx.moveTo(0, i * CELL); bx.lineTo(board.width, i * CELL); bx.stroke(); } drawParkRivalTaboo(P); // relational-safety RED taboo band (demo/tutorial only; static-form no-op) const ctr = (p) => ({ cx: p.x * CELL + CELL / 2, cy: p.y * CELL + CELL / 2 }); const g = _pulseGlow(); const shared = park.sharedClaim; const sharedStage = P.sharedClaimStage; // claimOrder(2026-08-05): stage names 'blue'/'dual' are just the state machine's own two beat // labels, not seat identifiers — which seat's notice fires at which beat depends on // shared.order ('pink' flips it, default 'blue' keeps the original blue-then-pink order). // declOrder[0] is the seat that declares at the 'blue' beat, declOrder[1] at the 'dual' beat; // reachedSeat(seat) says whether that seat's OWN declaration beat has been reached yet, so // the ring gates below read the order instead of hardcoding which stage name means which seat. const declOrder = (shared && shared.order === 'pink') ? [1, 0] : [0, 1]; const reachedSeat = (seat) => (seat === declOrder[0]) ? sharedStage !== 'unseen' : sharedStage === 'dual'; const fam = (park.cell && park.cell.hazard.kind) || 'lava'; // hazard family (capstone -> lava) // CHARRED-HUSK <-> DAMAGE LINK (fix R1 #5): the blackened gem husks carry a soft always-on // ember pulse (hazard family, not a pickup), and whenever a deep-entry damage flash is live // they FLASH the same ember rays IN SYNC with it — synchronized identical glyphs = one // meaning ("things that went in here burned"). LAVA-ONLY (fix diversity2 #7): charred/ember // debris on an ICE or MEADOW field was an incoherent second hazard grammar (the judge saw // "brown chips" on every family) — each family now speaks ONE mark (ice crack / meadow tuft / // lava husk+ember). Pure public-board render (C1). if (fam === 'lava') { for (const kk of _parkHuskCells(park)) { const hcx = (kk % n + 0.5) * CELL, hcy = (((kk / n) | 0) + 0.5) * CELL; bx.save(); // smoldering-ground UNDERGLOW: a soft warm radial FILL (not a stroked ring — the old ring // read as an eyelid and, with the shards, completed an "eye"; fix R1 #2). Reads as embers // in the debris, no closed outline. const hg = bx.createRadialGradient(hcx, hcy + CELL * 0.12, CELL * 0.05, hcx, hcy + CELL * 0.12, CELL * 0.46); hg.addColorStop(0, _alpha(PARK_HUES.ember, 0.14 + 0.12 * g)); hg.addColorStop(1, _alpha(PARK_HUES.ember, 0)); bx.fillStyle = hg; bx.beginPath(); bx.arc(hcx, hcy + CELL * 0.12, CELL * 0.46, 0, 7); bx.fill(); if (cue.deep || cue.hurt) { bx.globalAlpha = 0.85; bx.lineWidth = 2; bx.lineCap = 'round'; for (let i = 0; i < 8; i++) { const th = i * Math.PI / 4 + 0.39; bx.beginPath(); bx.moveTo(hcx + Math.cos(th) * CELL * 0.4, hcy + Math.sin(th) * CELL * 0.4); bx.lineTo(hcx + Math.cos(th) * CELL * 0.62, hcy + Math.sin(th) * CELL * 0.62); bx.stroke(); } } bx.restore(); } } let goalPin = null; // set by the beacon block, drawn after the actors (never occluded) const scopeMark = new Set(); // R2 goal fix #3/#4: token indices of the REMAINING objective set // DESTINATION BEACON: a pulsing gold ring on the current chain destination (the pursued goal). // The old dashed gold intent STUB toward the target is GONE (blind-judge fix R3 #5): it pointed // straight through the danger field while the agent detoured, so judges read planned-vs-traveled // as a contradiction. The traveled route is the breadcrumb wake + facing; the pursued goal is // the ring alone. Both public (seed chain; C1). // R2 (blind round 2): the beacon target mirrors the ENGINE's _parkDestCell exactly — collect // boards previously starred the first alive typed gem while the mover pursued the NEAREST // missing type, so the star could ride a token nobody was walking to. Pure public state (C1). let _bt = null, _bti = -1; if (park.needTypes) { const got = new Set(); for (const ci of park.chain) if (!st.tokens[ci].alive) got.add(st.tokens[ci].gtype); let bd = Infinity; for (const ci of park.chain) { const tt = st.tokens[ci]; if (!tt.alive || got.has(tt.gtype)) continue; const d = Math.abs(st.pos[0].x - tt.x) + Math.abs(st.pos[0].y - tt.y); if (d < bd) { bd = d; _bt = tt; _bti = ci; } } } else if (P.dest < park.chain.length) { // chainAnyOf (y46 v2's four exits): this leg is satisfied by ANY of several tokens, and the // engine's _parkDestCell aims at the NEAREST alive one. The beacon mirrors that EXACTLY, for the // R2 reason restated above — a star riding a token nobody is walking to is worse than no star, // and here it would have pinned exit #0 for the whole episode while the walker headed elsewhere. // No-op (and byte-identical) on every board that does not opt in. const any = park.chainAnyOf && park.chainAnyOf[P.dest]; if (any) { let bd = Infinity; for (const ci of any) { const tt = st.tokens[ci]; if (!tt || !tt.alive) continue; const d = Math.abs(st.pos[0].x - tt.x) + Math.abs(st.pos[0].y - tt.y); if (d < bd) { bd = d; _bt = tt; _bti = ci; } } } if (!_bt) { const tt = st.tokens[park.chain[P.dest]]; if (tt && tt.alive) { _bt = tt; _bti = park.chain[P.dest]; } } } { const t = _bt; if (t && !(shared && _bti === shared.gem && sharedStage === 'unseen')) { const c = ctr(t); // RING ARRIVAL (fix R2 #6): when the beacon advances to the next chain gem the ring // LANDS — oversized and bright, contracting onto the new target over ~600ms — so a // retarget is a visible event whose cause sits in the same frame (the previously // ringed gem's take pop), never a silent jump between clusters. Render-side cache // only (WeakMap keyed by the runtime — no P mutation, C1/scoring untouched). const dkk = t.y * n + t.x; let seen = _PARK_DEST_SEEN.get(P); if (!seen || seen.kk !== dkk) { seen = { kk: dkk, at: Date.now(), first: !seen }; _PARK_DEST_SEEN.set(P, seen); } const lt = seen.first ? 1 : clamp01((Date.now() - seen.at) / 600); bx.save(); // DESTINATION-CELL BEACON (fix round2 #1/#2/#7): the ONE active objective cell is lit from // the floor up — a dark vignette seats it apart from the terrain, a bright warm core glows, // and a thick keyed double-ring crowns it. Among look-alike collectibles/pads exactly one // cell carries this beacon, so "what is the goal here, right now" is answered by a single // unmistakable mark rather than guessed among diamonds, hexagons and teal rings. const floor = bx.createRadialGradient(c.cx, c.cy, CELL * 0.06, c.cx, c.cy, CELL * 0.62); floor.addColorStop(0, _alpha('#ffe9a6', 0.34 + 0.16 * g)); floor.addColorStop(0.55, _alpha('#e8c14a', 0.14)); floor.addColorStop(1, 'rgba(0,0,0,0.28)'); bx.fillStyle = floor; bx.beginPath(); bx.arc(c.cx, c.cy, CELL * 0.62, 0, 7); bx.fill(); // TARGET BRACKETS (R1 goal fix #8): the old steady closed GOLD RING around the active // cell read as a CONTAINER — "gems inside the ring" vs identical gems outside carried // phantom meaning (deposit bin? HUD counter? cache?). Four open corner brackets frame // the cell like a viewfinder: a highlight, never a receptacle — nothing is "in" or // "out" of it. The retarget flare keeps the old contracting ring, transient only. const bs = CELL * 0.46, bl = CELL * 0.22; bx.globalAlpha = 0.75 + 0.2 * g; bx.strokeStyle = '#ffd955'; bx.lineWidth = Math.max(2, CELL * 0.07); bx.lineCap = 'round'; for (const [sx, sy] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) { bx.beginPath(); bx.moveTo(c.cx + sx * bs - sx * bl, c.cy + sy * bs); bx.lineTo(c.cx + sx * bs, c.cy + sy * bs); bx.lineTo(c.cx + sx * bs, c.cy + sy * bs - sy * bl); bx.stroke(); } if (lt < 1) { // transient retarget flare (contracting) bx.globalAlpha = 0.5 * (1 - lt); bx.strokeStyle = '#ffd955'; bx.lineWidth = 2; bx.beginPath(); bx.arc(c.cx, c.cy, CELL * (0.5 + 0.55 * (1 - lt)), 0, 7); bx.stroke(); } bx.restore(); // OBJECTIVE LEDGER (R2 goal fixes #1/#3/#4/#6): the pin's slot under the star no longer // holds one ambiguous verb glyph (the lone up-arrow read as person/exclamation/eject and // three different objectives shared it) — it holds the WIN LEDGER: one mini-token per // remaining objective leg, in the goal grammar's own vocabulary. gather = a row of mini // gold gems (ALL of these); one-of-each = one mini shape per required TYPE; deliver = the // mini gem row flowing through an arrow INTO the mini basket; reach = a row of mini pads. // Collected/visited legs dim under a mint check. Public chain/tokens/goalVariant (C1). const gv = park.cell && park.cell.goalVariant; const padLegs = _parkChainPadLegs(P); // stand-legs, or null on a pickup board const dropT = (gv === 'deliver' && park.chain.length) ? park.chain[park.chain.length - 1] : -1; const verb = t.pad ? 'stand' : (dropT >= 0 && _bti === dropT) ? 'drop' : 'take'; let led; if (park.needTypes) { const got = new Set(); for (const ci of park.chain) if (!st.tokens[ci].alive) got.add(st.tokens[ci].gtype); const types = [...new Set(park.chain.map(ci => st.tokens[ci].gtype))].sort((a, b) => a - b); led = { kind: 'types', items: types.map(gt => ({ gt, done: got.has(gt) })) }; } else if (gv === 'deliver') { led = { kind: 'deliver', items: park.chain.slice(0, -1).map(ci => ({ done: !st.tokens[ci].alive })) }; } else if (gv === 'reach' || padLegs) { // A pad leg is a STAND leg on every board — see _parkChainPadLegs for why the plaque may not // key on the variant's NAME. The `gv === 'reach'` half stays as a belt: a reach board whose // legs somehow are not pads must still get its pad plaque and not fall through to gems. led = { kind: 'pads', items: padLegs || park.chain.map(ci => ({ done: !st.tokens[ci].alive })) }; } else { led = { kind: 'gems', items: park.chain.map(ci => ({ done: !st.tokens[ci].alive })) }; } // OBJECTIVE SCOPE MARKS (R2 goal fixes #3/#4): every REMAINING objective token wears mini // gold corner ticks (the beacon's own bracket idiom, small) so "which of these count" is // answered on the board itself — gather/deliver mark EVERY still-alive chain gem; one-of- // each marks the NEAREST alive instance of each still-missing type (any one of each kind); // reach marks nothing extra (one ring idiom per frame — R2 #10). Contract gems are never // marked (they wear the companion's claim ring instead). Public chain/tokens only (C1). if (park.needTypes) { const got = new Set(); for (const ci of park.chain) if (!st.tokens[ci].alive) got.add(st.tokens[ci].gtype); const near = {}; for (const ci of park.chain) { const tt = st.tokens[ci]; if (!tt.alive || got.has(tt.gtype)) continue; const d = Math.abs(st.pos[0].x - tt.x) + Math.abs(st.pos[0].y - tt.y); if (!(tt.gtype in near) || d < near[tt.gtype].d) near[tt.gtype] = { ci, d }; } for (const gt in near) if (near[gt].ci !== _bti) scopeMark.add(near[gt].ci); } else if (gv !== 'reach') { for (const ci of park.chain) { if (ci !== _bti && ci !== dropT && st.tokens[ci].alive) scopeMark.add(ci); } } // P8.6 §C2 AUDIT CUT — the DELIVER CARRY LINE (R2 goal fix #2's dashed gem->basket // line) is GONE: the cargo's destination is already stated three times over (the // unique basket glyph, its gold dashed floor footprint, and the pin ledger's // gem->arrow->basket row), so the line carried no public state of its own. // PIN ANCHOR (R2 goal fix #7): the pin used to float a fixed cell above the target, where // it covered the very tile it labeled (top-row flip), adjacent gems, and even the magenta // claimant standing at its station. It now picks the first of up/right/left/down whose // footprint covers no alive token, no actor and stays on-canvas — falling back to the old // flip only when every side is crowded. Pure function of public tokens/positions (C1). const pw = Math.max(_parkLedgerW(CELL, led) + CELL * 0.3, CELL * 1.0); const occ = new Set(); st.tokens.forEach((t2, ti2) => { if (t2.alive && ti2 !== _bti) occ.add(t2.y * n + t2.x); }); for (const s2 of [0, 1]) if (st.pos[s2]) occ.add(st.pos[s2].y * n + st.pos[s2].x); const fits = (dx2, dy2) => { const ex = dx2 ? c.cx + dx2 * (CELL * 0.9 + pw / 2) : c.cx; const ey = dy2 ? c.cy + dy2 * CELL * 1.62 : c.cy - CELL * 0.1; const hw = Math.max(pw / 2, CELL * 0.5); const x0 = ex - hw, x1 = ex + hw, y0 = ey - CELL * 0.86, y1 = ey + CELL * 0.62; if (x0 < 1 || x1 > board.width - 1 || y0 < 1 || y1 > board.height - 1) return null; for (let gx2 = Math.floor(x0 / CELL); gx2 <= Math.floor((x1 - 0.01) / CELL); gx2++) for (let gy2 = Math.floor(y0 / CELL); gy2 <= Math.floor((y1 - 0.01) / CELL); gy2++) if (occ.has(gy2 * n + gx2)) return null; return { ex, ey }; }; let anc = fits(0, -1) || fits(1, 0) || fits(-1, 0) || fits(0, 1); if (!anc) { const flip = c.cy - CELL * 1.9 < 0 ? -1 : 1; anc = { ex: c.cx, ey: c.cy - flip * CELL * 1.62 }; } // deferred to AFTER the actors (R1 goal fix #15/#9): drawn here the pin vanished behind // the magenta body; the ONE objective mark is never occluded. goalPin = { cx: c.cx, cy: c.cy, ex: anc.ex, ey: anc.ey, verb, led }; } } // P8.6 §C2 AUDIT CUT — the COMPANION->GEM dashed claim LINE (leash) is GONE (spec §C2, // RESOLVED): claim line + claim ring marked the SAME public state (the companion's // contracted gem), so the redundant pair is slimmed to the RING only — more local, a // quieter screen. The blocked-mid-relocate read survives on the companion itself (the // P.wait bob + dashed waiting ring in drawParkCompanion). { // DASHED claim ring, ALWAYS-ON for the companion's pending contract gem (R1 goal fix // #15 — was leash-gated on P.mode==='toGem', so on opener stills the companion stood // NEXT to unmarked gems and judges bound the NPC to the objective): the companion-hue // dashed ring owns those gems from frame one ("the little one's claim", never yours, // never the goal — the goal wears the white star + gold brackets). Public contract // fields + token state only (C1). // ... on EVERY still-pending contract gem (R2 goal fix #4/#5: with the objective set now // gold-ticked, an unmarked gold gem would be the one ambiguous token left — every gem on // the board is either bracketed (yours) or claim-ringed (the companion's)). for (let ci = P.contract; ci < park.contracts.length; ci++) { if (park.bombScene && ci === P.contract && !park.bombScene.pinkClaimed) continue; const tok = st.tokens[park.contracts[ci].gem]; const sharedContract = shared && park.contracts[ci].gem === shared.gem; if (sharedContract && !reachedSeat(1)) continue; if (tok && tok.alive) { const tc = ctr(tok); // 찜이 막 붙은 순간엔 링이 한 번 커졌다 제 크기로 돌아온다 (2026-08-06). 팝의 시작 // 시각은 보드가 들고 있고(bombScene.claimPop), 폭은 튜토리얼 보석 팝과 같은 수다. const sc0 = park.bombScene; const popOn = !!(sc0 && sc0.claimPop && Date.now() - sc0.claimPop < PARK_BOMB_CLAIM_MS); const pop = popOn ? 1 + PARK_CLAIM_POP * _pulseGlow() : 1; bx.save(); // subordinate to the gold objective marks (R2 goal fix #5): softer ring + the // companion's own HEART tag riding the ring — the same mark the magenta body wears // on its shoulder, so the claimed gem reads "the little one's darling", never a goal. bx.strokeStyle = _alpha(PARK_HUES.intent, 0.75); bx.lineWidth = 2; bx.setLineDash([4, 3]); bx.beginPath(); bx.arc(tc.cx, tc.cy, CELL * 0.42 * pop, 0, 7); bx.stroke(); bx.setLineDash([]); bx.fillStyle = PARK_HUES.companion; _heartPath(bx, tc.cx + CELL * 0.34, tc.cy - CELL * 0.38, Math.max(3, CELL * 0.1)); bx.fill(); bx.restore(); } } // BLUE'S CLAIM, and why it is no longer only the shared gem. Pink has worn a dashed ring on // what she wants since frame one; blue wore one only on the CONTESTED gem, and only for the two // beats of the discovery scene. So the instant that scene resolved — either way — the board // stopped saying what HE was walking to, and the one thing this demo exists to show (a // care-first mind MOVING its claim to the next gem) had no mark to move. His ring now rides the // CURRENT objective for the whole demo, in his own body hue, on the wider radius so her smaller // contract ring stays visible inside it whenever both want the same gem. // THE DISCOVERY GATE SURVIVES UNTOUCHED: on the shared gem itself nothing is drawn until he has // publicly noticed it. A claim shown before it is earned is a claim nobody watched him make. // Opted in by park.sharedClaim, so this is the demonstration board and nowhere else. if (shared && _bti >= 0) { const tok = st.tokens[_bti]; const earned = _bti !== shared.gem || reachedSeat(0); // 발견 프레임 동안은 말풍선만 산다 (2026-08-06). 링은 다음 프레임에 붙고, 붙는 순간 // 한 번 커졌다 제 크기로 돌아온다 — 튜토리얼 보석 팝과 같은 수다. if (tok && tok.alive && !tok.pad && earned && !cue.claimHold) { const tc = ctr(tok); const pop = cue.claim ? 1 + PARK_CLAIM_POP * _pulseGlow() : 1; bx.save(); bx.strokeStyle = _alpha(SPRITE_HUE.agent, 0.95); bx.lineWidth = 2.5; bx.setLineDash([CELL * 0.16, CELL * 0.1]); bx.beginPath(); bx.arc(tc.cx, tc.cy, CELL * 0.54 * pop, 0, 7); bx.stroke(); bx.setLineDash([]); bx.restore(); } } } // FOREGONE-SHORTCUT PRICE TAG (blind-judge fix R3-A2): whenever a strictly-shorter route to the // current destination would cross the deep field, a dashed neutral trace follows that beeline // INTO the field and a ghosted cracked heart sits on the field-entry cell — wordless "could have // cut through, would cost ♥". Environment knowledge: a pure function of the public board + the // mover's position + the public chain (identical for every persona at the same cell — C1). It // prices the option, so a walk that never touches the field still FALSIFIES "safety was forced". const sc = parkShortcutTrace(P); if (sc) { const ac = ctr(st.pos[0]); bx.save(); bx.strokeStyle = 'rgba(223,228,236,0.55)'; bx.lineWidth = 2; bx.setLineDash([3, 4]); bx.beginPath(); bx.moveTo(ac.cx, ac.cy); for (const p of sc.cells) { const c = ctr(p); bx.lineTo(c.cx, c.cy); } bx.stroke(); bx.setLineDash([]); const e = ctr(sc.cells[sc.cells.length - 1]); bx.globalAlpha = 0.6; drawHeartCrack(e.cx, e.cy, CELL * 0.36); bx.restore(); } // GOAL-GRAMMAR TOKENS (spec §C.1): reach = a ringed destination PAD (stand here), collect = // typed gems (one silhouette/hue per type), harvest/deliver = the gold gem cluster (size = // value). Companion contract gems stay gold in every variant. Public token fields only (C1). // DELIVER drop-off: the LAST chain token on a deliver board is the return basket (public // chain + public goalVariant — identical for every persona, C1). Drawn as the receptacle // glyph so a deliver board reads as "carry back", never as another harvest board. const goalV = park.cell && park.cell.goalVariant; const destTok = _bti; const dropIdx = (goalV === 'deliver' && park.chain.length) ? park.chain[park.chain.length - 1] : -1; // The R1 reach ITINERARY POLYLINE is RETIRED (R2 goal fix #10): threading the ghost pads // added dashed endpoints that were NOT the goal — route noise on an otherwise legible // objective. Remaining stops live in the pin's ledger (a row of mini pads) instead; the // board keeps exactly ONE ring idiom per frame (the active pad). // _parkMiniMark: the objective SCOPE tick (R2 goal fixes #3/#4) — small gold corner ticks, // the beacon's own bracket idiom at reduced strength, on every remaining objective token. const _parkMiniMark = (tx, ty) => { const mcx = (tx + 0.5) * CELL, mcy = (ty + 0.5) * CELL; const ms = CELL * 0.4, ml = CELL * 0.13; bx.save(); bx.globalAlpha = 0.8; bx.strokeStyle = '#ffd955'; bx.lineWidth = Math.max(1.4, CELL * 0.045); bx.lineCap = 'round'; for (const [sx2, sy2] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) { bx.beginPath(); bx.moveTo(mcx + sx2 * ms - sx2 * ml, mcy + sy2 * ms); bx.lineTo(mcx + sx2 * ms, mcy + sy2 * ms); bx.lineTo(mcx + sx2 * ms, mcy + sy2 * ms - sy2 * ml); bx.stroke(); } bx.restore(); }; // y46 v2: the four safe zones are tokens (pad, so no gem body ever — see the pad branch below), // but they are also DOORWAYS painted by the siege field layer, open or shut. A cyan reach-pad ring // or ghost dot on top of one says "stand here" about a gate that may already be shut behind // somebody else, i.e. two destination idioms on one cell disagreeing. The field painter owns those // four cells outright; the ONE active exit is still named by the goal star, which rides above // everything. Exactly the reason for the statue finish skip on the next line, one cell later. const sgGoals = (st.park.siege && st.park.siege.goals) || null; // ---- ONE ROUND AT A TIME (y50's three-round staging, 2026-08-03). 요구: "두 골이 동시에 사라지고 // 두 번째 골이 나온다." A chainAnyOf board stages its legs as ROUNDS, and a round that is not the // current one must not be on the board AT ALL — not as a live pad ahead of its turn, and not as // the dimmed ghost-with-a-check this loop leaves behind a completed one. // BOTH HALVES NEED SAYING HERE, and neither is something the engine can do for us. It strikes a // finished round's tokens dead (_parkAlleyRoundTick), which the ghost branch below then draws as // history — so "the two goals vanish together" would have shown two faded pads sitting there for // the rest of the match. And a FUTURE round's tokens are still alive, so they would have been on // screen from turn one, making round 0 look like a five-goal board. // The ghost/check grammar is right on a REACH board, where the legs are waypoints of one journey // and the history is the point. It is wrong on a board whose legs are separate questions. // THE LAST ROUND STAYS after the cursor runs off the end (P.dest === chain.length), so the final // frame still shows the pad he finished on rather than an empty yard. const roundStaged = st.park.chainAnyOf ? new Set(st.park.chainAnyOf.reduce((a, grp) => a.concat(grp), [])) : null; const roundNow = st.park.chainAnyOf ? (st.park.chainAnyOf[Math.min(P.dest, st.park.chainAnyOf.length - 1)] || []) : null; const padHue = _parkRoundPadHue(st.park, P.dest); const goalClaim = (st.park.dyn && st.park.dyn.alley && st.park.dyn.alley.goalClaim) || null; st.tokens.forEach((t, ti) => { if (sgGoals && sgGoals.includes(t.y * n + t.x)) return; if (roundStaged && roundStaged.has(ti) && !roundNow.includes(ti)) return; // a different round // y29: the finish cell wears the arrival mark (statue painter), never a gem body — // a coin on the finish line would claim the old goal grammar this module left behind. if (st.park.statue && st.park.statue.finishTi === ti) return; if (!t.alive) { // COMPLETED reach pad (goal-legibility R1 #1): the engine kills a reached pad token, but a // vanished pad leaves the chain's history unreadable in a still. It stays on the floor as a // DIMMED ghost (faded ring + faded inner dot) under a shape-only mint CHECK — the same done // grammar as the tutorial's mint underline. Well below the active pad's strength, so exactly // one full-strength pad idiom exists per frame. Pure public-token render (C1), zero text. if (t.pad) { const cx = (t.x + 0.5) * CELL, cy = (t.y + 0.5) * CELL; bx.save(); bx.globalAlpha = 0.28; bx.strokeStyle = PARK_PAD_HUE; bx.lineWidth = Math.max(1, CELL * 0.05); bx.beginPath(); bx.arc(cx, cy, CELL * 0.24, 0, 7); bx.stroke(); bx.fillStyle = _alpha(PARK_PAD_HUE, 0.55); bx.beginPath(); bx.arc(cx, cy, CELL * 0.09, 0, 7); bx.fill(); bx.globalAlpha = 0.8; bx.strokeStyle = '#7fce97'; bx.lineWidth = Math.max(1.6, CELL * 0.08); bx.lineCap = 'round'; bx.lineJoin = 'round'; bx.beginPath(); bx.moveTo(cx - CELL * 0.2, cy + CELL * 0.02); bx.lineTo(cx - CELL * 0.04, cy + CELL * 0.18); bx.lineTo(cx + CELL * 0.22, cy - CELL * 0.18); bx.stroke(); bx.restore(); } return; } if (ti === dropIdx) { // FLOOR FOOTPRINT first (deliver home often IS the spawn cell, so the actor sits on the // basket at t=0): a gold dashed cell-edge frame reads the drop TILE even around the agent. const fx = t.x * CELL, fy = t.y * CELL, ins = CELL * 0.12; bx.save(); bx.strokeStyle = _alpha('#e8c14a', 0.9); bx.lineWidth = Math.max(1.5, CELL * 0.07); bx.setLineDash([CELL * 0.16, CELL * 0.1]); bx.strokeRect(fx + ins, fy + ins, CELL - 2 * ins, CELL - 2 * ins); bx.setLineDash([]); bx.restore(); _parkDropZone(bx, (t.x + 0.5) * CELL, (t.y + 0.5) * CELL, CELL * 0.52); } else if (t.pad) { // REACH pads (goal-legibility R1 #1): exactly ONE full cyan pad per frame — the ACTIVE // destination, enlarged under a pulsing pad-hue halo (it also wears the white goal star). // FUTURE chain pads are faint DASHED ghost outlines only (no fill, no core dot): clearly // subordinate waypoints-to-come, never a competing "stand HERE" ring. Zero text. // ROUND HUE (y50): the pad's SHAPE never changes, only its colour, and only on the board that // hands each round to a different mind — see _parkRoundPadHue. null everywhere else, which is // the teal every other reach board has always drawn. const cx = (t.x + 0.5) * CELL, cy = (t.y + 0.5) * CELL; const padCol = padHue || PARK_PAD_HUE; // THE CLAIM (y50 round 0, 요구 2): pink has already spoken for one of the two pads, and the // whole question this round measures is whether he walks into hers anyway. If the mark is not // on the board before he chooses, the choice was never offered. Same dashed ring y46's four // exits wear (_parkClaimRing) in the claimant's seat hue: one dialect across both boards. // Drawn OUTSIDE the pad, so it reads as something ABOUT the goal, not as part of it. if (goalClaim && goalClaim.token === ti) { _parkClaimRing(t.x * CELL, t.y * CELL, CELL, SEAT_COL[goalClaim.seat] || SEAT_COL[1], 0); } // EVERY OPTION OF THIS ROUND IS A FULL PAD. On a chainAnyOf leg the tokens in the group are // INTERCHANGEABLE — standing on any one of them closes the round — so drawing the one the // engine currently aims at as a goal and its equal as a 0.1-cell dot would be a lie about the // rules, and the exact lie these rounds are built to test: round 0's two pads differ only in // that pink claimed one, round 2's only in how close the bull is. The dot grammar below is for // FUTURE legs of one journey (a reach board's waypoints-to-come), which is a different thing. // The pulsing halo still marks the aimed one alone, so there is still exactly one ring idiom. const inRound = !!(roundNow && roundNow.includes(ti)); if (ti === destTok || inRound) { if (ti === destTok) { bx.save(); bx.globalAlpha = 0.4 + 0.35 * g; bx.strokeStyle = padCol; bx.lineWidth = 2.5; bx.beginPath(); bx.arc(cx, cy, CELL * 0.52, 0, 7); bx.stroke(); bx.restore(); } _parkPad(bx, cx, cy, CELL * 0.42, padHue); } else { // ghost pad = a small pad-hue CORE DOT ONLY (R2 goal fix #10): even the dashed outline // read as a competing destination ring — with several pads up, "which cyan ring is the // goal" was ambiguous. Exactly ONE ring idiom per frame (the active pad); the dot keeps // the family read, the pin's mini-pad ledger carries "stops to come". bx.save(); bx.globalAlpha = 0.5; bx.fillStyle = PARK_PAD_HUE; bx.beginPath(); bx.arc(cx, cy, CELL * 0.1, 0, 7); bx.fill(); bx.restore(); } } else { // ambient GOLD recedes on reach/collect boards (there the win target is the pad / typed // set, not the gold) by SIZE, not by fade (R1 goal fix #12: alpha-faded "olive" diamonds // read as already-collected / inert / lower-value — three wrong stories; a smaller crisp // gem is just a subordinate gem). Harvest (gold IS the objective) and deliver (gold is // the cargo) stay full-strength. Public goalVariant only (C1). const ambient = (goalV === 'reach' || goalV === 'collect') && t.gtype == null; drawParkGems(t.x, t.y, t.v, t.gtype != null ? t.gtype : null, ambient ? 0.68 : 1); // NOTE: the win-defining token is now marked by the ONE unique active-goal STAR (_parkGoalPin // -> _parkStar), riding the current objective — not by a per-token corner reticle. Dropping the // reticle keeps exactly one active-goal idiom on the board (spec §2): among identical harvest // diamonds or the collect set, the single gold star answers "which one is the objective". // R2 goal fixes #3/#4: the objective SCOPE is marked too — every remaining objective token // (all chain gems on gather/deliver, nearest of each missing type on one-of-each) wears the // mini gold ticks, so "collect ALL of these / one of each of these" reads on the board. if (scopeMark.has(ti)) _parkMiniMark(t.x, t.y); } }); // BREADCRUMB WAKE: the agent's recent walked cells as fading dots, so the exact route between // sparse reads (and the step-aside/detour shape) is reconstructable from any single frame. drawParkCrumbs(P); // THE PATH-NOT-TAKEN GHOST IS NO LONGER DRAWN (2026-08-06). It was a dashed white disc on the // declined pull, and on a board where every neighbouring cell is walkable it read as "you may // move here" — pulling attention off the body and onto an empty square. The fork beat now says // what it means on the body itself: the spotlight ring plus the three thinking dots. The cue // field is still computed (the capture harness and the static end frame keep their shape), and // the painter still exists, so bringing it back is one line. // VERGE UNDERFOOT WARNING: while the mover stands ON the verge, its cell pulses pale-warm — // "accepted proximity risk" ground truth under the actor (pure position/terrain read; C1). { const apk = st.pos[0].y * n + st.pos[0].x; if (park.verge.has(apk)) { bx.save(); bx.globalAlpha = 0.22 + 0.20 * g; bx.fillStyle = '#f2a763'; bx.fillRect(st.pos[0].x * CELL + 1, st.pos[0].y * CELL + 1, CELL - 2, CELL - 2); bx.restore(); } } // DELIBERATE-PAUSE ring: a pulsing dashed ring on the mover's cell at a conflict read. if (cue.pause) { const c = ctr(st.pos[0]); bx.save(); bx.globalAlpha = 0.4 + 0.5 * (1 - g); bx.strokeStyle = '#e6ecf5'; bx.lineWidth = 2; bx.setLineDash([4, 4]); bx.beginPath(); bx.arc(c.cx, c.cy, CELL * 0.56, 0, 7); bx.stroke(); bx.restore(); } // P8.6 §C2 AUDIT CUT — the KIN BOND tie (round2 #8's dotted agent<->companion filament, // already tutorial-only) is GONE: the ally relation is fully carried by the companion's // own magenta body + halo + shoulder HEART tag, so the dash carried no state of its own. // P13 PUSH verb layer (live scene): the cargo crate + its destination pad, over the terrain // and under the actors (a body on the ground, not a token). No-op off push boards. _drawParkPushLayer(st, 0, 0, CELL); // ACTORS: the magenta scaled-down companion, then the big blue directional agent (keyline + halo). drawParkCompanion(P); drawParkAgent(st, cue); // FIELD MECH POST-PASS SEAM — the twin of the overlay seam above, and the ONLY thing that runs // after the actors. PARK_FIELD_RENDER paints UNDER the sprites by construction (it is a terrain // overlay), so a mechanic whose visual has to OCCLUDE a body — darkness, smoke, a closing shutter — // is inexpressible there: the gems and the residents float on top of it. Same discipline as the // first seam and for the same reason: keyed by the id the board stamps, so a mechanic registers // rather than being named here, and no-op on every board that registers nothing. // P 를 다섯째 인자로 더한다(2026-08-05): y58 블링커가 E._parkCompanionPlan(P) 를 읽어야 // 하는데 그 술어는 st 가 아니라 P 를 받는다. 여기는 drawParkScene(P, cue) 안이라 P 가 // 이미 스코프에 있다(바로 위 drawParkCompanion(P)). 기존 클라이언트 넷은 전부 // 4-매개변수라 여분 인자를 무시한다 — 호환을 깨지 않는 순수 추가다. if (park.fieldMech && PARK_FIELD_RENDER_POST[park.fieldMech]) PARK_FIELD_RENDER_POST[park.fieldMech](st, 0, 0, CELL, P); // the ONE active-goal pin, over everything (R1 goal fix #15/#9: an occluded star = an // unreadable objective; and a companion body over the pin re-binds the NPC to the goal). if (goalPin) _parkGoalPin(goalPin.cx, goalPin.cy, CELL, g, goalPin); if (cue.notice) drawParkNoticeMark(st, cue.notice); // YIELD BLINK + POP-AND-FLY: the companion took its contracted gem — expanding companion-hue // rings + a starburst at the emptied cell, the taken GEM popped up into the companion's badge // slot (a flight streak connects cell -> gem), and the collector BLINKS (white ring). The // carried-gem badge in drawParkCompanion keeps the attribution readable in every later frame. if (cue.take) { const c = ctr(cue.take); bx.save(); bx.globalAlpha = 0.55 + 0.4 * g; bx.strokeStyle = PARK_HUES.companion; bx.lineWidth = 3; bx.beginPath(); bx.arc(c.cx, c.cy, CELL * (0.35 + 0.25 * g), 0, 7); bx.stroke(); bx.strokeStyle = '#ffffff'; bx.lineWidth = 1.2; bx.beginPath(); bx.arc(c.cx, c.cy, CELL * (0.5 + 0.25 * g), 0, 7); bx.stroke(); bx.strokeStyle = _alpha(PARK_HUES.companion, 0.9); bx.lineWidth = 2; for (let i = 0; i < 8; i++) { const th = i * Math.PI / 4; bx.beginPath(); bx.moveTo(c.cx + Math.cos(th) * CELL * 0.55, c.cy + Math.sin(th) * CELL * 0.55); bx.lineTo(c.cx + Math.cos(th) * CELL * 0.85, c.cy + Math.sin(th) * CELL * 0.85); bx.stroke(); } bx.restore(); if (st.pos[1]) { const cc = ctr(st.pos[1]); const gx = cc.cx + CELL * 0.38, gy2 = cc.cy - CELL * 0.66; bx.save(); bx.strokeStyle = 'rgba(255,255,255,0.9)'; bx.lineWidth = 2; // collector blink bx.beginPath(); bx.arc(cc.cx, cc.cy, CELL * 0.38, 0, 7); bx.stroke(); bx.strokeStyle = _alpha(SPRITE_HUE.reward, 0.75); bx.lineWidth = 1.6; // flight streak bx.beginPath(); bx.moveTo(c.cx, c.cy); bx.lineTo(gx, gy2); bx.stroke(); bx.restore(); _parkGem(bx, gx, gy2, CELL * 0.3); // the gem, mid-pop } } // OWN-PICKUP POP: the mover harvested a gem — the gem pops up over the agent + a white blink // ring, so a pickup is an EVENT with a visible beneficiary, not a silent disappearance. if (cue.gem) { const ac2 = ctr(st.pos[0]); bx.save(); bx.strokeStyle = 'rgba(255,255,255,0.9)'; bx.lineWidth = 2; bx.beginPath(); bx.arc(ac2.cx, ac2.cy, CELL * 0.52, 0, 7); bx.stroke(); bx.restore(); _parkGem(bx, ac2.cx + CELL * 0.4, ac2.cy - CELL * 0.72, CELL * 0.3); } // SHARED-CLAIM HANDOFF: the old gem keeps a soft magenta echo while a care heart rises over // blue. The ordinary destination beacon has already jumped to the next gem in this same frame. if (cue.delegate) { const old = ctr(cue.delegate), ac = ctr(st.pos[0]); bx.save(); bx.globalAlpha = 0.72 + 0.22 * g; bx.strokeStyle = PARK_HUES.companion; bx.lineWidth = 2.4; bx.setLineDash([4, 3]); bx.beginPath(); bx.arc(old.cx, old.cy, CELL * (0.5 + 0.08 * g), 0, 7); bx.stroke(); bx.setLineDash([]); _heartPath(bx, ac.cx, ac.cy - CELL * 0.78, CELL * 0.2); bx.fillStyle = PARK_HUES.companion; bx.fill(); bx.strokeStyle = '#14161c'; bx.lineWidth = 1.2; bx.stroke(); // ...AND HIS CLAIM MOVES. The magenta echo says whose the old gem is now; this blue arc says // where his own went. Without it the handoff is two different stills and no event between them, // which is exactly the thing a care-first demo has to make visible. if (goalPin) { bx.strokeStyle = _alpha(SPRITE_HUE.agent, 0.8); bx.lineWidth = 2; bx.setLineDash([5, 4]); const mx = (old.cx + goalPin.cx) / 2, my = Math.min(old.cy, goalPin.cy) - CELL * 0.7; bx.beginPath(); bx.moveTo(old.cx, old.cy); bx.quadraticCurveTo(mx, my, goalPin.cx, goalPin.cy); bx.stroke(); bx.setLineDash([]); } bx.restore(); } // AFTER-YOU (the CARE channel): the mover stepped ASIDE onto the verge as the companion drew // alongside. Rendered in the companion HUE FAMILY so the step-aside reads on the companion/care // channel — the same color as its body, claim ring and intent leash — a rankable third axis, not // an unowned collision (a safety-first HOLD gets the recoil shrink-back instead, never this). The // care channel is now GLYPH-owned too: a companion-hue heart over the stepped-aside walker, the // parallel of the safety heart-crack and the goal gem/star. Zero-text (geometry only). if (cue.cede && st.pos[1]) { const ac = ctr(st.pos[0]), cc = ctr(cue.cede); const th = Math.atan2(cc.cy - ac.cy, cc.cx - ac.cx); bx.save(); // care-channel TIE: a soft companion-hue thread from the stepped-aside walker to the companion, // binding the yield to the companion (whose gem it clears the lane for), not to the terrain. bx.strokeStyle = _alpha(PARK_HUES.companion, 0.42); bx.lineWidth = 1.6; bx.setLineDash([3, 4]); bx.beginPath(); bx.moveTo(ac.cx, ac.cy); bx.lineTo(cc.cx, cc.cy); bx.stroke(); bx.setLineDash([]); bx.strokeStyle = _alpha(PARK_HUES.companion, 0.9); bx.lineWidth = 2.5; bx.lineCap = 'round'; for (let i = 0; i < 2; i++) { const d = CELL * (0.62 + 0.28 * i); const mxx = ac.cx + Math.cos(th) * d, myy = ac.cy + Math.sin(th) * d; bx.beginPath(); bx.moveTo(mxx - Math.cos(th - 0.6) * CELL * 0.18, myy - Math.sin(th - 0.6) * CELL * 0.18); bx.lineTo(mxx, myy); bx.lineTo(mxx - Math.cos(th + 0.6) * CELL * 0.18, myy - Math.sin(th + 0.6) * CELL * 0.18); bx.stroke(); } // CARE GLYPH: the companion-hue heart (its established care mark — worn on the kin's shoulder // and its claim ring) over the walker who stepped aside, so goal/safety/care are each glyph-owned. _heartPath(bx, ac.cx, ac.cy - CELL * 0.72, CELL * 0.2); bx.fillStyle = PARK_HUES.companion; bx.fill(); bx.strokeStyle = '#14161c'; bx.lineWidth = 1.2; bx.stroke(); bx.restore(); } // DEEP-FIELD ENTRY: LOCALIZED damage (a stage-wide tint made the whole map read as danger // ground) — a bold ring + ember burst rays on the entry cell + a cracked heart above it // (♥−1, physics). The hurt AFTERGLOW (cue.hurt) then lingers on the agent for ~3 frames. if (cue.deep) { const c = ctr(cue.deep); bx.save(); bx.strokeStyle = '#ff5040'; bx.lineWidth = 3; bx.strokeRect(cue.deep.x * CELL + 2, cue.deep.y * CELL + 2, CELL - 4, CELL - 4); bx.strokeStyle = _alpha(PARK_HUES.ember, 0.95); bx.lineWidth = 2.5; bx.lineCap = 'round'; for (let i = 0; i < 8; i++) { const th = i * Math.PI / 4 + 0.39; bx.beginPath(); bx.moveTo(c.cx + Math.cos(th) * CELL * 0.55, c.cy + Math.sin(th) * CELL * 0.55); bx.lineTo(c.cx + Math.cos(th) * CELL * 0.95, c.cy + Math.sin(th) * CELL * 0.95); bx.stroke(); } bx.restore(); drawHeartCrack(c.cx, c.cy - CELL * 0.9, CELL * 0.42); } // THE CLOCK GOES LAST (design 2026-07-30 D3). It first sat right after the field painter, which // was not late enough: the actors, the cracked-heart afterglow and the post-seam all paint after // that, and on y22 the fuse row landed under the very cracked heart the same turn had raised. // That is y46's accident (a pip row beneath the goal marker) recurring one layer up. A clock the // scene can cover is not a clock, so it is drawn after everything the scene has to say. _paintParkClockPips(st, 0, 0, CELL); } // drawParkCrumbs(P): the fading breadcrumb wake over the agent's recent path cells (PUBLIC // trajectory — the same read as the agent's own motion; C1). Oldest dimmest AND smallest — // the taper toward the agent reads as a PAST wake, never a planned future path. function drawParkCrumbs(P) { const path = P.path, K = 10; const lo = Math.max(0, path.length - 1 - K); bx.save(); bx.fillStyle = '#9fc0ff'; for (let i = lo; i < path.length - 1; i++) { const t = (i - lo + 1) / (path.length - 1 - lo); bx.globalAlpha = 0.10 + 0.35 * t; bx.beginPath(); bx.arc(path[i].x * CELL + CELL / 2, path[i].y * CELL + CELL / 2, CELL * (0.06 + 0.07 * t), 0, 7); bx.fill(); } bx.restore(); } // _paintParkTerrain(st, x0, y0, cell, deepAlpha): the park terrain classes at an arbitrary // scale/offset (shared by the live stage and the report minis). walkway = light gray, verge = // pale clay wash, deep = saturated lava, border = tree band. SEVERITY GRADIENT: ONLY the deep // tier carries the lava marks (ember spikes, pulsing fill). The old bright EMBER RIM stroke is // GONE (blind-judge fix R3-A1a): it read as a FENCE, making the field look unenterable and // safety>goal unfalsifiable — the deep field must read as open ground continuous with the // walkway edge, enterable-but-costly. That cost is said by the CHARRED GEM HUSKS (fix R3-A1b): // 2-3 blackened gem silhouettes ON deep cells, hash-placed by the PUBLIC seed (C1) — "things // that went in here burned". Fixed hash of the cell coordinate throughout (C1). function _paintParkTerrain(st, x0, y0, cell, deepAlpha) { const n = st.N, park = st.park; // hazard reskin tint (task cells only — the P1 capstone has no park.cell and keeps its // exact palette). Public hazard kind, never the persona (C1). const tint = (park.cell && PARK_HAZARD_TINT[park.cell.hazard.kind]) || PARK_HUES; const fam = (park.cell && park.cell.hazard.kind) || 'lava'; // capstone -> lava embers (unchanged) // P8.6 §C.1 PER-ARCHETYPE IDENTITY: the wall band + walkway shift hue per topology family // (park green-gray / serpent earthen / pools blue-slate / islands sand), so the four // archetypes read as four PLACES, not one recolored template. Public arch only (C1); // the capstone (no park.cell) keeps the exact legacy park hues. const arch = (park.cell && park.cell.arch) || 'park'; const pal = PARK_ARCH_PALETTE[arch] || PARK_ARCH_PALETTE.park; // P13 SLIDE ICE FLOOR: on a slide board (public park.slide flag — the movement VERB) the // WALKWAY reads as polished pale-blue ice with sheen streaks — the verb's own look. The // HAZARD FIELD is untouched: slide's G-C crossing cost stays the meadow field (measured // design law — damage-0 ice cannot carry a G-C game's stake; ice here is FLOOR, not hazard). const walkHue = park.slide ? '#a9c3d4' : pal.walk; for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) { const kk = y * n + x; const isDeep = park.deep.has(kk); if (st.wall.has(kk)) bx.fillStyle = pal.wall; else if (park.walkway.has(kk)) bx.fillStyle = walkHue; else if (park.verge.has(kk)) bx.fillStyle = tint.verge; else bx.fillStyle = _alpha(tint.deep, deepAlpha); bx.fillRect(x0 + x * cell, y0 + y * cell, cell + 0.5, cell + 0.5); // §C.1 SEEDED DECOR PROPS on the WALL band only (walkable classes untouched): trees/ // benches (park), masonry joints (serpent), water shimmer (pools), sand specks // (islands). A pure hash of (public seed, cell index, arch) — C1-DECOR. // 장식의 원본 칸은 _parkDecorKey 가 고른다: 보통은 이 칸 자신, y58 도로판에서는 한 박자에 // 한 행씩 흘러 내려오는 갓길(그 함수의 주석 참고). 그림만 움직이고 기하는 그대로다. if (st.wall.has(kk)) { _parkWallDecor(arch, park, _parkDecorKey(st, x, y, n), x0 + x * cell, y0 + y * cell, cell); continue; } if (!isDeep) continue; // §C.2 ICE COOL GLINT: the 0-damage frozen field sparkles (a pale crossing glint whose // alpha breathes) instead of pulsing its body — cool, static ground with a sheen. if (fam === 'ice' && (x * 31 + y * 17) % 5 === 0) { const gx1 = x0 + x * cell, gy1 = y0 + y * cell; bx.save(); bx.strokeStyle = _alpha('#ffffff', 0.16 + 0.30 * _pulseGlow()); bx.lineWidth = Math.max(1, cell * 0.05); bx.lineCap = 'round'; bx.beginPath(); bx.moveTo(gx1 + cell * 0.30, gy1 + cell * 0.62); bx.lineTo(gx1 + cell * 0.62, gy1 + cell * 0.30); bx.stroke(); bx.beginPath(); bx.moveTo(gx1 + cell * 0.40, gy1 + cell * 0.34); bx.lineTo(gx1 + cell * 0.52, gy1 + cell * 0.58); bx.stroke(); bx.restore(); } // RECESSED-BASIN read (fix round2 #9/#10): a dark inner-shadow lip on every deep cell sinks // the field into a hazard PIT below the walkway plane, so its family texture (ice cracks / // grass tufts / ember flecks) and the charred husks all read as "inside the costly ground" // — one hazard zone — rather than as loose surface decoration. Cheap top/left inner strokes. // DEEPER recessed BASIN (fix diversity2 R2 #8): the round-1 shadow was too faint, so a deep // hazard tile read as ordinary decorated floor. A darker top/left inner shadow PLUS a pale // bottom/right lip sink the cell into a pit below the walkway plane — "step down here and it // costs" — without the hard rim FENCE that made safety>goal unfalsifiable (kept enterable). const gx0 = x0 + x * cell, gy0 = y0 + y * cell, ins = cell * 0.05; bx.save(); bx.strokeStyle = 'rgba(0,0,0,0.42)'; bx.lineWidth = Math.max(1, cell * 0.1); bx.lineCap = 'butt'; bx.beginPath(); bx.moveTo(gx0 + ins, gy0 + cell - ins); bx.lineTo(gx0 + ins, gy0 + ins); bx.lineTo(gx0 + cell - ins, gy0 + ins); bx.stroke(); bx.strokeStyle = _alpha(tint.ember, 0.16); bx.lineWidth = Math.max(1, cell * 0.06); bx.beginPath(); bx.moveTo(gx0 + cell - ins, gy0 + ins); bx.lineTo(gx0 + cell - ins, gy0 + cell - ins); bx.lineTo(gx0 + ins, gy0 + cell - ins); bx.stroke(); bx.restore(); // denser family texture (fix diversity2 R2 #1/#9): ~1/2 of deep cells now carry the family mark // (was ~1/3) so ice cracks / meadow tufts / lava embers make the three biomes read as genuinely // different fields, not one skeleton recolored. if ((x * 13 + y * 29) % 2 === 0) _parkFieldMark(fam, tint, x0 + (x + 0.5) * cell, y0 + (y + 0.5) * cell, cell * 0.26); } // WALKWAY EDGE (fix R1 #5/#6/#9): a crisp light outline on every walkway↔field boundary makes // the ARCHETYPE silhouette (bands / serpent / islands / pools) read from SHAPE, not color — // stripping the tint no longer collapses boards into one template. Walk↔wall (map frame) edges // are skipped. Pure public-board render (C1). // BOLDER (fix diversity2 #3/#4): a two-pass edge (dark relief under a bright light line) so the // archetype SKELETON — ring+cross / serpent S-lane / islands+bridges / pools — reads as a hard // structural SILHOUETTE, not a faint hairline that collapsed into "one recolored template". bx.save(); bx.lineCap = 'round'; const edges = []; for (const kk of park.walkway) { const x = kk % n, y = (kk / n) | 0; const gx = x0 + x * cell, gy = y0 + y * cell; const sides = [[0, -1, gx, gy, gx + cell, gy], [0, 1, gx, gy + cell, gx + cell, gy + cell], [-1, 0, gx, gy, gx, gy + cell], [1, 0, gx + cell, gy, gx + cell, gy + cell]]; for (const [ddx, ddy, ax, ay, bxx, byy] of sides) { const nx = x + ddx, ny = y + ddy; if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue; const nk = ny * n + nx; if (park.walkway.has(nk) || st.wall.has(nk)) continue; // only walkway↔field boundaries edges.push([ax, ay, bxx, byy]); } } for (const [pass, style, wf] of [['d', 'rgba(9,11,15,0.5)', 0.13], ['l', 'rgba(236,242,250,0.6)', 0.07]]) { bx.strokeStyle = style; bx.lineWidth = Math.max(pass === 'd' ? 1.6 : 1, cell * wf); bx.beginPath(); for (const [ax, ay, bxx, byy] of edges) { bx.moveTo(ax, ay); bx.lineTo(bxx, byy); } bx.stroke(); } bx.restore(); if (fam === 'lava') for (const kk of _parkHuskCells(park)) { // charred husks: LAVA family ONLY const x = kk % n, y = (kk / n) | 0; // (fix diversity2 #7 — one mark/family) _parkHusk(bx, x0 + (x + 0.5) * cell, y0 + (y + 0.5) * cell, cell * 0.34); } // P13 SLIDE floor furniture (public board fn, ZERO-TEXT): (a) sheen streaks on ~1/3 of ice // walkway cells (fixed coordinate hash — C1) say "slick ground"; (b) LAMP-POSTS on the // engine's own junction cells (E._parkSlideJunction — the glide's PUBLIC brake anchors), so // the momentum rule is readable from the floor: glides run between posts. if (park.slide) { bx.save(); bx.strokeStyle = 'rgba(255,255,255,0.34)'; bx.lineCap = 'round'; bx.lineWidth = Math.max(1, cell * 0.06); for (const kk of park.walkway) { const x = kk % n, y = (kk / n) | 0; if ((x * 31 + y * 17) % 3) continue; const gx = x0 + x * cell, gy = y0 + y * cell; bx.beginPath(); bx.moveTo(gx + cell * 0.22, gy + cell * 0.66); bx.lineTo(gx + cell * 0.62, gy + cell * 0.26); bx.stroke(); bx.beginPath(); bx.moveTo(gx + cell * 0.5, gy + cell * 0.74); bx.lineTo(gx + cell * 0.78, gy + cell * 0.46); bx.stroke(); } bx.fillStyle = '#2b3540'; bx.strokeStyle = 'rgba(236,242,250,0.75)'; bx.lineWidth = Math.max(1, cell * 0.05); for (const kk of park.walkway) { if (!E._parkSlideJunction(st, kk, { x: 1, y: 0 }) && !E._parkSlideJunction(st, kk, { x: 0, y: 1 })) continue; const jx = x0 + (kk % n + 0.5) * cell, jy = y0 + (((kk / n) | 0) + 0.5) * cell; bx.beginPath(); bx.moveTo(jx, jy - cell * 0.3); bx.lineTo(jx, jy + cell * 0.06); bx.stroke(); bx.beginPath(); bx.arc(jx, jy - cell * 0.3, cell * 0.11, 0, 7); bx.fill(); bx.beginPath(); bx.arc(jx, jy - cell * 0.3, cell * 0.11, 0, 7); bx.stroke(); } bx.restore(); } // FIELD MECH RENDER SEAM (Task 1) — the app-side mirror of the engine's PARK_FIELD_MECHS. A field // mechanic paints itself as an OVERLAY over the finished terrain (so no existing board changes by // a pixel), and it REGISTERS that painter under its mech id rather than being named here. Same // reason as the engine seam: five more field cells are queued behind y12, and if each had to add // a line to this function they could not be built in parallel. Canvas code stays in app.js (C11 — // the engine may never touch a canvas), so the registry is here, keyed by the id the board stamps. const fm = park.fieldMech && PARK_FIELD_RENDER[park.fieldMech]; if (fm) fm(st, x0, y0, cell); // The entity clock is NOT drawn here. It is the last thing drawParkScene paints — see the note // at the foot of that function. Drawn at this seam it still ended up under the actors and the // afterglow. (The hub thumbnail path, which does not run drawParkScene, is a still frame with no // live fuse to show, so it loses nothing.) } // PARK_FIELD_RENDER[id] = painter(st, x0, y0, cell) — the app-side field-mechanic registry. // A mechanic block registers its own painter at the foot of its own section (see stones, below). const PARK_FIELD_RENDER = {}; // PARK_FIELD_CLOCK[id] — the CLOCK DECLARATION registry (design 2026-07-30 D3). // // This park drew WHERE well and WHEN unevenly. Not for want of an idiom — the pip row (y52), // the dotted next-cell (y10/y17), the rotating next-quadrant (y53) were all already here — // but because a missing application had nowhere to be noticed. So absence becomes a DECLARATION // rather than a silence: every mechanic has an entry, and one with no clock says so with a reason. // // beats the fx kinds that mean "the WORLD moved" for this mechanic (the demo's emphasis // set reads this — D5). [] is a legitimate answer. // own true = this mechanic already draws its own clock -> the shared pip painter stays // off. Drawing the same value twice invents the question "which one is true". // read(st) -> null | { scope, at, total, now, warnFrom } // scope 'entity' = a per-thing fuse (drawn beside the thing) // 'board' = a whole-board period (drawn in the HUD, where nothing can cover it) // at cell key, scope 'entity' only // total beats in one period / fuse length // now 0..total-1, the filled pip // warnFrom pips from this index carry the warning colour, FROM THE FIRST FRAME — the y29 // contract: "how long have I got" must be a read and not a memory. // read returning null = no clock is running right now (the bull is not aiming, the fuse is // unlit, the schedule is spent). Different from having no entry, which is banned. // // C1: every read below touches only public st.park / st.park.dyn / st.pos. Never the persona, // never _parkReads().atts, never awards. const PARK_FIELD_CLOCK = {}; // A mechanic with nothing to count. The `why` is not decoration: it is the evidence that somebody // looked, so the next reader does not have to re-derive the absence. const _clockNone = (why) => ({ beats: [], own: false, why, read: () => null }); // A mechanic that already draws its own clock. Same discipline — `why` names WHERE it draws it, // so a future edit that deletes that drawing can be caught against this claim. const _clockOwn = (beats, why) => ({ beats, own: true, why, read: () => null }); // ---- mechanics with NOTHING to count (measured 2026-07-30; the absence is real, not an omission) PARK_FIELD_CLOCK.stones = _clockNone('consumable ground: a stone sinks when you step OFF it. dyn.gone is a monotone set, not a countdown.'); PARK_FIELD_CLOCK.toll = _clockNone('the gate spends the gem gauge. no time axis at all.'); PARK_FIELD_CLOCK.carry = _clockNone('one stone, three places to spend it. a fork, not a clock.'); PARK_FIELD_CLOCK.mine = _clockNone('monotone dyn.dug + an NPC that walks one tile per tick. no countdown (verified 2026-07-30).'); PARK_FIELD_CLOCK.tower = _clockNone('sightline geometry. no time axis.'); PARK_FIELD_CLOCK.lantern = _clockNone('a carried radius of light. no time axis.'); PARK_FIELD_CLOCK.yield = _clockNone('the head-on meeting on a one-wide plank. geometry, not a clock.'); PARK_FIELD_CLOCK.relay = _clockNone('handover points. no time axis.'); PARK_FIELD_CLOCK.escape = _clockNone('two taggers chasing. the period clock belongs to statue, which that board also carries.'); PARK_FIELD_CLOCK.log = _clockNone('the rolling log: its POSITION is the warning (ents[0].i). no separate countdown.'); // ---- mechanics that ALREADY draw their own clock (no picture change; the declaration is new) PARK_FIELD_CLOCK.burst = _clockOwn(['burst', 'pop', 'singed'], 'a fuse pip row under each balloon (app.js, _paintParkBurst).'); PARK_FIELD_CLOCK.beacon = _clockOwn(['seen', 'sent'], 'the NEXT quadrant outlined in a breathing dash (app.js, _paintParkBeacon).'); PARK_FIELD_CLOCK.flood = _clockOwn(['flood'], 'the next ring, dotted.'); PARK_FIELD_CLOCK.fire = _clockOwn(['burn', 'douse', 'fill'], 'the next cell each front takes, dotted.'); PARK_FIELD_CLOCK.siege = _clockOwn(['swept'], 'the next band to drown, dotted (_parkSiegeNext), plus the four EXITS — each wearing the SHARED arrival mark every other map puts on its finish (_paintParkStatue), a taken one washed and bordered in its claimant\'s seat colour, and each body\'s target dash-ringed in its own hue (app.js, _parkClaimRing). The PERIOD itself is statue\'s, which this board carries.'); PARK_FIELD_CLOCK.ledge = _clockOwn(['hop'], 'a one-way drop plus a crate stair. no time axis; the marks are its own.'); // y58: own:true, not because the shared entity pip would be wrong here but because there is no // entity to hang it on — the period belongs to the WHOLE board (every lane drums together), and // the shared board-clock row (_paintParkHudClock) that used to draw scope:'board' clocks in the // HUD was retired 2026-08-03 by a concurrent process (see the note left at its old spot, app.js, // near drawParkHUD) — for reasons unrelated to y58. So this board draws its own gate-beat pip row // ON THE BOARD, plus the drummed band itself (solid now / dotted next), which already carries most // of "when" even before the pips are read. PARK_FIELD_CLOCK.road = _clockOwn(['rearend', 'overtake'], 'the gate-beat pip row (own board clock, since the shared HUD row was retired) plus the drummed ' + 'band drawn solid now and dotted next (app.js, _paintParkRoad).'); // y59 plaza. THE TIMETABLE IS THIS BOARD'S TIME AXIS: bodies arrive on a schedule the cursor cannot // change, and the whole cost of the game is how many beats it takes to walk to the lamp that rules // the doorway they are heading for. So the countdown to the NEXT arrival is not decoration, it is // the one number a plan is made of. Drawn by _paintParkPlaza as the last thing in its scene — a pip // row on the plaza side of that arrival's entrance (the shared _parkPipRow, so "how long have I got" // looks the same here as on every other board), plus delivery pips on the wall beside each exit. // The `beats` list is the brief's ('crash','toggle'). 'spawn' and 'deliver' are also world-moved fx // kinds and are deliberately NOT claimed here: the demo leg that reads this list does not exist yet // (y59 has no slot), so the day Task 8 seats one, re-derive whether those two need a long frame too. // patience pips are NOT declared and NOT drawn: spec §5 made them conditional and the falsifier's // row 1 never fired (measured twice, 2026-08-04), so ent.pips is never spent — a pip row over a // counter nothing decrements is exactly the Δ0 lie this registry exists to stop. PARK_FIELD_CLOCK.plaza = _clockOwn(['crash', 'toggle'], 'the countdown to the next scheduled arrival, as a pip row at its entrance, plus per-exit delivery pips (app.js, _paintParkPlaza — drawn last in that painter).'); // ---- ENTITY FUSES (the shared pip painter draws these) // The alley bull's charge warning. The red lane says WHERE; the pips say WHEN. The lane's alpha was // once 0.38 + 0.32*g and g is the TIME PULSE, so fuse 3 and fuse 1 painted identically — measured // Δ0 by the clock probe (docs/.../2026-07-30-legibility-baseline.md §1), which is why this fuse // exists at all. // This entry began as y25 bull's. y25 was DELETED on 2026-08-03 and the fuse stayed, because y50 // alley carries park.bull + dyn.ents[0] in the same shapes and reads the same aim — on its OWN // telegraph constant. Round 2 adds a SECOND schedule — the doze cycle (dyn.alley.wakeIn) — and it // deliberately does NOT get a pip row: two pip rows on one entity is the "which one is true" // problem this registry exists to stop. The doze is drawn as the SHORTENING BREATH of the sleep cue // instead (_paintParkBull -> _parkSleepBubbles), so the two clocks live in two different channels // and can never be confused for each other. PARK_FIELD_CLOCK.alley = { beats: ['charge', 'gore', 'thud', 'push'], own: false, why: "the lane is committed at aim time; the pips are the only WHEN on the board. " + "Round 2's doze rides the sleep cue's breath, not a second pip row.", read: (st) => { const B = st.park.dyn && st.park.dyn.ents && st.park.dyn.ents[0]; if (!B || !B.aim) return null; const total = E.PARK_ALLEY_TELEGRAPH; return { scope: 'entity', at: B.key, total, now: Math.max(0, Math.min(total - 1, total - B.aim.fuse)), warnFrom: total - 1 }; } }; // The fallen companion's self-recovery beat. This cell's ONE choice is "help him, or wait for him // to rise on his own" — and the ceiling that makes waiting a real option was not on screen at all // (engine: he drags himself when dyn.beat % park.downed.crawlEvery === 0). PARK_FIELD_CLOCK.downed = { beats: ['crawl', 'rise'], own: false, why: 'the self-recovery ceiling is what makes waiting a choice rather than neglect.', read: (st) => { const d = st.park.dyn; if (!d || !d.downed || d.downed.rescued || !st.pos[1]) return null; // risen: the clock is over const total = st.park.downed.crawlEvery; return { scope: 'entity', at: st.pos[1].y * st.N + st.pos[1].x, total, now: d.beat % total, warnFrom: total - 1 }; } }; // The lit bomb's fuse. The painter draws the blast cross dotted and a blinking spark, but reads // only dyn.lit.cells and dyn.lit.key — never dyn.lit.at. The engine blasts at // `dyn.beat - dyn.lit.at >= b.fuse` (engine.js), so the picture was identical across all three // beats and the spark's radius jumped with g, the time pulse, not with the fuse. Measured Δ0. const _clockBombRead = (st) => { const b = st.park.bomb, d = st.park.dyn; if (!b || !d || !d.lit) return null; // nothing burning const total = b.fuse; return { scope: 'entity', at: d.lit.key, total, now: Math.max(0, Math.min(total - 1, d.beat - d.lit.at)), warnFrom: total - 1 }; }; PARK_FIELD_CLOCK.bomb = { beats: ['plant', 'blast', 'take_bomb'], own: false, why: 'the dotted cross says WHERE it will strike; nothing said WHEN.', read: _clockBombRead }; // The ring schedule. Until now the board said "this ring is next" (dotted) and nothing anywhere // said "in how many beats". PARK_FIELD_CLOCK.storm = { beats: ['flood', 'impact', 'mate_down'], own: false, why: 'the dotted ring is WHERE; the period is WHEN, and it had no mark.', read: (st) => { const sm = st.park.storm, d = st.park.dyn; if (!sm || !d) return null; if (E._parkStormNextIdx(st) >= sm.rings.length) return null; // schedule spent: no clock left return { scope: 'board', total: sm.every, now: d.beat % sm.every, warnFrom: sm.every - 1 }; } }; // REGISTRY SURFACE — a painter with no clock declaration means nobody ever asks that mechanic // where its clock is, and the omission ends silently at zero (the registry-surface lesson). // Exposed as a function, never called at load: PARK_FIELD_RENDER fills in at the FOOT of each // mechanic's own section, far below this line, so an eager check here would always cry. if (typeof window !== 'undefined') { window.__parkClockMissing = () => Object.keys(PARK_FIELD_RENDER).filter(k => !PARK_FIELD_CLOCK[k]); } // _paintParkClockPips(st, x0, y0, cell) — draw one scope:'entity' clock beside its thing. // The placement collision-avoidance lives HERE and nowhere else: prefer an empty neighbour cell, // and only if all four are taken sit half a cell above the thing. y46 laid its pip row straight // under the finish marker; a per-mechanic copy of this logic would repeat that accident once per // mechanic (design 2026-07-30 §1 F3). function _paintParkClockPips(st, x0, y0, cell) { const park = st.park; const entry = park.fieldMech && PARK_FIELD_CLOCK[park.fieldMech]; if (!entry || entry.own) return; const c = entry.read(st); if (!c || c.scope !== 'entity' || c.at == null) return; const n = st.N, g = _pulseGlow(); const ex = c.at % n, ey = (c.at / n) | 0; const taken = (x, y) => x < 0 || y < 0 || x >= n || y >= n || st.wall.has(y * n + x) || (st.pos[0] && st.pos[0].x === x && st.pos[0].y === y) || (st.pos[1] && st.pos[1].x === x && st.pos[1].y === y); let px = ex, py = ey, off = -0.5; // all four taken: ride half a cell above for (const [dx, dy] of [[0, -1], [0, 1], [-1, 0], [1, 0]]) { if (!taken(ex + dx, ey + dy)) { px = ex + dx; py = ey + dy; off = 0; break; } } _parkPipRow(bx, x0 + (px + 0.5) * cell, y0 + (py + 0.5 + off) * cell, Math.min(cell * 0.26, (cell * 0.92) / Math.max(1, c.total)), cell * 0.13, cell * 0.09, c, g); } // _parkPipRow — the ONE beat-count glyph, shared by the board pips and the HUD row so the two // surfaces can never drift into two different counting languages. // Two deliberate departures from y29's original numbers: unlit pips at 0.62 instead of 0.42, and // a dark rim on every pip. A 3px translucent dot on the grey walkway (#767d8a) simply vanished, // and that was half of why the count could not be read. function _parkPipRow(ctx, cx, cy, step, rOn, rOff, c, g) { ctx.save(); const base = -(c.total - 1) / 2; // THE BACKING PLATE. A count has to be readable on whatever it happens to land on, and some of // these landings are busy: y22's lit crate sits inside a red outline, an orange blast cross, a // dotted preview ring and (now) a cracked-heart afterglow, and three small dots simply drowned // there — measured at a seventh of the same row's contrast on quiet ground. A dark strip under // the row buys that contrast back everywhere at once, and costs the board nothing where the // ground was already quiet. { const half = (Math.abs(base) * step) + rOn * 1.7, h = rOn * 1.9; ctx.fillStyle = 'rgba(9,11,15,0.55)'; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(cx - half, cy - h, half * 2, h * 2, h); else ctx.rect(cx - half, cy - h, half * 2, h * 2); ctx.fill(); } for (let i = 0; i < c.total; i++) { const x = cx + (base + i) * step, warn = i >= c.warnFrom, on = i === c.now; ctx.beginPath(); ctx.arc(x, cy, on ? rOn : rOff, 0, 7); ctx.fillStyle = on ? _alpha(warn ? '#ff6a4a' : '#eae0c8', 0.92 + 0.08 * g) : _alpha(warn ? '#e5432f' : '#9a927f', 0.62); ctx.fill(); ctx.strokeStyle = 'rgba(9,11,15,0.55)'; ctx.lineWidth = Math.max(1, rOff * 0.25); ctx.stroke(); if (on) { // the halo: which beat we are ON ctx.strokeStyle = _alpha('#ffffff', 0.72); ctx.lineWidth = Math.max(1.2, rOff * 0.38); ctx.beginPath(); ctx.arc(x, cy, rOn * 1.42, 0, 7); ctx.stroke(); } } ctx.restore(); } // y12 STONES FIELD LAYER (ZERO-TEXT; shared by the live scene and the hub thumbnail). Painted as // an OVERLAY over the finished terrain so no existing board changes by a pixel. Four marks, each a // pure function of the PUBLIC board + the runtime dyn set (C1 — never the persona): // STREAM (park.water) — dark moving water where the base pass painted tree-wall, so the // stream reads as a thing you cannot walk on rather than a hedge. // SOUND STONE (stones.fresh) — a solid pale slab with a seated shadow: ordinary footing. // ROTTEN STONE (stones.cracked) — the SAME slab, split by a dashed fissure. It is a deep cell, so // the base pass already tinted it hazard: the crack says WHY. // SUNK (dyn.gone) — the slab is gone; a fading double ripple sits on the water where // it stood. The mark is the CONSEQUENCE of a move the player made, // left on the board for as long as the board lasts. function _paintParkStones(st, x0, y0, cell) { const n = st.N, park = st.park, gone = park.dyn ? park.dyn.gone : new Set(); const g = _pulseGlow(); const water = (kk, sunk) => { const x = kk % n, y = (kk / n) | 0, gx = x0 + x * cell, gy = y0 + y * cell; bx.fillStyle = '#1d3a4d'; bx.fillRect(gx, gy, cell + 0.5, cell + 0.5); bx.save(); bx.strokeStyle = _alpha('#7fc4de', 0.20 + 0.10 * g); bx.lineWidth = Math.max(1, cell * 0.05); bx.lineCap = 'round'; for (let i = 0; i < 2; i++) { // slow surface chop (seeded by the cell) const oy = gy + cell * (0.32 + 0.3 * i) + Math.sin((x * 2 + y + i) * 1.7 + g * 2) * cell * 0.05; bx.beginPath(); bx.moveTo(gx + cell * 0.16, oy); bx.lineTo(gx + cell * 0.84, oy); bx.stroke(); } if (sunk) { // the ripple over a stone the walker spent bx.strokeStyle = _alpha('#cfe8f3', 0.34 + 0.24 * g); bx.lineWidth = Math.max(1, cell * 0.045); for (const rr of [0.20, 0.34]) { bx.beginPath(); bx.arc(gx + cell / 2, gy + cell / 2, cell * (rr + 0.05 * g), 0, 7); bx.stroke(); } } bx.restore(); }; const slab = (kk, cracked) => { const x = kk % n, y = (kk / n) | 0, gx = x0 + x * cell, gy = y0 + y * cell; water(kk, false); // the stone sits IN the stream bx.save(); const in0 = cell * 0.09; bx.fillStyle = cracked ? '#6e6a63' : '#8b8880'; bx.fillRect(gx + in0, gy + in0, cell - 2 * in0, cell - 2 * in0); bx.strokeStyle = 'rgba(9,11,15,0.45)'; bx.lineWidth = Math.max(1, cell * 0.05); bx.strokeRect(gx + in0, gy + in0, cell - 2 * in0, cell - 2 * in0); bx.strokeStyle = _alpha('#e7ecf2', 0.30); bx.lineWidth = Math.max(1, cell * 0.035); bx.beginPath(); bx.moveTo(gx + in0, gy + in0); bx.lineTo(gx + cell - in0, gy + in0); bx.stroke(); if (cracked) { // the dashed fissure: this one will not hold bx.setLineDash([Math.max(2, cell * 0.1), Math.max(2, cell * 0.07)]); bx.strokeStyle = 'rgba(14,15,19,0.8)'; bx.lineWidth = Math.max(1.5, cell * 0.07); bx.lineCap = 'round'; bx.beginPath(); bx.moveTo(gx + cell * 0.22, gy + cell * 0.24); bx.lineTo(gx + cell * 0.52, gy + cell * 0.50); bx.lineTo(gx + cell * 0.34, gy + cell * 0.66); bx.lineTo(gx + cell * 0.76, gy + cell * 0.80); bx.stroke(); bx.setLineDash([]); } bx.restore(); }; for (const kk of park.water) water(kk, false); for (const kk of park.stones.fresh) gone.has(kk) ? water(kk, true) : slab(kk, false); for (const kk of park.stones.cracked) gone.has(kk) ? water(kk, true) : slab(kk, true); } PARK_FIELD_RENDER.stones = _paintParkStones; // the render seam's first client (cf. PARK_FIELD_MECHS.stones) // y14 TOLL FIELD LAYER (ZERO-TEXT; shared by the live scene and the hub thumbnail). An OVERLAY over // the finished terrain, so no existing board changes by a pixel. Three marks, each a pure function // of the PUBLIC board + the runtime dyn (C1 — never the persona): // HEDGE (park.hedge) — a dense clipped wall where the base pass painted tree-wall, so the cut // across the board reads as a barrier with exactly two ways through. // GATE (park.toll.gate) — the ARCH: two posts and a lintel. SHUT (dyn.gateOpen.me false) it is // barred, and the bars go PALE when the purse cannot pay it (the gate is // a wall then — the mark says so before the player walks into it). Paid // for the walker, the bars lift and one post carries a lit lamp. Paid // for the COMPANION too (gateOpen.mate), the arch stands fully open and // a second lamp lights: the road that money bought for someone else. // TOLL SPARK (st.fx 'toll') — is emitted by the engine on payment; the coin glint is drawn by the // shared fx pass, so nothing here needs to remember it. function _paintParkToll(st, x0, y0, cell) { const n = st.N, park = st.park, dyn = park.dyn || { gateOpen: {} }; const gate = park.toll.gate, purse = st.score[0], price = park.toll.price; const g = _pulseGlow(); for (const kk of park.hedge) { // the hedge: a clipped, dense green wall const x = kk % n, y = (kk / n) | 0, gx = x0 + x * cell, gy = y0 + y * cell; bx.fillStyle = '#20361f'; bx.fillRect(gx, gy, cell + 0.5, cell + 0.5); bx.save(); bx.strokeStyle = _alpha('#4e7a45', 0.5); bx.lineWidth = Math.max(1, cell * 0.05); for (let i = 0; i < 3; i++) { // seeded leaf chop (no randomness at paint time) const oy = gy + cell * (0.22 + 0.28 * i); bx.beginPath(); bx.moveTo(gx + cell * (0.1 + 0.06 * ((x + i) % 3)), oy); bx.lineTo(gx + cell * (0.9 - 0.06 * ((y + i) % 3)), oy + cell * 0.06); bx.stroke(); } bx.restore(); } const gx = x0 + (gate % n) * cell, gy = y0 + (((gate / n) | 0)) * cell; const meOpen = !!dyn.gateOpen.me, mateOpen = !!dyn.gateOpen.mate; const afford = meOpen || purse >= price; bx.save(); bx.fillStyle = '#3a3128'; // the two posts + the lintel: the ARCH bx.fillRect(gx + cell * 0.04, gy + cell * 0.08, cell * 0.16, cell * 0.84); bx.fillRect(gx + cell * 0.80, gy + cell * 0.08, cell * 0.16, cell * 0.84); bx.fillRect(gx + cell * 0.04, gy + cell * 0.02, cell * 0.92, cell * 0.14); if (!meOpen) { // BARRED — and pale when it cannot be paid bx.strokeStyle = _alpha(afford ? '#d9c37a' : '#6d6a63', afford ? 0.85 : 0.45); bx.lineWidth = Math.max(1.2, cell * 0.06); bx.lineCap = 'round'; for (const fx of [0.30, 0.50, 0.70]) { bx.beginPath(); bx.moveTo(gx + cell * fx, gy + cell * 0.18); bx.lineTo(gx + cell * fx, gy + cell * 0.90); bx.stroke(); } } const lamp = (fx, lit) => { // one lamp per lane bought: me, then mate bx.fillStyle = lit ? _alpha('#ffe6a3', 0.55 + 0.35 * g) : 'rgba(120,116,108,0.5)'; bx.beginPath(); bx.arc(gx + cell * fx, gy + cell * 0.09, cell * 0.09, 0, 7); bx.fill(); }; lamp(0.12, meOpen); lamp(0.88, mateOpen); bx.restore(); } PARK_FIELD_RENDER.toll = _paintParkToll; // the render seam's SECOND client (cf. PARK_FIELD_MECHS.toll) // y3 DOWNED FIELD LAYER (ZERO-TEXT; shared by the live scene and the hub thumbnail). Painted as an // OVERLAY over the finished terrain, so no existing board changes by a pixel. The base pass has // already painted the meadow as the hazard field; this layer says the only three things the cell // needs the player to READ, and says them all in marks, never in words: // THE HOLLOW (while he is down) — a dark trampled depression under him, with the grass laid flat // in a ring: he did not sit down there, he WENT DOWN there. The // app draws the companion sprite on top of it, so he reads as // fallen rather than as a companion standing in a field. // THE DRAG TRAIL (dyn.downed.trail) — the flattened grass behind him, one smear per cell he has // hauled himself over. It is a CLOCK you can see: the longer you // leave him, the longer the trail, and it points at the bank he // is making for. Nobody has to be told he is running out of time. // THE REACH (when the walker is adjacent and he is still down) — a pulsing chevron from the // walker's cell INTO his, plus a bright rim on his cell: "the // way to help him is to WALK INTO HIM." There is no assist // button and there must not be one, so the board has to say it. // After the rescue the hollow closes to a pale trampled patch (the mark stays: the field remembers), // and the reach cue is gone. Pure function of the PUBLIC board + the runtime dyn (C1 — never the // persona), so the demo, the live game and the thumbnail all paint from the same read. function _paintParkDowned(st, x0, y0, cell) { const n = st.N, park = st.park, dyn = park.dyn; if (!park.downed || !dyn || !dyn.downed) return; const dn = dyn.downed, g = _pulseGlow(); const cx = (kk) => x0 + (kk % n + 0.5) * cell, cy = (kk) => y0 + (((kk / n) | 0) + 0.5) * cell; // (1) THE DRAG TRAIL — flattened grass, oldest faintest. Drawn first: everything else sits on it. bx.save(); bx.lineCap = 'round'; for (let i = 0; i < dn.trail.length; i++) { const kk = dn.trail[i], px = cx(kk), py = cy(kk); const age = (i + 1) / (dn.trail.length + 1); // the freshest smear is the brightest bx.strokeStyle = _alpha('#d8cfa8', 0.16 + 0.20 * age); bx.lineWidth = Math.max(1, cell * 0.09); for (const dy2 of [-0.16, 0.08]) { bx.beginPath(); bx.moveTo(px - cell * 0.30, py + cell * dy2); bx.lineTo(px + cell * 0.30, py + cell * (dy2 + 0.06)); bx.stroke(); } bx.fillStyle = _alpha('#3f4a33', 0.16 + 0.14 * age); // the scuffed earth under it bx.beginPath(); bx.ellipse(px, py, cell * 0.30, cell * 0.20, 0, 0, 7); bx.fill(); } bx.restore(); // (2) THE HOLLOW where he lies (or the trampled patch he left, once he is up) const at = st.pos[1], hx = x0 + (at.x + 0.5) * cell, hy = y0 + (at.y + 0.5) * cell; bx.save(); if (!dn.rescued) { bx.fillStyle = 'rgba(12,14,10,0.46)'; // he went DOWN here bx.beginPath(); bx.ellipse(hx, hy, cell * 0.42, cell * 0.30, 0, 0, 7); bx.fill(); bx.strokeStyle = _alpha('#c9be92', 0.34); // grass laid flat in a ring around him bx.lineWidth = Math.max(1, cell * 0.05); for (let i = 0; i < 8; i++) { const a = (i / 8) * Math.PI * 2; bx.beginPath(); bx.moveTo(hx + Math.cos(a) * cell * 0.26, hy + Math.sin(a) * cell * 0.18); bx.lineTo(hx + Math.cos(a) * cell * 0.44, hy + Math.sin(a) * cell * 0.31); bx.stroke(); } } else { bx.fillStyle = 'rgba(20,24,16,0.20)'; // the field remembers bx.beginPath(); bx.ellipse(hx, hy, cell * 0.34, cell * 0.24, 0, 0, 7); bx.fill(); } bx.restore(); // (3) THE REACH — the whole input grammar of the cell, said in one mark. Only while he is down and // the walker is beside him: a chevron out of the walker's cell pointing INTO his, and a breathing // rim on his cell. Walk into him. That is the assist. const me = st.pos[0], d = Math.abs(me.x - at.x) + Math.abs(me.y - at.y); if (!dn.rescued && d === 1) { const dx = Math.sign(at.x - me.x), dy = Math.sign(at.y - me.y); const mx = x0 + (me.x + 0.5) * cell, my = y0 + (me.y + 0.5) * cell; bx.save(); bx.strokeStyle = _alpha('#ffe9a8', 0.55 + 0.40 * g); bx.lineWidth = Math.max(1.6, cell * 0.09); bx.lineCap = 'round'; bx.lineJoin = 'round'; for (const t of [0.18, 0.40]) { // two chevrons, walker -> fallen man const px = mx + dx * cell * (t + 0.16 * g), py = my + dy * cell * (t + 0.16 * g); bx.beginPath(); bx.moveTo(px - dy * cell * 0.16 - dx * cell * 0.12, py - dx * cell * 0.16 - dy * cell * 0.12); bx.lineTo(px, py); bx.lineTo(px + dy * cell * 0.16 - dx * cell * 0.12, py + dx * cell * 0.16 - dy * cell * 0.12); bx.stroke(); } bx.strokeStyle = _alpha('#ffe9a8', 0.40 + 0.34 * g); // his cell: reachable NOW bx.lineWidth = Math.max(1.4, cell * 0.07); bx.strokeRect(x0 + at.x * cell + cell * 0.10, y0 + at.y * cell + cell * 0.10, cell * 0.80, cell * 0.80); bx.restore(); } } PARK_FIELD_RENDER.downed = _paintParkDowned; // the render seam's THIRD client (cf. PARK_FIELD_MECHS.downed) // y8 ROLLING-LOG FIELD LAYER (ZERO-TEXT; shared by the live scene and the hub thumbnail). Painted // as an OVERLAY over the finished terrain, so no existing board changes by a pixel. Every mark is a // pure function of the PUBLIC board + the runtime dyn entity (C1 — never the persona): // THE LANE (park.log.lane) — a pale rut worn down the cells the log runs, so the danger has a // DIRECTION before anything moves. It stops at the hedge gap. // THE LOG (dyn.ents[0]) — a barked cylinder lying ACROSS the lane, with a bark-grain hatch. // It is a BODY, not terrain: it sits on top of the ground it covers. // THE NEXT CELL — a chevron aimed into the cell the log will roll into, drawn ONLY // while the roll is imminent (the beat is public, so this is a read // of the board, not a hint): the cell you would have to be standing // on to stop it. This is the whole decision, said in one mark. // STUNNED (ent.stunned) — the log shudders and throws a chime ring: it is not rolling. The // mark is the CONSEQUENCE of a body that got in its way. // The impact itself is an fx event (k:'thud' from the engine tick) — the shared fx layer draws it. function _paintParkLog(st, x0, y0, cell) { const n = st.N, park = st.park, lg = park.log; const L = park.dyn && park.dyn.ents && park.dyn.ents[0]; if (!lg || !L) return; const g = _pulseGlow(); const cx = (kk) => x0 + (kk % n + 0.5) * cell, cy = (kk) => y0 + (((kk / n) | 0) + 0.5) * cell; // the rut: the lane it runs, painted before the body so the body sits IN it bx.save(); bx.strokeStyle = _alpha('#8a7a5e', 0.30); bx.lineWidth = Math.max(1.5, cell * 0.5); bx.lineCap = 'round'; bx.beginPath(); bx.moveTo(cx(lg.lane[0]), cy(lg.lane[0])); bx.lineTo(cx(lg.lane[lg.lane.length - 1]), cy(lg.lane[lg.lane.length - 1])); bx.stroke(); bx.restore(); // THE NEXT CELL — only while the roll is imminent (public beat + public schedule). The predicate // is the ENGINE'S OWN (_parkLogRollsAt, the single source the tick and the safety read also use): // a re-implementation here could silently desync from the physics and aim this mark at a cell the // log is not about to enter, which would be a lie told in the one place the player is looking. const ni = L.i + 1; if (E._parkLogRollsAt(st, park.dyn.beat + 1)) { const nx = cx(lg.lane[ni]), ny = cy(lg.lane[ni]); bx.save(); bx.strokeStyle = _alpha('#f0c26a', 0.45 + 0.35 * g); bx.lineWidth = Math.max(1.5, cell * 0.09); bx.lineCap = 'round'; bx.lineJoin = 'round'; bx.beginPath(); bx.moveTo(nx - cell * 0.26, ny - cell * 0.10); bx.lineTo(nx, ny + cell * 0.16); bx.lineTo(nx + cell * 0.26, ny - cell * 0.10); bx.stroke(); bx.restore(); } // THE LOG — a barked cylinder lying across its lane const kk = lg.lane[L.i]; const lx = x0 + (kk % n) * cell, ly = y0 + (((kk / n) | 0)) * cell; const shake = L.stunned > 0 ? Math.sin(g * 11) * cell * 0.05 : 0; bx.save(); bx.translate(lx + shake, ly); const px = cell * 0.06, py = cell * 0.22; bx.fillStyle = '#6b4a2c'; bx.fillRect(px, py, cell - 2 * px, cell - 2 * py); bx.strokeStyle = 'rgba(9,11,15,0.55)'; bx.lineWidth = Math.max(1, cell * 0.05); bx.strokeRect(px, py, cell - 2 * px, cell - 2 * py); bx.fillStyle = '#8a6238'; // the sunlit top of the barrel bx.fillRect(px, py, cell - 2 * px, cell * 0.14); bx.strokeStyle = _alpha('#3a2716', 0.55); bx.lineWidth = Math.max(1, cell * 0.035); for (let i = 1; i <= 3; i++) { // bark grain, along the length const gx1 = px + (cell - 2 * px) * (i / 4); bx.beginPath(); bx.moveTo(gx1, py + cell * 0.05); bx.lineTo(gx1, cell - py - cell * 0.05); bx.stroke(); } if (L.stunned > 0) { // the chime ring: it is NOT rolling bx.strokeStyle = _alpha('#ffe3a8', 0.30 + 0.35 * g); bx.lineWidth = Math.max(1, cell * 0.05); for (const rr of [0.30, 0.44]) { bx.beginPath(); bx.arc(cell / 2, cell / 2, cell * (rr + 0.06 * g), 0, 7); bx.stroke(); } } bx.restore(); } PARK_FIELD_RENDER.log = _paintParkLog; // the render seam's FOURTH client (cf. PARK_FIELD_MECHS.log) // THE BULL FIELD LAYER — y50 ALLEY'S, and NOT dead code. It was written for y25 bull; y25 was // DELETED on 2026-08-03 and this painter did not go with it, because the alley board carries // park.bull + dyn.ents[0] + dyn.downed in exactly the shapes drawn here and _paintParkAlley calls // this function directly as its last statement. y50 re-derived the bull's ENGINE grammar and never // its rendering, so this IS the alley's bull, and the ONLY live path into it is that direct call — // there is no PARK_FIELD_RENDER.bull registration any more (the mechanic it named is gone). // Anything read here is keyed off park.bull / dyn, never off a mechanic name, which is why the // re-homing costs zero marks: the alley board answers every read the y25 board did. // // (ZERO-TEXT; shared by the live scene and the hub thumbnail.) Painted as an OVERLAY over the // finished terrain so no existing board changes by a pixel. Every mark is a pure function of the // PUBLIC board + the runtime dyn entity (C1 — never the persona), mirroring the y8 log layer's // discipline above: // THE CHARGE LANE (dyn.ents[0].aim.lane) — a RED bar the length of the STORED (locked-at-aim-time) // lane, drawn ONLY while a charge is telegraphed. Unlike y8's log (whose // danger is only the next roll, one cell at a time), the bull's committed // warning covers the WHOLE run at once — that is what "몰랐다 불성립" // (the lane is locked, not a hint) means made visible: every cell of it is // live for the entire multi-beat fuse, not just the cell nearest the bull. // THE BULL (dyn.ents[0]) — a horned body at its current cell (home, on a fresh board; the lane's // near end mid-charge; its post-charge resting cell once stopped). // THUD (st.fx k:'thud') — the tick's own impact event, emitted ONLY on a body-block (the walker // or the companion is struck; the "nobody hit" wall/fence stun emits no // thud — see engine tick): an orange flash on the struck cell. A pure fx // replay: this painter owns its own consumption, because no separate // generic fx layer actually exists yet — every field paints its own. // STUNNED (ent.stunned) — the bull shakes and wears a small star: it is not charging, not // telegraphing, just recovering. The CONSEQUENCE of a body it just hit. // THE DOWNED COMPANION (dyn.downed) — the y3 hollow + drag trail, transplanted verbatim into this // module's own runtime bag (engine.js _parkBullCrawlTick): he self-crawls, // so there is no reach/assist chevron, only the HOLD. function _paintParkBull(st, x0, y0, cell) { const n = st.N, park = st.park, dyn = park.dyn; const B = dyn && dyn.ents && dyn.ents[0]; if (!park.bull || !B) return; const g = _pulseGlow(); const cx = (kk) => x0 + (kk % n + 0.5) * cell, cy = (kk) => y0 + (((kk / n) | 0) + 0.5) * cell; // (1) THE DOWNED COMPANION'S DRAG TRAIL — flattened grass, oldest faintest. Drawn first: everything // else (the hollow, the bull, the fx) sits on top of it. Mirrors _paintParkDowned's own trail block. if (dyn.downed) { const dn = dyn.downed; bx.save(); bx.lineCap = 'round'; for (let i = 0; i < dn.trail.length; i++) { const kk = dn.trail[i], px = cx(kk), py = cy(kk); const age = (i + 1) / (dn.trail.length + 1); bx.strokeStyle = _alpha('#d8cfa8', 0.16 + 0.20 * age); bx.lineWidth = Math.max(1, cell * 0.09); for (const dy2 of [-0.16, 0.08]) { bx.beginPath(); bx.moveTo(px - cell * 0.30, py + cell * dy2); bx.lineTo(px + cell * 0.30, py + cell * (dy2 + 0.06)); bx.stroke(); } bx.fillStyle = _alpha('#3f4a33', 0.16 + 0.14 * age); bx.beginPath(); bx.ellipse(px, py, cell * 0.30, cell * 0.20, 0, 0, 7); bx.fill(); } bx.restore(); // THE HOLLOW where he lies (or the trampled patch he left, once his own crawl stands him up) const at = st.pos[1], hx = x0 + (at.x + 0.5) * cell, hy = y0 + (at.y + 0.5) * cell; bx.save(); if (!dn.rescued) { bx.fillStyle = 'rgba(12,14,10,0.46)'; bx.beginPath(); bx.ellipse(hx, hy, cell * 0.42, cell * 0.30, 0, 0, 7); bx.fill(); bx.strokeStyle = _alpha('#c9be92', 0.34); bx.lineWidth = Math.max(1, cell * 0.05); for (let i = 0; i < 8; i++) { const a = (i / 8) * Math.PI * 2; bx.beginPath(); bx.moveTo(hx + Math.cos(a) * cell * 0.26, hy + Math.sin(a) * cell * 0.18); bx.lineTo(hx + Math.cos(a) * cell * 0.44, hy + Math.sin(a) * cell * 0.31); bx.stroke(); } } else { bx.fillStyle = 'rgba(20,24,16,0.20)'; bx.beginPath(); bx.ellipse(hx, hy, cell * 0.34, cell * 0.24, 0, 0, 7); bx.fill(); } bx.restore(); } const DIRV = [[0, -1], [0, 1], [-1, 0], [1, 0]]; // engine dirIdx: U D L R // (2) THE CHARGE LANE — the WHOLE stored lane, RED, only while a charge is telegraphed. The lane was // LOCKED at aim time (engine.js B.aim.lane), so this paints the committed warning, not a recomputed one. // // 2026-08-03, TWO REPAIRS. This is the player's ONLY warning that a charge is coming, and on the // y50 alley it had stopped being one: // - it is a BAR now, a filled red band down every cell of the committed run under a bright core, // not the old cell*0.16 hairline that the alley's street texture swallowed at a glance. // - its urgency rides the FUSE. The alpha used to be 0.38 + 0.32*g, and g is the shared TIME // PULSE, so fuse 3 and fuse 1 painted identically — the same Δ0 that put the pip row on this // board in the first place. The fuse is read from the board's OWN declared clock // (PARK_FIELD_CLOCK[fieldMech].read) instead of being re-derived here, so the pips and the bar // are two expressions of ONE number; two marks about time that can disagree is the thing this // park does not allow. // Guarded end to end: a missing, empty or single-cell lane must cost zero marks and never a thrown // paint (a 1-cell lane strokes as a round-capped dot, which is what a 1-cell charge is). { const clk = PARK_FIELD_CLOCK[park.fieldMech]; const ck = (clk && clk.read) ? clk.read(st) : null; const urg = (ck && ck.total > 1) ? Math.max(0, Math.min(1, ck.now / (ck.total - 1))) : 0; const lane = (B.aim && B.aim.lane) || []; if (lane.length) { const last = lane[lane.length - 1]; bx.save(); bx.lineCap = 'round'; bx.lineJoin = 'round'; bx.fillStyle = _alpha('#e5432f', 0.14 + 0.26 * urg); // the BAR — the whole committed run for (const kk of lane) { bx.fillRect(x0 + (kk % n) * cell + 0.5, y0 + ((kk / n) | 0) * cell + 0.5, cell, cell); } bx.strokeStyle = _alpha('#ff6a4a', 0.40 + 0.50 * urg); // the core down the middle of it bx.lineWidth = Math.max(2, cell * (0.13 + 0.15 * urg)); bx.beginPath(); bx.moveTo(cx(lane[0]), cy(lane[0])); for (let i = 1; i < lane.length; i++) bx.lineTo(cx(lane[i]), cy(lane[i])); if (lane.length === 1) bx.lineTo(cx(lane[0]), cy(lane[0])); // round cap = a dot bx.stroke(); // THE HEAD OF THE RUN — an arrowhead on the far cell, growing as the fuse burns down: WHERE it // ends is as much of the warning as where it passes, and on the last beat it is the loudest // thing on the board. Direction from the lane's own last leg (its committed shape), with the // stored dirIdx behind it for a 1-cell lane. let av = (B.aim && B.aim.dirIdx != null && DIRV[B.aim.dirIdx]) || [0, 1]; if (lane.length > 1) { const p = lane[lane.length - 2]; av = [Math.sign((last % n) - (p % n)), Math.sign(((last / n) | 0) - ((p / n) | 0))]; } const hx2 = cx(last), hy2 = cy(last), hs = cell * (0.20 + 0.16 * urg); const apx = -av[1], apy = av[0]; bx.fillStyle = _alpha('#ff8a63', 0.45 + 0.50 * urg); bx.beginPath(); bx.moveTo(hx2 + av[0] * hs * 1.35, hy2 + av[1] * hs * 1.35); bx.lineTo(hx2 - av[0] * hs * 0.35 + apx * hs, hy2 - av[1] * hs * 0.35 + apy * hs); bx.lineTo(hx2 - av[0] * hs * 0.35 - apx * hs, hy2 - av[1] * hs * 0.35 - apy * hs); bx.closePath(); bx.fill(); bx.restore(); } } // (3) THE BULL — the same ROUND, EYED actor the two players wear, with horns. // It used to be a brown rectangle with two 2px horn strokes. On y50 that put THREE brown // squares on one screen — the bull (#4a3626), the crate you may shove (#a8814e) and the // decorative benches — and at a 41px cell the horns are below the width where a stroke // survives a glance. y46's doll had already solved this and said so in its own comment: // "round + eyed = someone", so the tagger reads as a CHARACTER rather than as furniture. // Wearing the same glyph here makes one grammar hold across the whole park: // BROWN SQUARE = a thing you push. ROUND BODY WITH EYES = something alive. // Aiming turns it red, and its head is turned the way it is actually going (B.face, below), so // WHERE is legible from the body too and not from the lane alone — including on the beats before // there is a lane. (The pip row above it — PARK_FIELD_CLOCK.alley — is WHEN.) const bcx = x0 + (B.key % n + 0.5) * cell + (B.stunned > 0 ? Math.sin(g * 11) * cell * 0.05 : 0); const bcy = y0 + (((B.key / n) | 0) + 0.5) * cell; const br = cell * 0.34; // WHICH WAY IT IS LOOKING (2026-08-03). The facing used to be aim.dirIdx alone, so the bull faced // DOWN at every moment it was not telegraphing: it walked the y50 maze, turned corners and stalked // the walker with its horns pointing at the floor, and its head only ever snapped once, on the beat // it committed. "황소가 얼굴을 돌리지 않는데" is precisely that. The engine now carries B.face — the // same dirIdx alphabet (0 up · 1 down · 2 left · 3 right), rewritten every time it steps, aims or // charges — so the head follows the body's own last motion and WHERE IT IS HEADED is readable one // beat before any lane exists. aim.dirIdx stays behind it (a bull whose ent predates B.face still // points down its committed lane) and [0,1] behind that: an unknown facing must cost zero marks. const fdi = B.face != null ? B.face : ((B.aim && B.aim.dirIdx != null) ? B.aim.dirIdx : -1); const fv = DIRV[fdi] || [0, 1]; // DIRV is declared at (2), above // The horns go on FIRST, under the body, and sweep FORWARD from the head's front corners — a // horn drawn over the face reads as a scratch across it, and a squint on top of that made an X. // No squint here: in this park a narrowed eye is the doll's "caught you", and borrowing it would // say something this glyph does not mean. bx.save(); bx.strokeStyle = '#e8dcc4'; bx.lineWidth = Math.max(2, cell * 0.09); bx.lineCap = 'round'; const hpx = -fv[1], hpy = fv[0]; // across the facing for (const s of [-1, 1]) { // The base sits ON the rim (0.55f + 0.80across is ~0.97 of the radius), not inside it — drawn // under the body, any shorter and the head simply eats the horn. const bx0 = bcx + fv[0] * br * 0.55 + hpx * s * br * 0.80; const by0 = bcy + fv[1] * br * 0.55 + hpy * s * br * 0.80; bx.beginPath(); bx.moveTo(bx0, by0); bx.lineTo(bx0 + fv[0] * br * 0.70 + hpx * s * br * 0.34, // tip: forward and a little out by0 + fv[1] * br * 0.70 + hpy * s * br * 0.34); bx.stroke(); } bx.restore(); // ASLEEP (y50 round 2 — dyn.alley.sleeping). The round-2 bull dozes and wakes on a fixed public // beat cycle, and while it dozes it does not step, aim or gore (engine: the tick returns at the // sleep line). So it is drawn in the park's ONE sleep language — the shared actor's `lids`, a slow // breath, and _parkSleepBubbles — the same mark y46's pink companion wears. Two sleeping bodies on // two boards that looked like two different states would make the reader learn the word twice. // `&& !B.aim` IS THE CLEAN STOP the brief asks for. A committed aim survives the sleep (a charge // already promised is still owed), and on that beat the red bar is up — so the sleep cue must be // gone, not layered under a telegraph. The two states can never both be drawn. // THE BREATH SHORTENS AS IT IS ABOUT TO WAKE (dyn.alley.wakeIn), which is how the doze cycle is // read WITHOUT a counter — ZERO-TEXT rules out digits, and a pip row here would be a second clock // beside the fuse pips this entity already owns (PARK_FIELD_CLOCK.alley says so). // `2 - wakeIn`, CLAMPED, and the arithmetic is worth spelling out because the obvious version was // wrong: wakeIn is 0 only on the beat it is AWAKE (engine: phase 0 sets sleeping false, wakeIn 0), // so while it dozes on a 3-beat cycle the only values that ever reach this line are 2 and 1. A ramp // scaled to a 0..3 range would therefore have spent its whole life in the bottom half and the // stirring beat would have looked like the deep one. This maps the LAST sleeping beat to 1 and // every earlier one to 0, which is the true reading — "it wakes next beat" vs "not yet" — and it // assumes nothing about the period, which the painter cannot see. const AD = dyn && dyn.alley; const dozing = !!(AD && AD.sleeping && !B.aim); const wakeT = dozing ? Math.max(0, Math.min(1, 2 - (AD.wakeIn | 0))) : 0; if (dozing) { _parkActor(bx, bcx, bcy, br * (1 + 0.06 * _slowPulse()), '#6b4a33', fv[0], fv[1], 0, true); _parkSleepBubbles(bx, bcx, bcy, br, wakeT); } else { _parkActor(bx, bcx, bcy, br, B.aim ? '#a33a22' : '#6b4a33', fv[0], fv[1], 0); } if (B.stunned > 0 && !dozing) { // THE STUN STAR — recovering, not charging bx.save(); const scx = bcx, scy = bcy - br * 1.05, sr = cell * 0.10; bx.fillStyle = _alpha('#ffe27a', 0.55 + 0.35 * g); bx.beginPath(); for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + i * Math.PI / 5, r = i % 2 === 0 ? sr : sr * 0.42; const spx = scx + Math.cos(a) * r, spy = scy + Math.sin(a) * r; if (i === 0) bx.moveTo(spx, spy); else bx.lineTo(spx, spy); } bx.closePath(); bx.fill(); bx.restore(); } // (4) THUD FX — the tick's own impact event, emitted only on a body-block (walker or companion struck; // the "nobody hit" wall/fence stun emits no thud): an orange flash on the struck cell, for THAT BEAT. // 2026-08-03: it used to loop st.fx, which in the park is an append-only episode log and not a // per-beat buffer (the reset in drawGrid is the classic arena's), so on y50 an orange ring stayed // lit at every cell the bull had ever connected on — a warning about an impact long since paid. for (const f of _parkStepFx(st)) { if (f.k !== 'thud' || f.x == null) continue; const p = { x: x0 + (f.x + 0.5) * cell, y: y0 + (f.y + 0.5) * cell }; bx.save(); bx.strokeStyle = _alpha('#ff7a4a', 0.5 + 0.4 * g); bx.lineWidth = Math.max(1.5, cell * 0.09); bx.beginPath(); bx.arc(p.x, p.y, cell * (0.30 + 0.10 * g), 0, 7); bx.stroke(); bx.restore(); } } // y29 STATUE FIELD LAYER (ZERO-TEXT; shared by the live scene, the watch demo and the hub thumbnail). // Painted as an OVERLAY over the finished terrain so no existing board changes by a pixel. Every mark // is a pure function of the PUBLIC board + the PUBLIC beat + the two PUBLIC bodies (C1 — never the // persona), and NOTHING here is live-play-only: this cell's hazard is a clock, the clock is public by // construction, and a reader judging a trajectory has to see the same clock the walker was judged // against. (Contrast y24's fog, which is drawn for the human at the controls and for nobody else.) // THE DOLL — a small body standing on its wall cell, eyes SHUT while it sings and wide // while it looks. It is the fixture the whole cell is named after and it must // be findable at a glance. // THE COUNT — one pip per beat of the period, laid in the yard at the doll's feet, the // current beat filled. The last PARK_STATUE_GAZE pips are the looking beats and // they are drawn in the warning colour from the first frame, so "how long have // I got" is a read and not a memory. This is the telegraph, and the telegraph // is part of the mechanic: a player who cannot count the beats cannot be said // to have CHOSEN to hold still. Derived from `beat % period`, never a second // schedule the app keeps of its own. // THE LOOKING RIM — while it looks, a warning band inside the board edge, brightest on the doll's // own side. The whole yard is under the gaze, so the mark belongs to the yard. // THE HAND — while it looks AND the walker stands within one step of his companion, a warm // link between them plus a ring at his feet: he is being HELD, which is why he // is not walking into the gaze. The hold is the care act of this cell and an // invisible hold reads as a companion who mysteriously stopped. // THE MARKS — 'seen' (the walker billed a heart for moving) and 'sent' (the companion // caught walking and sent back), both drained with st.fx like every other fx. function _paintParkStatue(st, x0, y0, cell) { const n = st.N, park = st.park, S = park.statue, dyn = park.dyn; if (!S || !dyn) return; const period = S.sing + S.gaze, ph = (dyn.beat | 0) % period, gazing = ph >= S.sing; const g = _pulseGlow(); const dx = S.dollKey % n, dy = (S.dollKey / n) | 0, c = (n - 1) / 2; const inx = Math.sign(c - dx), iny = Math.sign(c - dy); // from the doll into the yard const px = Math.abs(iny), py = Math.abs(inx); // the doll's wall runs this way const cx = x0 + (dx + 0.5) * cell, cy = y0 + (dy + 0.5) * cell; // (1) THE LOOKING RIM — the yard is being watched. if (gazing) { bx.save(); bx.strokeStyle = _alpha('#e5432f', 0.20 + 0.16 * g); bx.lineWidth = Math.max(2, cell * 0.5); bx.strokeRect(x0 + cell * 0.25, y0 + cell * 0.25, n * cell - cell * 0.5, n * cell - cell * 0.5); bx.strokeStyle = _alpha('#ff8a6a', 0.30 + 0.28 * g); // brightest along the doll's own wall bx.lineWidth = Math.max(1.5, cell * 0.22); bx.beginPath(); bx.moveTo(cx - px * cell * 4.5 - py * cell * 0.5, cy - py * cell * 4.5 - px * cell * 0.5); bx.lineTo(cx + px * cell * 4.5 + py * cell * 0.5, cy + py * cell * 4.5 + px * cell * 0.5); bx.stroke(); bx.restore(); } // (2) THE DOLL — now the SAME round, eyed actor as the two players (round + eyed = "someone"), // so the tagger reads as a CHARACTER holding a weapon, not an overseer. It carries a marker gun // aimed into the yard: in this cell the hazard is BEING SEEN, and the gun is the face of that // "seeing". The gaze state still lives on the body (warning hue while it looks) and on the // muzzle, so trading the old trunk-and-head figure for the shared actor glyph loses no telegraph // — the looking rim (1) and the beat count (3) already own the clock. Pure render (C1). { // TURN MOTION (① — the red-light green-light tagger): the doll faces AWAY from the yard during // the song (green) and TURNS 180° to face the players for the gaze (red). `look`: 0 = fully // away, 1 = fully toward. It rotates across the beat the light changes — smooth within that // beat's dwell in the watch demo (parkGlideFrac sub-beat), a per-beat STEP in live play. The gun // barrel follows the facing (points away = safe, points at you = watching). SQUINT (② — the // tagger narrowing its eyes at a caught move): keyed to the seen/sent fx on the board. Both are // pure functions of the PUBLIC beat/fx, never the persona (C1). const A = G.parkAnim; const subFrac = (A && A.mode === 'demo') ? parkGlideFrac(A) : 0; let look; if (S.sing >= 1 && ph === S.sing - 1) look = subFrac; // last song beat: turning to face else if (ph === period - 1) look = 1 - subFrac; // last gaze beat: turning away else look = gazing ? 1 : 0; const turn = (1 - look) * Math.PI; // rotate the toward-facing outward const ct = Math.cos(turn), stn = Math.sin(turn); const alen = Math.hypot(inx, iny) || 1; const tox = inx / alen, toy = iny / alen; // unit facing INTO the yard const ax = tox * ct - toy * stn, ay = tox * stn + toy * ct; // the turned facing const hpx = -ay, hpy = ax; // the hand side (⊥ facing) const caught = (st.fx || []).some(f => f.k === 'seen' || f.k === 'sent'); const r = cell * 0.32; _parkActor(bx, cx, cy, r, gazing ? '#c9552f' : '#7a6a55', ax, ay, caught ? 0.6 + 0.4 * g : 0); // THE GUN — held to one side, barrel pointing where it looks. const hx = cx + hpx * r * 0.98 + ax * r * 0.15; // the hand const hy = cy + hpy * r * 0.98 + ay * r * 0.15; const mx = hx + ax * cell * 0.5, my = hy + ay * cell * 0.5; // the muzzle bx.save(); bx.lineCap = 'round'; bx.strokeStyle = '#20242c'; bx.lineWidth = Math.max(2, cell * 0.12); // the barrel bx.beginPath(); bx.moveTo(hx, hy); bx.lineTo(mx, my); bx.stroke(); bx.strokeStyle = '#3a3026'; bx.lineWidth = Math.max(2, cell * 0.10); // the grip stub bx.beginPath(); bx.moveTo(hx, hy); bx.lineTo(hx - ax * cell * 0.06 + hpx * cell * 0.15, hy - ay * cell * 0.06 + hpy * cell * 0.15); bx.stroke(); if (gazing) { // the muzzle is "firing" its look bx.fillStyle = _alpha('#ff6a4a', 0.75 + 0.25 * g); bx.beginPath(); bx.arc(mx, my, cell * 0.07, 0, 7); bx.fill(); bx.strokeStyle = _alpha('#ff8a5a', 0.35 + 0.25 * g); // faint aim line into the yard bx.setLineDash([cell * 0.12, cell * 0.10]); bx.lineWidth = Math.max(1, cell * 0.04); bx.beginPath(); bx.moveTo(mx, my); bx.lineTo(mx + ax * cell * 1.1, my + ay * cell * 1.1); bx.stroke(); } else { bx.fillStyle = 'rgba(150,160,170,0.6)'; // cold muzzle while it sings bx.beginPath(); bx.arc(mx, my, cell * 0.055, 0, 7); bx.fill(); } bx.restore(); } // (3) THE COUNT — moved to the HUD on 2026-07-30 (design D3). The contract that put it here // ("one pip per beat at the doll's feet, so 'how long have I got' is a read and not a memory") // keeps its INTENT and loses its collision. The yard placement broke two ways, both measured: // 1. y46 seats its finish one cell in FRONT of the doll (dollKey 162 -> finishKey 149 at // N=13), and the row was laid at cx + inx*cell*0.72 — i.e. underneath the gold goal bracket. // 2. radius cell*0.075 (3px at a 41px cell) at alpha 0.42 is past reading on grey walkway. // The clock probe put a number on it: the whole row moved 5248px between two adjacent GAZE // beats, a seventh of the weakest good idiom on the roster. The HUD cannot be covered by // anything, so the intent survived better there — until 2026-08-03, when the HUD row came out // with the rest of the board clocks (see the note where _paintParkHudClock used to be). The gaze // itself is still drawn on the doll; what left is the separate count. // (4) THE HAND — held, while it looks, by a walker standing within one step of him. // NOT WHEN HE IS ASLEEP. This mark draws y29's shoulder rule (legalMask: gazing && near <= 1 holds // him still), and on y46 v2 that clause is unreachable — `if (D.asleep) return true` fires first // and refuses him every cell anyway. A hand drawn there claims the walker is doing something for a // body that was never going to move: the same lie as the summon halo, one clause further down. const w = st.pos && st.pos[0], m = st.pos && st.pos[1]; const mateAsleep = !!(dyn.statue && dyn.statue.asleep); if (gazing && !mateAsleep && w && m && Math.max(Math.abs(w.x - m.x), Math.abs(w.y - m.y)) <= 1) { const wx = x0 + (w.x + 0.5) * cell, wy = y0 + (w.y + 0.5) * cell; const mx = x0 + (m.x + 0.5) * cell, my = y0 + (m.y + 0.5) * cell; bx.save(); bx.strokeStyle = _alpha('#ffd27a', 0.55 + 0.30 * g); bx.lineWidth = Math.max(1.5, cell * 0.11); bx.lineCap = 'round'; bx.beginPath(); bx.moveTo(wx, wy); bx.lineTo(mx, my); bx.stroke(); bx.lineWidth = Math.max(1, cell * 0.05); bx.beginPath(); bx.arc(mx, my, cell * 0.40, 0, 7); bx.stroke(); bx.restore(); } // (5) THE MARKS — one per confessed event, and only for the beat that confessed it. // 2026-08-03: this loop said "drained with the scene's fx", and that was never true of the park — // st.fx here is an append-only EPISODE log (nothing clears it; the reset in drawGrid belongs to the // classic arena), so every cracked heart and every sent-back bar the episode ever emitted was still // being redrawn on the last frame of it. On y46, where the doll fires often, the yard filled with // marks for tolls paid many turns ago. _parkStepFx is the same list this block always meant. for (const f of _parkStepFx(st)) { if ((f.k !== 'seen' && f.k !== 'sent') || f.x == null) continue; const fx = x0 + (f.x + 0.5) * cell, fy = y0 + (f.y + 0.5) * cell; bx.save(); if (f.k === 'seen') { // a heart SPENT under the gaze — // ② HEART-DRAIN: a cracked heart that bobs up and drips, so "moving while watched cost a // heart" is felt as a loss, not just a red X. Pure render off the public 'seen' fx (C1). const hs = cell * 0.20, hy = fy - cell * 0.04 - cell * 0.06 * g; bx.save(); bx.beginPath(); // the heart body (two lobes + point) bx.moveTo(fx, hy + hs * 0.85); bx.bezierCurveTo(fx - hs * 1.15, hy - hs * 0.15, fx - hs * 0.55, hy - hs * 1.05, fx, hy - hs * 0.25); bx.bezierCurveTo(fx + hs * 0.55, hy - hs * 1.05, fx + hs * 1.15, hy - hs * 0.15, fx, hy + hs * 0.85); bx.closePath(); bx.fillStyle = _alpha('#ff4a3a', 0.45 + 0.30 * g); bx.fill(); bx.strokeStyle = _alpha('#ff8a7a', 0.6 + 0.3 * g); bx.lineWidth = Math.max(1, cell * 0.03); bx.stroke(); bx.strokeStyle = _alpha('#3a0f0a', 0.55 + 0.25 * g); // the CRACK — a jagged split (broken heart) bx.lineWidth = Math.max(1.2, cell * 0.045); bx.lineJoin = 'round'; bx.lineCap = 'round'; bx.beginPath(); bx.moveTo(fx, hy - hs * 0.22); bx.lineTo(fx - hs * 0.22, hy + hs * 0.10); bx.lineTo(fx + hs * 0.16, hy + hs * 0.30); bx.lineTo(fx, hy + hs * 0.80); bx.stroke(); bx.fillStyle = _alpha('#ff5a48', 0.5 + 0.3 * g); // DRAIN drips from the point for (const [dx, dd] of [[-0.10, 0.5], [0.12, 0.9]]) { bx.beginPath(); bx.arc(fx + hs * dx, hy + hs * (1.05 + dd), cell * 0.035, 0, 7); bx.fill(); } bx.restore(); } else { // the companion, caught and sent back bx.strokeStyle = _alpha('#ffb03a', 0.6 + 0.3 * g); bx.lineWidth = Math.max(1.5, cell * 0.08); bx.lineCap = 'round'; bx.beginPath(); for (const s of [-1, 0, 1]) { bx.moveTo(fx - cell * 0.20, fy + s * cell * 0.16 - cell * 0.06); bx.lineTo(fx + cell * 0.20, fy + s * cell * 0.16 - cell * 0.06); } bx.stroke(); } bx.restore(); } // ---- ARRIVAL MARK (all views): the finish cell as a drawn goal square on the ground — // terrain layer, so the walker WALKS ONTO it. The token painter skips this token, so this // is the cell's only face. ZERO-TEXT. // ON ALL FOUR, ON y46 (2026-08-03). That board sets statue.finishKey to the FIRST of its four // exits so the y29 fixture stays well-formed, and painting only that one said the opposite of what // the board means: its four exits are INTERCHANGEABLE (park.chainAnyOf — any one of them ends the // run), so a gold square on exactly one of them sends the reader hunting for the difference. // A bespoke arch glyph was tried here first and reverted on the owner's call: an arrival point must // wear the mark arrival points wear on EVERY other map, or the player has to learn this yard's // private word for "the way out". Same mark, four cells — the y29 board still has exactly one. { const sgg = st.park.siege && st.park.siege.goals; const finishes = (sgg && sgg.length) ? sgg : [S.finishKey]; bx.save(); for (const fk2 of finishes) { const fxp = x0 + (fk2 % n) * cell, fyp = y0 + ((fk2 / n) | 0) * cell; bx.strokeStyle = _alpha('#ffd54a', 0.9); bx.lineWidth = Math.max(2, cell * 0.08); bx.strokeRect(fxp + cell * 0.18, fyp + cell * 0.18, cell * 0.64, cell * 0.64); bx.fillStyle = _alpha('#ffd54a', 0.22); bx.fillRect(fxp + cell * 0.30, fyp + cell * 0.30, cell * 0.40, cell * 0.40); } bx.restore(); } // ---- THE SHADOW (all views — it is a RULE, unlike y24's dark, which is a veil for the // human alone): the file behind the companion's body. Bold while the doll looks; a faint // promise during the song, so the shelter can be planned for rather than discovered. { const gz = E._parkStatueGazing(st); bx.save(); bx.fillStyle = gz ? 'rgba(8,10,18,0.42)' : 'rgba(8,10,18,0.14)'; for (let k = 0; k < n * n; k++) { if (!E._parkStatueShadow(st, k)) continue; bx.fillRect(x0 + (k % n) * cell, y0 + ((k / n) | 0) * cell, cell + 0.5, cell + 0.5); } bx.restore(); } } PARK_FIELD_RENDER.statue = _paintParkStatue; // the render seam's SIXTEENTH client (cf. PARK_FIELD_MECHS.statue) // y31 RELAY FIELD LAYER (ZERO-TEXT; shared by the live scene and the hub thumbnail). Painted as an // OVERLAY over the finished terrain, so no existing board changes by a pixel, and every mark is a // pure function of the PUBLIC board + dyn (C1 — never the persona): // THE COUNTER — a raised slab down the whole column, with a lip on the walker's side. The // cell's first sentence is "you cannot cross this", and a barrier the player // cannot SEE turns every refused move into a bug report. // THE SHELVES — a recess in the counter at each pass cell. ARMED (he is carrying and the // shelf is unserved) it breathes a warm ring: THE ONE MARK THAT IS NOT // DECORATION, because reaching over is a move that exists only while he holds // something, and a move you cannot tell is available cannot be said to have // been declined. Served, it goes flat and quiet. // THE OPENED GEM — once a shelf is served, the gem beside it gets a soft gate arc on the // COMPANION'S side: the whole mechanism is "this is his now", and the beat it // changes hands is the beat the picture must change. // THE CRATES — the two goods still in the yard, drawn as slatted boxes. // CARRYING — a small box riding the walker's own cell, up in the corner so the sprite // still reads. Without it "he walked past the counter with it in his hands" // is invisible, and that is the whole indictment. // THE WAITING MATE — three stacked pips over the companion whenever his plan is a STUCK hold. It // is DERIVED from _parkCompanionPlan and never a second state this layer keeps. function _paintParkRelay(st, x0, y0, cell) { const n = st.N, park = st.park, R = park.relay; const dyn = park.dyn, D = dyn && dyn.relay; if (!R || !D) return; const g = _pulseGlow(); const at = (kk) => ({ x: x0 + (kk % n) * cell, y: y0 + ((kk / n) | 0) * cell }); const inward = R.mateWest ? 1 : -1; // from the counter toward the walker's half // THE COUNTER SLAB for (const kk of R.counter) { const p = at(kk); bx.save(); bx.fillStyle = 'rgba(126,110,92,0.92)'; bx.fillRect(p.x, p.y, cell + 0.5, cell + 0.5); bx.strokeStyle = _alpha('#e8dcc4', 0.30); bx.lineWidth = Math.max(1, cell * 0.05); bx.beginPath(); // the grain, so it reads as a worked surface bx.moveTo(p.x + cell * 0.18, p.y + cell * 0.34); bx.lineTo(p.x + cell * 0.82, p.y + cell * 0.34); bx.moveTo(p.x + cell * 0.18, p.y + cell * 0.66); bx.lineTo(p.x + cell * 0.82, p.y + cell * 0.66); bx.stroke(); // the LIP on the walker's side — one edge, so which half is his is legible from across the board bx.strokeStyle = _alpha('#fdf3dd', 0.45); bx.lineWidth = Math.max(1.5, cell * 0.09); const lx = inward > 0 ? p.x + cell : p.x; bx.beginPath(); bx.moveTo(lx, p.y); bx.lineTo(lx, p.y + cell); bx.stroke(); bx.restore(); } // THE SHELVES for (let i = 0; i < R.pass.length; i++) { const p = at(R.pass[i]), in0 = cell * 0.18; const served = D.delivered.some(e => e.pass === i); bx.save(); bx.fillStyle = served ? 'rgba(64,58,50,0.75)' : 'rgba(48,44,40,0.60)'; bx.fillRect(p.x + in0, p.y + in0, cell - 2 * in0, cell - 2 * in0); if (served) { // the crate that is now sitting on it bx.fillStyle = '#a8814e'; bx.fillRect(p.x + in0 * 1.4, p.y + in0 * 1.4, cell - 2.8 * in0, cell - 2.8 * in0); } else if (D.carrying != null) { // ARMED — the reach is available right now bx.strokeStyle = _alpha('#ffd98a', 0.35 + 0.40 * g); bx.lineWidth = Math.max(1.5, cell * 0.08); bx.strokeRect(p.x + in0 * 0.6, p.y + in0 * 0.6, cell - 1.2 * in0, cell - 1.2 * in0); } bx.restore(); } // THE OPENED GEM — a gate arc on the companion's side of the counter. for (const e of D.delivered) { const ks = R.opens[e.pass]; if (!ks) continue; for (const kk of ks) { const p = at(kk); bx.save(); bx.strokeStyle = _alpha('#9fe0c0', 0.45 + 0.25 * g); bx.lineWidth = Math.max(1.5, cell * 0.09); bx.beginPath(); bx.arc(p.x + cell * (inward > 0 ? 0.06 : 0.94), p.y + cell * 0.5, cell * 0.34, inward > 0 ? -Math.PI / 2 : Math.PI / 2, inward > 0 ? Math.PI / 2 : Math.PI * 1.5); bx.stroke(); bx.restore(); } } // THE CRATES still in the yard for (const gi of D.left) { const p = at(R.goods[gi]), in0 = cell * 0.16; bx.save(); bx.fillStyle = '#a8814e'; bx.fillRect(p.x + in0, p.y + in0, cell - 2 * in0, cell - 2 * in0); bx.strokeStyle = 'rgba(9,11,15,0.55)'; bx.lineWidth = Math.max(1, cell * 0.06); bx.strokeRect(p.x + in0, p.y + in0, cell - 2 * in0, cell - 2 * in0); bx.strokeStyle = _alpha('#e6c88f', 0.55); bx.lineWidth = Math.max(1, cell * 0.05); bx.beginPath(); bx.moveTo(p.x + in0, p.y + cell * 0.5); bx.lineTo(p.x + cell - in0, p.y + cell * 0.5); bx.moveTo(p.x + cell * 0.5, p.y + in0); bx.lineTo(p.x + cell * 0.5, p.y + cell - in0); bx.stroke(); bx.restore(); } // CARRYING — a small crate riding his own square. if (D.carrying != null) { const p = { x: x0 + st.pos[0].x * cell, y: y0 + st.pos[0].y * cell }; const s = cell * 0.26; bx.save(); bx.fillStyle = '#c69a5c'; bx.fillRect(p.x + cell * 0.06, p.y + cell * 0.06, s, s); bx.strokeStyle = 'rgba(9,11,15,0.6)'; bx.lineWidth = Math.max(1, cell * 0.045); bx.strokeRect(p.x + cell * 0.06, p.y + cell * 0.06, s, s); bx.restore(); } // THE WAITING MATE — three stacked pips over the companion while a gem he is still contracted for // sits behind an unserved shelf. That condition IS the hold: his planner comes back // {next:null, stuck:true} for exactly as long as it lasts (engine.js:7398-7402), and deriving it // from the public board rather than re-running his plan keeps this layer a painter. The painter // signature carries the BOARD and not the runtime, which is the other half of the reason. const held = R.gems.some((gk, i) => { const tok = st.tokens[R.passGem[i]]; return tok && tok.alive && !D.delivered.some(e => e.pass === i); }); if (held) { const p = { x: x0 + st.pos[1].x * cell, y: y0 + st.pos[1].y * cell }; bx.save(); bx.fillStyle = _alpha('#cfe6f4', 0.35 + 0.35 * g); for (let i = 0; i < 3; i++) { bx.beginPath(); bx.arc(p.x + cell * (0.30 + 0.20 * i), p.y - cell * 0.10, cell * 0.055, 0, 6.283185); bx.fill(); } bx.restore(); } } PARK_FIELD_RENDER.relay = _paintParkRelay; // the render seam's SIXTEENTH client (cf. PARK_FIELD_MECHS.relay) // y10 RISING-WATER FIELD LAYER (ZERO-TEXT; shared by the live scene and the hub thumbnail). Painted as // an OVERLAY over the finished terrain so no existing board changes by a pixel. Every mark is a pure // function of the PUBLIC board + the PUBLIC beat (C1 — never the persona): // FLOOD (dyn.flood) — the rings that have gone under: dark water with a slow surface chop. // NEXT RING (the PREVIEW) — THE ONE MARK THAT IS NOT DECORATION. A DOTTED outline, breathing, on // every cell of the ring that sinks NEXT. The whole caution read is "keep // two rings of margin to the water", and a margin you cannot SEE is not a // margin you can be said to have chosen: without this the player has no // way to observe the danger, and safety-led play becomes unfalsifiable. // It is the observation window, and it is part of the mechanic. // MOUND / SUMMIT — the high ground reads as high ground (a warm lit plateau above the // waterline), and the SUMMIT carries the companion's RESERVED-SEAT ring: // the cell the care read is about. Dimmed once he is actually sitting in // it, because there is nothing left to yield. function _paintParkFlood(st, x0, y0, cell) { const n = st.N, park = st.park, fl = park.flood; const dyn = park.dyn, flood = dyn ? dyn.flood : new Set(); const g = _pulseGlow(); // THE PREVIEW comes from the ENGINE, which owns the timetable — the app must not re-derive "which // ring goes next" from the beat, or the drawn danger and the read danger can drift apart, and the // whole point of the preview is that what the player SEES is what the caution read PRICES. const nextRing = E._parkFloodNext(st); // 1) THE HILL reads as a lit plateau standing out of the lake. (Only the hill: the causeway is a // stone bridge AT the waterline, not high ground — painting it as plateau said the walker was // already safe when he was not.) for (const kk of fl.mound) { const x = kk % n, y = (kk / n) | 0, gx = x0 + x * cell, gy = y0 + y * cell; bx.fillStyle = '#7d8b6a'; bx.fillRect(gx, gy, cell + 0.5, cell + 0.5); bx.strokeStyle = 'rgba(255,255,255,0.16)'; bx.lineWidth = Math.max(1, cell * 0.04); bx.strokeRect(gx + cell * 0.1, gy + cell * 0.1, cell * 0.8, cell * 0.8); } // 1b) THE CAUSEWAY — the one way up, and the cell the endgame is a race for. Stone planks, low. { const gx = x0 + fl.cway.x * cell, gy = y0 + fl.cway.y * cell; bx.fillStyle = '#6b6c66'; bx.fillRect(gx, gy, cell + 0.5, cell + 0.5); bx.save(); bx.strokeStyle = 'rgba(20,22,26,0.45)'; bx.lineWidth = Math.max(1, cell * 0.05); for (let i = 1; i < 3; i++) { bx.beginPath(); bx.moveTo(gx + (i / 3) * cell, gy + cell * 0.12); bx.lineTo(gx + (i / 3) * cell, gy + cell * 0.88); bx.stroke(); } bx.restore(); } // 2) the water that has already taken the board for (const kk of flood) { const x = kk % n, y = (kk / n) | 0, gx = x0 + x * cell, gy = y0 + y * cell; bx.fillStyle = '#1d3a4d'; bx.fillRect(gx, gy, cell + 0.5, cell + 0.5); bx.save(); bx.strokeStyle = _alpha('#7fc4de', 0.20 + 0.10 * g); bx.lineWidth = Math.max(1, cell * 0.05); bx.lineCap = 'round'; for (let i = 0; i < 2; i++) { const oy = gy + cell * (0.32 + 0.3 * i) + Math.sin((x * 2 + y + i) * 1.7 + g * 2) * cell * 0.05; bx.beginPath(); bx.moveTo(gx + cell * 0.16, oy); bx.lineTo(gx + cell * 0.84, oy); bx.stroke(); } bx.restore(); } // 3) THE PREVIEW — the ring that goes next, dotted. The observation window (see above). if (nextRing.length) { bx.save(); bx.setLineDash([Math.max(2, cell * 0.16), Math.max(2, cell * 0.12)]); bx.strokeStyle = _alpha('#8fd4ec', 0.55 + 0.35 * g); bx.lineWidth = Math.max(1.5, cell * 0.09); bx.lineCap = 'round'; const in0 = cell * 0.13; for (const kk of nextRing) { if (flood.has(kk)) continue; const x = kk % n, y = (kk / n) | 0; bx.strokeRect(x0 + x * cell + in0, y0 + y * cell + in0, cell - 2 * in0, cell - 2 * in0); } bx.setLineDash([]); bx.restore(); } // 4) the companion's RESERVED SEAT on the summit — the cell the care read is about const sx = fl.seat.x, sy = fl.seat.y; const taken = st.pos[1].x === sx && st.pos[1].y === sy; bx.save(); bx.strokeStyle = _alpha('#f2d98a', taken ? 0.22 : 0.55 + 0.30 * g); bx.lineWidth = Math.max(1.5, cell * 0.08); bx.beginPath(); bx.arc(x0 + (sx + 0.5) * cell, y0 + (sy + 0.5) * cell, cell * 0.30, 0, 7); bx.stroke(); bx.restore(); } PARK_FIELD_RENDER.flood = _paintParkFlood; // the render seam's SIXTH client (cf. PARK_FIELD_MECHS.flood) // PARK_FIELD_RENDER_POST[id] = painter(st, x0, y0, cell, P) — the AFTER-THE-ACTORS half of the field // render seam, called from drawParkScene once the bodies are down. Registered exactly like // PARK_FIELD_RENDER and empty for every mechanic that does not opt in. // // WHY A SECOND REGISTRY RATHER THAN A FLAG ON THE FIRST. The two passes are different LAYERS, not // two modes of one painter: the overlay pass has to sit under the gems (it is ground), and the post // pass has to sit over them (it is air). y24 needs both, and needs them to disagree about the same // cells — its ground marks stay visible where its air does not. Naming the pass in the registry key // keeps every mechanic's opt-in explicit and leaves the first seam byte-identical. const PARK_FIELD_RENDER_POST = {}; // y24 LANTERN FIELD LAYER (ZERO-TEXT; shared by the live scene and the hub thumbnail). Painted as an // OVERLAY over the finished terrain so no existing board changes by a pixel. Every mark is a pure // function of the PUBLIC board + the runtime dyn (C1 — never the persona): // THE THROAT — the three-cell seam between the two lobes, edged so the one crossing on the // board reads as a place rather than as a gap in a wall. // THE HOOKS (3) — a short bracket on a post. The one in the throat wears the companion-hue ring, // because that is the hook his lane depends on and the choice the cell is about. // THE LANTERN — hung: a lamp on its bracket with a warm pool under it. Carried: the same lamp // riding at the walker's shoulder. Its position IS the confession, so it is drawn // at full strength in every view, fog or no fog. // THE LIGHT EDGE — the boundary of the Chebyshev ball, a warm square outline. The COMPANION's // physics is exactly this line (outside it he cannot take a step), so the line is // drawn from the ENGINE's own predicate rather than re-derived here — the drawn // edge and the read edge cannot drift apart. // THE SHIVER — a frozen companion (no lit step at all) gets a cold double arc: the harm the // care read is defending against, visible on the body it is happening to. function _paintParkLantern(st, x0, y0, cell) { const n = st.N, park = st.park, L = park.lantern; if (!L) return; const D = park.dyn && park.dyn.lantern; const g = _pulseGlow(); const ctr = (k) => ({ cx: x0 + (k % n + 0.5) * cell, cy: y0 + (((k / n) | 0) + 0.5) * cell }); // 1) THE THROAT — a warm sill across the seam, so the one crossing reads as a doorway. bx.save(); bx.strokeStyle = _alpha('#e8c27a', 0.30); bx.lineWidth = Math.max(1, cell * 0.05); for (const kk of L.corridor) { const x = kk % n, y = (kk / n) | 0; bx.strokeRect(x0 + x * cell + cell * 0.12, y0 + y * cell + cell * 0.12, cell * 0.76, cell * 0.76); } bx.restore(); // 2) THE HOOKS — post + bracket. The throat hook wears the companion's hue: it is HIS hook. for (const hk of L.hooks) { const c = ctr(hk), mine = hk === L.junction; bx.save(); bx.strokeStyle = mine ? _alpha(PARK_HUES.companion, 0.75) : 'rgba(214,206,190,0.55)'; bx.lineWidth = Math.max(1.4, cell * 0.07); bx.lineCap = 'round'; bx.beginPath(); bx.moveTo(c.cx - cell * 0.02, c.cy + cell * 0.28); bx.lineTo(c.cx - cell * 0.02, c.cy - cell * 0.22); bx.lineTo(c.cx + cell * 0.22, c.cy - cell * 0.22); bx.stroke(); if (mine) { // the ring that says "this is the one that helps" bx.strokeStyle = _alpha(PARK_HUES.companion, 0.30 + 0.28 * g); bx.lineWidth = Math.max(1.2, cell * 0.05); bx.beginPath(); bx.arc(c.cx, c.cy, cell * 0.34, 0, 7); bx.stroke(); } bx.restore(); } if (!D) return; // 3) THE LANTERN and its pool of light. const hung = D.key != null; const seat = hung ? ctr(D.key) : (() => { const p = st.pos[0]; return { cx: x0 + (p.x + 0.72) * cell, cy: y0 + (p.y + 0.16) * cell }; })(); const lx = hung ? seat.cx + cell * 0.20 : seat.cx, ly = hung ? seat.cy - cell * 0.16 : seat.cy; bx.save(); const pool = bx.createRadialGradient(lx, ly, cell * 0.05, lx, ly, cell * 0.9); pool.addColorStop(0, _alpha('#ffe9a8', 0.46 + 0.10 * g)); pool.addColorStop(1, _alpha('#ffe9a8', 0)); bx.fillStyle = pool; bx.beginPath(); bx.arc(lx, ly, cell * 0.9, 0, 7); bx.fill(); bx.fillStyle = '#ffe9a8'; bx.strokeStyle = '#3a3020'; bx.lineWidth = Math.max(1, cell * 0.04); bx.beginPath(); // the lamp body: a small four-sided glass bx.moveTo(lx, ly - cell * 0.17); bx.lineTo(lx + cell * 0.12, ly); bx.lineTo(lx, ly + cell * 0.17); bx.lineTo(lx - cell * 0.12, ly); bx.closePath(); bx.fill(); bx.stroke(); bx.restore(); // 4) THE LIGHT EDGE — the companion's actual physics, read off the engine predicate. bx.save(); bx.strokeStyle = _alpha('#ffd98a', 0.34 + 0.20 * g); bx.lineWidth = Math.max(1, cell * 0.05); for (let k = 0; k < n * n; k++) { if (!E._parkLantLit(st, k)) continue; const x = k % n, y = (k / n) | 0, gx = x0 + x * cell, gy = y0 + y * cell; const edge = (dx, dy) => { const nx2 = x + dx, ny2 = y + dy; return nx2 < 0 || ny2 < 0 || nx2 >= n || ny2 >= n || !E._parkLantLit(st, ny2 * n + nx2); }; bx.beginPath(); if (edge(0, -1)) { bx.moveTo(gx, gy); bx.lineTo(gx + cell, gy); } if (edge(0, 1)) { bx.moveTo(gx, gy + cell); bx.lineTo(gx + cell, gy + cell); } if (edge(-1, 0)) { bx.moveTo(gx, gy); bx.lineTo(gx, gy + cell); } if (edge(1, 0)) { bx.moveTo(gx + cell, gy); bx.lineTo(gx + cell, gy + cell); } bx.stroke(); } bx.restore(); // 5) THE SHIVER — he has no lit step at all, so his plan is stuck and he is holding where he is. const mate = st.pos[1]; if (mate) { const mk = mate.y * n + mate.x; let anyLit = E._parkLantLit(st, mk); for (const d of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const ax = mate.x + d[0], ay = mate.y + d[1]; if (ax < 0 || ay < 0 || ax >= n || ay >= n) continue; if (!st.wall.has(ay * n + ax) && !park.deep.has(ay * n + ax) && E._parkLantLit(st, ay * n + ax)) anyLit = true; } if (!anyLit) { const c = ctr(mk); bx.save(); bx.strokeStyle = _alpha('#9fd8f0', 0.45 + 0.35 * g); bx.lineWidth = Math.max(1.2, cell * 0.05); for (let i = 0; i < 2; i++) { bx.beginPath(); bx.arc(c.cx, c.cy, cell * (0.42 + 0.10 * i), -2.5, -0.7); bx.stroke(); } bx.restore(); } } } PARK_FIELD_RENDER.lantern = _paintParkLantern; // the render seam's FIFTEENTH client (cf. PARK_FIELD_MECHS.lantern) // y24 THE DARK — the POST pass, and the one place in this project where a visual is not a picture of // a rule but a rule of its own, addressed to a different reader. // // READ THIS BEFORE CHANGING IT. The engine has NO darkness in it: the oracle, the three minds and the // blind readout all see the whole board, which is what keeps y24's trajectories readable at all (see // the module header in engine.js). The fog below is drawn for the HUMAN at the controls and for // nobody else, so it runs ONLY in the live play view. The watch demo, the report replay and the hub // thumbnail are broad daylight on purpose: a reader judging a trajectory must see what the trajectory // was judged against, and a thumbnail that is 92% black is not a thumbnail. // // It draws AFTER the bodies because that is the whole point — a gem or a resident standing in the // dark has to be GONE, not tinted. The lantern layer above has already drawn the lamp and the light's // edge; this pass only takes away what is outside it. function _paintParkLanternDark(st, x0, y0, cell) { if (G.parkView !== 'play') return; // human-only, live-play-only. Never the readout. const n = st.N; if (!st.park.lantern) return; bx.save(); bx.fillStyle = 'rgba(6,7,10,0.92)'; for (let k = 0; k < n * n; k++) { if (E._parkLantLit(st, k)) continue; bx.fillRect(x0 + (k % n) * cell, y0 + ((k / n) | 0) * cell, cell + 0.5, cell + 0.5); } bx.restore(); } PARK_FIELD_RENDER_POST.lantern = _paintParkLanternDark; // the post seam's FIRST client // y29 THE GLOW AND THE POST (live play only): an affordance, not a rule — the demo, the // readout and the hub thumbnail never advertise a button the oracle does not have (the y24 // dark's audience discipline, reused for input instead of light). Drawn on the POST seam so // the halo rides OVER the companion's body. function _paintParkStatueLive(st, x0, y0, cell) { if (G.parkView !== 'play') return; const park = st.park, S = park.statue, D = park.dyn && park.dyn.statue; if (!S || !D) return; // A SLEEPING BODY CANNOT BE CALLED (y46 v2), so it must not be advertised as callable. The siege // board carries this whole y29 fixture, which is why the affordance is reachable here at all — // and engine.js parkStatueSummon now refuses on D.asleep, so an unguarded halo would be a control // that lights up, is clicked, and does nothing. The rule this park keeps: never draw a button the // engine will refuse. Her verb on that board is the walker's SHOULDER (the push), and the push // advertises itself on the bodies it can move — see the pushable rim in _paintParkSiege. if (D.asleep) return; const n = st.N; const near = E._parkStatueNear(st); // the module's ONE 'beside him' — never a fourth inline copy const dest = E._parkStatueSummonDest(st); if (D.mateStun === 0 && near <= 2 && dest >= 0) { const pulse = 0.55 + 0.30 * Math.sin(Date.now() / 220); bx.save(); bx.strokeStyle = _alpha('#7fd7ff', pulse); bx.lineWidth = Math.max(2, cell * 0.10); bx.beginPath(); bx.arc(x0 + (st.pos[1].x + 0.5) * cell, y0 + (st.pos[1].y + 0.5) * cell, cell * 0.55, 0, Math.PI * 2); bx.stroke(); bx.restore(); } if (D.summon != null) { const sx = D.summon % n, sy = (D.summon / n) | 0; bx.save(); bx.strokeStyle = _alpha('#7fd7ff', 0.5); bx.setLineDash([4, 3]); bx.strokeRect(x0 + sx * cell + 2, y0 + sy * cell + 2, cell - 4, cell - 4); bx.restore(); } } PARK_FIELD_RENDER_POST.statue = _paintParkStatueLive; // the post seam's SECOND client // ---- y46 v2 THE FOUR SEATS (design 2026-08-03 §4). Four bodies stand in this yard and only two of // them are MINDS: seat 0 = st.pos[0] (blue walker), seat 1 = st.pos[1] (pink companion), seats 2/3 = // dyn.siege.runners (green/yellow), scripted scenery that prices nothing. ONE array, because a safe // zone claimed by a seat has to paint in EXACTLY the hue that seat's body wears — 요구 5의 // "칸의 색이 바뀐다" is only a READ if the zone and the body cannot drift apart, and two copies of a // palette always drift. Deliberately NOT seatColor()/SEAT_COLORS (app.js:3268): that palette keys on // the ARENA's seat ids and belongs to a different registry with its own reasons to change. const SEAT_COL = ['#4f8bff', '#ff6ebe', '#49d97a', '#f2d23c']; // ---- 2026-08-03, THE BESPOKE FACE IS GONE. A _paintParkFace / _paintParkHpPips pair used to draw a // second face (dot eyes, ∪/∩ mouth) and a 3-pip damage row OVER the pink, green and yellow bodies. // It was rejected, and the reason is worth keeping so it is not rebuilt: every body in this park is // the SAME creature, and _parkActor already IS that creature — dark edge disc, white keyline, hue // body, two facing-aware eyes. Four bodies wearing one glyph and differing only in HUE is what makes // a first look count four characters; a smiley painted on three of them makes those three a // different species from the blue walker, and hue stops being the thing you read. // The shot expression is therefore _parkActor's own 8th argument, `squint` — the eyes shut. That is // the whole vocabulary: one channel, one beat, and nothing left behind afterwards. // The hp row went with it. Damage now reads exactly twice: the eyes shut on the beat it lands, and // the downed body fades to 0.35 when it is out. A pip row is a COUNT, and a count on a body that // prices nothing (the runners are scenery — §4) invited the reader to budget something the rules // never charge. // _parkSiegeSeatGoal(st, seat): WHICH EXIT that seat is heading for — the reservation each of the // four bodies is drawn to be holding (요구: 각 에이전트는 저마다 목표하는 골인 지점에 테두리 처리). // One function, four seats, because a ring that disagreed with where a body actually walks is worse // than no ring at all — and each answer here is the SAME source the mover itself uses: // 0 blue — _parkDestCell's chainAnyOf branch: the nearest still-alive exit. Mirrored rather than // imported because the field painter is handed `st` and not `P`; the mirror is exact on // THIS board's shape (chain length 1, all four in chainAnyOf[0]), and the one state P // would add — the leg already finished — is visible from st alone, because finishing is // what kills one of the four tokens. Any dead => no ring, exactly as the beacon stops. // 1 pink — E._parkSiegeMateGoal: the nearest still-open exit to a body that cannot choose. // 2/3 — the runner's own r.goal, the field its pathfinder walks. A downed or arrived runner // has no errand left, so it reserves nothing. // Pure public read (C1): tokens, positions, dyn.siege. Returns a cell key, or null for "no claim". function _parkSiegeSeatGoal(st, seat) { const park = st.park, sg = park.siege, D2 = park.dyn && park.dyn.siege; if (!sg || !sg.goals || !D2) return null; if (seat === 1) return E._parkSiegeMateGoal(st); if (seat >= 2) { const r = D2.runners.find(rr => rr.seat === seat); if (!r || r.hp <= 0 || r.locked) return null; return r.goal != null ? r.goal : null; } const n = st.N, me = st.pos[0]; const any = (park.chainAnyOf && park.chainAnyOf[0]) || []; let best = null, bd = Infinity; for (const ti of any) { const t = st.tokens[ti]; if (!t) continue; if (!t.alive) return null; // a dead exit = the leg is over = nothing to pin const d = Math.abs(t.x - me.x) + Math.abs(t.y - me.y); if (d < bd) { bd = d; best = t.y * n + t.x; } } return best; } // y46 SIEGE FIELD LAYER (ZERO-TEXT). The hybrid paints as a COMPOSITE, board-driven (the bomb2- // borrows-the-bomb-painter precedent): the water first (flood's drowned-ground and dotted-preview // idioms), then the statue yard verbatim — the y46 board carries the full park.statue fixture, so // the doll, the finish mark, the lane and the live-gaze halo all come from the y29 painters // unchanged. The dotted band is E._parkSiegeNext — the ENGINE owns the timetable, and the drawn // danger must be the priced danger (flood's design law, restated on the hybrid). function _paintParkSiege(st, x0, y0, cell) { const n = st.N, dyn = st.park.dyn, D2 = dyn ? dyn.siege : null; const g = _pulseGlow(); if (D2) for (const kk of D2.gone) { const x = kk % n, y = (kk / n) | 0, gx = x0 + x * cell, gy = y0 + y * cell; bx.fillStyle = '#1d3a4d'; bx.fillRect(gx, gy, cell + 0.5, cell + 0.5); bx.save(); bx.strokeStyle = _alpha('#7fc4de', 0.20 + 0.10 * g); bx.lineWidth = Math.max(1, cell * 0.05); bx.lineCap = 'round'; for (let i = 0; i < 2; i++) { const oy = gy + cell * (0.32 + 0.3 * i) + Math.sin((x * 2 + y + i) * 1.7 + g * 2) * cell * 0.05; bx.beginPath(); bx.moveTo(gx + cell * 0.16, oy); bx.lineTo(gx + cell * 0.84, oy); bx.stroke(); } bx.restore(); } const next = E._parkSiegeNext(st); if (next.length) { bx.save(); bx.setLineDash([Math.max(2, cell * 0.16), Math.max(2, cell * 0.12)]); bx.strokeStyle = _alpha('#8fd4ec', 0.55 + 0.35 * g); bx.lineWidth = Math.max(1.5, cell * 0.09); bx.lineCap = 'round'; const in0 = cell * 0.13; for (const kk of next) { if (D2 && D2.gone.has(kk)) continue; const x = kk % n, y = (kk / n) | 0; bx.strokeRect(x0 + x * cell + in0, y0 + y * cell + in0, cell - 2 * in0, cell - 2 * in0); } bx.setLineDash([]); bx.restore(); } // ---- THE FOUR EXITS (v2 §2·§5), under the statue yard because they are GROUND: the walker walks // ONTO one. Three marks, and only ONE of them is this board's own: // THE NECKS — a faint wash on the 2-cell doorway of each zone. "You enter only through here" is // a geometry fact the wall band alone does not tell (the neck cells are open floor // and look like any other open floor), and §2 puts the whole cost of 요구 7's far-goal // choice on that bottleneck. Deliberately faint: it is a hint about a route, not a // hazard, and it must never compete with the dotted next-band above it. // THE EXIT — NOT drawn here. It is the ARRIVAL MARK, the same gold goal square every other map // puts on its finish, painted on all four by _paintParkStatue immediately below. // A bespoke arch glyph lived here for one pass and was reverted on the owner's call: // "다른 맵에서 골(도착점)으로 보이는 entity로" — an arrival point that wears a private // glyph is a word only this yard speaks, and the four have to be interchangeable. // TAKEN EXIT — a wash of the claimant's own body hue over the whole cell under a solid border in // the same hue. This is 요구 5 itself ("칸의 색이 바뀐다"). It goes UNDER the arrival // mark, so a taken exit is still visibly an exit — with somebody in it. // Guarded on both halves: goals/necks are BOARD fields (park.siege) and claimed is RUNTIME // (dyn.siege), and either can be absent on a pre-v2 fixture — a missing field must cost zero marks, // never a thrown paint (the whole scene shares one canvas). const SG = st.park.siege, goals = (SG && SG.goals) || (D2 && D2.goals) || []; { const necks = (SG && SG.necks) || (D2 && D2.necks) || []; const claimed = D2 && D2.claimed; bx.save(); bx.fillStyle = _alpha('#7fd7ff', 0.06); for (const kk of necks) { bx.fillRect(x0 + (kk % n) * cell, y0 + ((kk / n) | 0) * cell, cell + 0.5, cell + 0.5); } const in0 = cell * 0.14; for (const kk of goals) { const seat = claimed && claimed.get ? claimed.get(kk) : undefined; if (seat == null) continue; const gx = x0 + (kk % n) * cell, gy = y0 + ((kk / n) | 0) * cell; const col = SEAT_COL[seat] || SEAT_COL[0]; bx.fillStyle = _alpha(col, 0.55); bx.fillRect(gx, gy, cell + 0.5, cell + 0.5); bx.strokeStyle = _alpha(col, 0.95); bx.lineWidth = Math.max(2, cell * 0.10); bx.strokeRect(gx + in0, gy + in0, cell - 2 * in0, cell - 2 * in0); } bx.restore(); } _paintParkStatue(st, x0, y0, cell); // ---- THE RESERVATIONS (요구: 각 에이전트는 저마다 목표하는 골인 지점에 찜하는 테두리 처리). Four bodies // are walking at four exits and there are only four exits, so WHO IS GOING WHERE is this board's // whole tension — and until now it was invisible until somebody arrived. One dashed ring per seat, // in that seat's hue, on the cell that seat is heading for; the borrowed claim-ring idiom, drawn // OVER the arrival mark because a reservation is news about the mark, not a replacement for it. // Rings are stacked concentrically by seat index (_parkClaimRing), so two bodies racing for // the same exit is legible as exactly that rather than as one thicker ring. if (goals.length) { const ringed = new Map(); // exit key -> how many rings already on it for (let seat = 0; seat < 4; seat++) { const kk = _parkSiegeSeatGoal(st, seat); if (kk == null) continue; const i = ringed.get(kk) || 0; ringed.set(kk, i + 1); _parkClaimRing(x0 + (kk % n) * cell, y0 + ((kk / n) | 0) * cell, cell, SEAT_COL[seat] || SEAT_COL[0], i); } } // ---- THE TWO RUNNERS (v2 §4) — seats 2·3, drawn AFTER the yard so they stand ON the ground like // the two minds do, and in the same round-body grammar (glow + disc) so a first look counts FOUR // characters rather than two characters and two markers. They are scripted scenery — the oracle // never sees them (§4: not in st.pos, so not in legal, metric or pairing) — but they SEIZE zones, // and a body that takes your zone has to look like somebody who took it. // DOWNED (hp 0) — the whole body at 0.35 alpha. It stays on its cell and it is still an obstacle // to read, but "this one is out" must be answerable from across the board. Fade is the one // channel that survives at thumbnail size. // SHOT (r.wince) — the eyes SHUT, via _parkActor's own squint argument. The engine sets wince=1 // and decrements it on the next tick, so the expression lasts exactly the beat it belongs to and // then it is gone: nothing accumulates, nothing is left behind on the body afterwards. for (const r of ((D2 && D2.runners) || [])) { if (!r || r.x == null) continue; const cx = x0 + (r.x + 0.5) * cell, cy = y0 + (r.y + 0.5) * cell; const col = SEAT_COL[r.seat] || SEAT_COL[2], rr = cell * 0.26; bx.save(); if (!(r.hp > 0)) bx.globalAlpha = 0.35; const gr = bx.createRadialGradient(cx, cy, rr * 0.45, cx, cy, rr * 2.0); gr.addColorStop(0, _alpha(col, 0.30)); gr.addColorStop(1, _alpha(col, 0)); bx.fillStyle = gr; bx.beginPath(); bx.arc(cx, cy, rr * 2.0, 0, 7); bx.fill(); // THE SHARED BODY, verbatim. Same call the blue walker and the pink companion get — dark edge // disc, white keyline, hue body, two facing-aware eyes — so the only thing that separates these // four characters is the HUE they wear. (The hand-rolled disc stack that used to stand here // dropped the white keyline, which is the actor CLASS marker: without it a runner was a coloured // blob of the same family as the terrain marks.) _parkActor(bx, cx, cy, rr, col, r.fdx, r.fdy, r.wince > 0 ? 1 : 0); bx.restore(); } // ---- THE SHOULDER, ADVERTISED (요구: 밀 수 있는 조건에서 밀 수 있는 에이전트에 빛나는 처리). // y46 replaced y29's call with a PUSH: step into an adjacent body and it slides two cells. That is // a HUMAN-ONLY affordance — the oracle never plays it, so no bar can see whether the player was // ever told it exists (memory: 사람 전용 어포던스 사각지대). This rim is the telling. // BOTH PREDICATES OR NOTHING. The engine's legalAdd opens the cell only when a body is standing // there AND that body has somewhere to land, so the rim asks the engine's own two questions in the // engine's own order. Glowing on target-only would light up bodies with a wall behind them: an // invitation the rules refuse the moment it is accepted, which is worse than no invitation. // It rides #7fd7ff, this park's affordance hue — the same blue the y29 summon glow wore. Nothing on // this board wears it any more (that halo is suppressed here, its verb having been replaced), so // the colour is free and its meaning is unchanged: THIS is the thing your hand can reach. if (E._parkSiegePushTarget && st.pos[0]) { const me = st.pos[0]; bx.save(); bx.lineWidth = Math.max(1.8, cell * 0.075); for (const d of [[0, -1], [0, 1], [-1, 0], [1, 0]]) { const px = me.x + d[0], py = me.y + d[1]; if (px < 0 || py < 0 || px >= n || py >= n) continue; const kk = py * n + px; if (!E._parkSiegePushTarget(st, kk)) continue; if (E._parkSiegePushDest(st, kk) == null) continue; // nowhere to land = not pushable = no lie const rcx = x0 + (px + 0.5) * cell, rcy = y0 + (py + 0.5) * cell; bx.strokeStyle = _alpha('#7fd7ff', 0.45 + 0.45 * g); bx.beginPath(); bx.arc(rcx, rcy, cell * 0.44, 0, 7); bx.stroke(); bx.strokeStyle = _alpha('#ffffff', 0.30 + 0.35 * g); // a bright inner lip so the rim survives bx.lineWidth = Math.max(1, cell * 0.03); // on top of a body of any hue bx.beginPath(); bx.arc(rcx, rcy, cell * 0.40, 0, 7); bx.stroke(); bx.lineWidth = Math.max(1.8, cell * 0.075); } bx.restore(); } // ---- THE SHOT (v2 §8). The engine bills nothing new here: 'seen' (the walker paid a heart for // moving under the gaze) and 'sent' (the companion was caught and sent back) are the SAME two fx // y29 has always emitted, now carrying `from` = the doll's cell. So this is the drawn face of an // already-priced event — the design law, restated: the drawn danger must be the priced danger. // // ONE FLIGHT, THEN NOTHING. Two changes from the first version, both about time: // - the record set is _parkStepFx(st), not st.fx. A park st.fx is an append-only episode log, so // looping it directly re-fired every shot of the whole match on every frame. // - the position is _parkStepFrac(), which is MONOTONIC 0->1 across the beat. It used to be the // shared pulse `g`, and `g` is a cosine: the bullet flew to the body, flew back to the doll, and // did that forever. A shot is not a heartbeat; it happens once and it is over. // Four marks, because a shot the eye can follow needs an origin, a path, a moving thing and an // arrival — any one alone reads as a static decoration at some other cell. Each is keyed to its own // part of the flight: the muzzle burns out as the bullet leaves, the impact only exists after it // lands. `from` is absent on pre-v2 fx — no from, no shot. { const t = _parkStepFrac(); const land = Math.max(0, (t - 0.55) / 0.45); // 0 until the bullet is most of the way there for (const f of _parkStepFx(st)) { if ((f.k !== 'seen' && f.k !== 'sent') || f.from == null || f.x == null) continue; const sx = x0 + (f.from % n + 0.5) * cell, sy = y0 + (((f.from / n) | 0) + 0.5) * cell; const tx = x0 + (f.x + 0.5) * cell, ty = y0 + (f.y + 0.5) * cell; // WHOSE shot it was, in that body's own key. y46's 'sent' now carries a `seat` (it fires for the // two runners as well as for the companion), so the tracer is coloured by the body it lands on // rather than assumed pink — four bodies get shot on this yard and the line has to say which. const col = f.k === 'seen' ? '#ffd166' : (SEAT_COL[f.seat != null ? f.seat : 1] || SEAT_COL[1]); bx.save(); bx.lineCap = 'round'; bx.fillStyle = _alpha('#fff2c4', 0.85 * (1 - t)); // (1) the muzzle flash — brightest at fire bx.beginPath(); bx.arc(sx, sy, cell * (0.30 - 0.16 * t), 0, 7); bx.fill(); bx.strokeStyle = _alpha(col, 0.34); // (2) the tracer — thin, so the line says bx.lineWidth = Math.max(1, cell * 0.045); // WHERE FROM without hiding the yard bx.beginPath(); bx.moveTo(sx, sy); bx.lineTo(tx, ty); bx.stroke(); bx.fillStyle = '#ffffff'; // (3) the bullet, one way only bx.beginPath(); bx.arc(sx + (tx - sx) * t, sy + (ty - sy) * t, Math.max(1.5, cell * 0.07), 0, 7); bx.fill(); if (land > 0) { // (4) the impact ring, only once it lands bx.strokeStyle = _alpha('#fff2c4', 0.85 * land); bx.lineWidth = Math.max(1.5, cell * 0.07); bx.beginPath(); bx.arc(tx, ty, cell * (0.20 + 0.26 * land), 0, 7); bx.stroke(); } bx.restore(); } } } PARK_FIELD_RENDER.siege = _paintParkSiege; // the render seam's TWENTY-FIRST client // y46 POST PASS = y29's, verbatim. Board-driven reuse: the y46 board carries park.statue + // dyn.statue in y29's shapes, so the summon halo and the summon-destination dash are the same two // marks for the same two reasons. A _paintParkSiegeLive wrapper briefly stood here to overpaint the // pink companion with a bespoke face and an hp row; both are gone (see the note above SEAT_COL), and // with nothing of its own left to add the wrapper was pure indirection. Her shot beat now reads // where every other body's does — as the shared actor's eyes shutting, drawn by drawParkCompanion // itself, which is the one place in the codebase that owns her body. PARK_FIELD_RENDER_POST.siege = _paintParkStatueLive; // the post seam's THIRD client // y50 ALLEY FIELD LAYER (ZERO-TEXT). The hybrid paints as a COMPOSITE, board-driven (the bomb2 and // y46 precedents): a crate layer of its own, then the bull painter VERBATIM — the y50 board carries // park.bull + dyn.ents[0] + dyn.downed in the shapes that painter reads, so the horns, the committed // red lane, the stun star, the drag trail and the thud fx all work unchanged. Since y25's deletion // (2026-08-03) the call at the foot of this function is the ONLY live path into _paintParkBull — // deleting it silently blanks the bull on this board. What is alley-own here: // THE CRATE (dyn.alley.crate) — the push demo's box, on purpose (the y26 lesson: the resemblance // IS the trap — the demo taught "crates belong on pads"; this one belongs in a charge lane). // THE SEAL CUE — a crate standing ON the street row glows: it is the single most consequential // placement on the board (the bull can never see past it again) and must not look ordinary. // PUSH FX — the shove's one-beat flash at the crate's landing cell. function _paintParkAlley(st, x0, y0, cell) { const n = st.N, park = st.park, dyn = park.dyn; const D = dyn ? dyn.alley : null; if (!park.alley || !D) return; const g = _pulseGlow(); if (D.lastCharge && D.lastCharge.length) { bx.save(); bx.strokeStyle = _alpha('#e5432f', 0.42 + 0.34 * g); bx.lineWidth = Math.max(2, cell * 0.18); bx.lineCap = 'round'; bx.beginPath(); for (let i = 0; i < D.lastCharge.length; i++) { const key = D.lastCharge[i], px = x0 + (key % n + 0.5) * cell; const py = y0 + (((key / n) | 0) + 0.5) * cell; if (i === 0) bx.moveTo(px, py); else bx.lineTo(px, py); } bx.stroke(); bx.restore(); } if (D.crate != null) { const p = { x: x0 + (D.crate % n) * cell, y: y0 + ((D.crate / n) | 0) * cell }; const in0 = cell * 0.12; bx.save(); bx.fillStyle = '#a8814e'; bx.fillRect(p.x + in0, p.y + in0, cell - 2 * in0, cell - 2 * in0); bx.strokeStyle = 'rgba(9,11,15,0.55)'; bx.lineWidth = Math.max(1, cell * 0.06); bx.strokeRect(p.x + in0, p.y + in0, cell - 2 * in0, cell - 2 * in0); bx.strokeStyle = _alpha('#e6c88f', 0.55); bx.lineWidth = Math.max(1, cell * 0.05); bx.beginPath(); // the slats bx.moveTo(p.x + in0, p.y + in0); bx.lineTo(p.x + cell - in0, p.y + cell - in0); bx.moveTo(p.x + cell - in0, p.y + in0); bx.lineTo(p.x + in0, p.y + cell - in0); bx.stroke(); if (((D.crate / n) | 0) === 6) { // ON THE STREET — the seal, glowing bx.strokeStyle = _alpha('#ffd98a', 0.35 + 0.35 * g); bx.lineWidth = Math.max(1.5, cell * 0.08); bx.strokeRect(p.x + in0 * 0.5, p.y + in0 * 0.5, cell - in0, cell - in0); } bx.restore(); } for (const f of (st.fx || [])) { // the shove flash (tick's 'push' event) if (f.k !== 'push' || f.x == null) continue; bx.save(); bx.strokeStyle = _alpha('#e6c88f', 0.45 + 0.35 * g); bx.lineWidth = Math.max(1.5, cell * 0.08); bx.strokeRect(x0 + f.x * cell + cell * 0.10, y0 + f.y * cell + cell * 0.10, cell * 0.80, cell * 0.80); bx.restore(); } _paintParkBull(st, x0, y0, cell); } PARK_FIELD_RENDER.alley = _paintParkAlley; // the render seam's TWENTY-SECOND client // ---- y51 ESCAPE: the two glyph primitives this module's marks are built from. Kept as named // helpers because the SAME cracked heart has to appear in three different hues (the walker's own // toll, the toll the companion took FOR him, and the freeze that toll bought) and a copy per site // would let the three drift apart. They compose the shared primitives (_heartPath, _alpha) and // call no sibling PAINTER — the module law. function _parkEscapeCrackedHeart(cx, cy, s, hue, g) { bx.save(); _heartPath(bx, cx, cy, s); bx.fillStyle = _alpha(hue, 0.45 + 0.30 * g); bx.fill(); bx.strokeStyle = _alpha(hue, 0.80); bx.lineWidth = Math.max(1, s * 0.16); bx.stroke(); bx.strokeStyle = 'rgba(16,10,14,0.72)'; // THE CRACK — a jagged split down the body bx.lineWidth = Math.max(1.2, s * 0.22); bx.lineJoin = 'round'; bx.lineCap = 'round'; bx.beginPath(); bx.moveTo(cx, cy - s * 0.55); bx.lineTo(cx - s * 0.26, cy - s * 0.05); bx.lineTo(cx + s * 0.18, cy + s * 0.22); bx.lineTo(cx, cy + s * 0.80); bx.stroke(); bx.restore(); } // THE WATER'S OWN MARK — cold arcs and droplets, and never a heart. It is the visual half of the // mateHurt/mateSwept split: a body the sea carried off pays nothing, so nothing that looks like a // cost may appear on it. function _parkEscapeSplash(cx, cy, cell, g) { bx.save(); bx.strokeStyle = _alpha('#8fd4ec', 0.50 + 0.35 * g); bx.lineWidth = Math.max(1.5, cell * 0.07); bx.lineCap = 'round'; for (let i = 0; i < 2; i++) { bx.beginPath(); bx.arc(cx, cy + cell * 0.10, cell * (0.42 + 0.16 * i + 0.05 * g), Math.PI * 1.10, Math.PI * 1.90); bx.stroke(); } bx.fillStyle = _alpha('#bfe8f7', 0.50 + 0.35 * g); for (const [ox, oy] of [[-0.44, -0.34], [0, -0.52], [0.44, -0.34]]) { bx.beginPath(); bx.arc(cx + ox * cell, cy + (oy - 0.08 * g) * cell, cell * 0.045, 0, 7); bx.fill(); } bx.restore(); } // _parkEscapeFreezeByPull(beat, D, esc): WHO CRACKED HIS HEART. dyn.escape.mateStun has TWO causes // and the render must not tell them alike: a pull (a toll he took FOR the walker) and the water // (it carried him off). The board says which — parkEscapePull stamps the beat on shieldBeat and the // freeze it buys lasts exactly esc.stun beats, so a shieldBeat inside that window IS this freeze's // cause. BOTH BOUNDS ARE CHECKED, and the lower one is the point: `beat - shieldBeat < stun` alone // is also true of every NEGATIVE difference, so the day dyn.beat stops dominating shieldBeat every // freeze on the board would read warm — silently and totally, not partially. (The engine's own // shield check was caught with the same one-sided shape once already.) Lifted out of the painter so // ESCAPE-CLICK-SEAM's cut-and-run harness can cover it; it is pure arithmetic over public state. function _parkEscapeFreezeByPull(beat, D, esc) { const stun = ((esc && esc.stun) | 0) || 1; if (!D || D.shieldBeat == null || D.shieldBeat < 0) return false; return beat >= D.shieldBeat && beat - D.shieldBeat < stun; } // _parkEscapeSquint(looking, fx): does THIS doll narrow its eyes this frame? The squint is a doll // reacting to a step IT caught, so only a doll whose eyes are OPEN may wear it — ungated, the // closed-eyed doll reacts to a toll it had no part in, which throws away exactly what the two hues // exist to say: WHICH ONE bit him. A pure decision, lifted out of the painter so a gate can RUN it: // canvas code cannot be executed by this suite, but the decision inside it need not live there. function _parkEscapeSquint(looking, fx) { return !!looking && (fx || []).some(f => f.k === 'seen'); } // _parkEscapeLooks(st): whose eyes are open, straight from the ENGINE's own two predicates. The // halo is the second priced danger on this board, so its source must be the module's — `ph >= sing` // recomputed in the painter is a SECOND source for a priced rule, equal today only because // PERIOD === SING + GAZE and the module's beat is dyn.beat. Same reason as _parkEscapeSquint for // living out here: a pure read, so a gate can run it against a real board instead of trusting a // regex about which function the painter happens to name. function _parkEscapeLooks(st) { return { a: E._parkEscapeGazeA(st), b: E._parkEscapeGazeB(st) }; } // THE TWO CLOCKS GET TWO HUES. y51's whole cell is that there are TWO dolls on DIFFERENT clocks; // if both wore the y29 gaze red a player could never see which one just bit him, and "hold still // through beat 2 but walk through beat 3" would be an unlearnable rule rather than a read. const PARK_ESCAPE_HUE_A = '#e5432f'; // the far doll, ON the wall: 6 beats, looks on the last 2 const PARK_ESCAPE_HUE_B = '#ffa23a'; // the near doll, ON the yard: 3 beats, looks on the last 1 // y51 ESCAPE FIELD LAYER (ZERO-TEXT; shared by the live scene, the readout replay and the hub // thumbnail). Painted as an OVERLAY over the finished terrain so no existing board changes by a // pixel. Every mark is a pure function of the PUBLIC board + the PUBLIC dyn (C1 — never the // persona, never who is playing, never the view): // THE WATER (dyn.escape.gone) — drowned ground in flood's own idiom (a cold fill under two // breathing ripples). Sunk ground is a wall for all three domains, so it may // never read as ordinary floor. // THE WARNING LINE — THE ONE MARK HERE THAT IS NOT DECORATION: E._parkEscapeNext(st), dotted and // breathing. The ENGINE owns the timetable and its tick consults that same // function, so the drawn danger IS the priced danger and the two cannot drift // (flood's design law, restated by y46 and again here). A deadline you cannot // see is not a deadline anyone can be said to have raced. // THE DRY GROUND — park.escape.finishKey and park.escape.dryKeys, READ off the board rather // than re-derived from the offsets: the far bank the front is cut at, and the // three cells the flood structurally never takes (his station, his contract // gem, the retire seat). Without this the companion's errand looks like luck. // THE TWO DOLLS — escape.a (on the far wall) and escape.b (standing on the yard), each with // its own pip count, its own halo and its own hue. While either looks, the // yard wears THAT doll's warning rim, because the toll is one heart per STEP // while either eye is open — one heart, not two, which is why the rims share // one shape and differ only in colour. // HIS CRACKED HEART — while dyn.escape.mateStun > 0 he is frozen: he is spending the beats a pull // cost him. Drawn over his head with one tick per beat still owed, so "why // will he not move" and "when can I pull again" are both a read. // THE MARKS (st.fx) — 'seen'/'swept' spend the WALKER's heart (an eye caught his step / the water // took the ground under him). 'mateHurt' and 'mateSwept' are TWO DIFFERENT // SCENES and Task 1 separated them on purpose: mateHurt is a toll he took FOR // the walker (warm, his own magenta, a shield arc) and mateSwept is the water // carrying him off (cold, blue, and NO heart at all — his body is scenery and // pays nothing). 'pulled' is the drag itself. function _paintParkEscape(st, x0, y0, cell) { const n = st.N, park = st.park, esc = park.escape, dyn = park.dyn; const D = dyn ? dyn.escape : null; if (!esc || !D) return; const g = _pulseGlow(); const beat = dyn ? (dyn.beat | 0) : 0; // (1) THE WATER — every line the sea has already taken. for (const kk of D.gone) { const x = kk % n, y = (kk / n) | 0, gx = x0 + x * cell, gy = y0 + y * cell; bx.fillStyle = '#1d3a4d'; bx.fillRect(gx, gy, cell + 0.5, cell + 0.5); bx.save(); bx.strokeStyle = _alpha('#7fc4de', 0.20 + 0.10 * g); bx.lineWidth = Math.max(1, cell * 0.05); bx.lineCap = 'round'; for (let i = 0; i < 2; i++) { const oy = gy + cell * (0.32 + 0.3 * i) + Math.sin((x * 2 + y + i) * 1.7 + g * 2) * cell * 0.05; bx.beginPath(); bx.moveTo(gx + cell * 0.16, oy); bx.lineTo(gx + cell * 0.84, oy); bx.stroke(); } bx.restore(); } // (2) THE DRY GROUND — the ground the front can never take. The three held-out errand cells wear // stepping stones; the finish wears the far bank's own bracket. Both come from the board. bx.save(); bx.strokeStyle = _alpha('#cbb98a', 0.42); bx.lineWidth = Math.max(1, cell * 0.05); bx.lineCap = 'round'; for (const kk of (esc.dryKeys || [])) { if (D.gone.has(kk)) continue; // belt and braces: a drawn claim, checked const x = kk % n, y = (kk / n) | 0; for (let i = 0; i < 3; i++) { const sy2 = y0 + y * cell + cell * (0.30 + 0.20 * i); bx.beginPath(); bx.moveTo(x0 + x * cell + cell * (0.24 + 0.06 * (i % 2)), sy2); bx.lineTo(x0 + x * cell + cell * (0.70 - 0.06 * (i % 2)), sy2); bx.stroke(); } } { const fk = esc.finishKey, fxp = x0 + (fk % n) * cell, fyp = y0 + ((fk / n) | 0) * cell; bx.strokeStyle = _alpha('#ffd54a', 0.55 + 0.25 * g); bx.lineWidth = Math.max(2, cell * 0.07); bx.strokeRect(fxp + cell * 0.14, fyp + cell * 0.14, cell * 0.72, cell * 0.72); } bx.restore(); // (3) THE WARNING LINE — the line that drowns NEXT, straight off the engine's timetable. const next = E._parkEscapeNext(st); if (next.length) { bx.save(); bx.setLineDash([Math.max(2, cell * 0.16), Math.max(2, cell * 0.12)]); bx.strokeStyle = _alpha('#8fd4ec', 0.55 + 0.35 * g); bx.lineWidth = Math.max(1.5, cell * 0.09); bx.lineCap = 'round'; const in0 = cell * 0.13; for (const kk of next) { if (D.gone.has(kk)) continue; const x = kk % n, y = (kk / n) | 0; bx.strokeRect(x0 + x * cell + in0, y0 + y * cell + in0, cell - 2 * in0, cell - 2 * in0); } bx.setLineDash([]); bx.restore(); } // (4) THE TWO DOLLS. One local painter driven twice off the BOARD's own two clocks (esc.a / esc.b), // never off a second schedule the app keeps: a doll drawn on a clock the engine does not bill // would be a lie told in colour. WHETHER IT IS LOOKING comes in as an argument, from // _parkEscapeLooks (the engine's own two predicates) and never from `ph >= sing` recomputed here. // The pip COUNT below stays board-derived (esc.a/esc.b), which is the telegraph, not the price. // ESCAPE-CLOCKS-ALIGNED pins the two sources to the same numbers so a per-board clock cannot // desync the halo from the pips in silence. const doll = (dk, sing, gaze, hue, looking) => { const period = Math.max(1, (sing | 0) + (gaze | 0)); const ph = ((beat % period) + period) % period; const dx = dk % n, dy = (dk / n) | 0, c = (n - 1) / 2; const inx = Math.sign(c - dx), iny = Math.sign(c - dy); // from the doll into the yard const cx = x0 + (dx + 0.5) * cell, cy = y0 + (dy + 0.5) * cell; if (looking) { bx.save(); // THE RIM — the whole yard is billed bx.strokeStyle = _alpha(hue, 0.15 + 0.12 * g); bx.lineWidth = Math.max(2, cell * 0.5); bx.strokeRect(x0 + cell * 0.25, y0 + cell * 0.25, n * cell - cell * 0.5, n * cell - cell * 0.5); bx.strokeStyle = _alpha(hue, 0.45 + 0.35 * g); // THE HALO — this one's eyes are open bx.lineWidth = Math.max(1.5, cell * 0.09); bx.beginPath(); bx.arc(cx, cy, cell * (0.64 + 0.08 * g), 0, 7); bx.stroke(); bx.strokeStyle = _alpha(hue, 0.22 + 0.20 * g); bx.lineWidth = Math.max(1, cell * 0.05); bx.beginPath(); bx.arc(cx, cy, cell * (0.86 + 0.10 * g), 0, 7); bx.stroke(); bx.restore(); } // THE BODY — the same round eyed actor as the two players, facing INTO the yard while it looks // and AWAY while it sings (a per-beat step: y51 has no sub-beat glide of its own). const alen = Math.hypot(inx, iny) || 1; const tox = inx / alen, toy = iny / alen; const caught = _parkEscapeSquint(looking, st.fx); _parkActor(bx, cx, cy, cell * 0.30, looking ? hue : '#7a6a55', looking ? tox : -tox, looking ? toy : -toy, caught ? 0.6 + 0.4 * g : 0); // THE COUNT — one pip per beat of ITS OWN clock, and this is where y29's idiom had to be // RE-DERIVED rather than copied. y29 lays the pips in a ROW at the doll's feet, inside the yard, // which works because nothing stands in front of its doll. y51's finish sits ONE CELL in front of // doll A, so that row lands squarely on the goal gem and its bracket and becomes unreadable // (measured on screen before this was changed). So the pips ring the doll's own body instead: a // CLOCK FACE, from the top and clockwise, entirely inside its own cell whatever stands next to // it. The looking beats are in the doll's warning hue from the first frame, so "how long have I // got" is a read and not a memory; two rings of different LENGTH (6 pips vs 3) is also how the // two clocks stay told apart while both are singing and neither halo is lit. const rad = cell * 0.47; for (let i = 0; i < period; i++) { const th = -Math.PI / 2 + (i / period) * Math.PI * 2; const ppx = cx + Math.cos(th) * rad, ppy = cy + Math.sin(th) * rad; const lk = i >= sing; bx.beginPath(); bx.arc(ppx, ppy, cell * (i === ph ? 0.085 : 0.055), 0, 7); bx.fillStyle = i === ph ? _alpha(lk ? hue : '#eae0c8', 0.90 + 0.10 * g) : _alpha(lk ? hue : '#cfc6ad', 0.55); bx.fill(); bx.strokeStyle = _alpha('#12161c', 0.75); bx.lineWidth = Math.max(1, cell * 0.02); bx.stroke(); if (i === ph) { bx.strokeStyle = _alpha('#ffffff', 0.75); bx.lineWidth = Math.max(1, cell * 0.03); bx.beginPath(); bx.arc(ppx, ppy, cell * 0.125, 0, 7); bx.stroke(); } } }; const looks = _parkEscapeLooks(st); doll(esc.a.key, esc.a.sing, esc.a.gaze, PARK_ESCAPE_HUE_A, looks.a); doll(esc.b.key, esc.b.sing, esc.b.gaze, PARK_ESCAPE_HUE_B, looks.b); // (5) HIS CRACKED HEART — dyn.escape.mateStun > 0: he cannot be moved and cannot move himself, // and this is the only mark that says why. Drawn over his head (clear of his body, which the actor // pass lays on top of this layer), with one tick per beat still owed. const mate = st.pos && st.pos[1]; // WHO CRACKED IT — warm magenta if a pull is paying for this freeze, cold blue if the sea is. The // arithmetic and the reason both live in _parkEscapeFreezeByPull, which a gate covers. (One // ambiguous case, unchanged: if the sea re-freezes him inside a pull's span this reads warm for // the remaining beat — and the cold splash on the sweep beat itself says the truth anyway.) const freezeHue = _parkEscapeFreezeByPull(beat, D, esc) ? PARK_HUES.companion : '#7fc4de'; if (mate && D.mateStun > 0) { const hx = x0 + (mate.x + 0.5) * cell; const hy = y0 + (mate.y + 0.5) * cell - cell * (0.60 + 0.05 * g); _parkEscapeCrackedHeart(hx, hy, cell * 0.18, freezeHue, g); bx.save(); // one tick per beat still owed, BESIDE the bx.fillStyle = _alpha(freezeHue, 0.85); // heart: under it they land on his halo bx.strokeStyle = _alpha('#12161c', 0.7); bx.lineWidth = Math.max(1, cell * 0.02); for (let i = 0; i < D.mateStun; i++) { bx.beginPath(); bx.arc(hx + cell * (0.26 + 0.13 * i), hy - cell * 0.10, cell * 0.045, 0, 7); bx.fill(); bx.stroke(); } bx.restore(); } // (6) THE MARKS — one per confessed event, drained with the scene's fx. for (const f of (st.fx || [])) { if (f.x == null) continue; const mx = x0 + (f.x + 0.5) * cell, my = y0 + (f.y + 0.5) * cell; if (f.k === 'seen') { // an eye was open and he stepped: HIS heart, warm red _parkEscapeCrackedHeart(mx, my - cell * 0.10 - cell * 0.06 * g, cell * 0.20, '#ff4a3a', g); } else if (f.k === 'swept') { // the sea took the ground under HIM: a heart, and a shove _parkEscapeSplash(mx, my, cell, g); _parkEscapeCrackedHeart(mx, my - cell * 0.38, cell * 0.15, '#ff4a3a', g); } else if (f.k === 'mateHurt') { // A TOLL HE TOOK *FOR* THE WALKER — WARM: a magenta // SHIELD, held between him and the eye that was billing, plus a burst at his feet. The heart // of it is the cracked heart (5) already over his head, which the same pull turned magenta; // drawing a second one here would only stack the same statement twice. bx.save(); bx.strokeStyle = _alpha(PARK_HUES.companion, 0.60 + 0.35 * g); bx.lineWidth = Math.max(2, cell * 0.11); bx.lineCap = 'round'; bx.beginPath(); bx.arc(mx, my, cell * 0.44, Math.PI * 1.08, Math.PI * 1.92); bx.stroke(); bx.lineWidth = Math.max(1, cell * 0.05); bx.beginPath(); bx.arc(mx, my, cell * (0.56 + 0.08 * g), Math.PI * 1.20, Math.PI * 1.80); bx.stroke(); bx.restore(); } else if (f.k === 'mateSwept') { // THE WATER CARRIED HIM OFF — COLD, and no shield and // no magenta anywhere: his body is scenery to the sea and it takes no toll from anyone. The // freeze glyph (5) is blue for the same reason. Nothing here may look like a price paid. _parkEscapeSplash(mx, my, cell, g); bx.save(); bx.strokeStyle = _alpha('#8fd4ec', 0.45 + 0.35 * g); bx.setLineDash([Math.max(2, cell * 0.12), Math.max(2, cell * 0.10)]); bx.lineWidth = Math.max(1.5, cell * 0.06); bx.beginPath(); bx.arc(mx, my, cell * (0.56 + 0.06 * g), 0, 7); bx.stroke(); bx.setLineDash([]); bx.restore(); } else if (f.k === 'pulled') { // THE DRAG — he arrived here, this instant, by a hand bx.save(); bx.strokeStyle = _alpha('#7fd7ff', 0.55 + 0.35 * g); bx.lineWidth = Math.max(1.5, cell * 0.08); bx.lineCap = 'round'; bx.beginPath(); bx.arc(mx, my, cell * 0.46, 0, 7); bx.stroke(); const w = st.pos && st.pos[0]; if (w) { bx.setLineDash([Math.max(2, cell * 0.14), Math.max(2, cell * 0.10)]); bx.lineWidth = Math.max(1, cell * 0.05); bx.beginPath(); bx.moveTo(x0 + (w.x + 0.5) * cell, y0 + (w.y + 0.5) * cell); bx.lineTo(mx, my); bx.stroke(); bx.setLineDash([]); } bx.restore(); } } } PARK_FIELD_RENDER.escape = _paintParkEscape; // a render seam client (cf. PARK_FIELD_MECHS.escape) // y51 THE PULL AFFORDANCE (live play only): an affordance, not a rule — the demo, the readout and // the hub thumbnail never advertise a button the oracle does not have (y24's audience discipline, // reused for input exactly as y29's summon glow reuses it). Drawn on the POST seam so the ring // rides OVER the companion's body. Its three conditions are the ENGINE's own three refusals // (freeze, reach, destination), consulted rather than re-spelt: an affordance that lights on a // click the engine will refuse is worse than no affordance at all. function _paintParkEscapeLive(st, x0, y0, cell) { if (G.parkView !== 'play') return; const D = st.park.dyn && st.park.dyn.escape; if (!st.park.escape || !D) return; if (D.mateStun !== 0) return; if (E._parkEscapeNear(st) > E.PARK_ESCAPE_REACH) return; const dest = E._parkEscapePullDest(st); if (dest < 0) return; const n = st.N; const pulse = 0.55 + 0.30 * Math.sin(Date.now() / 220); const mx = x0 + (st.pos[1].x + 0.5) * cell, my = y0 + (st.pos[1].y + 0.5) * cell; const dxp = x0 + (dest % n) * cell, dyp = y0 + (((dest / n) | 0)) * cell; bx.save(); bx.strokeStyle = _alpha('#7fd7ff', pulse); // the ring: HE is the button bx.lineWidth = Math.max(2, cell * 0.10); bx.beginPath(); bx.arc(mx, my, cell * 0.55, 0, Math.PI * 2); bx.stroke(); bx.strokeStyle = _alpha('#7fd7ff', 0.5); // and WHERE he would land, before you click bx.setLineDash([4, 3]); bx.strokeRect(dxp + 2, dyp + 2, cell - 4, cell - 4); bx.lineWidth = Math.max(1, cell * 0.04); // the hand between the two bx.beginPath(); bx.moveTo(mx, my); bx.lineTo(dxp + cell * 0.5, dyp + cell * 0.5); bx.stroke(); bx.setLineDash([]); bx.restore(); } PARK_FIELD_RENDER_POST.escape = _paintParkEscapeLive; // a post seam client (cf. PARK_FIELD_MECHS.escape) /* y53 BEACON FIELD LAYER (ZERO-TEXT). The marks, and why each one exists — the timetable is the ENGINE's and this layer only draws it, so seen danger and priced danger can never drift: ① the LIT QUADRANT, tinted warm, brighter on the stare beat than on the head-turn. The tint IS the ruling: these are exactly the cells _parkBeaconCone returns, and a step out of one on the stare costs a heart. The four dark diagonal spokes are simply where the tint is not. ② the NEXT quadrant, outlined in a breathing dash (flood's dotted-preview idiom) — the beam's timetable made visible one span ahead, which is what makes a plan possible at all. ③ the TOWER and its doll: the shared actor glyph with a facing wedge, squinting on the stare. ④ the SPAN PIPS at the tower's foot, one per beat of the span, the billing beat already in the warning colour before it arrives (statue's pip row, re-derived). ⑤ the companion's marks: a cracked heart where the light took him, a warm ring where a friend stood at his shoulder and it passed him by. No POST layer: this board has no human-only affordance to advertise — every verb is one the oracle takes too. */ const PARK_BEACON_LIT = '#ffd27a'; // the lamp's warm cone. Distinct from the crate browns, the const PARK_BEACON_DOLL = '#ff8f52'; // gem gold (angular, never a fill) and the bull's committed const PARK_BEACON_HOLD_HUE = '#7fd7ff'; // red lane: nothing else on a park board fills with warm amber. function _paintParkBeacon(st, x0, y0, cell) { const B = st.park && st.park.beacon, dyn = st.park && st.park.dyn; const D = dyn ? dyn.beacon : null; if (!B || !D) return; // other boards: no-op const n = st.N, g = _pulseGlow(); const face = E._parkBeaconFacing(st); const staring = E._parkBeaconStaring(st); const cone = E._parkBeaconCone(st, face); const next = E._parkBeaconCone(st, E._parkBeaconNextFace(st)); // ① the lit quadrant bx.save(); bx.fillStyle = _alpha(PARK_BEACON_LIT, staring ? 0.30 + 0.10 * g : 0.14); for (const kk of cone) { const x = kk % n, y = (kk / n) | 0; bx.fillRect(x0 + x * cell, y0 + y * cell, cell + 0.5, cell + 0.5); } bx.restore(); // ② the quadrant the beam turns to next bx.save(); bx.setLineDash([Math.max(2, cell * 0.16), Math.max(2, cell * 0.12)]); bx.strokeStyle = _alpha(PARK_BEACON_LIT, 0.40 + 0.30 * g); bx.lineWidth = Math.max(1.2, cell * 0.07); bx.lineCap = 'round'; const in0 = cell * 0.15; for (const kk of next) { if (cone.has(kk)) continue; const x = kk % n, y = (kk / n) | 0; bx.strokeRect(x0 + x * cell + in0, y0 + y * cell + in0, cell - 2 * in0, cell - 2 * in0); } bx.setLineDash([]); bx.restore(); // ③ the tower and the doll on it const tx = B.towerKey % n, ty = (B.towerKey / n) | 0; const cx = x0 + tx * cell + cell / 2, cy = y0 + ty * cell + cell / 2; const dirs = [{ x: 0, y: -1 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: -1, y: 0 }]; const d = dirs[face]; bx.save(); bx.beginPath(); bx.arc(cx, cy, cell * 0.44, 0, 7); bx.fillStyle = _alpha('#3b3630', 0.9); bx.fill(); bx.strokeStyle = _alpha(PARK_BEACON_LIT, 0.35 + 0.25 * g); bx.lineWidth = Math.max(1, cell * 0.05); bx.stroke(); bx.restore(); _parkActor(bx, cx, cy, cell * 0.26, PARK_BEACON_DOLL, d.x, d.y, staring ? 1 : 0); // the facing wedge — which way the lamp points, readable at thumbnail scale bx.save(); bx.fillStyle = _alpha(PARK_BEACON_LIT, staring ? 0.95 : 0.55); bx.beginPath(); bx.moveTo(cx + d.x * cell * 0.46, cy + d.y * cell * 0.46); bx.lineTo(cx + d.x * cell * 0.20 - d.y * cell * 0.16, cy + d.y * cell * 0.20 + d.x * cell * 0.16); bx.lineTo(cx + d.x * cell * 0.20 + d.y * cell * 0.16, cy + d.y * cell * 0.20 - d.x * cell * 0.16); bx.closePath(); bx.fill(); bx.restore(); // ④ the span pips, at the tower's foot, perpendicular to the beam const span = E.PARK_BEACON_SPAN, at = E.PARK_BEACON_STARE_AT; const ph = (dyn.beat | 0) % span; const px = -d.y, py = d.x; bx.save(); const step = cell * 0.26, base = -(span - 1) / 2; for (let i = 0; i < span; i++) { const off = (base + i) * step; const ppx = cx - d.x * cell * 0.66 + px * off, ppy = cy - d.y * cell * 0.66 + py * off; const bills = i === at; bx.beginPath(); bx.arc(ppx, ppy, cell * (i === ph ? 0.10 : 0.068), 0, 7); bx.fillStyle = i === ph ? _alpha(bills ? '#ff6a4a' : '#eae0c8', 0.85 + 0.15 * g) : _alpha(bills ? '#e5432f' : '#9a927f', 0.42); bx.fill(); } bx.restore(); // ⑤ the companion's ledger — the shelter that worked, and the one time it did not const co = st.pos[1]; if (co && D.mateStun > 0) { drawHeartCrack(x0 + co.x * cell + cell / 2, y0 + co.y * cell + cell * 0.22, cell * 0.17); } for (const f of (st.fx || [])) { if (f.k === 'seen') { drawHeartCrack(x0 + f.x * cell + cell / 2, y0 + f.y * cell + cell * 0.22, cell * 0.19); } else if (f.k === 'sent') { bx.save(); bx.strokeStyle = _alpha('#e5432f', 0.8); bx.lineWidth = Math.max(1.5, cell * 0.07); bx.beginPath(); bx.arc(x0 + f.x * cell + cell / 2, y0 + f.y * cell + cell / 2, cell * 0.40, 0, 7); bx.stroke(); bx.restore(); } } if (D.holds && D.holds.length && co && st.pos[0]) { const last = D.holds[D.holds.length - 1]; if (last && (dyn.beat | 0) - last.beat <= 1) { bx.save(); bx.strokeStyle = _alpha(PARK_BEACON_HOLD_HUE, 0.55 + 0.35 * g); bx.lineWidth = Math.max(1.5, cell * 0.08); bx.beginPath(); bx.arc(x0 + co.x * cell + cell / 2, y0 + co.y * cell + cell / 2, cell * 0.42, 0, 7); bx.stroke(); bx.beginPath(); bx.moveTo(x0 + co.x * cell + cell / 2, y0 + co.y * cell + cell / 2); bx.lineTo(x0 + st.pos[0].x * cell + cell / 2, y0 + st.pos[0].y * cell + cell / 2); bx.stroke(); bx.restore(); } } } PARK_FIELD_RENDER.beacon = _paintParkBeacon; // the render seam's TWENTY-THIRD client /* y52 BURST FIELD LAYER (ZERO-TEXT). The marks, and why each exists — the timetable belongs to the ENGINE and this layer only draws it (flood's law), so seen danger and priced danger never drift: ① the BALLOONS: a taut water sphere with a highlight, on a cell that is a wall. ② the FUSE PIPS under each one — one pip per beat left, the last one already in the warning colour. This is the whole reason the yard is fair: the countdown is public. ③ the CROSS ABOUT TO BURST, outlined in a breathing dash one beat ahead (the dotted-preview idiom every scheduled hazard in this park uses). ④ the BURSTING CROSS on the beat itself, a bright flash that drains with st.fx. ⑤ the BUBBLE around the companion: a shimmering ring while he is held, a splash when a body pops it. A bubble is drawn OUTSIDE the walls' vocabulary on purpose — it is not terrain, it is a person who cannot move. ⑥ costs: a cracked heart where the water caught the walker, a cool splash where it caught him (his is never a heart — the blue reads as "not a heart channel", escape's rule restated). */ const PARK_BURST_SKIN = '#5ec8e8'; // balloon water: cool, distinct from the beacon's warm amber const PARK_BURST_SPLASH = '#bfe9f7'; function _paintParkBurst(st, x0, y0, cell) { const B = st.park && st.park.burst, dyn = st.park && st.park.dyn; const D = dyn ? dyn.burst : null; if (!B || !D) return; // other boards: no-op const n = st.N, g = _pulseGlow(); const beat = dyn.beat | 0; const hotNow = E._parkBurstHot(st, beat), hotNext = E._parkBurstHot(st, beat + 1); // ④ the cross that is bursting on this beat if (hotNow.size) { bx.save(); bx.fillStyle = _alpha(PARK_BURST_SPLASH, 0.34 + 0.16 * g); for (const k of hotNow) bx.fillRect(x0 + (k % n) * cell, y0 + ((k / n) | 0) * cell, cell + 0.5, cell + 0.5); bx.restore(); } // ③ the cross that bursts NEXT if (hotNext.size) { bx.save(); bx.setLineDash([Math.max(2, cell * 0.16), Math.max(2, cell * 0.12)]); bx.strokeStyle = _alpha(PARK_BURST_SKIN, 0.45 + 0.35 * g); bx.lineWidth = Math.max(1.2, cell * 0.07); bx.lineCap = 'round'; const in0 = cell * 0.15; for (const k of hotNext) { if (hotNow.has(k)) continue; bx.strokeRect(x0 + (k % n) * cell + in0, y0 + ((k / n) | 0) * cell + in0, cell - 2 * in0, cell - 2 * in0); } bx.setLineDash([]); bx.restore(); } // ① + ② the balloons and their fuses for (const o of B.balloons) { const cx = x0 + o.x * cell + cell / 2, cy = y0 + o.y * cell + cell / 2; const fuse = E._parkBurstFuse(st, o); bx.save(); bx.beginPath(); bx.arc(cx, cy, cell * 0.34, 0, 7); bx.fillStyle = _alpha(PARK_BURST_SKIN, fuse === 0 ? 0.95 : 0.72 + 0.12 * g); bx.fill(); bx.strokeStyle = _alpha('#14161c', 0.55); bx.lineWidth = Math.max(1, cell * 0.04); bx.stroke(); bx.beginPath(); bx.arc(cx - cell * 0.11, cy - cell * 0.12, cell * 0.08, 0, 7); bx.fillStyle = _alpha('#ffffff', 0.5); bx.fill(); bx.restore(); // the fuse row, under the balloon bx.save(); const per = E.PARK_BURST_PERIOD, step = cell * 0.15, base = -(per - 1) / 2; for (let i = 0; i < per; i++) { const px = cx + (base + i) * step, py = cy + cell * 0.42; const left = i < (fuse === 0 ? per : fuse); bx.beginPath(); bx.arc(px, py, cell * 0.045, 0, 7); bx.fillStyle = fuse === 0 ? _alpha('#ff6a4a', 0.9) : _alpha(left ? PARK_BURST_SKIN : '#5a5a5a', left ? (fuse <= 1 ? 0.95 : 0.7) : 0.3); bx.fill(); } bx.restore(); } // ⑤ the bubble const co = st.pos[1]; if (co && D.bubbled) { const cx = x0 + co.x * cell + cell / 2, cy = y0 + co.y * cell + cell / 2; bx.save(); bx.strokeStyle = _alpha(PARK_BURST_SPLASH, 0.65 + 0.3 * g); bx.lineWidth = Math.max(1.5, cell * 0.08); bx.beginPath(); bx.arc(cx, cy, cell * 0.42, 0, 7); bx.stroke(); bx.strokeStyle = _alpha('#ffffff', 0.45); bx.lineWidth = Math.max(1, cell * 0.04); bx.beginPath(); bx.arc(cx - cell * 0.12, cy - cell * 0.12, cell * 0.14, 2.6, 4.6); bx.stroke(); bx.restore(); } // ⑥ the ledger for (const f of (st.fx || [])) { const cx = x0 + f.x * cell + cell / 2, cy = y0 + f.y * cell + cell / 2; if (f.k === 'singed') { drawHeartCrack(cx, cy - cell * 0.28, cell * 0.19); } else if (f.k === 'soaked' || f.k === 'pop') { bx.save(); bx.strokeStyle = _alpha(PARK_BURST_SPLASH, 0.85); bx.lineWidth = Math.max(1.5, cell * 0.07); bx.lineCap = 'round'; for (let i = 0; i < 6; i++) { const a = i * 1.047; bx.beginPath(); bx.moveTo(cx + Math.cos(a) * cell * 0.18, cy + Math.sin(a) * cell * 0.18); bx.lineTo(cx + Math.cos(a) * cell * 0.40, cy + Math.sin(a) * cell * 0.40); bx.stroke(); } bx.restore(); } else if (f.k === 'burst') { bx.save(); bx.strokeStyle = _alpha('#ffffff', 0.75); bx.lineWidth = Math.max(1.5, cell * 0.09); bx.beginPath(); bx.arc(cx, cy, cell * 0.45, 0, 7); bx.stroke(); bx.restore(); } } } PARK_FIELD_RENDER.burst = _paintParkBurst; // the render seam's TWENTY-FOURTH client /* y54 SHIFTER FIELD LAYER (ZERO-TEXT). The marks: ① the two wall rows, drawn as solid slabs so the maze reads at a glance ② the DOORWAYS as bright gaps in them ③ the doorways the rows are about to slide to, in a breathing dash (the timetable is the ENGINE's — this only draws it) ④ a pinched heart where a closing doorway caught the walker ⑤ a cool arrow where the moving wall shoved a body aside, which costs nobody a heart ⑥ a warm ring where a friend walked him through. */ const PARK_SHIFTER_SLAB = '#6b5f52'; const PARK_SHIFTER_DOOR = '#ffe6a8'; // y16 CARRY FIELD LAYER (ZERO-TEXT; shared by the live scene and the hub thumbnail). Painted as an // OVERLAY over the finished terrain so no existing board changes by a pixel. Every mark is a pure // function of the PUBLIC board + the runtime dyn (C1 — never the persona): // GREENHOUSE TONE (R2) — the carry family's own surface: a warm glazed wash over the whole plot, so // a carry tile is recognisable at a glance in the picker WITHOUT touching an // ANCHOR glyph (gems, companion, ♥ keep their reserved vocabulary exactly). // THE STONE (◍) — a grey cobble on the ground while it lies there; once PICKED UP it is gone // from the board and rides in the walker's hand instead (a ring at his feet). // One stone, and you can SEE that there is only one. // THE THREE SITES — each drawn as what it IS, and each drawn LIT (dashed, breathing) exactly // while it is still spendable, because a choice you cannot see is not a // choice: the STREAM cell you could bridge, the broken PLANK you could cap, // the glass PANE you could smash. The moment the stone is spent, the two you // did NOT take go dark and stay dark — irreversible, and legible as such. // THE SPENT SITE — the ford reads as a stepping stone in the water, the mended plank as // promenade, the smashed pane as an open gap with its shards on the sill. // THE SCREE — the pane's fallout apron. It is a deep cell and the base pass already // tints it hazard: the glass grit says WHY the shortcut costs a heart. function _paintParkCarry(st, x0, y0, cell) { const n = st.N, park = st.park, cy = park.carry, dyn = park.dyn; const held = !!(dyn && dyn.held), used = dyn ? dyn.used : null; const g = _pulseGlow(); const at = (kk) => ({ x: x0 + (kk % n) * cell, y: y0 + (((kk / n) | 0)) * cell }); // R2 — the family's surface tone (a glazed, noon-warm wash; anchors are never touched) bx.save(); bx.fillStyle = 'rgba(214,196,140,0.07)'; bx.fillRect(x0, y0, n * cell, n * cell); bx.restore(); const water = (kk, forded) => { // the stream const p = at(kk), x = kk % n, y = (kk / n) | 0; bx.fillStyle = '#1d3a4d'; bx.fillRect(p.x, p.y, cell + 0.5, cell + 0.5); bx.save(); bx.strokeStyle = _alpha('#7fc4de', 0.20 + 0.10 * g); bx.lineWidth = Math.max(1, cell * 0.05); bx.lineCap = 'round'; for (let i = 0; i < 2; i++) { const oy = p.y + cell * (0.32 + 0.3 * i) + Math.sin((x * 2 + y + i) * 1.7 + g * 2) * cell * 0.05; bx.beginPath(); bx.moveTo(p.x + cell * 0.16, oy); bx.lineTo(p.x + cell * 0.84, oy); bx.stroke(); } if (forded) { // the stone you gave him: a slab in the water const in0 = cell * 0.14; bx.fillStyle = '#8b8880'; bx.fillRect(p.x + in0, p.y + in0, cell - 2 * in0, cell - 2 * in0); bx.strokeStyle = 'rgba(9,11,15,0.45)'; bx.lineWidth = Math.max(1, cell * 0.05); bx.strokeRect(p.x + in0, p.y + in0, cell - 2 * in0, cell - 2 * in0); } bx.restore(); }; for (const kk of cy.water) water(kk, used === 'ford' && kk === cy.fordKey); // THE BROKEN PLANK — a hole in the promenade until it is capped, then promenade again. { const p = at(cy.pitKey); bx.save(); if (used === 'cover') { bx.fillStyle = '#8b8880'; // the capstone, seated flush bx.fillRect(p.x + cell * 0.06, p.y + cell * 0.06, cell * 0.88, cell * 0.88); bx.strokeStyle = 'rgba(9,11,15,0.4)'; bx.lineWidth = Math.max(1, cell * 0.05); bx.strokeRect(p.x + cell * 0.06, p.y + cell * 0.06, cell * 0.88, cell * 0.88); } else { bx.fillStyle = '#12151b'; // the hole bx.fillRect(p.x, p.y, cell + 0.5, cell + 0.5); bx.strokeStyle = 'rgba(140,120,90,0.55)'; bx.lineWidth = Math.max(1, cell * 0.07); bx.lineCap = 'round'; // the splintered plank ends for (const fy of [0.22, 0.78]) { bx.beginPath(); bx.moveTo(p.x + cell * 0.08, p.y + cell * fy); bx.lineTo(p.x + cell * 0.92, p.y + cell * fy); bx.stroke(); } } bx.restore(); } // THE GLASS PANE — a lit sheet in the hedge until it is smashed, then an open gap with shards. { const p = at(cy.glassKey); bx.save(); if (used === 'break') { bx.strokeStyle = _alpha('#cfe8f3', 0.5); bx.lineWidth = Math.max(1, cell * 0.06); bx.lineCap = 'round'; // the shards left standing in the frame for (const sx of [0.18, 0.5, 0.82]) { bx.beginPath(); bx.moveTo(p.x + cell * sx, p.y); bx.lineTo(p.x + cell * (sx + 0.06), p.y + cell * 0.22); bx.stroke(); } } else { bx.fillStyle = _alpha('#9fd8e8', 0.30); bx.fillRect(p.x + cell * 0.04, p.y + cell * 0.04, cell * 0.92, cell * 0.92); bx.strokeStyle = _alpha('#dff2fa', 0.55); bx.lineWidth = Math.max(1, cell * 0.05); bx.strokeRect(p.x + cell * 0.04, p.y + cell * 0.04, cell * 0.92, cell * 0.92); bx.beginPath(); // the pane's highlight bx.moveTo(p.x + cell * 0.18, p.y + cell * 0.82); bx.lineTo(p.x + cell * 0.82, p.y + cell * 0.18); bx.stroke(); } bx.restore(); } // THE SCREE — glass grit on the pane's apron (a deep cell: the base pass already tinted it hazard) { const p = at(cy.screeKey); bx.save(); bx.strokeStyle = _alpha('#cfe8f3', 0.34); bx.lineWidth = Math.max(1, cell * 0.04); for (const [sx, sy] of [[0.24, 0.3], [0.62, 0.24], [0.4, 0.6], [0.74, 0.68], [0.28, 0.78]]) { bx.beginPath(); bx.moveTo(p.x + cell * sx, p.y + cell * sy); bx.lineTo(p.x + cell * (sx + 0.1), p.y + cell * (sy + 0.08)); bx.stroke(); } bx.restore(); } // THE THREE LIVE SITES — dashed and breathing while the stone is still spendable, dark forever after. // This is the mark that is NOT decoration: the whole cell is "one stone, three places", and a place // the player cannot SEE is a place he cannot be said to have chosen against. if (!used) { bx.save(); bx.setLineDash([Math.max(2, cell * 0.16), Math.max(2, cell * 0.12)]); bx.strokeStyle = _alpha(held ? '#f2d98a' : '#9aa6b2', (held ? 0.60 : 0.28) + 0.30 * g); bx.lineWidth = Math.max(1.5, cell * 0.09); const in0 = cell * 0.1; for (const kk of [cy.fordKey, cy.pitKey, cy.glassKey]) { const p = at(kk); bx.strokeRect(p.x + in0, p.y + in0, cell - 2 * in0, cell - 2 * in0); } bx.setLineDash([]); bx.restore(); } // THE STONE — on the ground, or in his hand. There is exactly one, and you can see that. if (!held && !used) { const p = at(cy.stoneKey); bx.save(); bx.fillStyle = '#8b8880'; bx.beginPath(); bx.arc(p.x + cell / 2, p.y + cell / 2, cell * 0.26, 0, 7); bx.fill(); bx.strokeStyle = 'rgba(9,11,15,0.5)'; bx.lineWidth = Math.max(1, cell * 0.05); bx.beginPath(); bx.arc(p.x + cell / 2, p.y + cell / 2, cell * 0.26, 0, 7); bx.stroke(); bx.strokeStyle = _alpha('#e7ecf2', 0.35); bx.lineWidth = Math.max(1, cell * 0.035); bx.beginPath(); bx.arc(p.x + cell / 2, p.y + cell / 2, cell * 0.13, 2.4, 4.6); bx.stroke(); bx.restore(); } else if (held) { const px = x0 + (st.pos[0].x + 0.5) * cell, py = y0 + (st.pos[0].y + 0.5) * cell; bx.save(); // he is carrying it: a cobble at his feet bx.strokeStyle = _alpha('#cfd6dd', 0.5 + 0.3 * g); bx.lineWidth = Math.max(1.5, cell * 0.07); bx.beginPath(); bx.arc(px, py, cell * 0.36, 0, 7); bx.stroke(); bx.fillStyle = _alpha('#8b8880', 0.9); bx.beginPath(); bx.arc(px + cell * 0.26, py + cell * 0.26, cell * 0.12, 0, 7); bx.fill(); bx.restore(); } } PARK_FIELD_RENDER.carry = _paintParkCarry; // the render seam's SEVENTH client (cf. PARK_FIELD_MECHS.carry) // y26 LEDGE FIELD LAYER (ZERO-TEXT; shared by the live scene and the hub thumbnail). The cell is // "down is free, up is not", and a player who cannot SEE which way is down cannot be said to have // chosen to jump. So the whole layer exists to make ONE fact legible without a word of text: // THE TERRACES — the upper half is lit, the lower half sits in the cliff's shade. The eye reads // height before it reads anything else, and the shading is the only cue that // says which of the two identical-looking halves is "up". // THE SKIRT BAND — the cliff FACE, drawn as a hard bright lip along its top edge and hatched rock // below. The lip is on the UPPER side on purpose: that is the brink you step off. // THE RAMP — the gap in the band, drawn as a slope with tread lines instead of a face. It is // the always-open way (the soft-lock law made visible), so it must never read as // part of the wall. // THE STAIR — a fallen crate that filled a skirt cell. Drawn as real treads, because it is the // one place the band is two-way, and that is the whole payoff of the crate. // THE CRATE — the same box sprite the push demo uses. THIS IS THE INCONGRUENCE TRAP AND IT IS // DELIBERATE: in the demo a crate is a thing you shove onto a pad, here it is a // thing that is only worth anything once you have thrown it off a cliff. Same // picture, opposite use. It must LOOK like the demo's box or the trap does not // exist. (cf. the y22 crate-overlap note — the resemblance is the point.) // THE HOPS — a scuff on each cell he has dropped into. The confession, drawn. function _paintParkLedge(st, x0, y0, cell) { const n = st.N, park = st.park, L = park.ledge, dyn = park.dyn; const D = dyn && dyn.ledge; if (!L || !D) return; const g = _pulseGlow(); const at = (kk) => ({ x: x0 + (kk % n) * cell, y: y0 + (((kk / n) | 0)) * cell }); // R2 — the family's surface tone, then the HEIGHT wash: lower terrace darker, upper lifted. bx.save(); bx.fillStyle = 'rgba(214,196,140,0.06)'; bx.fillRect(x0, y0, n * cell, n * cell); bx.fillStyle = 'rgba(255,244,214,0.05)'; // sunlit top bx.fillRect(x0, y0, n * cell, L.hy * cell); bx.fillStyle = 'rgba(12,16,26,0.20)'; // the shade the cliff throws bx.fillRect(x0, y0 + (L.hy + 1) * cell, n * cell, (n - L.hy - 1) * cell); bx.restore(); // THE BAND — rock face under a bright brink line. for (const kk of L.skirt) { const p = at(kk); if (D.stairs.has(kk)) continue; // a filled cell is a stair, painted below bx.save(); bx.fillStyle = 'rgba(44,38,34,0.55)'; bx.fillRect(p.x, p.y, cell + 0.5, cell + 0.5); bx.strokeStyle = _alpha('#2a2622', 0.5); bx.lineWidth = Math.max(1, cell * 0.04); for (let i = 0; i < 3; i++) { // hatched rock const oy = p.y + cell * (0.26 + 0.24 * i); bx.beginPath(); bx.moveTo(p.x + cell * 0.1, oy); bx.lineTo(p.x + cell * 0.9, oy - cell * 0.06); bx.stroke(); } bx.strokeStyle = _alpha('#f0e2bc', 0.75); bx.lineWidth = Math.max(1.5, cell * 0.09); bx.beginPath(); // THE BRINK — the edge you step off bx.moveTo(p.x, p.y + cell * 0.045); bx.lineTo(p.x + cell + 0.5, p.y + cell * 0.045); bx.stroke(); bx.restore(); } // THE RAMP — a slope, not a wall. Tread lines run ACROSS it (the way you walk), and it carries the // upper terrace's light so it reads as continuous ground rather than a hole in the cliff. for (const kk of L.rampKeys) { const p = at(kk); bx.save(); bx.fillStyle = 'rgba(206,188,140,0.20)'; bx.fillRect(p.x, p.y, cell + 0.5, cell + 0.5); bx.strokeStyle = _alpha('#cdbb8e', 0.45); bx.lineWidth = Math.max(1, cell * 0.05); for (let i = 0; i < 3; i++) { const ox = p.x + cell * (0.24 + 0.26 * i); bx.beginPath(); bx.moveTo(ox, p.y + cell * 0.12); bx.lineTo(ox, p.y + cell * 0.88); bx.stroke(); } bx.restore(); } // THE HOP SCUFFS — where he has already gone over. Drawn under the stair so a stair built on top of // an old landing still reads as a stair. for (const h of D.hops) { const p = at(h.key); bx.save(); bx.strokeStyle = _alpha('#e8d9b0', 0.35); bx.lineWidth = Math.max(1, cell * 0.05); for (const [sx, sy] of [[0.3, 0.44], [0.52, 0.6], [0.66, 0.38]]) { bx.beginPath(); bx.moveTo(p.x + cell * sx, p.y + cell * sy); bx.lineTo(p.x + cell * (sx + 0.12), p.y + cell * (sy + 0.1)); bx.stroke(); } bx.restore(); } // THE STAIR — treads cut into the face. The one two-way cell, and it should look walkable. for (const kk of D.stairs) { const p = at(kk); bx.save(); bx.fillStyle = 'rgba(150,126,92,0.85)'; bx.fillRect(p.x, p.y, cell + 0.5, cell + 0.5); bx.strokeStyle = _alpha('#f4e6c2', 0.55); bx.lineWidth = Math.max(1.5, cell * 0.07); for (let i = 0; i < 3; i++) { const oy = p.y + cell * (0.24 + 0.26 * i); bx.beginPath(); bx.moveTo(p.x + cell * 0.12, oy); bx.lineTo(p.x + cell * 0.88, oy); bx.stroke(); } bx.strokeStyle = _alpha('#f4e6c2', 0.30); bx.lineWidth = Math.max(1, cell * 0.05); bx.beginPath(); // a rail, so it reads as a way UP bx.moveTo(p.x + cell * 0.12, p.y + cell * 0.86); bx.lineTo(p.x + cell * 0.88, p.y + cell * 0.16); bx.stroke(); bx.restore(); } // THE CRATE — the push demo's box, on purpose (see the header: the resemblance IS the trap). if (D.crate != null) { const p = at(D.crate), in0 = cell * 0.12; bx.save(); bx.fillStyle = '#a8814e'; bx.fillRect(p.x + in0, p.y + in0, cell - 2 * in0, cell - 2 * in0); bx.strokeStyle = 'rgba(9,11,15,0.55)'; bx.lineWidth = Math.max(1, cell * 0.06); bx.strokeRect(p.x + in0, p.y + in0, cell - 2 * in0, cell - 2 * in0); bx.strokeStyle = _alpha('#e6c88f', 0.55); bx.lineWidth = Math.max(1, cell * 0.05); bx.beginPath(); // the slats bx.moveTo(p.x + in0, p.y + in0); bx.lineTo(p.x + cell - in0, p.y + cell - in0); bx.moveTo(p.x + cell - in0, p.y + in0); bx.lineTo(p.x + in0, p.y + cell - in0); bx.stroke(); // ON THE BRINK — if the next shove sends it over, say so. A crate one push from becoming a stair // is the single most consequential object on the board and it must not look ordinary. if (L.skirt.has(D.crate + n) && !D.stairs.has(D.crate + n)) { bx.strokeStyle = _alpha('#ffd98a', 0.35 + 0.35 * g); bx.lineWidth = Math.max(1.5, cell * 0.08); bx.strokeRect(p.x + in0 * 0.5, p.y + in0 * 0.5, cell - in0, cell - in0); } bx.restore(); } } PARK_FIELD_RENDER.ledge = _paintParkLedge; // the render seam's FOURTEENTH client (cf. PARK_FIELD_MECHS.ledge) // y17 FIRE FIELD LAYER (ZERO-TEXT; shared by the live scene and the hub thumbnail). Painted as an // OVERLAY over the finished terrain so no existing board changes by a pixel, and — R2 — the fire // family's own DUSK tone is a whole-plot wash that never touches an ANCHOR glyph (gems, companion, ♥ // keep their reserved vocabulary exactly). Every mark is a pure function of the PUBLIC board + the // runtime dyn (C1 — never the persona): // DUSK WASH (R2) — a warm smoke-lit tint over the plot, so a fire tile is recognisable at a // glance in the picker. // THE STREAM (fire.water) — dark moving water where the base pass painted wall, so the fill source // reads as something you draw a bucket from rather than a hedge. // THE FIRE (dyn.burning) — flame tongues on every cell the fire has reached; the corridor a front // has NOT reached yet is charred, waiting. // THE NEXT TILE (dotted) — the cell each undoused front will take on its next beat, drawn DOTTED — // the flood family's PREVIEW idiom, so the clock is legible before it bites. // THE DOUSED FRONT — a wet, steaming corridor: the fire is out and the way is open. // THE BUCKET — a ring at the walker's feet while it holds water; empty and it is gone. function _paintParkFire(st, x0, y0, cell) { const n = st.N, park = st.park, fire = park.fire, dyn = park.dyn; const g = _pulseGlow(); const at = (kk) => ({ x: x0 + (kk % n) * cell, y: y0 + (((kk / n) | 0)) * cell }); // R2 — the family's dusk/smoke wash (anchors untouched) bx.save(); bx.fillStyle = 'rgba(196,104,58,0.07)'; bx.fillRect(x0, y0, n * cell, n * cell); bx.restore(); // THE STREAM const water = (kk) => { const p = at(kk), x = kk % n, y = (kk / n) | 0; bx.fillStyle = '#1d3a4d'; bx.fillRect(p.x, p.y, cell + 0.5, cell + 0.5); bx.save(); bx.strokeStyle = _alpha('#7fc4de', 0.20 + 0.10 * g); bx.lineWidth = Math.max(1, cell * 0.05); bx.lineCap = 'round'; for (let i = 0; i < 2; i++) { const oy = p.y + cell * (0.32 + 0.3 * i) + Math.sin((x * 2 + y + i) * 1.7 + g * 2) * cell * 0.05; bx.beginPath(); bx.moveTo(p.x + cell * 0.16, oy); bx.lineTo(p.x + cell * 0.84, oy); bx.stroke(); } bx.restore(); }; for (const kk of fire.water) water(kk); const burning = dyn ? (dyn.burning || new Set()) : new Set(); const doused = dyn ? dyn.doused : new Set(); // THE FRONT CORRIDORS — charred where unburned-but-undoused, flame where burning, steaming where out. for (let i = 0; i < fire.fronts.length; i++) { const f = fire.fronts[i], out = doused.has(i); for (const kk of f.corridor) { const p = at(kk); if (out) { // doused: wet, open ground with a wisp of steam bx.save(); bx.strokeStyle = _alpha('#9fb4bd', 0.22 + 0.12 * g); bx.lineWidth = Math.max(1, cell * 0.05); bx.lineCap = 'round'; for (const sx of [0.34, 0.62]) { bx.beginPath(); bx.moveTo(p.x + cell * sx, p.y + cell * 0.7); bx.lineTo(p.x + cell * (sx + 0.05 * Math.sin(g * 3)), p.y + cell * 0.3); bx.stroke(); } bx.restore(); continue; } if (burning.has(kk)) { // flame tongues bx.save(); for (const [cx, w, col] of [[0.5, 0.30, 'rgba(224,96,38,0.55)'], [0.5, 0.16, 'rgba(248,196,64,0.75)']]) { bx.fillStyle = col; bx.beginPath(); bx.moveTo(p.x + cell * (cx - w), p.y + cell * 0.82); bx.quadraticCurveTo(p.x + cell * cx, p.y + cell * (0.2 - 0.08 * g), p.x + cell * (cx + w), p.y + cell * 0.82); bx.closePath(); bx.fill(); } bx.restore(); } else { // charred, not yet reached bx.save(); bx.fillStyle = 'rgba(28,22,20,0.45)'; bx.fillRect(p.x + cell * 0.12, p.y + cell * 0.12, cell * 0.76, cell * 0.76); bx.restore(); } } // THE NEXT TILE — the cell this front will take next beat, DOTTED (the flood preview idiom). if (!out) { let nk = -1; for (const kk of f.corridor) if (!burning.has(kk)) { nk = kk; break; } if (nk < 0 && !burning.has(f.asset)) nk = f.asset; if (nk >= 0) { const p = at(nk); bx.save(); bx.setLineDash([Math.max(2, cell * 0.14), Math.max(2, cell * 0.12)]); bx.strokeStyle = _alpha('#f0a552', 0.34 + 0.30 * g); bx.lineWidth = Math.max(1.5, cell * 0.08); const in0 = cell * 0.12; bx.strokeRect(p.x + in0, p.y + in0, cell - 2 * in0, cell - 2 * in0); bx.setLineDash([]); bx.restore(); } } } // THE BUCKET — a ring at the walker's feet while it holds water; gone when empty. if (dyn && dyn.water) { const px = x0 + (st.pos[0].x + 0.5) * cell, py = y0 + (st.pos[0].y + 0.5) * cell; bx.save(); bx.strokeStyle = _alpha('#8fd0e6', 0.55 + 0.3 * g); bx.lineWidth = Math.max(1.5, cell * 0.08); bx.beginPath(); bx.arc(px, py, cell * 0.34, 0, 7); bx.stroke(); bx.fillStyle = _alpha('#2f6f88', 0.85); bx.beginPath(); bx.arc(px, py + cell * 0.02, cell * 0.16, 0, 7); bx.fill(); bx.restore(); } } PARK_FIELD_RENDER.fire = _paintParkFire; // the render seam's EIGHTH client (cf. PARK_FIELD_MECHS.fire) // y18 MINE FIELD LAYER (ZERO-TEXT; shared by the live scene and the hub thumbnail). Painted as an // OVERLAY over the finished terrain so no existing board changes by a pixel. Every mark is a pure // function of the PUBLIC board + the runtime dyn (C1 — never the persona), and R2's underground tone // keeps earth / dug-marks / pips on ONE palette while the anchor glyphs (gems, companion, ♥) are never // touched: // EARTH (undug) — a dark, mottled soil wash over every cell still unbroken (park.mine.earth minus // dyn.dug). It HIDES the gas pockets: a pocket reads as ordinary earth until it is // dug, which is exactly the spec's hiding — the danger is knowable only by the PIP. // DUG (dyn.dug) — the broken ground: a lighter excavated tile with a soft carved rim, so the maze // the walker has cut is legible as a tunnel through the soil. // PIPS (1·2·3) — on DUG tiles only (minesweeper), a cluster of amber dots = _parkMinePips: the // count of adjacent gas pockets. A pip>0 dug tile is the public warning that a // pocket lies next door; digging into one anyway is a choice made in full view. // GAS (dug pocket) — once a pocket itself is broken, a faint rising plume marks where the ♥ was spent. function _paintParkMine(st, x0, y0, cell) { const n = st.N, park = st.park, mn = park.mine, dyn = park.dyn; const dug = dyn ? dyn.dug : new Set(); const g = _pulseGlow(); const at = (kk) => ({ x: x0 + (kk % n) * cell, y: y0 + (((kk / n) | 0)) * cell }); // undug EARTH — the soil wash that buries the pockets (a pocket looks like any other earth cell). for (const kk of mn.earth) { if (dug.has(kk)) continue; const p = at(kk), x = kk % n, y = (kk / n) | 0; bx.fillStyle = 'rgba(58,44,30,0.42)'; bx.fillRect(p.x, p.y, cell + 0.5, cell + 0.5); bx.save(); // a couple of seeded soil flecks (no text) bx.strokeStyle = 'rgba(30,22,14,0.5)'; bx.lineWidth = Math.max(1, cell * 0.05); bx.lineCap = 'round'; for (let i = 0; i < 2; i++) { const fx = p.x + cell * (0.24 + 0.42 * ((x * 3 + y + i) % 3) / 2); const fy = p.y + cell * (0.3 + 0.4 * ((x + y * 2 + i) % 2)); bx.beginPath(); bx.moveTo(fx, fy); bx.lineTo(fx + cell * 0.12, fy + cell * 0.05); bx.stroke(); } bx.restore(); } // DUG tiles — the excavated tunnel, plus the PIP warning and the spent-pocket plume. for (const kk of dug) { const p = at(kk); bx.save(); bx.fillStyle = 'rgba(150,122,86,0.28)'; // lighter, carved floor bx.fillRect(p.x, p.y, cell + 0.5, cell + 0.5); bx.strokeStyle = 'rgba(38,28,18,0.45)'; bx.lineWidth = Math.max(1, cell * 0.05); bx.strokeRect(p.x + cell * 0.06, p.y + cell * 0.06, cell * 0.88, cell * 0.88); // GAS — a dug pocket vented its charge; a faint plume marks the heart it cost. if (mn.pockets.has(kk)) { bx.strokeStyle = _alpha('#a7d7c9', 0.28 + 0.20 * g); bx.lineWidth = Math.max(1, cell * 0.05); bx.lineCap = 'round'; for (const ox of [0.36, 0.6]) { bx.beginPath(); bx.moveTo(p.x + cell * ox, p.y + cell * 0.72); bx.quadraticCurveTo(p.x + cell * (ox + 0.1), p.y + cell * 0.5, p.x + cell * ox, p.y + cell * 0.28); bx.stroke(); } } // PIPS — amber dots = adjacent pockets (rendered on DUG tiles only; the minesweeper signal). const pips = E._parkMinePips(park, kk); if (pips > 0) { bx.fillStyle = _alpha('#f2c14e', 0.85); const rr = Math.max(1.2, cell * 0.07); const cxp = p.x + cell * 0.5, cyp = p.y + cell * 0.5, sp = cell * 0.18; const offs = pips === 1 ? [[0, 0]] : pips === 2 ? [[-sp, 0], [sp, 0]] : [[-sp, sp], [0, -sp], [sp, sp]]; for (const [ox, oy] of offs) { bx.beginPath(); bx.arc(cxp + ox, cyp + oy, rr, 0, 7); bx.fill(); } } bx.restore(); } } PARK_FIELD_RENDER.mine = _paintParkMine; // the render seam's EIGHTH client (cf. PARK_FIELD_MECHS.mine) // y19 TOWER FIELD LAYER (ZERO-TEXT; shared by the live scene and the hub thumbnail). Painted as an // OVERLAY over the finished terrain, a pure function of the PUBLIC board + dyn (C1 — never the // persona). The cell's whole read is "you are not the walker": pos[0] (the base sprite) is the CURSOR // at a console; what WALKS is the NPC resident. // CONTROL-DUSK TONE (R2) — a cool slate wash over the whole plot, so a tower tile is recognisable // at a glance in the picker WITHOUT touching an ANCHOR glyph (gems, the // companion and ♥ keep their reserved vocabulary exactly). // THE THREE CONSOLES — a bracketed panel on each console cell, drawn LIT (dashed, breathing) // while it is still uncommitted, and SOLID once its gate is toggled: a // choice you cannot see is not a choice. // THE THREE GATES (∩) — an arch in the barrier gap: dashed/dark while shut, open and lit once // its console is held. The wire from console to gate is left implicit // (they share a colour), so the remote-control reads without a word. // THE RESIDENT (◉) — a filled disc that WALKS: the NPC at dyn.ents[0], one tile per beat. function _paintParkTower(st, x0, y0, cell) { const n = st.N, park = st.park, tw = park.tower, dyn = park.dyn; const gates = dyn ? dyn.gates : { G: false, C: false, N: false }; const g = _pulseGlow(); const at = (kk) => ({ x: x0 + (kk % n) * cell, y: y0 + (((kk / n) | 0)) * cell }); // R2 — the family's surface tone (a cool control-room dusk; anchors are never touched) bx.save(); bx.fillStyle = 'rgba(96,118,150,0.08)'; bx.fillRect(x0, y0, n * cell, n * cell); bx.restore(); // the gates (∩) in the barrier gaps for (const att of ['G', 'C', 'N']) { const p = at(tw.gateOf[att]), open = gates[att]; bx.save(); bx.lineCap = 'round'; bx.lineWidth = Math.max(1.5, cell * 0.09); bx.strokeStyle = _alpha(open ? '#8fd4c0' : '#5b6472', (open ? 0.72 : 0.4) + 0.2 * g); if (!open) bx.setLineDash([Math.max(2, cell * 0.12), Math.max(2, cell * 0.1)]); const cx = p.x + cell / 2, by = p.y + cell * 0.78; bx.beginPath(); // an arch: open reads as a raised gate bx.moveTo(p.x + cell * 0.2, by); bx.lineTo(p.x + cell * 0.2, by - cell * (open ? 0.5 : 0.28)); bx.quadraticCurveTo(cx, p.y + cell * (open ? 0.02 : 0.24), p.x + cell * 0.8, by - cell * (open ? 0.5 : 0.28)); bx.lineTo(p.x + cell * 0.8, by); bx.stroke(); bx.setLineDash([]); bx.restore(); } // the three consoles for (const att of ['G', 'C', 'N']) { const p = at(tw.consoleOf[att]), committed = dyn && dyn.used === att, live = dyn && !dyn.used; bx.save(); const in0 = cell * 0.14; bx.strokeStyle = _alpha(committed ? '#8fd4c0' : '#7f93b0', (committed ? 0.8 : 0.34) + (live ? 0.34 * g : 0)); bx.lineWidth = Math.max(1.5, cell * 0.09); if (live) bx.setLineDash([Math.max(2, cell * 0.16), Math.max(2, cell * 0.12)]); // a bracketed panel [ ] — the console's face bx.beginPath(); bx.moveTo(p.x + in0 + cell * 0.16, p.y + in0); bx.lineTo(p.x + in0, p.y + in0); bx.lineTo(p.x + in0, p.y + cell - in0); bx.lineTo(p.x + in0 + cell * 0.16, p.y + cell - in0); bx.moveTo(p.x + cell - in0 - cell * 0.16, p.y + in0); bx.lineTo(p.x + cell - in0, p.y + in0); bx.lineTo(p.x + cell - in0, p.y + cell - in0); bx.lineTo(p.x + cell - in0 - cell * 0.16, p.y + cell - in0); bx.stroke(); bx.setLineDash([]); if (committed) { // a lit pip: this console is held bx.fillStyle = _alpha('#8fd4c0', 0.6 + 0.3 * g); bx.beginPath(); bx.arc(p.x + cell / 2, p.y + cell / 2, cell * 0.12, 0, 7); bx.fill(); } bx.restore(); } // the resident (◉) — the thing that WALKS const npc = dyn && dyn.ents && dyn.ents[0]; if (npc && !npc.done) { const px = x0 + (npc.x + 0.5) * cell, py = y0 + (npc.y + 0.5) * cell; bx.save(); bx.fillStyle = _alpha('#e7d9a8', 0.9); bx.beginPath(); bx.arc(px, py, cell * 0.26, 0, 7); bx.fill(); bx.strokeStyle = 'rgba(9,11,15,0.5)'; bx.lineWidth = Math.max(1, cell * 0.05); bx.beginPath(); bx.arc(px, py, cell * 0.26, 0, 7); bx.stroke(); bx.fillStyle = _alpha('#3a3320', 0.85); // the resident's own dot (◉) bx.beginPath(); bx.arc(px, py, cell * 0.1, 0, 7); bx.fill(); bx.restore(); } } PARK_FIELD_RENDER.tower = _paintParkTower; // the render seam's EIGHTH client (cf. PARK_FIELD_MECHS.tower) // y20 BOMB FIELD LAYER (ZERO-TEXT; shared by the live scene and the hub thumbnail). Painted as an // OVERLAY over the finished terrain so no existing board changes by a pixel, and — R2 — the bomb // family's own ARCADE-DUSK tone is a whole-plot translucent wash that never touches an ANCHOR glyph // (the ♥, the yellow gems, the blue walker avatar and the pink companion keep their reserved vocabulary // and MEANINGS byte-identical to the walk demo). Every mark is a pure function of the PUBLIC board + // the runtime dyn (C1 — never the persona): // ARCADE-DUSK WASH (R2) — a dusky violet tint over the plot, so a bomb tile is recognisable at a // glance in the picker WITHOUT recolouring an anchor. // THE CRATE WALLS — the standing crates (park.bomb.crates, which SHRINK as walls open): a // wood-grain slatted tile per crate, tinted by which mind's wall it guards. // THE OPENED WALLS — for every wall in dyn.opened (the confession, IN ORDER), the wallOf cells // are scorched rubble: the way is blown, and you can SEE which minds he spent. // THE BOMBS (●) — a dark round charge on every un-picked bomb (park.bomb.bombKeys shrinks as // he picks them up); a HELD bomb is a small ● on the walker's shoulder. // THE FUSE / BLAST (dotted) — while dyn.lit burns, its cross (dyn.lit.cells) is drawn as a DOTTED // preview ring (the flood/fire preview idiom) with a blinking fuse ● at the // lit crate, so the blast-to-come is legible a beat before it strikes. // THE BUBBLED COMPANION — when dyn.bubbled, a translucent ring around the companion (seat 1): the // care STATE channel, a blast caught him and he waits to be bumped free. // THE STEEL BOXES — park.bomb.steel: boxes a bomb will never open. See _parkSteelBox. // ---- TWO MATERIALS, 2026-08-03. The guided y20 stage exists to teach ONE thing: look at what the box // is MADE OF before you spend the bomb (engine _parkBombGuidedStage). It could not teach it, because // on screen there was only one kind of box. Steel keys go into st.wall, so the terrain painter drew // them as HEDGE — scenery, not an object — and a player never even asked whether they were bombable. // So the two materials get two glyph FAMILIES, and the difference is carried by SURFACE PATTERN, not // by hue: a reader who cannot separate warm brown from cold blue still separates // HORIZONTAL PLANK SEAMS + butt joints (wood: a thing built out of boards, and boards break) // from // A RIVETED PLATE UNDER AN X-BRACE (steel: a thing bolted together, and bolts do not). // Both wear the same 0.76-of-a-cell box silhouette, because they must read as the same CLASS of // object — "a box you could imagine bombing" — or the question the board asks never gets asked. // _parkWoodBox(ctx, gx, gy, cell): the bombable face. Stacked boards with the seams and the butt // joints drawn, over a warm base, so it reads as timber at 41px and at the ~14px hub tile. function _parkWoodBox(ctx, gx, gy, cell) { const in0 = cell * 0.12, s = cell * 0.76; ctx.save(); ctx.fillStyle = '#a8794a'; ctx.fillRect(gx + in0, gy + in0, s, s); ctx.fillStyle = _alpha('#c08e59', 0.55); // alternating plank faces (light board tops) for (const t of [0.0, 0.5]) ctx.fillRect(gx + in0, gy + in0 + s * t, s, s * 0.25); ctx.strokeStyle = _alpha('#5f4527', 0.85); ctx.lineWidth = Math.max(1, cell * 0.04); ctx.beginPath(); for (const t of [0.25, 0.5, 0.75]) { // the seams between the boards ctx.moveTo(gx + in0, gy + in0 + s * t); ctx.lineTo(gx + in0 + s, gy + in0 + s * t); } for (const [t, u] of [[0.125, 0.62], [0.375, 0.34], [0.625, 0.66], [0.875, 0.38]]) { ctx.moveTo(gx + in0 + s * u, gy + in0 + s * (t - 0.115)); // the butt joints, staggered per board ctx.lineTo(gx + in0 + s * u, gy + in0 + s * (t + 0.115)); } ctx.stroke(); ctx.strokeStyle = '#5f4527'; ctx.lineWidth = Math.max(1, cell * 0.06); ctx.lineJoin = 'miter'; ctx.strokeRect(gx + in0, gy + in0, s, s); ctx.restore(); } // _parkSteelBox(ctx, gx, gy, cell, refusing): the box a bomb will never open. // It fills the WHOLE cell first, because a steel key is also in st.wall and the terrain painter has // already laid the arch band's hedge/tree decor there; anything short of full coverage leaves a // shrub growing out of the plate. // `refusing` is the third requirement of this job made visible: while the walker HOLDS a bomb, the // wooden faces breathe a warm ember halo ("spend it here") and steel answers in the opposite register // — a hard, bright, COMPLETELY STILL rim. Motion vs stillness is the contrast, so the answer survives // for a reader who cannot use the warm/cold hue pair at all. It never gets the ember: a steel box // wearing the plant-target glow would be precisely the lie this board exists to prevent. function _parkSteelBox(ctx, gx, gy, cell, refusing) { const in0 = cell * 0.06, s = cell * 0.88; ctx.save(); ctx.fillStyle = '#242a33'; // full-cell seat: hides the wall-band decor ctx.fillRect(gx, gy, cell + 0.5, cell + 0.5); ctx.fillStyle = '#5f6b7a'; // the plate ctx.fillRect(gx + in0, gy + in0, s, s); ctx.lineJoin = 'miter'; ctx.lineCap = 'butt'; ctx.strokeStyle = _alpha('#a6b7c9', 0.85); // bevel: lit from the top-left, hard corners ctx.lineWidth = Math.max(1, cell * 0.05); ctx.beginPath(); ctx.moveTo(gx + in0, gy + in0 + s); ctx.lineTo(gx + in0, gy + in0); ctx.lineTo(gx + in0 + s, gy + in0); ctx.stroke(); ctx.strokeStyle = _alpha('#1b212a', 0.9); ctx.beginPath(); ctx.moveTo(gx + in0 + s, gy + in0); ctx.lineTo(gx + in0 + s, gy + in0 + s); ctx.lineTo(gx + in0, gy + in0 + s); ctx.stroke(); ctx.strokeStyle = _alpha('#39434f', 0.9); // the X-brace — the pattern that is NOT planks ctx.lineWidth = Math.max(1, cell * 0.055); ctx.beginPath(); ctx.moveTo(gx + in0 + s * 0.1, gy + in0 + s * 0.1); ctx.lineTo(gx + in0 + s * 0.9, gy + in0 + s * 0.9); ctx.moveTo(gx + in0 + s * 0.9, gy + in0 + s * 0.1); ctx.lineTo(gx + in0 + s * 0.1, gy + in0 + s * 0.9); ctx.stroke(); ctx.fillStyle = '#c3d2e2'; // the rivets for (const [dx, dy] of [[0.13, 0.13], [0.87, 0.13], [0.13, 0.87], [0.87, 0.87], [0.5, 0.5]]) { ctx.beginPath(); ctx.arc(gx + in0 + s * dx, gy + in0 + s * dy, Math.max(1, cell * 0.04), 0, 7); ctx.fill(); } if (refusing) { ctx.strokeStyle = _alpha('#dbe8f5', 0.95); // STILL, at a fixed alpha: it is not an offer ctx.lineWidth = Math.max(1.5, cell * 0.06); ctx.strokeRect(gx + in0 * 0.4, gy + in0 * 0.4, cell - in0 * 0.8, cell - in0 * 0.8); } ctx.restore(); } function _paintParkBomb(st, x0, y0, cell) { const n = st.N, park = st.park, b = park.bomb, dyn = park.dyn; const g = _pulseGlow(); const at = (kk) => ({ x: x0 + (kk % n) * cell, y: y0 + (((kk / n) | 0)) * cell }); const WALL_HUE = { gem: '#e0c14e', safe: '#7fb8de', cage: '#d98fc4' }; // tints ECHO the mind, never an anchor // R2 — the family's arcade-dusk wash (a dusky violet; anchors are never touched) bx.save(); bx.fillStyle = 'rgba(150,92,164,0.07)'; bx.fillRect(x0, y0, n * cell, n * cell); bx.restore(); // THE OPENED WALLS — scorched rubble where a bomb already blew the crate (dyn.opened, in order). const openedWalls = dyn && dyn.opened ? dyn.opened.slice() : []; if (dyn && dyn.introOpened) openedWalls.push('intro'); for (const w of openedWalls) { for (const kk of (b.wallOf[w] || [])) { const p = at(kk); bx.save(); // Track D-1 (spec 2026-07-16): a deep cell under rubble keeps its DEEP read — the dark // fill is skipped so the hazard stays legible (the measured death trap: opened cage // rubble read as a cleared path). Scratch marks alone say "blown open". if (!park.deep.has(kk)) { bx.fillStyle = 'rgba(34,26,30,0.4)'; bx.fillRect(p.x + cell * 0.1, p.y + cell * 0.1, cell * 0.8, cell * 0.8); } bx.strokeStyle = _alpha('#6a4a5a', 0.4); bx.lineWidth = Math.max(1, cell * 0.05); bx.lineCap = 'round'; for (const [sx, sy] of [[0.28, 0.34], [0.6, 0.66], [0.44, 0.72]]) { // a little scattered rubble bx.beginPath(); bx.moveTo(p.x + cell * sx, p.y + cell * sy); bx.lineTo(p.x + cell * (sx + 0.08), p.y + cell * (sy - 0.06)); bx.stroke(); } bx.restore(); } } // THE PLANT-TARGET GLOW — while the walker HOLDS a bomb (and no fuse burns), every un-opened wall's // FACE crate is a live plant target, so an ember outer halo breathes BEHIND it (drawn before the crate // bodies below, so it sits under them). The ember echoes the held bomb's own charge — "this bomb → // plant HERE". HELD-ONLY: the hub thumbnail (dyn.held=false) and the fuse beat (dyn.lit) never show it. // Pure overlay on crates/terrain; no anchor glyph is touched (C1 — reads only PUBLIC board + dyn). // WOOD ONLY, AND THAT IS THE LAW OF THIS BOARD. The set it walks is b.crates, which is wood by // construction (the engine keeps steel out of it), and the `b.steel` line below is deliberate // belt-and-braces rather than dead code: the day a stage puts one key in both sets, a steel box // would light up as a plant target and the player would spend their only bomb on it. This glow is // a PROMISE that a bomb spent here opens something, and steel cannot keep it. if (dyn && dyn.held && !dyn.lit) { for (const kk of b.crates) { if (b.steel && b.steel.has(kk)) continue; // never advertise an unbombable box if (kk !== b.face[b.crateWall.get(kk)]) continue; // only a wall's FACE is a plant target const p = at(kk), gx = p.x + cell / 2, gy = p.y + cell / 2, rr = cell * (0.82 + 0.16 * g); const grad = bx.createRadialGradient(gx, gy, cell * 0.24, gx, gy, rr); grad.addColorStop(0, _alpha('#ffe6a6', 0.22 + 0.16 * g)); grad.addColorStop(0.5, _alpha('#ffca6a', 0.62 + 0.30 * g)); grad.addColorStop(1, _alpha('#f0a94a', 0)); bx.save(); bx.fillStyle = grad; bx.fillRect(p.x - cell * 0.7, p.y - cell * 0.7, cell * 2.4, cell * 2.4); bx.restore(); } } // THE STEEL BOXES — park.bomb.steel, drawn AFTER the plant-target glow on purpose: the ember spills // 0.7 of a cell past the wood it belongs to, and steel that let that spill sit on top of it would be // wearing a piece of the invitation. The plate covers it instead, which is the picture — the offer // stops at the steel. Absent on every non-guided bomb board (and on y22's fork), where this loop is // a no-op and the frame is byte-identical to before. for (const kk of (b.steel || [])) { const p = at(kk); _parkSteelBox(bx, p.x, p.y, cell, !!(dyn && dyn.held && !dyn.lit)); } // THE CRATE WALLS — every standing crate (the set shrinks as walls open), and every one of them is // WOOD: these are the bombable faces, which is what a crate has always meant here. A face crate // carries a brighter mind-tinted rim so the plant target reads. // The `w === 'intro'` branch that used to paint one crate as riveted grey is GONE, and it is worth // saying why: on the re-laid guided stage `intro` is the wooden face at (4,9) — the ONE non-steel // cell on the pen's boundary, i.e. the first bomb the player must spend. Painting it steel told the // reader the exact opposite of the lesson. Steel is now its own set and its own glyph, above. for (const kk of b.crates) { const p = at(kk), w = b.crateWall.get(kk), hue = WALL_HUE[w] || '#a8794a'; const isFace = kk === b.face[w]; // STRUCK (design 2026-08-05): the crate the companion has just walked into rocks and stops. // Small on purpose — the wood has to read as HOLDING, not as about to give way. The glyph is // untouched; this is a translate around it, and the offset rides on the mark rather than being // derived from the pulse, so a frozen capture frame and its angle are pinned together. const hit = park.bombScene && park.bombScene.crateHit; bx.save(); bx.translate((hit && hit.key === kk) ? hit.dx * cell : 0, 0); _parkWoodBox(bx, p.x, p.y, cell); bx.strokeStyle = _alpha(hue, isFace ? 0.7 + 0.25 * g : 0.4); // the mind-tinted rim (face pulses) bx.lineWidth = Math.max(1.2, cell * (isFace ? 0.08 : 0.05)); bx.lineJoin = 'round'; bx.strokeRect(p.x + cell * 0.12, p.y + cell * 0.12, cell * 0.76, cell * 0.76); bx.restore(); } // THE FUSE + BLAST PREVIEW — while a fuse burns, the cross it will strike is a DOTTED ring (the flood/ // fire preview idiom) and the lit crate blinks a fuse ●, so the blast is legible a beat before it lands. if (dyn && dyn.lit && dyn.lit.cells) { bx.save(); bx.setLineDash([Math.max(2, cell * 0.14), Math.max(2, cell * 0.12)]); bx.strokeStyle = _alpha('#f0794a', 0.4 + 0.35 * g); bx.lineWidth = Math.max(1.5, cell * 0.08); const in0 = cell * 0.12; for (const kk of dyn.lit.cells) { const p = at(kk); bx.strokeRect(p.x + in0, p.y + in0, cell - 2 * in0, cell - 2 * in0); } bx.setLineDash([]); // 그리고 그 십자가 무엇을 뺏는지 (2026-08-06). 점선은 자리만 말했다: 실제 비용은 걷는 몸의 // 하트 하나다. 이미 있는 도형을 쓴다 — 지름길 가격표가 밭 진입 칸에 놓는 것과 같은 마크이고, // 시연의 위험 말풍선 안에 들어가는 것과도 같다. 두 다리가 위험을 같은 글자로 말한다. // 벽(철·테두리)에는 안 붙인다: 설 수 없는 칸에 "여기 서면 문다"는 거짓이다. 나무 상자는 // 걸러지지 않는데 그것이 맞다 — 이 폭발로 사라질 칸이고, 사라진 뒤엔 설 수 있다. for (const kk of dyn.lit.cells) { if (st.wall.has(kk)) continue; const hp = at(kk); bx.save(); bx.globalAlpha = 0.55 + 0.25 * g; drawHeartCrack(hp.x + cell * 0.5, hp.y + cell * 0.5, cell * 0.30); bx.restore(); } const lp = at(dyn.lit.key); // the blinking fuse spark on the lit crate bx.fillStyle = _alpha('#ffd24a', 0.5 + 0.45 * g); bx.beginPath(); bx.arc(lp.x + cell / 2, lp.y + cell * 0.2, Math.max(1.5, cell * (0.06 + 0.03 * g)), 0, 7); bx.fill(); bx.restore(); } // THE BOMBS (●) — a dark round charge on every bomb still on the ground (bombKeys shrinks as he picks). for (const kk of (b.bombKeys || [])) { const p = at(kk); bx.save(); bx.fillStyle = _alpha('#1a1c22', 0.92); bx.beginPath(); bx.arc(p.x + cell / 2, p.y + cell / 2, cell * 0.24, 0, 7); bx.fill(); bx.strokeStyle = _alpha('#c94a2a', 0.6 + 0.3 * g); bx.lineWidth = Math.max(1, cell * 0.05); bx.beginPath(); bx.arc(p.x + cell / 2, p.y + cell / 2, cell * 0.24, 0, 7); bx.stroke(); bx.strokeStyle = _alpha('#ffca6a', 0.7); bx.lineWidth = Math.max(1, cell * 0.045); bx.lineCap = 'round'; bx.beginPath(); bx.moveTo(p.x + cell * 0.5, p.y + cell * 0.28); // the little fuse stub bx.lineTo(p.x + cell * 0.62, p.y + cell * 0.16); bx.stroke(); bx.restore(); } // THE HELD BOMB — a small ● on the walker's shoulder while he carries one to a face. if (dyn && dyn.held) { const px = x0 + (st.pos[0].x + 0.5) * cell, py = y0 + (st.pos[0].y + 0.5) * cell; bx.save(); bx.fillStyle = _alpha('#1a1c22', 0.92); // Track D-2 (spec 2026-07-16): the held bomb needs a visible pulse — radius now breathes // with g (0.12 was static and read as inert), stroke alpha unchanged. bx.beginPath(); bx.arc(px + cell * 0.26, py - cell * 0.26, cell * (0.14 + 0.03 * g), 0, 7); bx.fill(); bx.strokeStyle = _alpha('#c94a2a', 0.6 + 0.3 * g); bx.lineWidth = Math.max(1, cell * 0.045); bx.beginPath(); bx.arc(px + cell * 0.26, py - cell * 0.26, cell * (0.14 + 0.03 * g), 0, 7); bx.stroke(); bx.restore(); } // THE BUBBLED COMPANION — the care STATE channel: a blast caught the companion, so a translucent ring // marks him (seat 1) waiting for the walker's rescue bump. The pink anchor glyph itself is untouched. if (dyn && dyn.bubbled && st.pos[1]) { const px = x0 + (st.pos[1].x + 0.5) * cell, py = y0 + (st.pos[1].y + 0.5) * cell; bx.save(); // Track D-3 (spec 2026-07-16): the bubbled companion's ring needed contrast — both the // stroke and fill alphas are raised so the ring reads against the arcade-dusk wash. bx.fillStyle = _alpha('#bfe0ea', 0.16 + 0.08 * g); bx.beginPath(); bx.arc(px, py, cell * 0.42, 0, 7); bx.fill(); bx.strokeStyle = _alpha('#dff2fa', 0.55 + 0.3 * g); bx.lineWidth = Math.max(1.2, cell * 0.05); bx.beginPath(); bx.arc(px, py, cell * 0.42, 0, 7); bx.stroke(); bx.restore(); } } PARK_FIELD_RENDER.bomb = _paintParkBomb; // the render seam's ELEVENTH client (cf. PARK_FIELD_MECHS.bomb) // y23 STORM FIELD LAYER (ZERO-TEXT; shared by the live scene and the hub thumbnail). Painted as an // OVERLAY over the finished terrain so no existing board changes by a pixel. flood (y10, ~5158) is // this module's own lineage — same concentric-ring idiom, reused verbatim below — but storm inverts // what the ring MEANS: water is terminal, the field is a per-beat ♥ DoT, and its target is not fixed. // Every mark is a pure function of the PUBLIC board + the runtime dyn (C1 — never the persona): // THE ZONE (dyn.storm.zone) — every stormed cell tinted by CURRENT POLARITY: cool blue for 'me' // (the walker is being billed), pink for 'mate' (the companion is). The // tint COLOUR IS the polarity badge — it is the single fastest answer on // screen to "who is being hurt right now", and it has to track the // CONSOLE toggle exactly, because polarity is a confession made by // walking there (design spec, "극성"). // THE PREVIEW (dotted) — the ring that storms NEXT, taken from the ENGINE's own timetable // (E._parkStormNext) — flood's exact preview idiom. See flood's own // comment at 5162-5165: re-deriving the schedule here would let the // DRAWN danger drift from the danger the caution read actually PRICES. // THE CONSOLE (⇄) — a swap glyph on the CORE-rim cell that toggles polarity on entry, plus // a pip in the CURRENT polarity's colour, so the console reads as "this // is the switch, and this is where it is thrown right now". function _paintParkStorm(st, x0, y0, cell) { const n = st.N, park = st.park, sm = park.storm, dyn = park.dyn; const zone = dyn ? dyn.storm.zone : new Set(); const pol = dyn ? dyn.storm.polarity : 'me'; const g = _pulseGlow(); const polColor = pol === 'me' ? '#508cff' : '#ff6ebe'; // the badge: cool blue ('me') / pink ('mate') // THE ZONE — the tint IS the polarity badge (see header). for (const kk of zone) { const x = kk % n, y = (kk / n) | 0, gx = x0 + x * cell, gy = y0 + y * cell; bx.fillStyle = _alpha(polColor, 0.30); bx.fillRect(gx, gy, cell + 0.5, cell + 0.5); } // THE PREVIEW comes from the ENGINE (see header) — flood's exact dotted idiom (app.js ~5205-5220). const nextRing = E._parkStormNext(st); if (nextRing.length) { bx.save(); bx.setLineDash([Math.max(2, cell * 0.16), Math.max(2, cell * 0.12)]); bx.strokeStyle = _alpha(polColor, 0.55 + 0.35 * g); bx.lineWidth = Math.max(1.5, cell * 0.09); bx.lineCap = 'round'; const in0 = cell * 0.13; for (const kk of nextRing) { if (zone.has(kk)) continue; const x = kk % n, y = (kk / n) | 0; bx.strokeRect(x0 + x * cell + in0, y0 + y * cell + in0, cell - 2 * in0, cell - 2 * in0); } bx.setLineDash([]); bx.restore(); } // THE CONSOLE — a swap glyph (⇄: two opposed arrows) + a pip in the current polarity's colour. { const ck = sm.consoleKey, cx0 = x0 + ((ck % n) + 0.5) * cell, cy0 = y0 + (((ck / n) | 0) + 0.5) * cell; bx.save(); bx.strokeStyle = _alpha('#e7ecf2', 0.55 + 0.30 * g); bx.lineWidth = Math.max(1.4, cell * 0.07); bx.lineCap = 'round'; const ax = cell * 0.22, ay = cell * 0.11, hd = cell * 0.09; bx.beginPath(); // top shaft: points RIGHT bx.moveTo(cx0 - ax, cy0 - ay); bx.lineTo(cx0 + ax, cy0 - ay); bx.moveTo(cx0 + ax - hd, cy0 - ay - hd); bx.lineTo(cx0 + ax, cy0 - ay); bx.lineTo(cx0 + ax - hd, cy0 - ay + hd); bx.stroke(); bx.beginPath(); // bottom shaft: points LEFT bx.moveTo(cx0 + ax, cy0 + ay); bx.lineTo(cx0 - ax, cy0 + ay); bx.moveTo(cx0 - ax + hd, cy0 + ay - hd); bx.lineTo(cx0 - ax, cy0 + ay); bx.lineTo(cx0 - ax + hd, cy0 + ay + hd); bx.stroke(); bx.restore(); bx.save(); // the pip — CURRENT polarity, at a glance bx.fillStyle = _alpha(polColor, 0.85); bx.beginPath(); bx.arc(cx0, cy0, cell * 0.085, 0, 7); bx.fill(); bx.strokeStyle = 'rgba(9,11,15,0.5)'; bx.lineWidth = Math.max(1, cell * 0.03); bx.beginPath(); bx.arc(cx0, cy0, cell * 0.085, 0, 7); bx.stroke(); bx.restore(); } // THE ACHE (2026-07-20) — the polarity's CURRENT victim, standing in the field, carries the // cracked-heart glyph over their head. The DoT itself was invisible on the board (hearts fall in // the HUD only; mateHeat has no body read at all), so a viewer could not see WHO is paying the // field's price right now. Pure public read (C1): polarity + zone + pos; a downed mate is not // marked (the ache is over — the downed channel owns that body). Display-only; nothing mutates. { const victim = pol === 'me' ? st.pos[0] : (dyn && !dyn.storm.mateDown ? st.pos[1] : null); if (victim && zone.has(victim.y * n + victim.x)) { const ax0 = x0 + (victim.x + 0.5) * cell, ay0 = y0 + victim.y * cell - cell * 0.22; bx.save(); bx.globalAlpha = 0.60 + 0.35 * g; // the DoT beat, breathing with the pulse drawHeartCrack(ax0, ay0, cell * 0.30); bx.restore(); } } } PARK_FIELD_RENDER.storm = _paintParkStorm; // the render seam's THIRTEENTH client (cf. PARK_FIELD_MECHS.storm) // y33 YIELD FIELD LAYER (ZERO-TEXT; shared by the live scene and the hub thumbnail). An OVERLAY // over the finished terrain, so no existing board changes by a pixel. Three marks, each a pure // function of the PUBLIC board + the runtime dyn (C1 — never the persona): // CHASM (park.water) — dark churning water where the base pass painted tree-wall: the // split park reads as uncrossable except where the plank runs. // PLANK (yield.lane, sound) — laid slats over the water line: the one narrow file. The rotten // segment (yield.rotten) keeps its hazard tint from the base pass; // split slats + a dashed fissure say WHY it costs a body. // YIELD RIPPLE (fx 'yield') — emitted by the engine when a walker steps into a pocket to // clear the companion's lane; the shared fx pass draws transient // marks, and the pocket's hazard tint already says what it costs. function _paintParkYield(st, x0, y0, cell) { const n = st.N, park = st.park; const g = _pulseGlow(); const water = (kk) => { const x = kk % n, y = (kk / n) | 0, gx = x0 + x * cell, gy = y0 + y * cell; bx.fillStyle = '#1d3a4d'; bx.fillRect(gx, gy, cell + 0.5, cell + 0.5); bx.save(); bx.strokeStyle = _alpha('#7fc4de', 0.20 + 0.10 * g); bx.lineWidth = Math.max(1, cell * 0.05); bx.lineCap = 'round'; for (let i = 0; i < 2; i++) { // slow surface chop (seeded by the cell) const oy = gy + cell * (0.32 + 0.3 * i) + Math.sin((x * 2 + y + i) * 1.7 + g * 2) * cell * 0.05; bx.beginPath(); bx.moveTo(gx + cell * 0.16, oy); bx.lineTo(gx + cell * 0.84, oy); bx.stroke(); } bx.restore(); }; const slats = (kk, rotten) => { const x = kk % n, y = (kk / n) | 0, gx = x0 + x * cell, gy = y0 + y * cell; bx.save(); const in0 = cell * 0.10; if (!rotten) { // sound plank: laid boards bx.fillStyle = '#7a6647'; bx.fillRect(gx + in0, gy + in0, cell - 2 * in0, cell - 2 * in0); } bx.strokeStyle = rotten ? 'rgba(14,15,19,0.65)' : 'rgba(9,11,15,0.40)'; bx.lineWidth = Math.max(1, cell * 0.04); bx.lineCap = 'round'; for (const fx of [0.32, 0.52, 0.72]) { // the board seams (vertical: a footbridge file) bx.beginPath(); bx.moveTo(gx + cell * fx, gy + in0); bx.lineTo(gx + cell * fx, gy + cell - in0); bx.stroke(); } if (rotten) { // the split slats: this stretch will not hold bx.setLineDash([Math.max(2, cell * 0.1), Math.max(2, cell * 0.07)]); bx.strokeStyle = 'rgba(14,15,19,0.8)'; bx.lineWidth = Math.max(1.5, cell * 0.07); bx.beginPath(); bx.moveTo(gx + cell * 0.20, gy + cell * 0.30); bx.lineTo(gx + cell * 0.55, gy + cell * 0.52); bx.lineTo(gx + cell * 0.36, gy + cell * 0.72); bx.stroke(); bx.setLineDash([]); } bx.restore(); }; for (const kk of park.water) water(kk); for (const kk of park.yield.lane) { if (park.yield.rotten.has(kk)) slats(kk, true); // hazard tint underneath stays visible else slats(kk, false); } // THE HEAD-ON (2026-08-02). y33 declares no clock — `_clockNone('the head-on meeting on a // one-wide plank. geometry, not a clock.')` — and that declaration is true, but it left this // cell's ONE question with no mark at all: two bodies are on a one-wide file and one of them // has to give way. The board said WHERE the plank is and WHERE the rot is; nothing said // "you two are on the same file, this is how far apart, and here is where you could stand // aside". A player who has not read a manual has no way to know the pockets are for stepping // into, because they are painted like the hazard they also are. // // Three marks, all pure functions of PUBLIC state (C1 — positions, lane, pockets; never the // persona, never the awards): // THE AXIS one dash per cell between the two bodies. The dash COUNT is the distance, so // the countdown needs no clock — it is the geometry itself, counted. // THE FACES a chevron at each body pointing at the other: this is a meeting, not a passing. // THE PLACES a breathing ring on each pocket beside the WALKER — the step-aside, drawn in // the pocket's own hazard warmth so the ring never pretends the yield is free. const A = st.pos[0], B = st.pos[1]; if (A && B && A.y === B.y && park.yield.pockets) { const lane = park.yield.lane; const kA = A.y * n + A.x, kB = B.y * n + B.x; const lo = Math.min(A.x, B.x), hi = Math.max(A.x, B.x); let onFile = lane.has(kA) && lane.has(kB) && hi - lo >= 1 && hi - lo <= 7; // every cell BETWEEN them must be lane too — otherwise they are not actually facing each // other down one file and the mark would be a lie about the geometry. if (onFile) for (let x = lo + 1; x < hi; x++) if (!lane.has(A.y * n + x)) { onFile = false; break; } if (onFile) { // the marks ride the cell's LOWER strip, not its centre: a gem or a pad sits centred in // the free cell and swallowed the first cut whole (measured — the ticks were drawn and the // capture showed nothing but the diamond on top of them). const cy = y0 + A.y * cell + cell * 0.82; const HOT = '#ffcf8a'; bx.save(); bx.lineCap = 'round'; // ONE TICK PER FREE CELL. A dash PATTERN would have been prettier and would have lied: its // dash count is a function of the line length and the phase, not of the distance. A tick // drawn in the middle of each cell BETWEEN the bodies can only be counted one way — three // ticks means three steps before they touch, and zero ticks means they already do. bx.strokeStyle = _alpha(HOT, 0.5 + 0.35 * g); bx.lineWidth = Math.max(1.3, cell * 0.07); for (let x = lo + 1; x < hi; x++) { bx.beginPath(); bx.moveTo(x0 + (x + 0.32) * cell, cy); bx.lineTo(x0 + (x + 0.68) * cell, cy); bx.stroke(); } if (hi - lo === 1) { // ALREADY TOUCHING: no cell is left to tick, and this is the beat the cell exists for. // A bar on the shared edge — the impasse itself, drawn where it is. bx.strokeStyle = _alpha(HOT, 0.75 + 0.25 * g); bx.lineWidth = Math.max(1.8, cell * 0.10); bx.beginPath(); bx.moveTo(x0 + hi * cell, cy - cell * 0.30); bx.lineTo(x0 + hi * cell, cy + cell * 0.30); bx.stroke(); } else { // the two faces, drawn INSIDE the gap (at the body's edge they sit under the figure and // the mark is spent on nothing — measured: the first cut drew them there and they were // invisible at gap 2). const chev = (px, dir) => { const w = cell * 0.15, h = cell * 0.16; bx.beginPath(); bx.moveTo(px - dir * w, cy - h); bx.lineTo(px, cy); bx.lineTo(px - dir * w, cy + h); bx.stroke(); }; bx.strokeStyle = _alpha(HOT, 0.7 + 0.25 * g); bx.lineWidth = Math.max(1.4, cell * 0.075); chev(x0 + (lo + 1.28) * cell, 1); chev(x0 + (hi - 0.28) * cell, -1); } bx.restore(); // the step-aside places, beside the WALKER only (his choice is the one being read). bx.save(); bx.setLineDash([Math.max(2, cell * 0.16), Math.max(2, cell * 0.12)]); bx.strokeStyle = _alpha(HOT, 0.35 + 0.35 * g); bx.lineWidth = Math.max(1.2, cell * 0.06); const in0 = cell * 0.16; for (const d of [-1, 1]) { const py = A.y + d; if (py < 0 || py >= n) continue; const pk = py * n + A.x; if (!park.yield.pockets.has(pk)) continue; bx.strokeRect(x0 + A.x * cell + in0, y0 + py * cell + in0, cell - 2 * in0, cell - 2 * in0); } bx.setLineDash([]); bx.restore(); } } } PARK_FIELD_RENDER.yield = _paintParkYield; // the render seam's y33 client (cf. PARK_FIELD_MECHS.yield) /* y58 흐르는 도로 FIELD LAYER (ZERO-TEXT; Task 7 — 판이 규칙서다). 위험이 어디 있는지뿐 아니라 "언제" 오는지도 글자 하나 없이 읽혀야 한다. 일곱 마크, 전부 PUBLIC st (never the persona — C1) 의 순수 함수이고, 엔진이 이미 내보낸 네 술어(_parkRoadBandAt/_parkRoadDrumAt/_parkRoadGateBeat/ _parkRoadPocket — 전부 engine.js, Task 1·3·5)를 그대로 읽는다. 이 파일은 다시 계산하지 않는다: ① 중앙분리대 — 두 파선 열(x∈{2,4}). 흐르는 페인트 대시의 위상이 dyn.road.scroll(+부드러운 보간용 _parkStepFrac)의 함수라 "도로가 나를 향해 온다"는 유일한 신호다 — 그래서 정적이면 안 되는 그 하나(브리프 원문 그대로). 기하 자체(어느 행이 벽인가)는 빌드 시점에 정적으로 굳는다(engine.js, "기하는 정적이다") — 그 위에 밝은 차단바를 겹쳐 그려 "여기는 못 건넌다"는 정적 사실과 "도로는 흐른다"는 동적 사실을 같은 열에서 서로 다른 채널로 낸다. ② 드럼 밴드, 지금 — _parkRoadBandAt(x,y) 의 채색 타일. 2026-08-05 에 이 술어가 행에서 **칸**으로 승격했다(차선마다 다른 위상) — 한 행의 다섯 차선이 통째로 빨갛던 "5차선 동시" 결함이 사라졌고, 그래서 옆 차선으로 피하는 수가 뜻을 갖는다. ③ 드럼 밴드, 다음 — _parkRoadDrumAt(x,y) 의 점선 테두리. 이 함수 자신의 주석이 이미 "곧(scroll+1) 드럼 밴드에 덮이는가"라고 적어 뒀다 — siege 의 "다음 밴드, 점선" 관용구(_paintParkSiege, E._parkSiegeNext)를 그대로 따른다: 엔진이 시간표를 쥐고 화면은 그걸 그릴 뿐이다(flood 의 설계 법칙). ②와 겹치는 칸은 다시 그리지 않는다(burst 의 hotNow/hotNext 선례와 같은 규율). ④ 차단 박자 핍 + 닫힌 전방 — own:true(PARK_FIELD_CLOCK.road 참고). 공유 HUD 시계 줄이 2026-08-03 에 제거됐고(y58 과 무관한 동시 작업, 이 파일의 drawParkHUD 근처에 그 메모가 남아 있다) 그 줄이 서던 자리에 이 판의 몫은 없었다 — 그러니 own:true 는 대체가 아니라 이 판의 첫 시계다. 0 이 되면(_parkRoadGateBeat) legalMask 가 실제로 닫는 그 한 칸 (워커 행-1, _parkRoadLegalMask 의 frontY)에 차단바를 그린다 — 그려지는 위험이 곧 청구되는 위험이어야 한다. ⑤ 안전 포켓 — 차단 박자에 _parkRoadPocket 이 non-null 이면 그 칸에 점선 테두리. **41.7%는 null 이다**(Task 3 실측, 시드 1..24×40턴의 차단 박자 144회 표본) — 그리면 안 되는 게 정상 빈도로 일어난다는 뜻이라, 브리프가 명시적으로 경고한 널 가드를 여기서도 지킨다. ⑥ 추월 토큰 — 살아있는 토큰마다 얇은 위쪽 쐐기(⌃), PARK_PAD_HUE(=reach pad 색) — 이 토큰들이 실은 pad:true reach 픽업이라서(engine.js 주석) 같은 "여기 서면 된다" 어휘를 빌린다. chainAnyOf[0] 세 개뿐(gtype 3, 동료의 계약 표적은 추월 대상이 아니다 — engine.js 주석 "지워지지 않는 표적" 참고, Task 7 범위 밖으로 남긴다). ⑦ 교통 NPC — 워커(파랑, drawParkAgent)·동료(자홍, drawParkCompanion)와 같은 종이되 색만 다른 원형 에이전트(PARK_ROAD_VEHICLE_HUE). 둘 다 y+1 로 흐른다(엔진 주석: "제 차선에서 한 박자에 한 칸씩 앞으로") — 워커의 전진(y 감소)과 반대 방향이라 마주 오는 몸이다; 눈 둘을 진행 방향(아래)에 찍어 그림으로 남긴다. 차체였던 것을 2026-08-05 에 몸으로 바꿨다: 이 판이 재는 것은 마음이고, 차는 배경으로 읽히지만 에이전트는 상대로 읽힌다. */ const PARK_ROAD_DRUM_HUE = '#e5432f'; // 드럼 — 핍 줄의 경고 빨강과 같은 계열(한 어휘) const PARK_ROAD_POCKET_HUE = '#ffd166'; // 안전 포켓 — 따뜻한 금색, "서면 된다" const PARK_ROAD_VEHICLE_HUE = '#94a3ba'; // 교통 NPC — 강철빛 회청색. SEAT_COL·PARK_HUES 어느 // 색과도 안 겹친다(워커 파랑·동료 자홍과 혼동되면 안 된다) const PARK_ROAD_LANE_HUE = '#e7ecf5'; // 중앙분리대 흐르는 페인트 — 옅은 흰색(도로 페인트) const PARK_ROAD_SIGNAL_GO = '#4ade80'; // 자유 박자 — 초록. 전진이 legal 하다 const PARK_ROAD_SIGNAL_WARN = '#fbbf24'; // 예고 박자(period-1) — 노랑. 다음 박자에 닫힌다 const PARK_ROAD_SIGNAL_STOP = '#e5432f'; // 차단 박자 — 빨강. 드럼(PARK_ROAD_DRUM_HUE)과 같은 값이다 // 일부러 같다: "저 빨강과 이 빨강은 같은 것"이 한 어휘로 읽혀야 한다(L1-2) /* _parkRoadFence(gx, gy, cell, alpha, ghost): 칸 하나에 A자 공사 바리케이드를 그린다. 가로 널 둘 + 사선 빗금 + 다리 둘. 널은 PARK_ROAD_DRUM_HUE 와 흰색의 교대 줄무늬다 — 실제 공사 펜스의 어휘이고, 동시에 신호등 STOP 램프와의 "한 어휘" 계약 (PARK_ROAD_SIGNAL_STOP === PARK_ROAD_DRUM_HUE)을 색으로 잇는다. 왜 채색이 아니라 모양인가 (사용자 결정 2026-08-05). 이 판의 빨강은 이미 신호등과 공유 중이라 채널이 포화됐다 — 모양은 비어 있었다. "칸이 빨갛다"에서 "칸에 펜스가 서 있다"로 옮기면, 재중심화와 맞물려 위험의 인과가 뒤집힌다: 내가 밀려서 밟는 벌칙이 아니라 펜스가 나를 향해 오는 회피 게임이 된다. ghost=true 면 "다음 밴드"다 — 같은 모양을 옅게 그린다. 모양이 같아야 "저게 곧 온다"가 읽힌다(점선 테두리로는 그 관계가 안 보인다). */ function _parkRoadFence(gx, gy, cell, alpha, ghost) { const pad = cell * 0.14, w = cell - 2 * pad; const topY = gy + cell * 0.34, botY = gy + cell * 0.60; const railH = cell * 0.13; bx.save(); bx.globalAlpha = alpha; // 다리 둘 — A 자로 벌어진다 bx.strokeStyle = ghost ? 'rgba(200,205,215,0.5)' : 'rgba(28,30,36,0.85)'; bx.lineWidth = Math.max(1, cell * 0.045); bx.lineCap = 'round'; for (const s of [-1, 1]) { bx.beginPath(); bx.moveTo(gx + cell * 0.5 + s * w * 0.18, topY); bx.lineTo(gx + cell * 0.5 + s * w * 0.42, gy + cell * 0.84); bx.stroke(); } // 널 둘 — 빨강/흰색 교대 줄무늬. 네 칸씩 나눠 칠한다. for (const ry of [topY, botY]) { for (let k = 0; k < 4; k++) { bx.fillStyle = (k % 2 === 0) ? PARK_ROAD_DRUM_HUE : '#eef1f6'; bx.fillRect(gx + pad + (w / 4) * k, ry, w / 4, railH); } bx.strokeStyle = 'rgba(28,30,36,0.7)'; bx.lineWidth = Math.max(0.8, cell * 0.02); bx.strokeRect(gx + pad, ry, w, railH); } bx.restore(); } function _paintParkRoad(st, x0, y0, cell) { const road = st.park.road; if (!road) return; // 다른 판: no-op const n = st.N, dyn = st.park.dyn.road, g = _pulseGlow(); const laneSet = new Set(road.lanes), period = road.period; // ---- ① 차선 분리선: 칸 사이 모서리 (파선=건널 수 있다 / 실선=못 건넌다) -------------- // L3(2026-08-05): 예전에는 분리대가 통짜 **열**이라 5칸 중 3칸만 차선으로 보였다. // 이제 선은 칸 사이 모서리에 산다 — 차선 다섯이 전부 주행칸이다. // 실선 여부는 엔진(E._parkRoadSolidEdge)이 쥔다. 화면은 다시 계산하지 않는다. // // 파선만 흐른다: 흐름은 순수 dyn.road.scroll(+ 부드러운 보간용 _parkStepFrac) 의 함수다 — // 시계가 여기 없으면 frame-legibility 프로브가 Δ0 을 낸다. _parkStepFrac 은 G.parkAnim 이 // 없으면 1을 돌려준다(안전한 상수) — 시연/입력 밖에서 호출돼도 죽지 않는다. { const lanesArr = road.lanes; const dashOn = cell * 0.30, dashGap = cell * 0.26, span = dashOn + dashGap; const flow = ((dyn.scroll + _parkStepFrac()) * cell) % span; bx.save(); bx.lineCap = 'butt'; for (let i = 0; i + 1 < lanesArr.length; i++) { const xLo = lanesArr[i]; const gx = x0 + (xLo + 1) * cell; // 두 차선 **사이** 모서리 for (let y = 0; y < n; y++) { const gy = y0 + y * cell; if (E._parkRoadSolidEdge(st, xLo, y)) { bx.setLineDash([]); bx.strokeStyle = _alpha(PARK_ROAD_LANE_HUE, 0.88 + 0.12 * g); bx.lineWidth = Math.max(1.8, cell * 0.115); // 실선은 굵다 — 무게가 곧 금지다 } else { bx.setLineDash([dashOn, dashGap]); bx.lineDashOffset = -flow; // 파선만 흐른다 — 도로가 나를 향해 온다 bx.strokeStyle = _alpha(PARK_ROAD_LANE_HUE, 0.5 + 0.18 * g); bx.lineWidth = Math.max(1.4, cell * 0.09); } bx.beginPath(); bx.moveTo(gx, gy); bx.lineTo(gx, gy + cell); bx.stroke(); } } bx.setLineDash([]); bx.restore(); } // ---- ② + ③ 펜스: 지금(실물) / 다음(유령) ------------------------------------------- // 2026-08-05: 빨간 채색을 A자 공사 바리케이드로 바꿨다. 채색은 남기되 알파를 크게 // 낮춘다(0.42+0.16g -> 0.12+0.06g) — 채널을 색에서 모양으로 옮기는 것이지 색을 지우는 // 게 아니다. 밴드 술어는 이제 칸 술어라(차선별 위상) 차선마다 다른 행이 펜스다. { // 차단 박자에는 펜스가 신호등 빨강과 같은 박자로 세게 뛴다 — "저 위 빨간 램프"와 // "이 빨간 칸"을 잇는 신호다(색만 같으면 우연으로 읽힌다). const gateBeatPulse = E._parkRoadGateBeat(st) ? (1 + 0.55 * g) : 1; bx.save(); for (const lx of road.lanes) for (let y = 0; y < n; y++) { if (!E._parkRoadBandAt(st, lx, y)) continue; const gx = x0 + lx * cell, gy = y0 + y * cell; bx.fillStyle = _alpha(PARK_ROAD_DRUM_HUE, Math.min(0.40, (0.12 + 0.06 * g) * gateBeatPulse)); bx.fillRect(gx, gy, cell + 0.5, cell + 0.5); _parkRoadFence(gx, gy, cell, Math.min(1, 0.88 * gateBeatPulse), false); } bx.restore(); bx.save(); for (const lx of road.lanes) for (let y = 0; y < n; y++) { if (E._parkRoadBandAt(st, lx, y)) continue; // 이미 '지금' 이 그렸다 if (!E._parkRoadDrumAt(st, lx, y)) continue; _parkRoadFence(x0 + lx * cell, y0 + y * cell, cell, 0.30 + 0.10 * g, true); } bx.restore(); } // ---- ⑥ 추월 토큰: 얇은 위쪽 쐐기 -------------------------------------------------------- { const overtakeSet = (st.park.chainAnyOf && st.park.chainAnyOf[0]) || [0, 1, 2]; bx.save(); bx.strokeStyle = _alpha(PARK_PAD_HUE, 0.85 + 0.15 * g); bx.lineWidth = Math.max(1.4, cell * 0.075); bx.lineCap = 'round'; bx.lineJoin = 'round'; for (const i of overtakeSet) { const t = st.tokens[i]; if (!t || !t.alive) continue; const cx = x0 + (t.x + 0.5) * cell, cy = y0 + (t.y + 0.5) * cell; const w = cell * 0.16, h = cell * 0.15; bx.beginPath(); bx.moveTo(cx - w, cy + h); bx.lineTo(cx, cy - h); bx.lineTo(cx + w, cy + h); bx.stroke(); } bx.restore(); } // ---- ⑦ 교통 NPC: 다른 색 에이전트 -------------------------------------------------------- // 차체가 아니라 몸이다. 이 판이 재는 것은 마음이고, 차는 배경으로 읽히지만 에이전트는 // 협상 상대로 읽힌다(사용자 결정 2026-08-05). 워커(drawParkAgent, 파랑)·동료 // (drawParkCompanion, 자홍)와 같은 종이되 색만 다르다 — PARK_ROAD_VEHICLE_HUE 는 그 // 둘과 안 겹치도록 이미 고른 강철 회청색이라 그대로 쓴다. { bx.save(); for (const nn of (road.npc || [])) { if (nn.y < 0 || nn.y >= n) continue; const cx = x0 + (nn.x + 0.5) * cell, cy = y0 + (nn.y + 0.5) * cell; const r = cell * 0.30; bx.fillStyle = _alpha(PARK_ROAD_VEHICLE_HUE, 0.94); bx.strokeStyle = 'rgba(14,15,19,0.7)'; bx.lineWidth = Math.max(1.2, cell * 0.05); bx.beginPath(); bx.arc(cx, cy, r, 0, 7); bx.fill(); bx.stroke(); // 눈 둘 — 이 판의 모든 몸이 그렇듯 위(전진 방향)를 본다. 시선 고정은 사용자 결정 // 2026-08-06(_parkFaceOf 의 주석 참고): 이 판에서 시선은 아무 상태도 안 나르고, 몸마다 // 다른 쪽을 보면 그 차이가 뜻이 있는 것처럼 읽힌다. 교통이 어느 쪽으로 흐르는지는 눈이 // 아니라 움직임과 드럼 밴드가 말한다. bx.fillStyle = 'rgba(14,15,19,0.82)'; for (const dx of [-0.34, 0.34]) { bx.beginPath(); bx.arc(cx + dx * r, cy - 0.22 * r, Math.max(1, r * 0.17), 0, 7); bx.fill(); } } // xx 액터 — 같은 종, 눈만 다르다. 다른 NPC 는 눈이 점 둘인데 이 액터는 xx 다. // 색까지 바꾸면 "다른 종"으로 읽혀 조심의 대상이 아니라 배경이 된다. const hz = road.hazard; if (hz && hz.y >= 0 && hz.y < n) { const cx = x0 + (hz.x + 0.5) * cell, cy = y0 + (hz.y + 0.5) * cell, r = cell * 0.30; bx.fillStyle = _alpha(PARK_ROAD_VEHICLE_HUE, 0.94); bx.strokeStyle = 'rgba(14,15,19,0.7)'; bx.lineWidth = Math.max(1.2, cell * 0.05); bx.beginPath(); bx.arc(cx, cy, r, 0, 7); bx.fill(); bx.stroke(); bx.strokeStyle = 'rgba(14,15,19,0.88)'; bx.lineWidth = Math.max(1.2, cell * 0.045); bx.lineCap = 'round'; for (const ex of [-0.34, 0.34]) { // 눈 둘, 각각 x 자 const ecx = cx + ex * r, ecy = cy - 0.20 * r, s = r * 0.20; // 위 — 판 전체 고정 시선 bx.beginPath(); bx.moveTo(ecx - s, ecy - s); bx.lineTo(ecx + s, ecy + s); bx.stroke(); bx.beginPath(); bx.moveTo(ecx + s, ecy - s); bx.lineTo(ecx - s, ecy + s); bx.stroke(); } } bx.restore(); } // ---- ⑧ 달리는 표시 (재중심화 2026-08-05) ------------------------------------------- // 워커가 화면에서 안 움직이므로 "달리고 있다"를 다른 채널로 내야 한다. 안 그러면 이 판은 // 정지 화면으로 읽힌다 — 재중심화가 만드는 유일한 새 위험이다. // · 다리 위상 — odometer 의 홀짝. 몸마다 발이 번갈아 뜬다 // · 속도선 — 몸 뒤로 뻗는 짧은 수평선. 길이는 이번 박자의 **상대속도**에 비례하고, // 뒤로 밀린 박자에는 반대 방향(앞쪽)으로 뻗는다 // odometer 만 읽는다 (PUBLIC st, C1) — persona 도 award 도 안 읽는다. { const od = dyn.odometer | 0; const step = od - (dyn.odoPrev === undefined ? od : dyn.odoPrev); // 이번 박자 순변위 const legUp = (od & 1) === 0; // 다리 위상 — 두 프레임 토글 const bodies = []; if (st.pos[0]) bodies.push({ x: st.pos[0].x, y: st.pos[0].y, self: true }); if (st.pos[1]) bodies.push({ x: st.pos[1].x, y: st.pos[1].y, self: false }); for (const nn of (road.npc || [])) bodies.push({ x: nn.x, y: nn.y, self: false }); bx.save(); bx.lineCap = 'round'; for (const b of bodies) { if (b.y < 0 || b.y >= n) continue; const cx = x0 + (b.x + 0.5) * cell, cy = y0 + (b.y + 0.5) * cell, r = cell * 0.30; // 다리 둘 — 위상에 따라 한 쪽이 길다. 몸(원) 아래로 짧게 뻗는다. bx.strokeStyle = 'rgba(14,15,19,0.62)'; bx.lineWidth = Math.max(1, cell * 0.045); for (const [i, sx] of [[0, -0.42], [1, 0.42]]) { const long = (i === 0) === legUp; bx.beginPath(); bx.moveTo(cx + sx * r, cy + r * 0.55); bx.lineTo(cx + sx * r, cy + r * (long ? 1.25 : 0.95)); bx.stroke(); } // 속도선 — 워커에게만 그린다(다른 몸은 제 속도가 따로 있어 이 값이 거짓이 된다). if (b.self && step !== 0) { const back = step > 0 ? 1 : -1; // 전진이면 뒤(아래)로, 밀리면 앞(위)으로 const len = Math.min(3, Math.abs(step)); bx.strokeStyle = _alpha(PARK_ROAD_SIGNAL_GO, 0.30 + 0.25 * g); bx.lineWidth = Math.max(1, cell * 0.035); for (let k = 0; k < len; k++) { const oy = cy + back * r * (1.35 + k * 0.42); bx.beginPath(); bx.moveTo(cx - r * 0.5, oy); bx.lineTo(cx + r * 0.5, oy); bx.stroke(); } } } bx.restore(); } // ---- ④ 차단 박자 핍 + 닫힌 전방 --------------------------------------------------------- { const gate = E._parkRoadGateBeat(st); // 3구 신호등. 이 판의 시계는 주기 전체가 아니라 "지금 내 전진이 legal 한가" 하나다 — // 차단 박자에 _parkRoadLegalMask 가 실제로 앞칸을 닫으므로(engine.js) 빨강은 은유가 아니다. // 6점 핍 줄이 있던 자리다: 핍은 "몇 박자 남았나"를 8px 점으로 말했는데, 사람이 알아야 할 // 것은 "지금 가도 되나"였다(사용자 실측 지적 2026-08-05). { const beat = dyn.scroll % period; const state = gate ? 2 : (beat === period - 1 ? 1 : 0); // 0 자유 / 1 예고 / 2 차단 const hues = [PARK_ROAD_SIGNAL_GO, PARK_ROAD_SIGNAL_WARN, PARK_ROAD_SIGNAL_STOP]; const r = cell * 0.15, gapY = r * 2.35; const bx0 = x0 + n * cell / 2, by0 = y0 + cell * 0.22; bx.save(); // 케이스 — 램프 셋을 담는 어두운 세로 상자. 판이 밝은 칸 위에 와도 대비가 산다 // (_parkPipRow 의 backing plate 와 같은 이유). const cw = r * 2.9, ch = gapY * 2 + r * 2.9; bx.fillStyle = 'rgba(9,11,15,0.72)'; bx.beginPath(); if (bx.roundRect) bx.roundRect(bx0 - cw / 2, by0 - r * 1.45, cw, ch, r * 0.7); else bx.rect(bx0 - cw / 2, by0 - r * 1.45, cw, ch); bx.fill(); for (let i = 0; i < 3; i++) { const on = i === state; bx.beginPath(); bx.arc(bx0, by0 + i * gapY, on ? r : r * 0.72, 0, 7); bx.fillStyle = on ? _alpha(hues[i], 0.92 + 0.08 * g) : _alpha(hues[i], 0.16); bx.fill(); if (on) { // 켜진 램프만 광륜을 쓴다 bx.strokeStyle = _alpha(hues[i], 0.45 + 0.3 * g); bx.lineWidth = Math.max(1, cell * 0.05); bx.beginPath(); bx.arc(bx0, by0 + i * gapY, r * 1.5, 0, 7); bx.stroke(); } // STOP 램프(i===2)에는 펜스와 같은 빗금을 작게 넣는다 — 색만 같으면 "저 빨강과 // 이 빨강이 같은 것"이 우연으로 읽힌다. 모양이 그 연결을 잇는다. if (i === 2) { bx.save(); bx.globalAlpha = on ? 0.9 : 0.3; bx.strokeStyle = 'rgba(20,12,10,0.85)'; bx.lineWidth = Math.max(0.8, cell * 0.022); const rr = (on ? r : r * 0.72) * 0.72; for (const k of [-1, 0, 1]) { bx.beginPath(); bx.moveTo(bx0 + k * rr * 0.6 - rr * 0.35, by0 + i * gapY - rr * 0.5); bx.lineTo(bx0 + k * rr * 0.6 + rr * 0.35, by0 + i * gapY + rr * 0.5); bx.stroke(); } bx.restore(); } } bx.restore(); } if (gate) { const fy = st.pos[0].y - 1; if (fy >= 0 && fy < n) { const gx = x0 + st.pos[0].x * cell, gy = y0 + fy * cell; bx.save(); bx.fillStyle = _alpha('#ff6a4a', 0.55 + 0.3 * g); bx.fillRect(gx + 2, gy + 2, cell - 4, cell - 4); bx.strokeStyle = _alpha('#ffe0d4', 0.9); bx.lineWidth = Math.max(1.4, cell * 0.06); bx.lineCap = 'round'; for (const fx of [-0.5, 0, 0.5]) { // 차단 게이트 빗금 — "여긴 지금 닫혔다" bx.beginPath(); bx.moveTo(gx + cell * (0.5 + fx) - cell * 0.14, gy + cell * 0.18); bx.lineTo(gx + cell * (0.5 + fx) + cell * 0.14, gy + cell * 0.82); bx.stroke(); } bx.restore(); } } } // ---- ⑤ 안전 포켓 ------------------------------------------------------------------------ { const pocket = E._parkRoadPocket(st); // 41.7% 는 null(Task 3 실측) — 널 가드 필수 if (pocket) { const gx = x0 + pocket.x * cell, gy = y0 + pocket.y * cell, in0 = cell * 0.13; bx.save(); bx.setLineDash([Math.max(2, cell * 0.14), Math.max(2, cell * 0.10)]); bx.strokeStyle = _alpha(PARK_ROAD_POCKET_HUE, 0.6 + 0.35 * g); bx.lineWidth = Math.max(1.6, cell * 0.08); bx.strokeRect(gx + in0, gy + in0, cell - 2 * in0, cell - 2 * in0); bx.setLineDash([]); bx.restore(); } } } PARK_FIELD_RENDER.road = _paintParkRoad; // the render seam's y58 client (cf. PARK_FIELD_MECHS.road) /* y58 갈 수 있는 칸 (POST 심; 사용자 결정 2026-08-06). 이 판의 어려움은 "지금 어디로 갈 수 있나"를 사람이 매 박자 머리로 다시 푸는 데 있다 — 차선·실선·차단 박자·동료 점유가 전부 동시에 legal 집합을 깎기 때문이다. 그 집합은 이미 엔진이 쥐고 있으므로(E._parkLegal) 화면은 그걸 그대로 칠하기만 한다. 화면이 다시 계산하면 안 된다는 규율은 블링커와 같다. 초록은 새 색이 아니라 이 판의 신호등 GO 램프(PARK_ROAD_SIGNAL_GO)와 **같은 상수**다: 저 초록이 켜졌을 때 갈 수 있는 칸이 이 초록이라는 한 어휘로 읽혀야 한다(드럼-빨강이 신호-빨강과 같은 값인 것과 같은 이유). 그래서 차단 박자에 전방이 닫히면 그 칸의 초록은 저절로 사라진다 — legal 집합이 실제로 줄기 때문이지 여기서 따로 지우는 게 아니다. 'stay' 는 안 칠한다: 제자리는 이동 방향이 아니고, 워커 몸 밑을 칠하면 파란 몸의 대비만 깎는다. PRE 가 아니라 POST 인 이유는 이 칸에 교통 NPC 가 서 있을 수 있어서다 — "차가 있는 저 칸도 legal 이다"는 이 판에서 가장 비싼 정보이므로 몸에 가려지면 안 된다. P.over 면 안 그린다. 그 외에는 매 프레임 살아있는 P 에서 다시 읽으므로 판이 끝날 때까지 매 턴 갱신된다 — 전용 카운터도, 꺼지는 조건도 없다. */ function _paintParkRoadMoves(st, x0, y0, cell, P) { if (!st.park || !st.park.road || !P || P.over) return; // 다른 판/컨텍스트 없음/끝난 판: no-op const from = st.pos[0]; if (!from) return; const g = _pulseGlow(); bx.save(); for (const m of E._parkLegal(P)) { if (m.x === from.x && m.y === from.y) continue; // 제자리는 이동 방향이 아니다 const gx = x0 + m.x * cell, gy = y0 + m.y * cell, in0 = cell * 0.08; const w = cell - 2 * in0; bx.fillStyle = _alpha(PARK_ROAD_SIGNAL_GO, 0.18 + 0.10 * g); bx.fillRect(gx + in0, gy + in0, w, w); bx.strokeStyle = _alpha(PARK_ROAD_SIGNAL_GO, 0.70 + 0.22 * g); bx.lineWidth = Math.max(1.4, cell * 0.055); bx.strokeRect(gx + in0, gy + in0, w, w); } bx.restore(); } /* y58 동료 깜빡이 (POST 심). 사람이 모르는 것은 자기 수가 아니라 **남의 수**다 — 이 판이 재는 것도 그거다(사용자 결정 2026-08-05). 화살표는 동료 몸 위에 서야 읽히므로 PRE 심이 아니라 POST 심에 등록한다: PRE(_paintParkRoad)는 액터보다 먼저 그려져 drawParkCompanion 이 덮는다. 정직성이 이 함수의 전부다. _parkCompanionPlan 은 네 형태를 돌려준다 — null(목표 없음), {arrived:true}, {stuck:true}, {next:{x,y}}. arrived 와 stuck 은 **둘 다 next:null** 이라 구분 못 하면 "막혔다"를 "간다"로 그린다. 그건 이 프로젝트에서 가장 비싼 종류의 버그다. 화살표는 오직 next 가 실재하고 그게 가로 이동일 때만 뜬다 — 세로 전진은 차선 변경이 아니므로 깜빡이가 아니다. */ function _paintParkRoadBlinker(st, x0, y0, cell, P) { if (!st.park || !st.park.road || !P) return; // 다른 판/컨텍스트 없음: no-op const n = st.N, g = _pulseGlow(); const blink = 0.35 + 0.65 * g; // 깜빡임 — 실제 방향지시등의 리듬 // 그릴 대상 둘. 부재가 정보다 (사용자 결정 2026-08-05): npc[0] 은 차선을 안 바꾸고 // xx 액터는 예고 없이 튼다 — 둘 다 깜빡이가 없는 것이 옳다. 그래야 판의 규칙이 선다: // "깜빡이가 켜지면 차선을 바꾼다", "깜빡이 없이 트는 놈이 위험한 놈". const marks = []; // ① 동료 — _parkCompanionPlan 의 가로 이동. 정직성이 이 절의 전부다: 그 함수는 네 형태를 // 돌려주고(null / {arrived} / {stuck} / {next}), arrived 와 stuck 은 둘 다 next:null 이라 // 구분 못 하면 "막혔다"를 "간다"로 그린다. 화살표는 next 가 실재하고 가로일 때만 뜬다. const plan = E._parkCompanionPlan(P); const mate = st.pos[1]; if (mate && plan && !plan.stuck && !plan.arrived && plan.next) { const dx = plan.next.x - mate.x; if (dx !== 0) marks.push({ x: mate.x, y: mate.y, side: dx > 0 ? 1 : -1 }); } // ② 추월자 — 엔진이 tick 에서 쓴 intent 를 그대로 읽는다. 화면은 다시 계산하지 않는다. // 미설정 가드: 첫 프레임 등 intent 가 아직 없는 박자에 undefined.dx 로 죽지 않게. const ov = st.park.road.npc && st.park.road.npc[1]; const odx = (ov && ov.intent && ov.intent.dx) | 0; if (ov && odx !== 0) marks.push({ x: ov.x, y: ov.y, side: odx > 0 ? 1 : -1 }); if (!marks.length) return; bx.save(); bx.fillStyle = _alpha('#ffb020', blink); // 호박색 — 실제 깜빡이 색 bx.strokeStyle = _alpha('#2a1c06', 0.55 * blink); bx.lineWidth = Math.max(1, cell * 0.03); for (const m of marks) { if (m.y < 0 || m.y >= n) continue; const cx = x0 + (m.x + 0.5 + m.side * 0.62) * cell, cy = y0 + (m.y + 0.5) * cell; const w = cell * 0.17, h = cell * 0.20; bx.beginPath(); bx.moveTo(cx + m.side * w, cy); bx.lineTo(cx - m.side * w * 0.35, cy - h); bx.lineTo(cx - m.side * w * 0.35, cy + h); bx.closePath(); bx.fill(); bx.stroke(); } bx.restore(); } // POST 심은 id 당 하나뿐이라, 이 판의 두 사후 마크를 한 디스패처가 순서대로 부른다. 순서가 // 곧 층이다: 갈 수 있는 칸(바닥 위) 다음에 동료 깜빡이(그 위) — 초록 칠이 호박색 화살표를 // 덮으면 남의 수를 읽는 채널이 죽는다. function _paintParkRoadPost(st, x0, y0, cell, P) { _paintParkRoadMoves(st, x0, y0, cell, P); _paintParkRoadBlinker(st, x0, y0, cell, P); } PARK_FIELD_RENDER_POST.road = _paintParkRoadPost; // the post seam's y58 client (액터 위에 뜬다) // NO HUD POLARITY CHIP, deliberately (owner decision 2026-07-20). The y23 design spec asked for a // chip beside the HUD hearts, but drawParkHUD is monolithic and has NO per-mechanic seam — no field // module, flood included, touches it — so a chip would mean editing shared HUD code, which this // task's additive-only rule forbids. It is also redundant: the ZONE TINT COLOUR already is the // polarity badge, and zone + preview + console pip all swap together, so "who is being hurt" is // legible from the board alone. Whoever wants the chip should open a HUD seam first, not reach in. /* y59 PLAZA FIELD LAYER (ZERO-TEXT; shared by the live scene and the hub thumbnail). An OVERLAY over the finished terrain and a pure function of the PUBLIC board + dyn (C1 — never the persona, never awards). The read is y19 tower's, one board wider: pos[0] is a CURSOR at a console and what WALKS is a crowd of residents, each with a colour and a door of that colour. The cursor's COMMUTE is the only cost in the game (the farthest two lamps sit 6 beats apart), so every mark below exists to turn "which doorway must I reach, and by when" into a READ instead of a memory. Nothing here takes input — the painter draws, the five keys decide (spec §2-⑥). The marks, and why each is here: ① CONTROL-DUSK TONE — the same cool slate wash tower lays over its whole plot (PARK_PLAZA_TONE is _paintParkTower's literal, unchanged). Same family, same palette: a control board is recognisable in the picker at a glance, and no ANCHOR glyph (gems, the companion, ♥) is touched to get it. ② THE TWO-COLOUR CROWD— each resident is a SHRUNKEN _parkActor ringed in its EXIT's colour, and the two exit cells wear that same colour as a wash + frame. One hue answers "where is this body going" and "whose door is that" at once, which is the only way crossing traffic reads at thumbnail scale. A STUNNED resident gets statue's 'sent' bars (three amber rules) over the actor's own squint — the roster's existing word for "this body is out for a beat", not a new one. Bodies go through _parkActor and nothing else, so a resident is visibly the same creature as every other body in the park. ③ THE LAMP RULES THE DOORWAY — a two-eyed lamp housing on each sigKeys cell (red eye up, green eye down, the world's own convention) and its governed doorKeys cell tinted in the LIVE colour with a barrier across it: one bar right across the gap while shut, retracted to two jamb stubs while open. THE TINT IS THE VERDICT (beacon's idiom) — nobody has to trace a wire to know whether that gap is passable. A dashed tie from lamp to doorway names the wiring anyway, because in the engine that pairing is index alignment and index alignment is invisible. The lamp's own CELL carries a fainter wash of the same colour, because this layer paints UNDER the actors and a body standing on a lamp hides the housing outright (measured in capture) — a cell's corners are the one place a round body cannot reach. And when the CURSOR is that body, the cell takes a breathing frame: on that one cell of the board `stay` is not rest, it is the toggle. ④ THE SPAWN TELEGRAPH — every scheduled arrival within PARK_PLAZA_TELEGRAPH beats outlines its ENTRANCE in a breathing dash and shows a disc in the colour that is about to stand there (beacon's breathing-dash preview, re-derived). The due beat is read as `row.beat + dyn.plaza.slip`, never `row.beat`: a blocked entrance pushes the whole tail back, and a telegraph that ignored the slip would blink for a body that is not coming yet. ⑤ patience pips — DELIBERATELY ABSENT. Spec §5 made them conditional on the falsifier showing an all-close bot winning; it was measured twice (2026-08-04, seeds 1..10) and that row never fired, so `ent.pips` is never spent. A pip row over a counter nothing decrements is a countdown that never counts — the exact Δ0 lie the clock registry exists to stop. ⑥ THE CLOCK (LAST in the scene — registry convention) — delivery pips on the wall beside each exit, one per body of that colour with the delivered ones filled, and a pip row on the plaza side of the NEXT arrival's entrance counting down to it. The countdown uses the shared _parkPipRow, so "how long have I got" looks the same here as on every other board. Declared at PARK_FIELD_CLOCK.plaza. ⑦ THE EVENTS — a cracked heart where a tangle billed the controller ('crash'), and tower's own lit console pip where the cursor flipped a lamp ('toggle'). Read off _parkStepFx, never st.fx directly: the park's fx list is an APPEND-ONLY episode log, so a raw scan would leave every crash of the whole run painted on the last frame (the bug statue's mark loop had). No POST layer: this board has no human-only affordance to advertise — the toggle is `stay`, a verb the oracle takes too. */ const PARK_PLAZA_TONE = 'rgba(96,118,150,0.08)'; // tower's wash, unchanged — one family, one tone const PARK_PLAZA_COL = { R: '#e05a4a', B: '#5cc0e8' }; // the two exits and the two crowds. Lighter and // smaller than the walker's own #3f7df6, so the // blue CROWD never reads as the blue CURSOR. const PARK_PLAZA_GO = '#7fd6a2'; // lamp: pass const PARK_PLAZA_STOP = '#e5432f'; // lamp: hold (the roster's warning red) const PARK_PLAZA_SENT = '#ffb03a'; // statue's 'sent' amber — a body that is out function _paintParkPlaza(st, x0, y0, cell) { if (!st.park || !st.park.plaza || !st.park.dyn || !st.park.dyn.plaza) return; const n = st.N, park = st.park, dyn = park.dyn, Z = park.plaza, dz = dyn.plaza; const g = _pulseGlow(), beat = dyn.beat | 0; const kx = (k) => k % n, ky = (k) => (k / n) | 0; const cx0 = (k) => x0 + kx(k) * cell, cy0 = (k) => y0 + ky(k) * cell; const ccx = (k) => x0 + (kx(k) + 0.5) * cell, ccy = (k) => y0 + (ky(k) + 0.5) * cell; const lw = (f) => Math.max(1, cell * f); const hueOf = (c) => PARK_PLAZA_COL[c] || PARK_PLAZA_COL.R; // ① the family's surface tone (anchors are never touched) bx.save(); bx.fillStyle = PARK_PLAZA_TONE; bx.fillRect(x0, y0, n * cell, n * cell); bx.restore(); // ② the two exits, each wearing the colour of the crowd it takes. The chain gem on that cell is // an ANCHOR and is drawn by the shared law on top of this — the wash sits under it, untouched. for (const [key, hue] of [[Z.exitR, PARK_PLAZA_COL.R], [Z.exitB, PARK_PLAZA_COL.B]]) { const ex = cx0(key), ey = cy0(key); bx.save(); bx.fillStyle = _alpha(hue, 0.17); bx.fillRect(ex, ey, cell + 0.5, cell + 0.5); bx.strokeStyle = _alpha(hue, 0.72); bx.lineWidth = lw(0.06); bx.strokeRect(ex + cell * 0.09, ey + cell * 0.09, cell * 0.82, cell * 0.82); bx.restore(); } // ③ the lamps and the doorways they rule. sigKeys[i] rules doorKeys[i] — index alignment, which // is why the tie line below is drawn at all. const me = st.pos && st.pos[0]; for (let i = 0; i < Z.sigKeys.length; i++) { const open = !!(dz.signals && dz.signals[i]); const hue = open ? PARK_PLAZA_GO : PARK_PLAZA_STOP; const sk = Z.sigKeys[i], dk = Z.doorKeys[i]; // the doorway — THE TINT IS THE VERDICT, the bar only says it twice const dx = cx0(dk), dy = cy0(dk), mid = dy + cell * 0.5; bx.save(); bx.fillStyle = _alpha(hue, open ? 0.10 : 0.28); bx.fillRect(dx, dy, cell + 0.5, cell + 0.5); bx.strokeStyle = _alpha(hue, open ? 0.55 : 0.80 + 0.20 * g); bx.lineWidth = lw(0.085); bx.lineCap = 'round'; bx.beginPath(); if (open) { // retracted into the jambs bx.moveTo(dx + cell * 0.05, mid); bx.lineTo(dx + cell * 0.24, mid); bx.moveTo(dx + cell * 0.76, mid); bx.lineTo(dx + cell * 0.95, mid); } else { // lowered right across the gap bx.moveTo(dx + cell * 0.05, mid); bx.lineTo(dx + cell * 0.95, mid); } bx.stroke(); bx.restore(); // the tie — which lamp this doorway obeys bx.save(); bx.strokeStyle = _alpha(hue, 0.26 + 0.20 * g); bx.lineWidth = lw(0.035); bx.setLineDash([Math.max(1.5, cell * 0.09), Math.max(1.5, cell * 0.09)]); bx.beginPath(); bx.moveTo(ccx(sk), ccy(sk)); bx.lineTo(ccx(dk), ccy(dk)); bx.stroke(); bx.setLineDash([]); bx.restore(); // the lamp CELL, washed in the live colour. This exists because the field layer paints UNDER // the actors: a body standing on a lamp hides the housing completely (measured in capture, // 2026-08-04), and a wash keeps the state readable at the cell's corners, which a round body // can never cover. It is deliberately fainter than the doorway's — the doorway is the verdict // that decides passage, the lamp cell only says which switch this is. const sx = cx0(sk), sy = cy0(sk); bx.save(); bx.fillStyle = _alpha(hue, open ? 0.07 : 0.16); bx.fillRect(sx, sy, cell + 0.5, cell + 0.5); bx.restore(); // the lamp itself const lx = ccx(sk), ly = ccy(sk), rad = cell * 0.125, off = cell * 0.165; bx.save(); bx.fillStyle = 'rgba(14,16,22,0.82)'; bx.beginPath(); const bw = cell * 0.40, bh = cell * 0.68; if (bx.roundRect) bx.roundRect(lx - bw / 2, ly - bh / 2, bw, bh, cell * 0.13); else bx.rect(lx - bw / 2, ly - bh / 2, bw, bh); bx.fill(); bx.strokeStyle = _alpha('#7f93b0', 0.45); bx.lineWidth = lw(0.035); bx.stroke(); for (const [dyo, eye, lit] of [[-off, PARK_PLAZA_STOP, !open], [off, PARK_PLAZA_GO, open]]) { bx.beginPath(); bx.arc(lx, ly + dyo, lit ? rad : rad * 0.70, 0, 7); bx.fillStyle = _alpha(eye, lit ? 0.90 + 0.10 * g : 0.15); bx.fill(); if (lit) { bx.strokeStyle = _alpha(eye, 0.35 + 0.30 * g); bx.lineWidth = lw(0.045); bx.beginPath(); bx.arc(lx, ly + dyo, rad * 1.55, 0, 7); bx.stroke(); } } bx.restore(); // The cursor is ON this lamp: here `stay` is the TOGGLE, not rest — the one cell on the board // where the rest key is not rest. A breathing FRAME says so, deliberately not a ring: the // 'toggle' event mark below is a ring at the same cell, and two circles a few pixels apart // read as one smudge. A square's corners are also the only part of a cell a round body cannot // cover, which is the same reason the wash above exists. if (me && me.y * n + me.x === sk) { bx.save(); bx.strokeStyle = _alpha(hue, 0.55 + 0.40 * g); bx.lineWidth = lw(0.06); bx.lineCap = 'round'; bx.setLineDash([Math.max(2, cell * 0.17), Math.max(2, cell * 0.12)]); bx.strokeRect(sx + cell * 0.05, sy + cell * 0.05, cell * 0.90, cell * 0.90); bx.setLineDash([]); bx.restore(); } } // ④ the spawn telegraph. `slip` is the accumulated push-back, so the due beat is row.beat + slip. const sched = dz.sched || []; for (let i = dz.next | 0; i < sched.length; i++) { const row = sched[i], due = (row.beat | 0) + (dz.slip | 0), left = due - beat; if (left > E.PARK_PLAZA_TELEGRAPH) break; // the schedule is in beat order if (left < 0) continue; const ek = row.entr === 'N' ? Z.entrN : Z.entrS, hue = hueOf(row.color); const ex = cx0(ek), ey = cy0(ek), in0 = cell * 0.13; bx.save(); bx.setLineDash([Math.max(2, cell * 0.16), Math.max(2, cell * 0.12)]); bx.strokeStyle = _alpha(hue, 0.38 + 0.42 * g); bx.lineWidth = lw(0.075); bx.lineCap = 'round'; bx.strokeRect(ex + in0, ey + in0, cell - 2 * in0, cell - 2 * in0); bx.setLineDash([]); bx.fillStyle = _alpha(hue, 0.26 + 0.38 * g); // the colour that is about to stand here bx.beginPath(); bx.arc(ex + cell * 0.5, ey + cell * 0.5, cell * 0.17, 0, 7); bx.fill(); bx.restore(); } // ② (cont.) the crowd. Facing is derived from the body's OWN exit — a public field, so this stays // a pure render. The exit ring is drawn outside the actor so the shared silhouette is untouched. for (const e of (dyn.ents || [])) { if (e.done) continue; const px = x0 + (e.x + 0.5) * cell, py = y0 + (e.y + 0.5) * cell; const hue = hueOf(e.color), rr = cell * 0.24; const ddx = kx(e.exitKey) - e.x, ddy = ky(e.exitKey) - e.y; const fdx = Math.abs(ddx) >= Math.abs(ddy) ? Math.sign(ddx) : 0; const fdy = fdx ? 0 : Math.sign(ddy); _parkActor(bx, px, py, rr, hue, fdx, fdy, e.stun > 0 ? 1 : 0); bx.save(); bx.strokeStyle = _alpha(hue, 0.85); bx.lineWidth = lw(0.055); bx.beginPath(); bx.arc(px, py, rr * 1.36, 0, 7); bx.stroke(); bx.restore(); if (e.stun > 0) { // statue's 'sent' bars: out for a beat bx.save(); bx.strokeStyle = _alpha(PARK_PLAZA_SENT, 0.62 + 0.30 * g); bx.lineWidth = lw(0.07); bx.lineCap = 'round'; bx.beginPath(); for (const s of [-1, 0, 1]) { bx.moveTo(px - cell * 0.20, py + s * cell * 0.16 - cell * 0.06); bx.lineTo(px + cell * 0.20, py + s * cell * 0.16 - cell * 0.06); } bx.stroke(); bx.restore(); } } // ⑦ this beat's events only (_parkStepFx — st.fx is an append-only episode log) for (const f of _parkStepFx(st)) { if (f.x == null) continue; const fx = x0 + (f.x + 0.5) * cell, fy = y0 + (f.y + 0.5) * cell; if (f.k === 'crash') { drawHeartCrack(fx, fy - cell * 0.06, cell * 0.20); } else if (f.k === 'toggle') { // tower's committed-console mint, reused // A toggle ALWAYS happens on the cursor's own cell, and the cursor is drawn over this layer, // so the mark has to clear the sprite or it does not exist: r 0.36 and the centre pip were // both measured invisible (capture, 2026-08-04). One ring at r 0.58 is the whole mark. bx.save(); bx.strokeStyle = _alpha('#8fd4c0', 0.60 + 0.35 * g); bx.lineWidth = lw(0.085); bx.lineCap = 'round'; bx.beginPath(); bx.arc(fx, fy, cell * 0.58, 0, 7); bx.stroke(); bx.restore(); } } // ⑥ THE CLOCK — last in this scene, by the registry's convention. // (a) delivery pips, on the wall cell beside each exit so they can never sit under a body. const rail = (key) => { const ax = kx(key), ay = ky(key); for (const d of [[1, 0], [-1, 0], [0, -1], [0, 1]]) { const nx = ax + d[0], ny = ay + d[1]; if (nx < 0 || ny < 0 || nx >= n || ny >= n) continue; if (st.wall.has(ny * n + nx)) return { cx: x0 + (nx + 0.5) * cell, cy: y0 + (ny + 0.5) * cell, vert: d[1] === 0 }; } return { cx: x0 + (ax + 0.5) * cell, cy: y0 + (ay + 0.5) * cell, vert: true }; }; const totR = sched.filter(r => r.color === 'R').length; for (const [key, tot, remain, hue] of [[Z.exitR, totR, dz.remainR, PARK_PLAZA_COL.R], [Z.exitB, sched.length - totR, dz.remainB, PARK_PLAZA_COL.B]]) { if (!tot) continue; const R = rail(key), step = Math.min(cell * 0.22, (cell * 0.88) / tot), base = -(tot - 1) / 2; const half = Math.abs(base) * step + cell * 0.09, thick = cell * 0.10; const w = R.vert ? thick * 2 : half * 2, h = R.vert ? half * 2 : thick * 2; bx.save(); bx.fillStyle = 'rgba(9,11,15,0.55)'; // the backing plate (_parkPipRow's lesson) bx.beginPath(); if (bx.roundRect) bx.roundRect(R.cx - w / 2, R.cy - h / 2, w, h, Math.min(w, h) / 2); else bx.rect(R.cx - w / 2, R.cy - h / 2, w, h); bx.fill(); const done = tot - (remain | 0); for (let i = 0; i < tot; i++) { const o = (base + i) * step, on = i < done; const ppx = R.cx + (R.vert ? 0 : o), ppy = R.cy + (R.vert ? o : 0); bx.beginPath(); bx.arc(ppx, ppy, cell * (on ? 0.072 : 0.05), 0, 7); bx.fillStyle = _alpha(hue, on ? 0.95 : 0.20); bx.fill(); if (!on) { bx.strokeStyle = _alpha(hue, 0.55); bx.lineWidth = Math.max(1, cell * 0.02); bx.stroke(); } } bx.restore(); } // (b) the beats down to the NEXT arrival, at the entrance it will use. if ((dz.next | 0) < sched.length) { const row = sched[dz.next | 0], due = (row.beat | 0) + (dz.slip | 0); const ek = row.entr === 'N' ? Z.entrN : Z.entrS; const total = Math.max(1, E.PARK_PLAZA_SPAWN_GAP | 0); const left = Math.max(0, Math.min(total - 1, due - beat)); const inward = ky(ek) < n / 2 ? 1 : -1; // the entrance sits on the rim; face the plaza const step = Math.min(cell * 0.22, (cell * 0.92) / total); _parkPipRow(bx, x0 + (kx(ek) + 0.5) * cell, y0 + (ky(ek) + 0.5 + inward * 0.46) * cell, step, Math.max(1.6, cell * 0.075), Math.max(1.2, cell * 0.05), { total, now: total - 1 - left, warnFrom: total - 1 }, g); } } PARK_FIELD_RENDER.plaza = _paintParkPlaza; // the render seam's y59 client (cf. PARK_FIELD_MECHS.plaza) // P13 PUSH VERB LAYER (shared by the live scene and the hub thumbnail): the CARGO CRATE at // st.box (slatted square — a body, not a token: it borrows no reserved goal vocabulary) and // the DESTINATION PAD at park.pad (the reserved concentric pad glyph — the same "stand/land // here" grammar reach uses, so box-on-pad reads without words). Pure public board fn (C1). function _drawParkPushLayer(st, x0, y0, cell) { if (!st.box) return; const park = st.park; const px = x0 + (park.pad.x + 0.5) * cell, py = y0 + (park.pad.y + 0.5) * cell; _parkPad(bx, px, py, cell * 0.42); const bxx = x0 + st.box.x * cell, byy = y0 + st.box.y * cell; bx.save(); bx.fillStyle = '#a8794a'; bx.fillRect(bxx + cell * 0.14, byy + cell * 0.14, cell * 0.72, cell * 0.72); bx.strokeStyle = '#5f4527'; bx.lineWidth = Math.max(1.2, cell * 0.09); bx.lineJoin = 'round'; bx.strokeRect(bxx + cell * 0.14, byy + cell * 0.14, cell * 0.72, cell * 0.72); bx.beginPath(); // cross braces = "crate", at any scale bx.moveTo(bxx + cell * 0.14, byy + cell * 0.14); bx.lineTo(bxx + cell * 0.86, byy + cell * 0.86); bx.moveTo(bxx + cell * 0.86, byy + cell * 0.14); bx.lineTo(bxx + cell * 0.14, byy + cell * 0.86); bx.stroke(); bx.restore(); } // P8.6 §C.1 — per-archetype palette (wall band + walkway hue per topology family). 'park' // is byte-identical to the legacy PARK_HUES pair, so the P1 capstone keeps its exact look. const PARK_ARCH_PALETTE = { park: { wall: '#242a21', walk: '#767d8a' }, // tree band + gray path (legacy, unchanged) serpent: { wall: '#2e2a24', walk: '#837a6c' }, // dry-stone earthworks + earthen lane pools: { wall: '#1e282e', walk: '#6e7d88' }, // wet blue-slate rim + cool stone islands: { wall: '#2e2a1e', walk: '#8d8470' }, // driftwood margin + sandy path }; // _parkDecorKey(st, x, y, n): 장식을 뽑을 **원본 칸**의 인덱스. 보통은 그 칸 자신(y*n+x)이라 // 장식은 판에 못박혀 있다 — 기하가 정적이니 소품도 정적인 게 맞다. y58 흐르는 도로만 예외다 // (사용자 결정 2026-08-06): 도로가 흐르는데 양옆 갓길이 못박혀 있으면 움직이는 것은 차선 페인트 // 뿐이고 세상은 서 있다. 원본 행을 scroll 만큼 거꾸로 당겨(wrap) 뽑으면, 한 박자에 소품이 한 행씩 // **아래로**(교통과 같은 방향, 워커의 전진과 반대) 밀려난다 — 워커가 위로 나아간다는 사실이 // 갓길에서도 읽힌다. 해시가 원본 kk 로 도니 기둥 하나가 제 하위-칸 오프셋을 그대로 데리고 // 내려간다: 같은 소품이 흘러가는 것이지 매 박자 새로 뽑히는 게 아니다. // 기하는 건드리지 않는다 — 어느 칸이 벽인가는 빌드 때 굳은 그대로고(engine.js "기하는 정적이다"), // 여기서 움직이는 것은 그 벽칸 위에 찍히는 그림뿐이다. PUBLIC dyn.road.scroll 만 읽는다 (C1). function _parkDecorKey(st, x, y, n) { const dyn = st.park.road && st.park.dyn && st.park.dyn.road; if (!dyn) return y * n + x; // 다른 판/썸네일: 옛 정적 장식 그대로 // 2026-08-05 재중심화: scroll 이 아니라 odometer 를 읽는다. scroll 은 매 박자 무조건 // +1 인 박자 카운터라, 워커가 뒤로 밀리는 박자에도 갓길이 같은 방향으로 흘렀다 — // 화면이 "전진 중"이라고 거짓말한 것이다. odometer 는 순 전진량이라 밀리는 박자에는 // 갓길이 반대로 흐른다. return ((((y - (dyn.odometer | 0)) % n) + n) % n) * n + x; } // _parkWallDecor(arch, park, kk, px, py, cell): the §C.1 seeded decor glyph prop for ONE // wall/margin cell — a PURE function of the PUBLIC cell fields (park.seed, park.k, arch, // cell index): same public cell -> byte-identical decor across personas; decor changes // with arch (C1-DECOR). Wall band ONLY (never walkway/verge/deep — the two-tone hazard // read is untouched; walkability untouched). Glyph primitives only (ZERO-TEXT), and NO // prop borrows the reserved object vocabulary (gem diamond / 5-point star / concentric // pad / basket / collect circle-square-triangle): trees are lumpy canopies, benches are // slats, masonry is joint lines, shimmer is waves, sand is sub-pixel specks. function _parkWallDecor(arch, park, kk, px, py, cell) { const h = (((kk * 2654435761) ^ ((((park.seed >>> 0) * 131) + ((park.k | 0) * 17)) >>> 0)) >>> 0); if (h % 3) return; // ~1/3 of wall cells carry a prop const cx = px + cell * (0.32 + 0.12 * ((h >>> 4) % 4)), cy = py + cell * (0.34 + 0.11 * ((h >>> 7) % 4)); bx.save(); bx.lineCap = 'round'; bx.lineJoin = 'round'; if (arch === 'serpent') { // dry-stone masonry: two offset course joints + one header joint (pale, recessed) bx.strokeStyle = 'rgba(148,138,120,0.5)'; bx.lineWidth = Math.max(1, cell * 0.07); bx.beginPath(); bx.moveTo(px + cell * 0.14, cy); bx.lineTo(px + cell * 0.6, cy); bx.stroke(); bx.beginPath(); bx.moveTo(px + cell * 0.4, cy + cell * 0.26); bx.lineTo(px + cell * 0.88, cy + cell * 0.26); bx.stroke(); bx.beginPath(); bx.moveTo(px + cell * 0.52, cy); bx.lineTo(px + cell * 0.52, cy + cell * 0.26); bx.stroke(); } else if (arch === 'pools') { // water shimmer: two short stacked waves in a pale aqua (rim moisture) bx.strokeStyle = 'rgba(158,208,228,0.42)'; bx.lineWidth = Math.max(1, cell * 0.06); for (const oy of [0, cell * 0.22]) { bx.beginPath(); bx.moveTo(cx - cell * 0.26, cy + oy); bx.quadraticCurveTo(cx - cell * 0.13, cy + oy - cell * 0.12, cx, cy + oy); bx.quadraticCurveTo(cx + cell * 0.13, cy + oy + cell * 0.12, cx + cell * 0.26, cy + oy); bx.stroke(); } } else if (arch === 'islands') { // sand specks: three sub-pixel pale grains (never a token-sized dot) bx.fillStyle = 'rgba(216,201,154,0.5)'; for (const [dx, dy] of [[-0.18, 0.1], [0.12, -0.14], [0.2, 0.18]]) { bx.beginPath(); bx.arc(cx + dx * cell, cy + dy * cell, Math.max(0.8, cell * 0.045), 0, 7); bx.fill(); } } else if (arch === 'road') { // 갓길: 가드레일 기둥 하나 + 그 뒤 짧은 풀. 도로 판의 벽은 공원 가구가 아니라 노견이다. bx.strokeStyle = 'rgba(176,184,196,0.55)'; bx.lineWidth = Math.max(1, cell * 0.075); bx.beginPath(); // 기둥 bx.moveTo(cx, cy - cell * 0.16); bx.lineTo(cx, cy + cell * 0.14); bx.stroke(); bx.strokeStyle = 'rgba(122,142,110,0.42)'; bx.lineWidth = Math.max(1, cell * 0.05); for (const dx of [-0.20, 0.18]) { // 노견 풀 bx.beginPath(); bx.moveTo(cx + dx * cell, cy + cell * 0.18); bx.lineTo(cx + dx * cell + cell * 0.05, cy + cell * 0.04); bx.stroke(); } } else if ((h >>> 9) % 2) { // park bench: two horizontal slats + two legs (warm wood) bx.strokeStyle = 'rgba(122,100,70,0.75)'; bx.lineWidth = Math.max(1, cell * 0.08); bx.beginPath(); bx.moveTo(cx - cell * 0.24, cy - cell * 0.06); bx.lineTo(cx + cell * 0.24, cy - cell * 0.06); bx.stroke(); bx.beginPath(); bx.moveTo(cx - cell * 0.24, cy + cell * 0.08); bx.lineTo(cx + cell * 0.24, cy + cell * 0.08); bx.stroke(); bx.lineWidth = Math.max(1, cell * 0.06); bx.beginPath(); bx.moveTo(cx - cell * 0.18, cy + cell * 0.08); bx.lineTo(cx - cell * 0.18, cy + cell * 0.24); bx.stroke(); bx.beginPath(); bx.moveTo(cx + cell * 0.18, cy + cell * 0.08); bx.lineTo(cx + cell * 0.18, cy + cell * 0.24); bx.stroke(); } else { // park tree: a LUMPY three-arc canopy over a trunk tick (organic silhouette — not the // collect circle) in a deeper green than the wall band bx.fillStyle = 'rgba(74,110,64,0.85)'; for (const [dx, dy, r] of [[-0.1, 0, 0.16], [0.1, -0.04, 0.14], [0, -0.14, 0.13]]) { bx.beginPath(); bx.arc(cx + dx * cell, cy + dy * cell, cell * r, 0, 7); bx.fill(); } bx.strokeStyle = 'rgba(52,40,26,0.9)'; bx.lineWidth = Math.max(1, cell * 0.07); bx.beginPath(); bx.moveTo(cx, cy + cell * 0.08); bx.lineTo(cx, cy + cell * 0.26); bx.stroke(); } bx.restore(); } // _parkHuskCells(park): 2-3 deep cells carrying charred gem husks — chosen by a fixed hash of // the PUBLIC seed/layout over the deep-rim cells (visible from the walkway), spaced apart. // Pure function of the public board (C1); cached per layout. const _PARK_HUSK_CACHE = {}; function _parkHuskCells(park) { const ckey = park.seed + ':' + park.k + ':' + (park.game ? 1 : 0); if (_PARK_HUSK_CACHE[ckey]) return _PARK_HUSK_CACHE[ckey]; const n = park.N; const rim = []; for (const kk of park.deep) { const x = kk % n, y = (kk / n) | 0; const edge = !park.deep.has((y - 1) * n + x) || !park.deep.has((y + 1) * n + x) || !park.deep.has(y * n + (x - 1)) || !park.deep.has(y * n + (x + 1)); if (edge) rim.push(kk); } const h = (kk) => (((kk * 2654435761) ^ (park.seed * 97 + park.k * 13)) >>> 0) % 4093; rim.sort((a, b) => h(a) - h(b) || a - b); const out = []; for (const kk of rim) { // spread: >= 4 cells apart if (out.length >= 3) break; const x = kk % n, y = (kk / n) | 0; if (out.every(o => Math.abs(o % n - x) + Math.abs(((o / n) | 0) - y) >= 4)) out.push(kk); } _PARK_HUSK_CACHE[ckey] = out; return out; } // _parkHusk(ctx, cx, cy, s): one BURNT gem husk — a low pile of charred RUBBLE (fix R1 #2: // the previous two-shard-plus-central-ember version was vertically symmetric and read as an // "orange eye"/creature on every field board). Redesigned to defeat any face read: 3 small // dark chips of unequal size scattered ASYMMETRICALLY along the CELL FLOOR (no vertical axis, // no central iris, hugging the ground), sitting on a faint scorch smudge — obviously debris, // never an agent, never a pickup. The take-flare sync with damage frames (R1 #5) is unchanged. function _parkHusk(ctx, cx, cy, s) { ctx.save(); ctx.fillStyle = 'rgba(10,8,6,0.35)'; // scorch smudge under the pile ctx.beginPath(); ctx.ellipse(cx, cy + s * 0.42, s * 0.95, s * 0.34, 0, 0, 7); ctx.fill(); // three unequal chips, hand-placed off-axis (dx, dy in units of s; size; rotation) const chips = [[-0.52, 0.28, 0.52, 0.5], [0.14, 0.46, 0.40, -0.7], [0.46, 0.16, 0.62, 0.25]]; for (const [dx, dy, cs, rot] of chips) { const px = cx + dx * s, py = cy + dy * s, r = s * cs; ctx.save(); ctx.translate(px, py); ctx.rotate(rot); ctx.beginPath(); // a squat charred shard (flat-topped) ctx.moveTo(-r * 0.9, r * 0.5); ctx.lineTo(-r * 0.3, -r * 0.5); ctx.lineTo(r * 0.5, -r * 0.3); ctx.lineTo(r * 0.9, r * 0.5); ctx.closePath(); ctx.fillStyle = '#17120e'; ctx.fill(); ctx.strokeStyle = '#453629'; ctx.lineWidth = Math.max(1, s * 0.12); ctx.stroke(); ctx.strokeStyle = 'rgba(240,112,48,0.55)'; ctx.lineWidth = Math.max(1, s * 0.09); ctx.beginPath(); ctx.moveTo(-r * 0.2, r * 0.3); ctx.lineTo(r * 0.2, -r * 0.25); ctx.stroke(); ctx.restore(); } ctx.restore(); } // _parkFieldMark(fam, tint, cx, cy, s): the hazard family's deep-cell accent (spec §B.3) — // ONE mark shape per family so the field's identity reads from any single cell: ice = a pale // jagged CRACK stroke, meadow = a small GRASS TUFT (2-3 blades), lava = the warm EMBER spike/ // fleck (the legacy mark, kept for the capstone). Pure render; hazard kind is public (C1). function _parkFieldMark(fam, tint, cx, cy, s) { if (fam === 'ice') { // SUBORDINATED (fix diversity2 #8): a fainter, thinner crack reads as a fracture IN the frozen // pit (function: deep=harmful) rather than a bright white "water zigzag" decoration on top. bx.strokeStyle = _alpha(tint.ember, 0.55); bx.lineWidth = Math.max(1, s * 0.18); bx.lineCap = 'round'; bx.beginPath(); bx.moveTo(cx - s * 0.8, cy - s * 0.5); bx.lineTo(cx - s * 0.1, cy - s * 0.02); bx.lineTo(cx + s * 0.35, cy - s * 0.34); bx.lineTo(cx + s * 0.82, cy + s * 0.55); bx.stroke(); } else if (fam === 'meadow') { // SUBORDINATED (fix diversity2 #8): fainter tufts read as overgrowth in the sunken pit, not // free-floating grass decor competing with the tokens. bx.strokeStyle = _alpha(tint.ember, 0.6); bx.lineWidth = Math.max(1, s * 0.18); bx.lineCap = 'round'; for (const b of [-0.42, 0, 0.42]) { bx.beginPath(); bx.moveTo(cx + b * s * 0.7, cy + s * 0.62); bx.quadraticCurveTo(cx + b * s * 1.5, cy - s * 0.1, cx + b * s * 1.8, cy - s * 0.7); bx.stroke(); } } else { // lava = smoldering ember FLECKS (fix R1 #3: // the bold filled triangle read as a discrete "hazard icon"; a small scatter of embers reads // as ground texture of the burnt field instead — family identity kept via the warm ember hue). bx.fillStyle = _alpha(tint.ember, 0.72); for (const [dx, dy, r] of [[-0.5, 0.28, 0.34], [0.42, -0.22, 0.26], [0.08, 0.58, 0.2]]) { bx.beginPath(); bx.arc(cx + dx * s, cy + dy * s, s * r, 0, 7); bx.fill(); } } } // _parkActor(ctx, cx, cy, r, hue, fdx, fdy, squint, lids): the park ACTOR glyph — a round creature // body (dark board edge + white keyline discs) with two eyes looking along the facing. The // silhouette is IDENTICAL at every facing (the rotated 7x7 mask morphed house→pac→crown and // broke identity tracking) and categorically distinct from the faceted diamond gems: round + // eyed = someone, angular + glinting = something. Idle faces the viewer. Pure render (C1). // EVERY BODY IN THE PARK IS THIS ONE CALL. Three eye states, and they live HERE rather than in a // per-board overpaint, because the moment a board draws its own face its bodies stop being the same // creature as everybody else's (the y46 lesson of 2026-08-03, recorded above SEAT_COL): // default — round eyes, looking along the facing // squint — a flat sliver + a straight dark slit + an angry brow tick: CAUGHT / STRUCK, one beat // lids — a bare downward curve, no white and no brow: ASLEEP, a lasting condition // `lids` is a 9th argument, so every existing 8-argument call is byte-identical. function _parkActor(ctx, cx, cy, r, hue, fdx, fdy, squint, lids) { const fx = fdx || 0, fy = (fdx || fdy) ? (fdy || 0) : 1; const px = -fy, py = fx; // eye-spread axis (⊥ facing) const sq = (squint > 0 && !lids) ? Math.min(1, squint) : 0; // 0 = round eyes; >0 = narrowed const disc = (rr, col) => { ctx.beginPath(); ctx.arc(cx, cy, rr, 0, 7); ctx.fillStyle = col; ctx.fill(); }; disc(r * 1.18, SPRITE_EDGE); // dark board edge disc(r * 1.08, '#ffffff'); // white keyline (the actor-class marker) disc(r, hue); // body for (const s of [-1, 1]) { const ex = cx + fx * r * 0.34 + px * s * r * 0.38, ey = cy + fy * r * 0.34 + py * s * r * 0.38; if (sq > 0) { // SQUINT (the tagger narrowing its eyes at a caught move): a flattened white sliver crossed by // a dark slit + a short angry brow tick above. Facing-robust (screen-space brow). Pure render. ctx.save(); const ry = Math.max(1, r * 0.26 * (1 - 0.72 * sq)); ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.ellipse(ex, ey, r * 0.26, ry, 0, 0, 7); ctx.fill(); ctx.strokeStyle = SPRITE_EDGE; ctx.lineCap = 'round'; ctx.lineWidth = Math.max(1.2, r * 0.13); ctx.beginPath(); ctx.moveTo(ex - r * 0.22, ey); ctx.lineTo(ex + r * 0.22, ey); ctx.stroke(); ctx.lineWidth = Math.max(1, r * 0.10); // brow: outer-high, inner(toward center)-low ctx.beginPath(); ctx.moveTo(ex + s * r * 0.24, ey - r * 0.30); ctx.lineTo(ex - s * r * 0.08, ey - r * 0.15); ctx.stroke(); ctx.restore(); } else if (lids) { // SHUT (a body that is asleep, y46's companion): a relaxed CURVED lid where the eye would be, // and nothing else — no white, no brow. It has to be separable from the squint above at one // glance, because on this board the same body can be both asleep and, for one beat, shot: the // squint is a STRAIGHT slit under an angry brow tick, the lid is a bare downward curve. Curve // vs line vs brow is a silhouette difference, which is the only kind that survives 41px. ctx.save(); ctx.strokeStyle = SPRITE_EDGE; ctx.lineCap = 'round'; ctx.lineWidth = Math.max(1.2, r * 0.12); ctx.beginPath(); ctx.arc(ex, ey - r * 0.16, r * 0.28, Math.PI * 0.18, Math.PI * 0.82); // ⌣ — the bottom of a ctx.stroke(); // circle set above it ctx.restore(); } else { ctx.beginPath(); ctx.arc(ex, ey, r * 0.26, 0, 7); ctx.fillStyle = '#ffffff'; ctx.fill(); ctx.beginPath(); ctx.arc(ex + fx * r * 0.1, ey + fy * r * 0.1, r * 0.13, 0, 7); ctx.fillStyle = SPRITE_EDGE; ctx.fill(); } } } // _parkSleepBubbles(ctx, cx, cy, r): THE SLEEP CUE — three bubbles rising off a sleeping body's // shoulder and fading out, each on the same slow clock a third of a phase apart, so there is always // one leaving and one arriving. // BUBBLES, NOT A 'zzz'. This whole surface is ZERO-TEXT by law (spec §5, the banner at the head of // the park render section), and a letter is also the one glyph on the board that a reader has to know // an alphabet to use. Shared by the sleeping bodies this park has — y46's pink companion, and (via the // `tight` argument below) y50's round-2 bull — because two bodies in the same condition that are // drawn differently invite the reader to hunt for the difference between them, and there is none. // Rides Date.now directly, like every other ambient park motion: a frozen capture yields a frozen // (still perfectly legible) arrangement rather than nothing at all. // `tight` (0..1, optional) SHORTENS THE BREATH. y50's round-2 bull dozes on a fixed beat cycle and // wakes to charge; the sleep cue is the only place that cycle can be read, and it has to be readable // without a number (ZERO-TEXT bans the obvious counter). So as the wake beat approaches the breath // gets shallower and quicker — the bubbles rise less far, fade sooner and cycle faster — which is // what a body about to wake actually does. 0 (the default, and what y46's companion — the park's // other sleeper — passes) is the long, slow, undisturbed breath, byte-identical to before this // argument. function _parkSleepBubbles(ctx, cx, cy, r, tight) { const q = Math.max(0, Math.min(1, tight || 0)); const ph = (Date.now() % (2600 - 1400 * q)) / (2600 - 1400 * q); const rise = 1.55 - 0.85 * q, out = 0.55 - 0.30 * q; ctx.save(); for (let i = 0; i < 3; i++) { const t = (ph + i / 3) % 1; // 0 = just left the body, 1 = gone const bxx = cx + r * (0.85 + out * t) + Math.sin(t * 5.2 + i) * r * 0.18; const byy = cy - r * (0.85 + rise * t); const rr = r * (0.13 + (0.22 - 0.12 * q) * t); ctx.globalAlpha = (0.55 - 0.20 * q) * (1 - t) * (1 - t); // fading as it rises and swells ctx.fillStyle = '#eaf6ff'; ctx.beginPath(); ctx.arc(bxx, byy, rr, 0, 7); ctx.fill(); ctx.strokeStyle = _alpha(SPRITE_EDGE, 0.5); ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(bxx, byy, rr, 0, 7); ctx.stroke(); } ctx.restore(); } // _parkGem(ctx, cx, cy, s): one faceted gold diamond (dark edge + light top facet + white // glint) — the collectible class, never confusable with the round eyed actors. function _parkGem(ctx, cx, cy, s) { ctx.beginPath(); ctx.moveTo(cx, cy - s); ctx.lineTo(cx + s * 0.78, cy); ctx.lineTo(cx, cy + s); ctx.lineTo(cx - s * 0.78, cy); ctx.closePath(); ctx.fillStyle = SPRITE_HUE.reward; ctx.fill(); ctx.strokeStyle = SPRITE_EDGE; ctx.lineWidth = Math.max(1, s * 0.16); ctx.stroke(); ctx.beginPath(); // light top facet ctx.moveTo(cx, cy - s * 0.62); ctx.lineTo(cx + s * 0.45, cy); ctx.lineTo(cx - s * 0.45, cy); ctx.closePath(); ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.fill(); ctx.beginPath(); ctx.arc(cx - s * 0.18, cy - s * 0.38, s * 0.14, 0, 7); // glint ctx.fillStyle = '#ffffff'; ctx.fill(); } // _parkStar(ctx, cx, cy, s, glow): THE active-goal marker (canonical shape-role system) — a pulsing // five-point GOLD STAR used for NOTHING ELSE on any park surface. Exactly one is visible per live // board, riding the current objective (the pursued gem / the reach pad / the deliver basket / the // next-needed collect type). Its 5-point silhouette is disjoint from the 4-point faceted gem, the // round eyed actors, the concentric pad and the collect shapes, so "this is your objective right // now" is answered by one unmistakable mark rather than guessed among look-alikes. Pure render; // target = public chain/goalVariant (C1). Shapes only, ZERO-TEXT. function _parkStar(ctx, cx, cy, s, glow) { const g = glow == null ? 1 : glow; ctx.save(); ctx.lineJoin = 'round'; ctx.lineCap = 'round'; // COOL-WHITE glow, not gold (fix diversity3: a gold star collided with the gold gems / collect // circles / carried badges — the objective mark now owns WHITE, a hue no collectible uses). const rg = ctx.createRadialGradient(cx, cy, s * 0.2, cx, cy, s * 2.0); // soft goal glow rg.addColorStop(0, _alpha('#eaf6ff', 0.6 * g)); rg.addColorStop(1, _alpha('#eaf6ff', 0)); ctx.fillStyle = rg; ctx.beginPath(); ctx.arc(cx, cy, s * 2.0, 0, 7); ctx.fill(); const star = (rr) => { ctx.beginPath(); for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + i * Math.PI / 5; const R = (i % 2 === 0) ? rr : rr * 0.44; const px = cx + Math.cos(a) * R, py = cy + Math.sin(a) * R; i ? ctx.lineTo(px, py) : ctx.moveTo(px, py); } ctx.closePath(); }; const R = s * (1 + 0.06 * g); star(R); ctx.fillStyle = '#ffffff'; ctx.fill(); // WHITE body — unique to the objective (no gem is white) ctx.strokeStyle = SPRITE_EDGE; ctx.lineWidth = Math.max(1.6, s * 0.24); ctx.stroke(); star(R * 0.5); // dark inner core (contrast so the white star reads as a shape, not a glare blob) ctx.fillStyle = _alpha(SPRITE_EDGE, 0.55); ctx.fill(); ctx.restore(); } // _parkGemType(ctx, cx, cy, s, gt): one COLLECT-type token (canonical shape-role system) — a per-type // bold SHAPE (0 = circle, 1 = square, 2 = triangle) in a distinct jewel hue. Deliberately NOT the // faceted diamond: diamonds are reserved for HARVEST collectibles, so a collect board never mixes a // diamond in and its "one of each kind" set reads by shape AND color. Pure render; type is a public // token field (C1). function _parkGemType(ctx, cx, cy, s, gt) { const hue = PARK_GEM_TYPES[gt % 3], shape = gt % 3; // 0 circle · 1 square · 2 triangle (never a diamond) ctx.save(); ctx.lineJoin = 'round'; ctx.beginPath(); if (shape === 0) { ctx.arc(cx, cy, s * 0.92, 0, 7); } else if (shape === 1) { const q = s * 0.82; ctx.rect(cx - q, cy - q, q * 2, q * 2); } else { ctx.moveTo(cx, cy - s); ctx.lineTo(cx + s * 0.92, cy + s * 0.72); ctx.lineTo(cx - s * 0.92, cy + s * 0.72); ctx.closePath(); } ctx.fillStyle = hue; ctx.fill(); ctx.strokeStyle = SPRITE_EDGE; ctx.lineWidth = Math.max(1, s * 0.18); ctx.stroke(); ctx.beginPath(); // soft top highlight (a bright cap, NOT a diamond facet) ctx.arc(cx - s * 0.2, cy - s * 0.28, s * 0.22, 0, 7); ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fill(); ctx.restore(); } // _parkPad(ctx, cx, cy, s): the REACH destination pad (spec §C.1) — a concentric ring target // in the pad hue, categorically distinct from the angular gems and the round eyed actors: // "stand HERE" rather than "take THIS". Pure render (C1). // `hue` (optional) recolours the pad without touching its SHAPE, which is the whole contract: y50's // three rounds each hand the goal to a different mind (round 1 is hers, round 2 is his) and wear // that mind's body colour, but a goal must stay ONE CLASS of object across the rounds or the player // re-learns what a destination looks like every time one closes. Omitted = the teal every other pad // on every other board wears, so every existing call is byte-identical. function _parkPad(ctx, cx, cy, s, hue) { const col = hue || PARK_PAD_HUE; ctx.save(); ctx.strokeStyle = SPRITE_EDGE; ctx.lineWidth = Math.max(1, s * 0.16); ctx.beginPath(); ctx.arc(cx, cy, s * 0.94, 0, 7); ctx.stroke(); ctx.strokeStyle = col; ctx.lineWidth = Math.max(1, s * 0.26); ctx.beginPath(); ctx.arc(cx, cy, s * 0.82, 0, 7); ctx.stroke(); ctx.fillStyle = _alpha(col, 0.28); ctx.beginPath(); ctx.arc(cx, cy, s * 0.6, 0, 7); ctx.fill(); ctx.fillStyle = col; ctx.beginPath(); ctx.arc(cx, cy, s * 0.3, 0, 7); ctx.fill(); ctx.restore(); } // _parkRoundPadHue(park, dest): the hue THIS round's goal wears on y50's three-round staging, or // null (= the ordinary teal) everywhere else. Board-scoped on purpose: `P.dest` is a chain cursor on // every park board and means nothing about colour on any of them except this one, which is why the // table is not indexed by dest alone. Round 0 is the shared teal (the pair is goal-vs-care and the // goal belongs to nobody yet), round 1 wears HER body colour (she is the one who has to get in), // round 2 wears HIS. Deliberately SEAT_COL, the same four-body palette y46's claim rings read from: // one palette means a pink pad and the pink body cannot drift apart. Referenced inside the function // rather than hoisted to a module const because SEAT_COL is declared further down the file. function _parkRoundPadHue(park, dest) { if (!(park.alley && park.alley.runner) || !park.chainAnyOf) return null; return [null, SEAT_COL[1], SEAT_COL[0]][dest | 0] || null; } // _parkClaimRing(gx, gy, cell, col, i): ONE reservation mark on a cell, in the claimant's colour. // THE IDIOM IS BORROWED, NOT INVENTED. This park already says "that one is spoken for" exactly one // way — a DASHED ring in the claimant's colour on the claimed thing (the arena's contested-reward // ring in drawDemoOverlay; the companion's contract-gem ring in drawParkScene). Reusing its look is // the whole point: a player who has seen either of those reads this without being taught, and the // two boards that now use it (y46's four exits, y50's claimed round-0 pad) speak one dialect. // A RING IS NOT A FILL. A seat-hue FILL means the thing is TAKEN — y46's claimed exit is closed and // its cell has changed colour. The ring means only "this is where somebody is headed", which may be // the same cell two bodies are headed for and may never come true. Two facts, two marks. // OVERLAP: several rings can land on one cell, so `i` insets each and offsets its dashes — // concentric, never coincident, and stable frame to frame. function _parkClaimRing(gx, gy, cell, col, i) { const k = i | 0; bx.save(); bx.strokeStyle = _alpha(col, 0.85); bx.lineWidth = Math.max(1.5, cell * 0.055); bx.setLineDash([Math.max(3, cell * 0.11), Math.max(2, cell * 0.08)]); bx.lineDashOffset = k * Math.max(2, cell * 0.09); bx.beginPath(); bx.arc(gx + cell * 0.5, gy + cell * 0.5, cell * (0.46 - 0.055 * k), 0, 7); bx.stroke(); bx.setLineDash([]); bx.restore(); } // _parkDropZone(ctx, cx, cy, s): the DELIVER drop-off receptacle (spec §C.1) — an OPEN-TOP basket // (a rim + tapering walls + a down-chevron "deposit here"), categorically distinct from the faceted // gem (take THIS), the concentric pad (stand HERE) and the round eyed actors. So a deliver board no // longer reads as a harvest board: its objective is "carry the gems back to the basket". The gold // family ties it to the harvest gems it receives; the open cradle says RETURN, not COLLECT. (C1) function _parkDropZone(ctx, cx, cy, s) { ctx.save(); ctx.strokeStyle = SPRITE_EDGE; ctx.lineWidth = Math.max(1, s * 0.2); ctx.lineJoin = 'round'; // basket body: an open trapezoid cradle (wide rim on top, narrower base) ctx.beginPath(); ctx.moveTo(cx - s * 0.9, cy - s * 0.5); ctx.lineTo(cx + s * 0.9, cy - s * 0.5); ctx.lineTo(cx + s * 0.6, cy + s * 0.72); ctx.lineTo(cx - s * 0.6, cy + s * 0.72); ctx.closePath(); ctx.fillStyle = _alpha('#e8c14a', 0.24); ctx.fill(); ctx.stroke(); // bright rim lip (the open mouth — reads as a receptacle, never a solid tile) ctx.strokeStyle = '#e8c14a'; ctx.lineWidth = Math.max(1.4, s * 0.26); ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(cx - s, cy - s * 0.5); ctx.lineTo(cx + s, cy - s * 0.5); ctx.stroke(); // down-chevron INSIDE, a FILLED band: "deposit here" (R2 goal fix #8 — the old STROKED // chevron's line width scaled differently against the basket size, so small baskets read // "solid gold" while big ones read "outline" and judges saw two different objects; a filled // polygon keeps one silhouette at every scale: board, hub badge, suite rail, pin ledger). ctx.fillStyle = '#fff0c0'; ctx.beginPath(); ctx.moveTo(cx - s * 0.38, cy - s * 0.26); ctx.lineTo(cx, cy + s * 0.16); ctx.lineTo(cx + s * 0.38, cy - s * 0.26); ctx.lineTo(cx + s * 0.38, cy + 0.02 * s); ctx.lineTo(cx, cy + s * 0.44); ctx.lineTo(cx - s * 0.38, cy + 0.02 * s); ctx.closePath(); ctx.fill(); ctx.restore(); } // _parkLedgerW(cell, led) / _parkLedger(ctx, x, y, cell, led): the WIN LEDGER (R2 goal fixes // #1/#3/#4/#6) — the pin plaque's content: one mini-token per objective leg in the goal // grammar's own vocabulary (mini gold gems = gather ALL of these; one mini shape per required // TYPE = one of each; the gem row through an arrow INTO the mini basket = deliver; a row of // mini pads = stops to reach). Completed legs dim under a mint check. The four objectives get // four visibly different plaques where one ambiguous up-arrow used to sit. Pure render off the // public chain/tokens (C1); shapes only, ZERO-TEXT. function _parkLedgerW(cell, led) { const p = cell * 0.42; return led.items.length * p + (led.kind === 'deliver' ? cell * 0.86 : 0); } function _parkLedger(ctx, x, y, cell, led) { const p = cell * 0.42, w = _parkLedgerW(cell, led); let ix = x - w / 2 + p / 2; ctx.save(); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; for (const it of led.items) { ctx.save(); if (it.done) ctx.globalAlpha = 0.35; if (led.kind === 'types') _parkGemType(ctx, ix, y, cell * 0.16, it.gt); else if (led.kind === 'pads') _parkPad(ctx, ix, y, cell * 0.16); else _parkGem(ctx, ix, y, cell * 0.17); ctx.restore(); if (it.done) { // mint check = this leg is done ctx.strokeStyle = '#7fce97'; ctx.lineWidth = Math.max(1.4, cell * 0.055); ctx.beginPath(); ctx.moveTo(ix - cell * 0.11, y + cell * 0.01); ctx.lineTo(ix - cell * 0.02, y + cell * 0.1); ctx.lineTo(ix + cell * 0.13, y - cell * 0.1); ctx.stroke(); } ix += p; } if (led.kind === 'deliver') { // ... -> INTO the basket const ax0 = ix - p / 2 + cell * 0.04, ax1 = ax0 + cell * 0.3; ctx.strokeStyle = '#ffffff'; ctx.lineWidth = Math.max(1.4, cell * 0.05); ctx.beginPath(); ctx.moveTo(ax0, y); ctx.lineTo(ax1, y); ctx.stroke(); ctx.beginPath(); ctx.moveTo(ax1 - cell * 0.09, y - cell * 0.09); ctx.lineTo(ax1, y); ctx.lineTo(ax1 - cell * 0.09, y + cell * 0.09); ctx.stroke(); _parkDropZone(ctx, ax1 + cell * 0.28, y + cell * 0.02, cell * 0.2); } ctx.restore(); } // _parkGoalPin(cx, cy, cell, g, pin): the floating ACTIVE-GOAL marker — the UNIQUE white star // (_parkStar) over the WIN-LEDGER plaque, bobbing, dashed-tethered to the win-defining cell. // Pure render; grammar/target = public chain/goalVariant (C1). Shapes only, ZERO-TEXT. function _parkGoalPin(cx, cy, cell, g, pin) { // ACTIVE-GOAL STAR (canonical shape-role system): the single dominant floating mark that says // "THIS is your objective right now". Its shape (a pulsing star) is UNIQUE on the board and // used for nothing else. The slot under it is the LEDGER PLAQUE (R2 goal fixes #1/#6): the // old single verb chip gave gather / one-of-each / deliver the SAME tiny up-arrow (read as // person/exclamation/eject); the plaque instead shows the whole win in the goal grammar's own // tokens (see _parkLedger). Position = the caller's occlusion-free anchor (R2 goal fix #7): // first of up/right/left/down that covers no alive token, actor or canvas edge — the pin // never sits on the tile it labels, on neighboring gems, or on the magenta claimant. const bob = Math.sin(Date.now() / 360) * cell * 0.06; const ex = pin.ex, ey = pin.ey + bob; const w = Math.max(_parkLedgerW(cell, pin.led) + cell * 0.3, cell * 1.0); bx.save(); bx.lineJoin = 'round'; bx.lineCap = 'round'; const ux = ex - cx, uy = ey - cy, ul = Math.hypot(ux, uy) || 1; // tether: cell edge -> plaque edge bx.strokeStyle = _alpha('#e8c14a', 0.5); bx.lineWidth = 2.4; bx.setLineDash([2, 3]); bx.beginPath(); bx.moveTo(cx + ux / ul * cell * 0.42, cy + uy / ul * cell * 0.42); bx.lineTo(ex - ux / ul * cell * 0.68, ey - uy / ul * cell * 0.68); bx.stroke(); bx.setLineDash([]); bx.fillStyle = 'rgba(9,11,15,0.82)'; // dark backing so the star pops off ANY terrain hue bx.beginPath(); bx.arc(ex, ey - cell * 0.34, cell * 0.5, 0, 7); bx.fill(); bx.fillRect(ex - w / 2, ey + cell * 0.05, w, cell * 0.56); // ledger plaque backing _parkStar(bx, ex, ey - cell * 0.34, cell * 0.34, g); _parkLedger(bx, ex, ey + cell * 0.33, cell, pin.led); bx.restore(); _parkActionCue(cx, cy, cell, g, pin.verb); } // _parkActionCue(cx, cy, cell, g, gv): the ARRIVAL-ACTION pictogram on the starred active target // (goal-legibility R1 #2) — a STATIC-legible answer to "and then DO what, here?" so a single still // carries the verb, not just the destination: reach = two footprint dots ON the pad ("stand here"); // deliver = a tiny gem arcing DOWN INTO the basket mouth ("drop here"); harvest / collect (and the // capstone's gem chain, gv undefined) = a subtle open grasp ring around the token ("pick up"). // Shapes only, ZERO-TEXT; grammar = public goalVariant, anchor = the win-defining cell (C1). function _parkActionCue(cx, cy, cell, g, gv) { // `gv` = arrival VERB ('take' | 'drop' | 'stand'), keyed off the starred token's class. bx.save(); bx.lineJoin = 'round'; bx.lineCap = 'round'; if (gv === 'stand') { // RETICLE TICKS on the pad (R1 goal fix #11): the old two footprint dots sat ON the round // teal pad and completed a FACE — judges saw an eyed NPC blob, not a destination. Four // crosshair ticks extend the pad's own concentric-target idiom: unmistakably a spot to // occupy, and nothing on it can read as eyes. bx.strokeStyle = 'rgba(255,255,255,0.9)'; bx.lineWidth = Math.max(1.4, cell * 0.05); for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { bx.beginPath(); bx.moveTo(cx + dx * cell * 0.3, cy + dy * cell * 0.3); bx.lineTo(cx + dx * cell * 0.46, cy + dy * cell * 0.46); bx.stroke(); } } else if (gv === 'drop') { // a tiny gold gem above the basket + a dashed arc swooping INTO the open rim, tipped by an // arrowhead pointing down into the mouth: "the cargo goes IN here". const gx = cx + cell * 0.52, gy2 = cy - cell * 0.66; _parkGem(bx, gx, gy2, cell * 0.17); bx.strokeStyle = _alpha('#ffd955', 0.9); bx.lineWidth = Math.max(1.2, cell * 0.05); bx.setLineDash([cell * 0.09, cell * 0.07]); bx.beginPath(); bx.moveTo(gx - cell * 0.06, gy2 + cell * 0.2); bx.quadraticCurveTo(cx + cell * 0.5, cy - cell * 0.18, cx + cell * 0.12, cy - cell * 0.05); bx.stroke(); bx.setLineDash([]); bx.fillStyle = '#ffd955'; bx.beginPath(); // arrowhead INTO the rim bx.moveTo(cx + cell * 0.04, cy + cell * 0.1); bx.lineTo(cx + cell * 0.26, cy - cell * 0.06); bx.lineTo(cx + cell * 0.04, cy - cell * 0.14); bx.closePath(); bx.fill(); } // 'take' draws nothing ON the token: the target brackets (drawParkScene beacon) frame it // and the pin's LEDGER says the rest (R2 goal fix #6 retired the ambiguous up-arrow chip; // the old "grasp ring" already read as one more container ring — R1 goal fix #8). bx.restore(); } // drawParkGems(x, y, v, gtype): a cluster of 1-3 gems in one cell (value = count, no text). // `gtype` (collect variant) selects the typed gem; otherwise the gold harvest gem. function drawParkGems(x, y, v, gtype, sc) { const off = { 1: [[0.5, 0.5]], 2: [[0.33, 0.35], [0.67, 0.65]], 3: [[0.5, 0.3], [0.3, 0.68], [0.7, 0.68]] }[Math.max(1, Math.min(3, v | 0))]; const s = CELL * (v <= 1 ? 0.3 : 0.21) * (sc || 1); // sc < 1 = subordinate (ambient) gems for (const [ox, oy] of off) gtype != null ? _parkGemType(bx, (x + ox) * CELL, (y + oy) * CELL, s, gtype) : _parkGem(bx, (x + ox) * CELL, (y + oy) * CELL, s); } // _parkFaceOf(st, f): 이 판에서 몸이 실제로 그릴 시선을 고른다. 기본은 그 몸이 마지막으로 걸은 // 방향(st.facing)이지만, y58 흐르는 도로만은 시선을 위(전진 방향)로 못박는다 — 사용자 결정 // 2026-08-06. 이 판에서 사람이 읽어야 하는 것은 "누가 어느 쪽을 봤나"가 아니라 "언제 건너나"이고, // 좌우 한 칸 옆걸음마다 파란 몸의 고개가 홱 돌아가면 그 회전이 시간 신호(드럼 밴드·신호등)와 // 경쟁해 오독을 만든다. 다른 판의 시선은 그 판의 배려·감시 관계를 나르므로 건드리지 않는다: // 도로 보드(st.park.road)에서만 켜지고 그 외에는 인자를 그대로 돌려주는 순수 함수다 (C1 — 공개 // 보드 필드 하나만 읽는다). function _parkFaceOf(st, f) { if (st && st.park && st.park.road) return { dx: 0, dy: -1 }; return f; } // drawParkAgent(st, cue): the big blue round actor over a soft blue glow (the legacy backplate // + square keyline + mask ensemble read as a blue HOUSE at rest). `cue.recoil` offsets it a // half-step back with a slight shrink: the verge safety-recoil read. `cue.hurt` keeps a cracked // heart floating over it for a few frames after a deep entry. Pure kind/position render (C1). function drawParkAgent(st, cue) { const p = st.pos[0]; // 곁눈질은 엔진 상태를 안 건드린다 (2026-08-06): st.facing 을 쓰고 되돌리는 방식은 복구를 // 한 번만 놓쳐도 그 뒤 전 프레임의 시선이 틀어진다. 표시 시점에만 덮어쓴다. const f = cue.glance ? { dx: cue.glance.dx, dy: cue.glance.dy } : _parkFaceOf(st, st.facing[0]); const dx = cue.recoil ? cue.recoil.dx : 0, dy = cue.recoil ? cue.recoil.dy : 0; // SLIDE GLIDE (demo-only, C1): while a slide is mid-flight the agent is drawn at its // INTERPOLATED cell (cue.glide, computed in drawParkFrame) instead of teleporting to the rest // cell, and every cell already swept gets a short pale-ice streak behind it. At frac==1 // cue.glide.x/y === st.pos[0], so this is continuous with the ordinary rest render. cue.glide is // set ONLY in the demo (never play/hub/tutorial), so those renders stay byte-identical. if (cue.glide) { bx.save(); for (const c of cue.glide.trail) { bx.fillStyle = _alpha('#bfe3ff', 0.28); bx.fillRect(c.x * CELL + CELL * 0.28, c.y * CELL + CELL * 0.28, CELL * 0.44, CELL * 0.44); bx.strokeStyle = _alpha('#dff1ff', 0.5); bx.lineWidth = 1.5; bx.lineCap = 'round'; bx.beginPath(); bx.moveTo(c.x * CELL + CELL * 0.30, c.y * CELL + CELL * 0.5); bx.lineTo(c.x * CELL + CELL * 0.70, c.y * CELL + CELL * 0.5); bx.stroke(); } bx.restore(); } const cx = cue.glide ? (cue.glide.x + 0.5) * CELL : (p.x + dx + 0.5) * CELL; // 기다림 (2026-08-06): 동료가 쓰는 것과 같은 bob 과 같은 링이다. 동료 쪽 코드는 안 건드린다 // (y46 잠든 몸의 asleep 가드가 거기 얽혀 있다) — 상수만 그대로 빌린다. const holdBob = cue.hold ? -CELL * 0.09 * (0.5 + 0.5 * _pulseGlow()) : 0; const cy = (cue.glide ? (cue.glide.y + 0.5) * CELL : (p.y + dy + 0.5) * CELL) + holdBob; const r = CELL * 0.4 * (cue.recoil ? 0.86 : 1); const g = bx.createRadialGradient(cx, cy, r * 0.5, cx, cy, r * 2.1); g.addColorStop(0, _alpha(SPRITE_HUE.agent, 0.35)); g.addColorStop(1, _alpha(SPRITE_HUE.agent, 0)); bx.fillStyle = g; bx.beginPath(); bx.arc(cx, cy, r * 2.1, 0, 7); bx.fill(); _parkActor(bx, cx, cy, r, SPRITE_HUE.agent, f && f.dx, f && f.dy); // STANDING-DAMAGE PERSISTENCE (blind-judge fix R3 #10 + #3): while ON a deep cell a continuous // ember SIZZLE licks the body (mid-crossing frames keep reading "being hurt", not just the // entry flash); while ON the harmless verge a fainter, paler SHIMMER reads "risk, no damage". // Pure position/terrain reads (C1). const apk = p.y * st.N + p.x, gp = _pulseGlow(); const flick = (count, len, col, w) => { bx.save(); bx.strokeStyle = col; bx.lineWidth = w; bx.lineCap = 'round'; for (let i = 0; i < count; i++) { const th = i * (Math.PI * 2 / count) + 0.7 + gp * 0.5; bx.beginPath(); bx.moveTo(cx + Math.cos(th) * r * 1.05, cy + Math.sin(th) * r * 1.05); bx.lineTo(cx + Math.cos(th) * r * (1.05 + len), cy + Math.sin(th) * r * (1.05 + len)); bx.stroke(); } bx.restore(); }; if (st.park.deep.has(apk)) flick(6, 0.5 + 0.2 * gp, _alpha(PARK_HUES.ember, 0.95), 2.5); else if (st.park.verge.has(apk)) flick(3, 0.3 + 0.12 * gp, 'rgba(242,180,110,0.6)', 1.8); if (cue.hurt) drawHeartCrack(cx, cy - CELL * 0.9, CELL * 0.34); // hurt afterglow if (cue.hold) { bx.save(); bx.globalAlpha = 0.45 + 0.4 * _pulseGlow(); bx.strokeStyle = PARK_HUES.companion; bx.lineWidth = 2; bx.setLineDash([3, 3]); bx.beginPath(); bx.arc(cx, cy, CELL * 0.42, 0, 7); bx.stroke(); bx.setLineDash([]); bx.restore(); } // DELIVER CARGO (canonical shape-role system, spec §6): on a deliver board the gems the agent has // picked up ride ON the player as a gold gem badge — "carry these to the basket" — so a deliver // board reads as carry-back, not silent harvesting. Public score / goalVariant only (C1). if (st.park.cell && st.park.cell.goalVariant === 'deliver' && st.score[0] > 0) { const bcx = cx + CELL * 0.4, bcy = cy - CELL * 0.52, br = CELL * 0.3; bx.beginPath(); bx.arc(bcx, bcy, br, 0, 7); bx.fillStyle = 'rgba(14,15,19,0.92)'; bx.fill(); bx.strokeStyle = '#ffffff'; bx.lineWidth = 1.4; bx.stroke(); _parkGem(bx, bcx, bcy, br * 0.74); } } // drawParkCompanion(P): the weaker kin — the SAME round eyed actor at 0.6 of the agent's scale, // tinted the companion MAGENTA (never the gem's gold — fix R3 #6), so it reads as an ACTOR and // its claims are color-owned. While it is BLOCKED and WAITING (P.wait > 0 — a public runtime // counter), it bobs in place under a pulsing hollow ring: unmistakably "wants to move, held up". // Once it has harvested anything (st.score[1] — PUBLIC), a white-ringed dark BADGE bubble with // a gem floats over its shoulder: a carried-gem tag, never a world gem on the ground. (C1) function drawParkCompanion(P) { const st = P.st, p = st.pos[1]; if (!p) return; const f = _parkFaceOf(st, st.facing[1]); // ASLEEP (y46 v2's companion — dyn.statue.asleep). Her legalMask refuses every cell, so she has no // planner, no errand and no step: she is a body the yard happens to contain, and the walker's // shoulder is the only thing that ever moves her. Everything below that would suggest agency is // therefore switched off — the facing (she is drawn at the idle facing forever, never turning // toward anything), the wait bob and the wait ring ("wants to move, held up" is a lie about a // body that wants nothing). What is switched ON is a slow breath and a drift of bubbles. const asleep = !!(st.park.dyn && st.park.dyn.statue && st.park.dyn.statue.asleep); const bob = (P.wait > 0 && !asleep) ? -CELL * 0.09 * (0.5 + 0.5 * _pulseGlow()) : 0; // THE OPENER'S SHOVE (design 2026-08-05): while she is walking into the wooden face on y20's // guided stage, her body is pushed toward it and springs back. Read off the PUBLIC board (C1). // This is deliberately NOT `cue.recoil`: that mark means "stepped back off the brink", it belongs // to the blue walker, and it carries a shrink. One vocabulary must not carry two opposite // meanings — being pushed INTO something is the opposite of flinching away from it. const _sc = st.park.bombScene, nudge = (_sc && _sc.pinkNudge) || null; const cx = (p.x + 0.5) * CELL + (nudge ? nudge.dx * CELL : 0); const cy = (p.y + 0.5) * CELL + bob + (nudge ? nudge.dy * CELL : 0); // COMPANION GROUND-GLOW (fix R1 #4): a soft magenta halo under the companion, the exact // parallel of the agent's blue glow — so a still opener frame reads as TWO characters (blue // you + magenta kin), not "a purple orb doing nothing" next to the player. Companion-hue, // never confusable with the blue agent. const cr = CELL * 0.3; const cg = bx.createRadialGradient(cx, cy, cr * 0.5, cx, cy, cr * 2.0); cg.addColorStop(0, _alpha(PARK_COMPANION_SOFT, 0.28)); cg.addColorStop(1, _alpha(PARK_COMPANION_SOFT, 0)); bx.fillStyle = cg; bx.beginPath(); bx.arc(cx, cy, cr * 2.0, 0, 7); bx.fill(); // 0.30 (was 0.24 — fix R1 #7): on the N=20 park the companion shrank to an unreadable dot // next to its own claim ring; a touch larger keeps "small kin" while the eyes stay legible. // Body + halo use the SOFT tint (goal-legibility R1 #3) — see PARK_COMPANION_SOFT. // SHOT (y29 'sent' — the doll caught him walking and sent him back to the start of his patience): // his eyes SHUT for that one beat. Same channel every other body in this park uses for being hit // (_parkActor's squint), so there is one vocabulary for it and not one per cell — and it is read // off THIS beat's records only. A park st.fx is an append-only episode log, so `st.fx.some(...)` // would shut his eyes permanently from the first time it ever happened, which is the opposite of // what being shot means. Hit once, shown once, gone. // `seat` GUARD: y46 fires 'sent' for the two scripted runners as well, stamped with their seat. // Unfiltered, the pink body winced every time the doll shot somebody else — a body reacting to a // toll it had no part in (the y51 lesson, restated on this cell). y29's own 'sent' carries no seat // and is always hers, so an absent seat still counts. const shot = _parkStepFx(st).some(f => f.k === 'sent' && (f.seat == null || f.seat === 1)); // BREATH: a slow swell of the body while she sleeps. It is the ONE motion she has, and it must not // be the fast _pulseGlow every alarm on this board rides — the point of the mark is that nothing is // urgent about her. _slowPulse is the park's existing 2.6s breathe. const br2 = asleep ? cr * (1 + 0.06 * _slowPulse()) : cr; _parkActor(bx, cx, cy, br2, PARK_COMPANION_SOFT, asleep ? 0 : (f && f.dx), asleep ? 0 : (f && f.dy), (shot && !asleep) ? 1 : 0, asleep); if (asleep) _parkSleepBubbles(bx, cx, cy, cr); // FRIENDLY TAG (canonical shape-role system, spec §1): a small companion-hue HEART on the kin's // shoulder so a first-timer reads it as an ALLY at a glance and never wastes attention wondering if // it is a threat. Paired with the faint dotted tether to the player, "these two go together, and // this one is friendly" is unmistakable. Left shoulder — clear of the right-shoulder carried-gem // badge. Pure render (C1). { const hx0 = cx - cr * 1.25, hy0 = cy - cr * 1.35, hs = cr * 0.42; _heartPath(bx, hx0, hy0, hs); bx.fillStyle = PARK_HUES.companion; bx.fill(); bx.strokeStyle = '#ffffff'; bx.lineWidth = 1.1; bx.stroke(); } if (P.wait > 0 && !asleep) { // WAITING ring (in-place patience read) bx.save(); bx.globalAlpha = 0.45 + 0.4 * _pulseGlow(); bx.strokeStyle = PARK_HUES.companion; bx.lineWidth = 2; bx.setLineDash([3, 3]); bx.beginPath(); bx.arc(cx, cy, CELL * 0.42, 0, 7); bx.stroke(); bx.restore(); } if (st.score[1] > 0) { const bcx = cx + CELL * 0.38, bcy = cy - CELL * 0.5, br = CELL * 0.3; bx.beginPath(); bx.arc(bcx, bcy, br, 0, 7); bx.fillStyle = 'rgba(14,15,19,0.92)'; bx.fill(); bx.strokeStyle = '#ffffff'; bx.lineWidth = 1.4; bx.stroke(); _parkGem(bx, bcx, bcy, br * 0.74); } } // drawHeartCrack(cx, cy, s): a red heart with a dark crack — the ♥−1 body-channel glyph (bx). function drawHeartCrack(cx, cy, s) { _heartPath(bx, cx, cy, s); bx.fillStyle = '#e0594f'; bx.fill(); bx.strokeStyle = '#14161c'; bx.lineWidth = Math.max(2, s * 0.2); bx.lineCap = 'round'; bx.beginPath(); bx.moveTo(cx, cy - s * 0.85); bx.lineTo(cx - s * 0.3, cy - s * 0.2); bx.lineTo(cx + s * 0.22, cy + s * 0.15); bx.lineTo(cx - s * 0.08, cy + s * 0.8); bx.stroke(); } // drawParkHUD(): the live side panel — ZERO TEXT (spec §5). Hearts as ♥ glyph shapes (filled = // remaining; the just-cracked one flashes on a deep entry) + the harvest gauge (gem icon + fill // bar — it visibly STALLS whenever a sacrifice declines a gem). Nothing else. A persona violation // = the gauge frame flashes red (the score-bar flash channel; hearts stay the body channel). function drawParkHUD() { hx.clearRect(0, 0, hud.width, hud.height); const run = G.campaign, a = G.parkAnim; const P = (a && a.mode === 'demo') ? a.P : (a && a.task && run.park.task) ? run.park.task.game.P : (run.park.game ? run.park.game.P : null); if (!P) return; const cue = (a && a.cue) || {}; const X = 24; // §2 HANDOFF CEREMONY: at demo->play the hearts refill (staggered, eased). // IN-SLOT GROWTH (fix R2 #2 — supersedes R1 #2's slide lane): the sliding heart + // comet trail sampled as a FOURTH slot left of the row and as garbled filled/hollow // overlaps on neighbor slots. Now nothing is ever drawn outside a slot's own center: // every slot keeps its hollow outline (the count is heartsMax on every frame) and the // arriving heart GROWS + fades up INSIDE it, capped by a white landing blink — a still // frame reads as "this slot is filling", never damage, never an extra slot. const cer = (a && a.mode === 'game' && a.ceremony) ? Date.now() - a.ceremony : null; for (let i = 0; i < P.heartsMax; i++) { // hearts (body channel) const cx = X + 16 + i * 42; const cy = 46; if (cer != null && cer < PARK_HEARTFLY_MS + i * 130 + 240) { const t = clamp01((cer - i * 130) / PARK_HEARTFLY_MS); _heartPath(hx, cx, cy, 13); // the slot outline: count stays legible hx.strokeStyle = '#5a5f6b'; hx.lineWidth = 1.6; hx.stroke(); if (t > 0) { const e = 1 - (1 - t) * (1 - t); // ease-out arrival INSIDE the slot hx.save(); // FULL-SIZE fade-in (fix R2 #2b — supersedes the scale ramp): a sampled mid-flight // frame of the old 0.35->1 growth read as a PARTIALLY-FILLED heart, a third state // the grammar doesn't have. Alpha-only: every frame reads filled-or-empty. hx.globalAlpha = 0.25 + 0.75 * e; _heartPath(hx, cx, cy, 13); hx.fillStyle = '#e0594f'; hx.fill(); hx.restore(); } if (t >= 1) { // landing blink (the arrival event) hx.save(); hx.globalAlpha = clamp01(1 - (cer - i * 130 - PARK_HEARTFLY_MS) / 240); hx.strokeStyle = 'rgba(255,255,255,0.95)'; hx.lineWidth = 2; hx.beginPath(); hx.arc(cx, cy, 18, 0, 7); hx.stroke(); hx.restore(); } continue; } _heartPath(hx, cx, cy, 13); if (i < P.hearts) { hx.fillStyle = '#e0594f'; hx.fill(); } else { hx.strokeStyle = '#5a5f6b'; hx.lineWidth = 1.6; hx.stroke(); // ONE heart grammar across every frame (fix R2 #2): filled = remaining, CRACKED-empty // = spent. The crack is now PERSISTENT on every spent slot (was cue-frame-only, so a // plain hollow outline and a cracked one read as two different states across surfaces). hx.strokeStyle = 'rgba(224,89,79,0.55)'; hx.lineWidth = 1.6; hx.lineCap = 'round'; hx.beginPath(); hx.moveTo(cx, cy - 11); hx.lineTo(cx - 4, cy - 3); hx.lineTo(cx + 3, cy + 2); hx.lineTo(cx - 1, cy + 10); hx.stroke(); } // the just-lost heart flashes its crack red — always at index P.hearts, i.e. hearts fill // from the LEFT and deplete RIGHTMOST-FIRST, one consistent direction (fix R3 #8); the // flash persists through the hurt afterglow frames so sampled frames can't miss it. if ((cue.deep || cue.hurt) && i === P.hearts) { hx.strokeStyle = '#ff5050'; hx.lineWidth = 2; hx.lineCap = 'round'; hx.beginPath(); hx.moveTo(cx, cy - 11); hx.lineTo(cx - 4, cy - 3); hx.lineTo(cx + 3, cy + 2); hx.lineTo(cx - 1, cy + 10); hx.stroke(); } } // harvest gauge (goal channel): a mini BLUE actor + gem icon head the fill bar, so the bar // reads as "the blue agent's take" (whose gems the bar counts, zero text). // REACH VARIANT (live-bug fix R2 #1): reach turns every chain token into a v=0 PAD — no // pickup, no score — so the fill bar stayed EMPTY across a whole successful walk (and // _parkGaugeMarks bailed on need=0). The goal channel for reach is LEG PIPS in the pad's // own grammar: one pad glyph per chain leg, mint-checked the moment its pad is completed. // Same row, same actor head, same violation-flash frame (the character channel unchanged). const gy = 104, bx0 = X + 46, bw = hud.width - bx0 - 20; const gvHud = P.st.park.cell && P.st.park.cell.goalVariant; // THE SAME QUESTION THE PLAQUE ASKS, THROUGH THE SAME FUNCTION (_parkChainPadLegs). On y46 v2 the // variant name is 'harvest' but the one leg is an exit you stand in and every token is v:0, so the // gem-and-fill-bar branch below showed a gold gem the board does not contain over a bar that could // never move. Two surfaces answering "what is this run's goal" differently is worse than either // being wrong: the player would be told to collect by the HUD and to arrive by the board. const padLegsHud = _parkChainPadLegs(P); if (gvHud === 'reach' || padLegsHud) { const chain = P.st.park.chain; hx.save(); _parkActor(hx, X + 6, gy, 9, SPRITE_HUE.agent, 0, 0); const step = Math.min(36, Math.max(24, (bw - 24) / Math.max(1, chain.length))); chain.forEach((ci, j) => { const px = bx0 + 14 + j * step; const done = padLegsHud ? padLegsHud[j].done : !P.st.tokens[ci].alive; hx.globalAlpha = done ? 1 : 0.5; _parkPad(hx, px, gy, 9); hx.globalAlpha = 1; if (done) { // completed leg: mint check (done grammar) hx.strokeStyle = C_MAINT; hx.lineWidth = 2.4; hx.lineCap = 'round'; hx.beginPath(); hx.moveTo(px - 5, gy + 1); hx.lineTo(px - 1, gy + 5); hx.lineTo(px + 6, gy - 5); hx.stroke(); } }); hx.restore(); } else { const total = P.st.tokens.reduce((s, t) => s + t.v, 0); const frac = total ? clamp01(P.st.score[0] / total) : 0; hx.save(); _parkActor(hx, X + 6, gy, 9, SPRITE_HUE.agent, 0, 0); _parkGem(hx, X + 30, gy, 10); hx.restore(); hx.fillStyle = '#23252c'; hx.fillRect(bx0, gy - 8, bw, 16); hx.fillStyle = _alpha(SPRITE_HUE.reward, 0.92); hx.fillRect(bx0, gy - 8, bw * frac, 16); _parkGaugeMarks(P, bx0, gy, bw); } if (cue.violated) { // score-bar flash = order violation hx.strokeStyle = '#ff5050'; hx.lineWidth = 3; hx.strokeRect(bx0 - 2, gy - 10, bw + 4, 20); } // COLLECT-SET LEGEND (fix diversity2 #1): when the goal is a TYPED SET, a HUD row shows the // required gem TYPES as their distinct SHAPES (circle/square/triangle) so "collect ONE OF EACH" // reads without any letters — a typed pickup is categorically not plain gold harvesting. // Remaining types get a pulsing call ring; already-collected types dim under a mint check ring. const park = P.st.park; if (park && park.needTypes) { const chain = park.chain, toks = P.st.tokens; const order = [], seen = new Set(); for (const i of chain) { const gt = toks[i].gtype; if (gt != null && !seen.has(gt)) { seen.add(gt); order.push(gt); } } const got = new Set(); for (const i of chain) if (!toks[i].alive) got.add(toks[i].gtype); const ly = 158, s = 12, step = 36; for (let j = 0; j < order.length; j++) { const gt = order[j], gcx = X + 18 + j * step, have = got.has(gt); hx.save(); if (have) hx.globalAlpha = 0.4; _parkGemType(hx, gcx, ly, s, gt); hx.restore(); if (have) { // collected -> mint check ring hx.strokeStyle = C_MAINT; hx.lineWidth = 2.2; hx.beginPath(); hx.arc(gcx, ly, s * 1.45, 0, 7); hx.stroke(); } else { // still needed -> pulsing dashed call ring hx.save(); hx.globalAlpha = 0.4 + 0.4 * _pulseGlow(); hx.strokeStyle = 'rgba(236,242,250,0.75)'; hx.lineWidth = 1.8; hx.setLineDash([3, 3]); hx.beginPath(); hx.arc(gcx, ly, s * 1.5, 0, 7); hx.stroke(); hx.restore(); } } } // 남은 턴 바(설계 2026-07-30 D1, y=236)는 2026-08-04 에 제거되었다. 셋이 겹쳤다: // ① 캡이 60~260턴인데 실제 걸음은 10~40수라 한 판 내내 90% 넘게 채워진 채 앉아 있었다. // ② 짝이던 y=200 판 주기 박자 줄이 2026-08-03 에 먼저 철거되어 텅 빈 아래쪽 한복판에 // 아이콘 머리도 없이 혼자 떠 있었다(하트엔 ♥, 목표 게이지엔 주민+보석 머리가 붙는다). // ③ 이름으로도 좌표로도 붙잡는 게이트가 하나도 없었다 — 측정되지 않는 표면이었다. // D1 의 계약(`reason:'cap'` 은 완주·죽음·소음과 구분되는 실제 종료 사유다)은 리포트 // 화면이 계속 진다 — 거기서는 글자가 허용되므로 종료 사유가 그대로 읽힌다. // 이 자리는 이제 미니 시연 창이 쓴다(_parkReplayBlit, PARK_REPLAY_Y = 200). } // _paintParkHudClock 은 2026-08-03 에 제거되었다. 판 전체 주기를 HUD 한 줄로 그리던 물건인데, // 그 줄이 뜨던 세 자리가 모두 자기 화면에 이미 예고를 갖고 있었다: y20 bomb 은 점선 폭발 십자와 // 불붙은 상자의 퓨즈 불꽃, y23 storm 은 다음 링 점선, y46 siege 는 다음 밴드 점선(그리고 그것은 // own:true 라 핍을 안 그리는데도 park.statue 를 실었다는 이유로 HUD 줄을 하나 더 얻고 있었다). // 남은 핍은 ENTITY 시계뿐이다 — y50 황소의 퓨즈처럼 몸 옆에 붙는 것들(_paintParkClockPips). // 알려진 비용: y20 은 '퓨즈가 탄다'와 '어디가 터진다'는 그대로지만 '몇 박 남았나'를 잃었다. // 되살릴 자리는 HUD 가 아니라 퓨즈 불꽃 자체다(시계는 그 물건 위에 있어야 한다). // _parkGaugeMarks(P, gx0, gy, bw): gauge COUNTABILITY + TARGET (fix R1 #9) — thin unit // notches (one per gem unit: the bar reads as "k of N", not a directionless fill) and a // gold TARGET TICK at the chain's total gem value (the journey's required take — a pure // function of the PUBLIC seed chain, C1): "fill to at least here". The tick + its pointer // turn mint once the take passes it. Shapes only, drawn on the HUD ctx (zero text). function _parkGaugeMarks(P, gx0, gy, bw) { const park = P.st.park; const total = P.st.tokens.reduce((s, t) => s + t.v, 0); if (!total) return; hx.save(); if (total <= 48) { hx.strokeStyle = 'rgba(9,11,15,0.75)'; hx.lineWidth = 1; for (let i = 1; i < total; i++) { const x = gx0 + bw * i / total; hx.beginPath(); hx.moveTo(x, gy - 8); hx.lineTo(x, gy + 8); hx.stroke(); } } if (park && park.chain && park.clusters) { const need = park.chain.reduce((s, i) => s + park.clusters[i].v, 0); if (need > 0) { const x = gx0 + bw * clamp01(need / total); const met = P.st.score[0] >= need; // PIN FIRES (fix R2 #3): the target tick was a static notch the fill "never reached" // to a cold reader. Armed, the pointer now BREATHES (a live target, not decoration); // the moment the take crosses it, a mint burst — expanding ring + rays — fires AT the // pin for ~900ms (the tutorial's beacon take lands exactly on it, so the demo teaches // the event). Met moment tracked render-side only (WeakMap; runtime untouched). if (met && !_PARK_PIN_MET.has(P)) _PARK_PIN_MET.set(P, Date.now()); const col = met ? '#7fce97' : '#e8c14a'; const gp = met ? 0 : _pulseGlow(); hx.strokeStyle = col; hx.lineWidth = 2; hx.globalAlpha = met ? 1 : 0.7 + 0.3 * gp; hx.beginPath(); hx.moveTo(x, gy - 12); hx.lineTo(x, gy + 12); hx.stroke(); hx.fillStyle = col; const ps = 1 + 0.3 * gp; // pointer above the bar (breathes) hx.beginPath(); hx.moveTo(x - 4.5 * ps, gy - 17 - 2 * gp); hx.lineTo(x + 4.5 * ps, gy - 17 - 2 * gp); hx.lineTo(x, gy - 11); hx.closePath(); hx.fill(); hx.globalAlpha = 1; const metAt = met ? _PARK_PIN_MET.get(P) : null; if (metAt != null && Date.now() - metAt < 900) { const bt = (Date.now() - metAt) / 900; hx.save(); hx.globalAlpha = 0.95 * (1 - bt); hx.strokeStyle = col; hx.lineWidth = 2.5; hx.lineCap = 'round'; hx.beginPath(); hx.arc(x, gy - 14, 8 + 16 * bt, 0, 7); hx.stroke(); for (let i = 0; i < 6; i++) { const th = i * Math.PI / 3 - Math.PI / 2; hx.beginPath(); hx.moveTo(x + Math.cos(th) * (11 + 10 * bt), gy - 14 + Math.sin(th) * (11 + 10 * bt)); hx.lineTo(x + Math.cos(th) * (17 + 13 * bt), gy - 14 + Math.sin(th) * (17 + 13 * bt)); hx.stroke(); } hx.restore(); } } } hx.restore(); } // ---- MINI WATCH REPLAY 페인터 (설계 2026-08-04) ----------------------------- // 렌더러를 새로 짜지 않는다. drawParkScene 은 4531~5173 줄짜리 단일 함수이고 전역 bx 가 안쪽 // 수십 곳에 박혀 있어 ctx 인자로 여는 리팩터가 위험하다. 대신 한 프레임 안에서 board 캔버스를 // 두 번 쓴다 — ①시연을 그려 축소 복사하고 ②drawParkFrame 이 진짜 플레이 판으로 덮는다. // // 순서가 규칙이다. CELL 은 let 전역이고 drawParkScene 이 호출마다 board.width/n 으로 다시 // 쓰므로, 시연 보드와 플레이 보드의 n 이 다를 때 **플레이를 나중에** 그려야 // 프레임이 끝날 때 CELL 이 플레이 판 값으로 남는다. // // 알려진 한계 하나는 고치지 않고 기록만 해 둔다: 이 미니는 이번-박자 fx 마크를 하나도 못 // 그린다. drawParkScene 의 이벤트 레이어(트레이서, seen/sent 하트 드레인, thud 링, crash· // toggle 마크, y29의 wince — 전부)는 하나같이 _parkStepFx(st) 를 거치고, _parkStepFx 는 // `a.fxSt !== st` 로 게이트한다. a 는 G.parkAnim — 즉 **플레이 다리의** `{mode:'game'}` anim이고, // 그 fxSt 는 언제나 플레이 자신의 P.st다. 미니는 자기만의 엔진 인스턴스(G.parkReplay.P)를 따로 // 굴리므로 그 st 는 플레이의 st 와 객체로서 결코 같을 수 없다 — 그래서 매 미니 프레임에서 // _parkStepFx(m.P.st) 는 항상 빈 배열을 돌려준다. 예컨대 cx:y46 에서 시연이 가르치는 박자는 // 인형이 쏘는 순간인데, 미니가 같은 수를 그대로 재생해도 그 트레이서는 뜨지 않는다. // 방향은 안전하다 — 언제나 빈 배열이라 거짓 마크가 뜨는 경우는 없고, PARK-REPLAY-BOARD- // MATCHES-DEMO 도 이 결손을 못 본다(그 게이트는 수·경로·턴·하트만 비교하지 렌더 마크는 안 // 본다). 고치려면 _parkStepFx 자체를 여러 애니메이션 인스턴스가 공유하도록 다시 짜야 하는데, // 이 설계는 그 공유 경로를 건드리지 않기로 처음부터 정했다(바로 위 "렌더러를 새로 짜지 // 않는다") — 그러니 이건 남겨 둔 한계지 이번 통과의 버그가 아니다. // // 사람이 draw() 중간 상태를 볼 일은 없다 — draw() 는 동기 함수다. let _parkReplayCv = null, _parkReplayCx = null; function _parkReplayPaint() { const m = G.parkReplay; if (!m || !m.P) return false; if (!_parkReplayCv) { _parkReplayCv = document.createElement('canvas'); _parkReplayCv.width = _parkReplayCv.height = PARK_REPLAY_PX; _parkReplayCx = _parkReplayCv.getContext('2d'); } drawParkScene(m.P, m.cue || {}); // 갈림길 프레임에는 라이브 시연과 같은 무언의 스포트라이트 링을 얹는다(의미 없는 주의 앵커). if (m.cue && (m.cue.pause || m.cue.conflict)) drawParkDemoRing(m.P.st.pos[0]); _parkReplayCx.clearRect(0, 0, PARK_REPLAY_PX, PARK_REPLAY_PX); _parkReplayCx.drawImage(board, 0, 0, board.width, board.height, 0, 0, PARK_REPLAY_PX, PARK_REPLAY_PX); return true; } // _parkReplayBlit(): drawParkHUD 가 hx 를 지운 **뒤에** 부른다. 테두리와 ▶ 칩으로 "저쪽 공원"을 // 표시한다 — ▶=지켜보기 / d-pad=플레이 는 drawParkPhaseChips() 가 이미 쓰는 어휘라 // 새 문법을 만들지 않는다. 글자 없음(PARK-ZERO-TEXT). function _parkReplayBlit() { const m = G.parkReplay; if (!m || !m.P || !_parkReplayCv) return; const X = PARK_REPLAY_X, Y = PARK_REPLAY_Y, S = PARK_REPLAY_PX; hx.save(); hx.globalAlpha = 0.92; // 본판보다 한 겹 물러서 있게 — 참조지 주인공이 아니다 hx.drawImage(_parkReplayCv, X, Y, S, S); hx.globalAlpha = 1; hx.strokeStyle = 'rgba(230,236,245,0.45)'; hx.lineWidth = 1.4; hx.strokeRect(X + 0.5, Y + 0.5, S - 1, S - 1); const cw = 26, ch = 18, cx0 = X + 6, cy0 = Y + 6; // ▶ 칩 (지켜보기) hx.fillStyle = 'rgba(9,11,15,0.85)'; hx.fillRect(cx0, cy0, cw, ch); hx.strokeStyle = 'rgba(230,236,245,0.55)'; hx.lineWidth = 1; hx.strokeRect(cx0 + 0.5, cy0 + 0.5, cw - 1, ch - 1); const ccx = cx0 + cw / 2, ccy = cy0 + ch / 2; hx.fillStyle = '#9aa0ac'; hx.beginPath(); hx.moveTo(ccx - 3.5, ccy - 5); hx.lineTo(ccx + 5.5, ccy); hx.lineTo(ccx - 3.5, ccy + 5); hx.closePath(); hx.fill(); hx.restore(); } /* ==== PARK TUTORIAL RENDER (P3a §1 — LIVE practice-yard frames, ZERO-TEXT) ==== */ // the skip chip's hit rect (drawn top-right; clicking it skips the whole tutorial). // EVERY-VISIT (2026-07-10): with the localStorage return-skip removed, this chip is a // returner's ONLY one-gesture exit — promoted from a 46x26 corner affordance to a // PROMINENT 78x32 card (justification: the boot now always lands here, so the skip must // be findable on the very first frame; geometry stays clear of the centered beat pips // [~x202-338 at board 540] and the top-left phase chips). function _tutSkipRect() { return { x: board.width - 90, y: 8, w: 78, h: 32 }; } // drawParkTutorial(): the practice-yard frame — the real park scene renderer over the fixed // tutorial board + the beat overlays: beat pips (progress), guidance chevrons with the input // echo, the T4 watch ring, the completed-beat continue affordance, and the skip chip. Every // overlay is a drawn glyph shape — NO letters (PARK-ZERO-TEXT covers these frames). function drawParkTutorial() { const tut = G.parkTut; if (!tut) return; const P = tut.P, st = P.st; drawParkScene(P, tut.cue || {}); const g = _pulseGlow(); // BEAT PIPS -> LESSON GLYPHS (fix R2 #9): abstract discs were unattributable ("what // fills them?"). Each pip is the LESSON'S OWN glyph — ▶ (P8.6 §A.3 WATCH mini-demo) / // d-pad (move) / gem (collect) / heart (danger) / the magenta little one (companion) — // a wordless checklist: done = mint underline, current = pulsing white ring, future = dim. { const y = 17, x0 = board.width / 2 - 2 * 34; for (let b = 0; b <= 4; b++) { const cx = x0 + b * 34; const done = b < tut.beat || (b === 4 && tut.ready); bx.save(); bx.globalAlpha = done || b === tut.beat ? 0.95 : 0.32; if (b === 0) { // WATCH: the ▶ phase-chip triangle bx.fillStyle = '#e6ecf5'; bx.beginPath(); bx.moveTo(cx - 4.5, y - 6.5); bx.lineTo(cx + 7, y); bx.lineTo(cx - 4.5, y + 6.5); bx.closePath(); bx.fill(); } else if (b === 1) _dpadGlyph(bx, cx, y, 7, '#e6ecf5'); else if (b === 2) _parkGem(bx, cx, y, 8); else if (b === 3) { _heartPath(bx, cx, y, 7.5); bx.fillStyle = '#e0594f'; bx.fill(); } else _parkActor(bx, cx, y, 6, PARK_HUES.companion, 0, 0); if (done) { bx.globalAlpha = 0.95; bx.strokeStyle = '#7fce97'; bx.lineWidth = 2.4; bx.lineCap = 'round'; bx.beginPath(); bx.moveTo(cx - 6, y + 13); bx.lineTo(cx + 6, y + 13); bx.stroke(); } else if (b === tut.beat) { bx.globalAlpha = 0.55 + 0.4 * g; bx.strokeStyle = '#e6ecf5'; bx.lineWidth = 2; bx.beginPath(); bx.arc(cx, y, 11 + 1.5 * g, 0, 7); bx.stroke(); } bx.restore(); } } // §A.3 WATCH beat: watch-only — a pulsing ring on the self-walking resident (the same // "the stage plays itself" read as the T4 companion walk); no chevrons, no input cue. if (tut.beat === 0) { const p = st.pos[0]; bx.save(); bx.globalAlpha = 0.4 + 0.45 * g; bx.strokeStyle = '#e6ecf5'; bx.lineWidth = 2.5; bx.beginPath(); bx.arc((p.x + 0.5) * CELL, (p.y + 0.5) * CELL, CELL * (0.6 + 0.15 * g), 0, 7); bx.stroke(); bx.restore(); } // GUIDANCE CHEVRONS (action affordances; the pressed one flashes = input echo). if (tut.beat === 1) { drawParkDirChevrons(st, ['U', 'D', 'L', 'R'], tut.echo); } else if (tut.beat === 2 || tut.beat === 3) { // point toward the lesson: T2 = the beacon gem; T3 = the nearest deep band. const p = st.pos[0]; let tgt = null; if (tut.beat === 2) { // nearest ALIVE of the two T2 lesson gems (the unringed pair first on the walk — R1 #4) const cands = [0, 3].map(i => st.tokens[i]).filter(t => t && t.alive) .sort((a, b) => (Math.abs(a.x - p.x) + Math.abs(a.y - p.y)) - (Math.abs(b.x - p.x) + Math.abs(b.y - p.y))); if (cands[0]) tgt = { x: cands[0].x, y: cands[0].y }; } else { tgt = { x: Math.max(3, Math.min(7, p.x)), y: p.y <= 5 ? 3 : 7 }; } if (tgt) { const dirs = []; if (tgt.x > p.x) dirs.push('R'); else if (tgt.x < p.x) dirs.push('L'); if (tgt.y > p.y) dirs.push('D'); else if (tgt.y < p.y) dirs.push('U'); drawParkDirChevrons(st, dirs.length ? dirs : ['U'], tut.echo); } } else if (tut.beat === 4) { // watch-only: a pulsing ring on the walking companion; once done, the continue affordance // (a pulsing d-pad glyph bottom-center: "press anything"). const c = st.pos[1]; if (!tut.ready && c) { bx.save(); bx.globalAlpha = 0.4 + 0.45 * g; bx.strokeStyle = PARK_HUES.companion; bx.lineWidth = 2.5; bx.beginPath(); bx.arc((c.x + 0.5) * CELL, (c.y + 0.5) * CELL, CELL * (0.55 + 0.15 * g), 0, 7); bx.stroke(); bx.restore(); } if (tut.ready) { bx.save(); bx.globalAlpha = 0.5 + 0.45 * g; _dpadGlyph(bx, board.width / 2, board.height - 22, 11, '#e6ecf5'); bx.restore(); } } // P8.6 §C2 AUDIT CUTS: the EARLY-COMPANION dashed leash+dup-ring (R1 #7) and the GOAL // LEASH (R2 #6) are GONE — both duplicated state another mark already carries (the // always-on claim RING owns the companion's target; the white star + gold brackets own // yours). One criterion: a dash that adds no public state is cut. const acx = (st.pos[0].x + 0.5) * CELL, acy = (st.pos[0].y + 0.5) * CELL; const apk = st.pos[0].y * st.N + st.pos[0].x; const mendFresh = tut.regenFx && Date.now() - tut.regenFx < 700; // VERGE = WARNING, never a pickup (fix R2 #1 — supersedes R1 #6's whole-heart): the // heart-with-shimmer over the verge read as a HEAL right before the damage beat, so the // red zone seemed to heal->hurt->heal within three frames. The warning is now HAZARD- // family iconography only: a pulsing ember spike — the exact triangle the deep field is // textured with — hovering over the player ("the burnt ground is close"). Heart glyphs // appear ONLY when the body actually changes (crack on damage, mend on the restore). if (st.park.verge.has(apk) && !(tut.cue && (tut.cue.deep || tut.cue.hurt))) { const wy = acy - CELL * 0.85, ws = CELL * (0.22 + 0.05 * g); bx.save(); bx.globalAlpha = 0.65 + 0.3 * g; bx.beginPath(); bx.moveTo(acx, wy - ws); bx.lineTo(acx - ws * 0.9, wy + ws * 0.7); bx.lineTo(acx + ws * 0.9, wy + ws * 0.7); bx.closePath(); bx.fillStyle = _alpha(PARK_HUES.ember, 0.9); bx.fill(); bx.strokeStyle = '#14161c'; bx.lineWidth = 1.4; bx.stroke(); bx.restore(); } // T3 MEND read (fix R1 #1): while the tutorial-only regeneration lands, a WHOLE heart with // mint mend rays over the player — the heal is an explicit EVENT (crack frame -> mend frame), // never a silent flicker back to full. if (mendFresh) { bx.save(); _heartPath(bx, acx, acy - CELL * 0.9, CELL * 0.34); bx.fillStyle = '#e0594f'; bx.fill(); bx.strokeStyle = '#7fce97'; bx.lineWidth = 2.5; bx.lineCap = 'round'; for (let i = 0; i < 6; i++) { const th = i * Math.PI / 3 - Math.PI / 2; bx.beginPath(); bx.moveTo(acx + Math.cos(th) * CELL * 0.5, acy - CELL * 0.9 + Math.sin(th) * CELL * 0.5); bx.lineTo(acx + Math.cos(th) * CELL * 0.72, acy - CELL * 0.9 + Math.sin(th) * CELL * 0.72); bx.stroke(); } bx.restore(); } // PHASE CHIPS from the FIRST tutorial frame (fix R2 #5/#7/#8 — supersedes R1 #8's // slashed d-pad under the player, which read as an opaque trap glyph): the tutorial // carries the SAME ▶/d-pad chip pair as the live board, in the same top-left slot. // d-pad lit = your input drives the stage; ▶ lit + pulsing = watch (the §A.3 WATCH // mini-demo and the T4 companion walk). The chip grammar is thus LEARNED here, so the // demo's watch chip and the handoff's play-chip pulse are re-reads of a known control. drawParkPhaseChips(tut.beat === 0 || (tut.beat === 4 && !tut.ready) ? 'watch' : 'play', tut.beat === 0 || tut.beat === 4); // SKIP CHIP (always available, PROMINENT since every-visit 2026-07-10): a double- // forward-triangle glyph on a card, top-right, visible from the FIRST tutorial frame // (this block is on drawParkTutorial's unconditional path — beat 0 included). Full // opacity + a bright 2px keyline + larger glyphs: the boot now always lands on the // tutorial, so the whole-skip must read as a first-class control, not a corner hint. // Still zero-text (triangles only — PARK-ZERO-TEXT). { const r = _tutSkipRect(); bx.save(); bx.fillStyle = 'rgba(9,11,15,0.92)'; bx.fillRect(r.x, r.y, r.w, r.h); bx.strokeStyle = 'rgba(230,236,245,0.9)'; bx.lineWidth = 2; bx.strokeRect(r.x + 1, r.y + 1, r.w - 2, r.h - 2); bx.fillStyle = '#eef2f8'; const cy = r.y + r.h / 2; for (const off of [-11, 1]) { bx.beginPath(); bx.moveTo(r.x + r.w / 2 + off, cy - 7); bx.lineTo(r.x + r.w / 2 + off + 10, cy); bx.lineTo(r.x + r.w / 2 + off, cy + 7); bx.closePath(); bx.fill(); } bx.restore(); } // Act 3 SCORE STRIP (full-cycle vignette, design 2026-07-10): the display-only glyph // strip over the finished vignette board — zero-text, pure view (tut.strip snapshot). if (tut.beat === 0 && tut.watch && tut.watch.act === 3) drawTutScoreStrip(tut); // §C.1 EVENT SPOTLIGHT in the practice yard too (first gem / first deep entry): the same // scope-gated dim + ring vocabulary the demo uses. Shapes only — zero-text. drawParkAnnot(); } // drawTutScoreStrip(tut): Act 3's "a cycle ends in a scorecard" — ONE glyph line on a small // card (ending glyph + the two core meters as countable glyphs: hearts left, gauge pips of // the banked gems). Composed ENTIRELY from the existing glyph primitives (_parkEndGlyph / // _heartPath / _parkGem) — zero fillText (the tutorial is a live PARK-ZERO-TEXT surface). // Reads ONLY the tut.strip snapshot: never C.parkSessionReport, never run.park/results, // never G.sessionStrip (TUTORIAL-UNSCORED byte-zero discipline). function drawTutScoreStrip(tut) { const s = tut.strip; if (!s) return; const w = 320, h = 84, x0 = (board.width - w) / 2, y0 = board.height - h - 46; const cy = y0 + h / 2; bx.save(); bx.fillStyle = 'rgba(9,11,15,0.88)'; bx.fillRect(x0, y0, w, h); bx.strokeStyle = 'rgba(230,236,245,0.5)'; bx.lineWidth = 1; bx.strokeRect(x0 + 0.5, y0 + 0.5, w - 1, h - 1); _parkEndGlyph(x0 + 40, cy, 15, s.reason); // HOW the walk ended (§4.3 glyphs) for (let i = 0; i < s.heartsMax; i++) { // hearts left (body budget) const cx = x0 + 92 + i * 30; _heartPath(bx, cx, cy, 9); if (i < s.hearts) { bx.fillStyle = '#e0594f'; bx.fill(); } else { bx.strokeStyle = '#5a5f6b'; bx.lineWidth = 1.4; bx.stroke(); } } const gx0 = x0 + 92 + s.heartsMax * 30 + 20; // gauge pips: banked-of-total gems for (let i = 0; i < s.total; i++) { const cx = gx0 + i * 22; if (i < s.score) _parkGem(bx, cx, cy, 8); else { bx.strokeStyle = 'rgba(232,193,74,0.5)'; bx.lineWidth = 1.4; bx.beginPath(); bx.arc(cx, cy, 6.5, 0, 7); bx.stroke(); } } bx.restore(); } // drawParkTutorialHUD(): hearts + gauge for the PRACTICE runtime (zero-text): the same two // widgets the live park HUD teaches, plus the T3 regeneration read — the just-cracked slot // pulses while the restore is pending and blinks white when it lands (tutorial-only physics). function drawParkTutorialHUD() { const tut = G.parkTut; if (!tut) return; hx.clearRect(0, 0, hud.width, hud.height); const P = tut.P, cue = tut.cue || {}; const X = 24, now = Date.now(); for (let i = 0; i < P.heartsMax; i++) { const cx = X + 16 + i * 42, cy = 46; _heartPath(hx, cx, cy, 13); if (i < P.hearts) { hx.fillStyle = '#e0594f'; hx.fill(); } else if (tut.regenAt && now < tut.regenAt + 400) { // restoring: the slot pulses warm hx.strokeStyle = '#ffcf5c'; hx.lineWidth = 2.2; hx.globalAlpha = 0.5 + 0.5 * _pulseGlow(); hx.stroke(); hx.globalAlpha = 1; } else { hx.strokeStyle = '#5a5f6b'; hx.lineWidth = 1.6; hx.stroke(); // ONE heart grammar (fix R2 #2): spent = cracked-empty, here too (matches the live HUD). hx.strokeStyle = 'rgba(224,89,79,0.55)'; hx.lineWidth = 1.6; hx.lineCap = 'round'; hx.beginPath(); hx.moveTo(cx, cy - 11); hx.lineTo(cx - 4, cy - 3); hx.lineTo(cx + 3, cy + 2); hx.lineTo(cx - 1, cy + 10); hx.stroke(); } if ((cue.deep || cue.hurt) && i === P.hearts) { // the crack flash (same read as live) hx.strokeStyle = '#ff5050'; hx.lineWidth = 2; hx.lineCap = 'round'; hx.beginPath(); hx.moveTo(cx, cy - 11); hx.lineTo(cx - 4, cy - 3); hx.lineTo(cx + 3, cy + 2); hx.lineTo(cx - 1, cy + 10); hx.stroke(); } if (tut.regenFx && now - tut.regenFx < 700 && i === P.heartsMax - 1) { // MEND event (fix R1 #1): white ring + mint rays on the restored slot — the heal is a // visible event of its own, matching the board's mend heart, never a silent refill. hx.strokeStyle = 'rgba(255,255,255,0.9)'; hx.lineWidth = 2; hx.beginPath(); hx.arc(cx, cy, 19, 0, 7); hx.stroke(); hx.strokeStyle = '#7fce97'; hx.lineWidth = 2; hx.lineCap = 'round'; for (let k = 0; k < 6; k++) { const th = k * Math.PI / 3 - Math.PI / 2; hx.beginPath(); hx.moveTo(cx + Math.cos(th) * 21, cy + Math.sin(th) * 21); hx.lineTo(cx + Math.cos(th) * 27, cy + Math.sin(th) * 27); hx.stroke(); } } } // gauge (goal channel): gem icon pulses in sync with a pickup (T2's gauge = gems lesson). const total = P.st.tokens.reduce((s, t) => s + t.v, 0); // token v persists after a take const frac = total ? clamp01(P.st.score[0] / total) : 0; const gy = 104, bx0 = X + 46, bw = hud.width - bx0 - 20; const gemPulse = cue.gem ? 1 + 0.35 * _pulseGlow() : 1; hx.save(); _parkActor(hx, X + 6, gy, 9, SPRITE_HUE.agent, 0, 0); _parkGem(hx, X + 30, gy, 10 * gemPulse); hx.restore(); hx.fillStyle = '#23252c'; hx.fillRect(bx0, gy - 8, bw, 16); hx.fillStyle = _alpha(SPRITE_HUE.reward, 0.92); hx.fillRect(bx0, gy - 8, bw * frac, 16); _parkGaugeMarks(P, bx0, gy, bw); // countable units + target tick (R1 #9) } /* ==== PARK REPORT (post-run analyst channel — text ALLOWED; spec §5) ==== */ // PARK_AXIS: the three legible axes' FIXED hues + analyst labels. Report-only text; the hues match // the live grammar (gold gems = goal, vermillion field = safety, magenta companion/intent = care). const PARK_AXIS = { goal: { label: '목표', color: '#e8c14a' }, safety: { label: '안전', color: '#D55E00' }, care: { label: '배려', color: '#c85ce0' }, }; // English gloss for the three priority axes — lets the order scoreline render self-contained // ("care>goal>safety (배려>목표>안전)") so an English reader never cross-references a legend. const PARK_AXIS_EN = { goal: 'goal', safety: 'safety', care: 'care' }; // C_UNREAD (Task 9 §B): the "cannot read this" tone — deliberately NEITHER the match green // (C_STAR) nor the differs red. An undetermined readout is not a failure, it is an ABSENCE of // evidence, and the color must not sort it into either verdict. Shared by the readout headline // and the hub's preview-tile mark, so the two surfaces read as one statement. const C_UNREAD = '#9aa0ac'; // _parkNoReadGlyph(ctx, cx, cy, s): the hub's THIRD tile state, in the picker's own glyph // vocabulary (the picker is glyph-only — no prose ever reaches a canvas tile). An EYE, STRUCK // THROUGH: this game is playable, but it cannot read you. The eye is the game's own thesis icon // (watch a walk, read its hidden priorities); the slash is the honest denial of exactly that. // Distinct from BOTH the live hollow ready-ring (measured, readable) and the old inert hourglass // (not playable at all) — three states, three marks. function _parkNoReadGlyph(ctx, cx, cy, s, color) { ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = Math.max(1.3, s * 0.16); ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.beginPath(); // the eye: two arcs meeting at the corners ctx.moveTo(cx - s, cy); ctx.quadraticCurveTo(cx, cy - s * 0.82, cx + s, cy); ctx.quadraticCurveTo(cx, cy + s * 0.82, cx - s, cy); ctx.stroke(); ctx.beginPath(); ctx.arc(cx, cy, s * 0.3, 0, 7); ctx.fillStyle = color; ctx.fill(); // pupil ctx.beginPath(); // the strike ctx.moveTo(cx - s * 0.92, cy + s * 0.72); ctx.lineTo(cx + s * 0.92, cy - s * 0.72); ctx.stroke(); ctx.restore(); } // _parkEndGlyph(cx, cy, s, reason): the P8.5 §4.3 episode-ENDING glyph — one mark at the // top-right corner of a readout mini-board frame saying HOW that walk terminated, drawn // with the existing glyph vocabulary (0 fillText even though the readout is report-exempt): // complete = checkmark polyline (walkway green) · cap = hourglass (two stacked triangles, // neutral gray) · death = the existing cracked-heart primitive · noise = static burst // (8 radial jitter spikes, desaturated). Pure render off the row's public reason (C1). function _parkEndGlyph(cx, cy, s, reason) { if (!reason) return; bx.save(); if (reason === 'complete') { bx.strokeStyle = C_MAINT; bx.lineWidth = 2.4; bx.lineCap = 'round'; bx.lineJoin = 'round'; bx.beginPath(); bx.moveTo(cx - s, cy + s * 0.05); bx.lineTo(cx - s * 0.25, cy + s * 0.75); bx.lineTo(cx + s, cy - s * 0.7); bx.stroke(); } else if (reason === 'cap') { bx.fillStyle = '#9aa0ac'; bx.beginPath(); bx.moveTo(cx - s * 0.8, cy - s); bx.lineTo(cx + s * 0.8, cy - s); bx.lineTo(cx, cy - s * 0.05); bx.closePath(); bx.fill(); bx.beginPath(); bx.moveTo(cx - s * 0.8, cy + s); bx.lineTo(cx + s * 0.8, cy + s); bx.lineTo(cx, cy + s * 0.05); bx.closePath(); bx.fill(); } else if (reason === 'death') { drawHeartCrack(cx, cy, s); // the existing ♥−1 body-channel glyph } else if (reason === 'noise') { bx.strokeStyle = '#8a90a0'; bx.lineWidth = 1.6; bx.lineCap = 'round'; for (let i = 0; i < 8; i++) { // 8 radial spikes, deterministic jitter const th = i * Math.PI / 4 + (i % 2 ? 0.23 : -0.19); const r0 = s * (i % 2 ? 0.3 : 0.44), r1 = s * (i % 3 ? 1.0 : 0.7); bx.beginPath(); bx.moveTo(cx + Math.cos(th) * r0, cy + Math.sin(th) * r0); bx.lineTo(cx + Math.cos(th) * r1, cy + Math.sin(th) * r1); bx.stroke(); } } bx.restore(); } // _parkReportMini(st, segs, x0, y0, w, label, ending): one labeled mini park (board-size // agnostic — cell = w / st.N) with 1+ trajectory segments overlaid (each {path, color} gets // the hollow-start/filled-end markers). Shared by the capstone and task report overlays. // Report-only text. `ending` (optional reason string) draws the §4.3 ending glyph at the // frame's top-right corner (~14px). function _parkReportMini(st, segs, x0, y0, w, label, ending) { const cell = w / st.N; _paintParkTerrain(st, x0, y0, cell, 0.62); for (const t of st.tokens) { // goal-grammar dots: pad ring / typed / gold const cx = x0 + (t.x + 0.5) * cell, cy = y0 + (t.y + 0.5) * cell; if (t.pad) { bx.strokeStyle = PARK_PAD_HUE; bx.lineWidth = Math.max(1, cell * 0.14); bx.beginPath(); bx.arc(cx, cy, cell * 0.3, 0, 7); bx.stroke(); bx.fillStyle = PARK_PAD_HUE; bx.beginPath(); bx.arc(cx, cy, cell * 0.12, 0, 7); bx.fill(); } else { bx.fillStyle = t.gtype != null ? PARK_GEM_TYPES[t.gtype % 3] : SPRITE_HUE.reward; bx.beginPath(); bx.arc(cx, cy, cell * 0.3, 0, 7); bx.fill(); } } for (const { path, color } of segs) { if (!path || !path.length) continue; // the walked trajectory bx.strokeStyle = color; bx.lineWidth = 2.5; bx.lineJoin = 'round'; bx.globalAlpha = 0.9; bx.beginPath(); path.forEach((p, i) => { const px = x0 + (p.x + 0.5) * cell, py = y0 + (p.y + 0.5) * cell; if (i === 0) bx.moveTo(px, py); else bx.lineTo(px, py); }); bx.stroke(); bx.globalAlpha = 1; const s = path[0], e = path[path.length - 1]; bx.strokeStyle = color; bx.lineWidth = 2; // hollow start marker bx.beginPath(); bx.arc(x0 + (s.x + 0.5) * cell, y0 + (s.y + 0.5) * cell, cell * 0.5, 0, 7); bx.stroke(); bx.fillStyle = color; // filled end marker bx.beginPath(); bx.arc(x0 + (e.x + 0.5) * cell, y0 + (e.y + 0.5) * cell, cell * 0.55, 0, 7); bx.fill(); } if (ending) _parkEndGlyph(x0 + w - 11, y0 + 11, 7, ending); // §4.3 ending glyph (~14px) bx.fillStyle = '#9aa0ac'; bx.font = '12px ui-monospace, monospace'; bx.textAlign = 'center'; bx.fillText(label, x0 + w / 2, y0 + w + 20); bx.textAlign = 'left'; } // drawParkReportBoard(): the trajectory OVERLAY picture on the board canvas — the demo journey and // the player's game path, each over its own mini park (they are different public sub-seed boards), // side by side: the Kwon-et-al. trajectory-SHAPE read (perimeter loop vs star-shaped beelines). function drawParkReportBoard() { const run = G.campaign, pk = C.runReport(run).park; bx.clearRect(0, 0, board.width, board.height); bx.fillStyle = ARC.bg; bx.fillRect(0, 0, board.width, board.height); bx.fillStyle = '#cfe0ff'; bx.font = '14px ui-monospace, monospace'; bx.textAlign = 'center'; bx.fillText('궤적 비교 / trajectory comparison', board.width / 2, 24); const w = (board.width - 60) / 2, y0 = 48; // §4.3: each mini frame carries ITS walk's ending glyph (demo = the oracle's, play = yours). _parkReportMini(E.makeParkBoard(run.park.demoSeed), [{ path: pk.trajectories.demo.path, color: SPRITE_HUE.agent }], 20, y0, w, '시연 (같은 성격)', pk.demo.reason || 'complete'); _parkReportMini(E.makeParkBoard(run.park.gameSeed, { game: true }), [{ path: pk.trajectories.game.path, color: '#7fce97' }], 40 + w, y0, w, '플레이 (당신)', pk.game.reason); } // drawFitted(): a canvas has no overflow model — a string wider than the space you meant // for it does not clip or wrap, it silently runs off the edge or straight through whatever // was already drawn there. Every text bug on this report surface came from that. So no // report text is drawn raw any more: measure, shrink to fit (down to minPx), and only if // even the floor is too narrow, wrap on spaces. Returns the baseline of the LAST line so // callers can stack without hand-counting. function drawFitted(s, cx, y, maxW, px, color, minPx) { minPx = minPx || 9; const font = (n) => n + 'px ui-monospace, monospace'; const wide = (str, n) => { bx.font = font(n); return bx.measureText(str).width > maxW; }; let size = px; while (size > minPx && wide(s, size)) size--; let lines = [s]; if (wide(s, size)) { // at the floor and still too wide -> wrap lines = []; let cur = ''; for (const word of s.split(' ')) { const next = cur ? cur + ' ' + word : word; if (cur && wide(next, size)) { lines.push(cur); cur = word; } else cur = next; } if (cur) lines.push(cur); } bx.font = font(size); bx.fillStyle = color; bx.textAlign = 'center'; const lh = size + 3; lines.forEach((ln, i) => bx.fillText(ln, cx, y + i * lh)); return y + (lines.length - 1) * lh; } // drawParkTaskReportBoard(): the trajectory overlay for the just-finished MINIGAME — demo board + // play board side by side (same read as the capstone). M7 is ONE board: the stranger's prefix walk // (blue) and the player's continuation (green) on the SAME park — the control-flip picture. function drawParkTaskReportBoard() { const run = G.campaign, t = run.park.task, kind = t.tile.kind; bx.clearRect(0, 0, board.width, board.height); bx.fillStyle = ARC.bg; bx.fillRect(0, 0, board.width, board.height); // KR and EN on their own lines. As one line the title ran to x=520 and disappeared under // the hub chip (x>=484); centred text must stop short of it, so cap the half-width. const titleMaxW = 2 * (_hubChipRect().x - 8 - board.width / 2); drawFitted(t.transfer ? '전이 판독 — 다른 공원, 같은 자기' : '과제 궤적 — 모양이 성격을 말합니다', board.width / 2, 20, titleMaxW, 14, '#cfe0ff'); drawFitted(t.transfer ? 'a different park, the same self' : 'task trajectories', board.width / 2, 36, titleMaxW, 11, '#7f8694'); const w = (board.width - 60) / 2, y0 = 48; // §4.3: each mini frame carries ITS walk's ending glyph (demo = the oracle's, play = yours). if (t.observer) { const pre = t.demo.path; _parkReportMini(E.makeParkTask(kind, t.playCell), [{ path: pre, color: SPRITE_HUE.agent }, { path: t.game.P.path.slice(Math.max(0, pre.length - 1)), color: '#7fce97' }], (board.width - w) / 2, y0, w, '타인의 걸음 (파랑) → 당신의 이어가기 (초록)', t.game.P.reason); } else { // a CROSSING's cells rebuild through the crossing convention (phase/relational play cells // are candidate-0 boards — the exact board the game ran on); other tasks keep makeParkTask. const mini = (cell) => t.crossing ? C._parkCrossBoard(kind, cell) : E.makeParkTask(kind, cell); _parkReportMini(mini(t.demoCell), [{ path: t.demo.path, color: SPRITE_HUE.agent }], 20, y0, w, '시연 (같은 성격)', t.demo.reason || 'complete'); _parkReportMini(mini(t.playCell), [{ path: t.game.P.path, color: '#7fce97' }], 40 + w, y0, w, '플레이 (당신)', t.game.P.reason); if (t.transfer) drawTransferReadout(t, y0, w); // §B.3: contexts + distance + loop affordances } } // KR + EN names for the three transfer axes (spec §A.1 — the PUBLIC cell fields a pair may // differ on). Bilingual so the readout's changed-axes caption never half-translates. // A CROSSING reports a different axis set than a random transfer does — the MECHANISM axes it // pins (goalMech/safetyMech/moveMech/fieldMech, + arch). They were missing here, so the readout // printed the raw keys ('바뀐 축: goalMech·moveMech·fieldMech') on every picker cell. Both sets // live in one table because ONE readout draws both. const _TRANSFER_AXIS_KR = { goalVariant: '목표', hazard: '위험', arch: '지형', safetyForm: '안전 규칙', goalMech: '목표 방식', safetyMech: '안전 방식', moveMech: '이동 방식', fieldMech: '마당 장치' }; const _TRANSFER_AXIS_EN = { goalVariant: 'goal', hazard: 'hazard', arch: 'terrain', safetyForm: 'safety form', goalMech: 'goal mech', safetyMech: 'safety mech', moveMech: 'move mech', fieldMech: 'field mech' }; // _transferCtxStrip(cx, y, cell): ONE context's public axes as the hub's own glyph vocabulary — // arch silhouette (_parkArchGlyph) + hazard family swatch with its heart cost (the drawHubBadges // read) + goal-grammar badge (_parkGoalBadge) — centered under that context's thumbnail. Pure // public-cell inputs (C1); report surface, but the strip itself stays glyph-only so the demo-vs- // play axis comparison is the SAME visual language the hub tiles taught. // The strip's glyphs are drawn CENTRED on y and reach ~8px either side of it (arch glyph // r=7, hazard swatch s=13, goal badge r=6.5). drawTransferReadout stacks text underneath // and has no other way to know how far down the strip actually comes — the two used to // disagree, and the readout printed through the glyphs. One constant, read by both. const TRANSFER_STRIP_H = 16; function _transferCtxStrip(cx, y, cell) { const tint = PARK_HAZARD_TINT[cell.hazard.kind] || PARK_HUES; let x = cx - 44; _parkArchGlyph(x, y, 7, cell.arch || 'park'); x += 28; const s = 13; bx.fillStyle = tint.deep; bx.fillRect(x - s / 2, y - s / 2, s, s); bx.fillStyle = _alpha(tint.ember, 0.95); bx.beginPath(); bx.moveTo(x, y - s * 0.38); bx.lineTo(x - s * 0.3, y + s * 0.34); bx.lineTo(x + s * 0.3, y + s * 0.34); bx.closePath(); bx.fill(); bx.fillStyle = '#e0594f'; for (let h = 0; h < (cell.hazard.damage || 0); h++) { _heartPath(bx, x + s / 2 + 7 + h * 8, y, 3.2); bx.fill(); } x += s + 24; _parkGoalBadge(bx, x, y, 6.5, cell.goalVariant || 'harvest'); } // drawTransferReadout(t, y0, w): the §B.3 readout additions under the two trajectory thumbnails — // per-context glyph strips (demo axes vs play axes, side by side), the transfer-distance badge // (filled pip per changed axis over the kind's feasible maximum; m3's single arch caps it at 2), // the changed-axes caption, and the episode-loop affordances (Enter/click = next random episode; // the corner chip = the deliberate hub). Reports are the sanctioned text surface. function drawTransferReadout(t, y0, w) { const gy = y0 + w + 40; _transferCtxStrip(20 + w / 2, gy, t.demoCell); _transferCtxStrip(40 + w + w / 2, gy, t.playCell); // the SCALE comes from the episode that counted (campaign.js: pair.transferMax / axesPossible). // Re-deriving it here is what printed '3/2' on crossings: this screen knew only the random pool's // {goal,hazard,arch} story, and a crossing counts mechanism axes. const axesAll = t.transfer.axes || []; const maxD = axesAll.length; const d = t.transfer.distance; const cx = board.width / 2, maxW = board.width - 24; const krAx = t.transfer.axesChanged.map(a => _TRANSFER_AXIS_KR[a] || a).join('·'); const enAx = t.transfer.axesChanged.map(a => _TRANSFER_AXIS_EN[a] || a).join(' & '); // The readout is centred on the board and is wide enough to span BOTH strips, so it may // not share their vertical band — it would print through the glyphs. The old layout put // the first line 14px under the strips' CENTRE, i.e. inside them. Start below their reach. let y = gy + TRANSFER_STRIP_H / 2 + 18; y = drawFitted('전이 거리 / transfer distance ' + d + ' / ' + maxD, cx, y, maxW, 12, '#cfe0ff'); y = drawFitted('바뀐 축 / changed axes: ' + (krAx || '없음') + ' / ' + (enAx || 'none'), cx, y + 16, maxW, 12, '#9aa0ac'); const py = y + 18; for (let i = 0; i < maxD; i++) { // the distance badge: 1..maxD pips const px = cx + (i - (maxD - 1) / 2) * 20; bx.beginPath(); bx.arc(px, py, 6, 0, 7); if (i < d) { bx.fillStyle = '#e8c14a'; bx.fill(); } else { bx.strokeStyle = 'rgba(230,236,245,0.35)'; bx.lineWidth = 1.5; bx.stroke(); } } // distance-scale legend (plain, bilingual): what 0..maxD mean. One endpoint per line — // as a single line it measured 530px on a 540px canvas and sat flush against both edges. let ly = drawFitted('0 = 같은 세계 / same world', cx, py + 22, maxW, 10, '#7f8694'); // the top of the scale NAMES ITS OWN AXES (2026-07-28): this line used to read '3 = 목표·위험·지형' // on every episode, which is the random pool's axis set — a crossing counts mechanism axes and // was described by the wrong three words at the wrong number. drawFitted(maxD + ' = ' + axesAll.map(a => _TRANSFER_AXIS_KR[a] || a).join('·') + ' 모두 다름 / ' + axesAll.map(a => _TRANSFER_AXIS_EN[a] || a).join(' & ') + ' all differ', cx, ly + 13, maxW, 10, '#7f8694'); drawFitted('Enter / 클릭 = 다음 전이 / next episode 우상단 칩 = 허브 / corner chip → hub', cx, board.height - 14, maxW, 11, '#9aa0ac'); bx.textAlign = 'left'; drawHubCornerChip(); } // drawParkReport(pk): the analyst panel on hx — the order inferred FROM THE PLAYER'S ACTUAL MOVES // (E.parkRecoverOrder, blind) vs the demonstrated order, + per-conflict tempted -> honored // denominators for demo and game, + the run terminals (hearts / violations / turns). // _reportHearts(x, cy, hearts, max): the readout's heart row in the SAME glyph grammar as // the live HUD (fix R2 #2): filled = remaining, cracked-empty = spent — never a bare numeral. function _reportHearts(x, cy, hearts, max) { for (let i = 0; i < max; i++) { const cx = x + 7 + i * 16; _heartPath(hx, cx, cy, 6); if (i < hearts) { hx.fillStyle = '#e0594f'; hx.fill(); } else { hx.strokeStyle = '#5a5f6b'; hx.lineWidth = 1.2; hx.stroke(); hx.strokeStyle = 'rgba(224,89,79,0.55)'; hx.lineWidth = 1.2; hx.lineCap = 'round'; hx.beginPath(); hx.moveTo(cx, cy - 5); hx.lineTo(cx - 2, cy - 1); hx.lineTo(cx + 1.5, cy + 1); hx.lineTo(cx - 0.5, cy + 4.6); hx.stroke(); } } } // verdict copy (R2 #7: plain KO on the title row + a short EN line under it). // P8.5 §4.3: 'noise' gets its OWN entry — before this it fell through to the '턴 소진' // fallback, mislabeling an invalid-input termination as running out of turns. const _PARK_END_KO = { death: '탈락 — ♥ 0', complete: '완주', cap: '턴 소진', noise: '무효 입력 한도 초과' }; const _PARK_END_EN = { death: 'out of hearts', complete: 'walk complete', cap: 'out of turns', noise: 'invalid-input limit reached' }; function drawParkReport(pk) { const lab = (o) => (o || []).map(ax => PARK_AXIS[ax] ? PARK_AXIS[ax].label : '?').join(' > '); const labT = (o) => (o || []).map(ax => PARK_AXIS[ax] ? PARK_AXIS[ax].label : '?').join('>'); txtH(20, 18, '결과 — 공원 판독', C_AGENT, 15); const endTxt = _PARK_END_KO[pk.game.reason] || '턴 소진'; let y = 46; // §4.3 layout: the noise label ('무효 입력 한도 초과 / invalid-input limit reached') outgrows // the title row's right slot — give it its own right-aligned rows instead of overlapping. if (pk.game.reason === 'noise') { txtH(20, 31, 'Park readout', '#6f7480', 9); txtH(221, 31, endTxt, '#9aa0ac', 9, 'right'); txtH(221, 43, _PARK_END_EN.noise, '#6f7480', 9, 'right'); y = 56; } else { txtH(221, 18, endTxt, pk.game.reason === 'death' ? '#e0594f' : '#9aa0ac', 10, 'right'); txtH(20, 31, 'Park readout · ' + (_PARK_END_EN[pk.game.reason] || 'out of turns'), '#6f7480', 9); } txtH(20, y, '성격 (숨은 우선순위)', '#9aa0ac', 11); y += 16; txtH(28, y, '시연: ' + lab(pk.demonstrated), '#cfe0ff', 11); y += 15; txtH(28, y, '이동 추론: ' + (pk.inferred ? lab(pk.inferred) : '판정 불가') + (pk.match ? ' ✓ 일치' : ' ✗'), pk.match ? C_STAR : '#e0594f', 11); y += 24; // R2 #7: the section is named plainly ("kept its order") — the denominator key stays as detail. txtH(20, y, '자기 지킴 · kept its order', '#9aa0ac', 11); y += 13; txtH(20, y, '갈등별 유혹 N → 지킴 M (시연 · 플레이)', '#6f7480', 9); y += 15; const kinds = [['gc', '목표×안전'], ['gk', '목표×배려'], ['ck', '안전×배려']]; for (const [kk, name] of kinds) { const d = pk.conflicts.demo[kk], g = pk.conflicts.game[kk]; dotH(28, y - 3, C_MAINT, 5); txtH(40, y, name, '#cfe0ff', 11); txtH(112, y, `${d.honored}/${d.tempted}`, '#9aa0ac', 10); txtH(152, y, `${g.honored}/${g.tempted}`, g.tempted > 0 ? '#cfe0ff' : '#e0594f', 10); if (g.tempted > 0) barH(152, y + 4, 74, 6, g.honored / g.tempted, C_MAINT); y += 24; } y += 4; // P2 scoring rebuild (spec §A): the blind Bayesian posterior (primary readout), continuous // sigma per axis pair, C*-normalized pursuit, early-convergence discovery, fat-finger tally. const pct = (v) => v == null ? '—' : Math.round(v * 100) + '%'; txtH(20, y, '블라인드 판독', '#9aa0ac', 11); y += 16; // R2 #7: the primary row is named plainly ('recovered priority'); 사후 MAP stays in parens. txtH(28, y, '복원된 우선순위 · recovered priority', '#9aa0ac', 10); y += 13; txtH(28, y, `${labT(pk.posterior.map)} (사후 MAP ${pct(pk.posterior.mass)})`, '#cfe0ff', 11); y += 15; txtH(28, y, `σ 목표×안전 ${pct(pk.sigma.gc)} · 목표×배려 ${pct(pk.sigma.gk)} · 안전×배려 ${pct(pk.sigma.ck)}`, '#cfe0ff', 10); y += 15; txtH(28, y, `추구 ${pct(pk.pursuit)} · 발견 ${pct(pk.discovery)}`, '#cfe0ff', 11); y += 24; // hearts as GLYPHS (fix R2 #2): the same filled/cracked grammar as the live HUD. _reportHearts(20, y - 4, pk.game.hearts, pk.game.heartsMax); txtH(20 + pk.game.heartsMax * 16 + 8, y, `위반 ${pk.game.violations} · 잡음 ${pk.inputNoise} · ${pk.game.turns}턴`, '#cfe0ff', 11); drawParkSuiteRows(y + 26); // P2b: the battery rows + suite headline under the capstone readout } /* ==== PARK TASK REPORT + SUITE ROWS (post-episode analyst channel — text allowed) ==== */ // The kind's opposed pulls as colored dots (m4/park = all three; m5 = the neutral stranger // glyph — the observe/continue probe). Shared by the hub badges (bx) and report rows (hx); // pure kind lookup, zero text, never the persona (C1). const PARK_KIND_PULLS = { m1: ['goal', 'safety'], m2: ['goal', 'care'], m3: ['safety', 'care'], m4: ['goal', 'safety', 'care'], park: ['goal', 'safety', 'care'], }; // the two axes each conflict-pair key opposes (sigma/conflict readout dot colors). const _PARK_PAIR_AXES = { gc: ['goal', 'safety'], gk: ['goal', 'care'], ck: ['safety', 'care'] }; function drawKindPulls(ctx, x, y, kind, r) { const pulls = PARK_KIND_PULLS[kind]; if (!pulls) { _parkActor(ctx, x + r, y, r * 0.9, '#9aa0ac', 0, 0); return; } pulls.forEach((ax, i) => { ctx.fillStyle = PARK_AXIS[ax].color; ctx.beginPath(); ctx.arc(x + r + i * (r * 2 + r * 0.55), y, r, 0, 7); ctx.fill(); // gap rides r (BADGE_SPAN) }); } // pooled Maintenance of one finished row: honored/tempted over the three pairs (S1 discipline). function _rowMaintenance(conflicts) { let t = 0, h = 0; for (const p of ['gc', 'gk', 'ck']) { t += conflicts[p].tempted; h += conflicts[p].honored; } return t > 0 ? h / t : null; } // drawParkSuiteRows(y): the battery readout — one compact row per FINISHED task (kind pulls // icon, blind posterior MAP + mass bar, per-pair sigma bars, pursuit, discovery) + the pooled // Discovery x Maintenance suite headline (C.parkSuiteReport). Skipped while nothing finished. function drawParkSuiteRows(y) { const run = G.campaign; const suite = C.parkSuiteReport(run); const done = suite.rows.filter(r => r.done); if (!done.length) return; const pct = (v) => v == null ? '—' : Math.round(v * 100) + '%'; // R2 #7: full Korean words in the suite rows — the recovered order in full axis labels // (was '배>목>안' first-letter shorthand), '추구/발견' spelled out (was '추/발'). const sh = (o) => (o || []).map(ax => PARK_AXIS[ax] ? PARK_AXIS[ax].label : '?').join('>'); txtH(20, y, '누적 배터리 / battery — 과제별 / per task', '#9aa0ac', 10); y += 15; for (const r of done) { if (y > hud.height - 57) break; drawKindPulls(hx, 22, y - 3, r.kind, 3.5); txtH(52, y, r.kind === 'park' ? '공원' : r.kind.toUpperCase(), '#cfe0ff', 10); txtH(86, y, sh(r.posterior.map), '#cfe0ff', 10); barH(186, y - 8, 36, 8, r.posterior.mass, C_AGENT); // posterior mass (confidence) y += 13; let x = 52; // per-pair sigma bars (violation magnitude) for (const kk of ['gc', 'gk', 'ck']) { _PARK_PAIR_AXES[kk].forEach((ax, j) => dotH(x - 6 + j * 5, y - 3, PARK_AXIS[ax].color, 2.5)); if (r.sigma[kk] == null) hatchSlot(x + 4, y - 7, 24, 7); else barH(x + 4, y - 7, 24, 7, r.sigma[kk], '#e0594f'); x += 44; } txtH(x - 6, y, r.observer ? `관찰 ${pct(r.discovery)}` : `추구 ${pct(r.pursuit)}`, '#9aa0ac', 9); y += 13; txtH(52, y, `발견 ${pct(r.discovery)}`, '#9aa0ac', 9); y += 16; } y += 3; txtH(20, y, `종합 / overall 발견/disc ${pct(suite.discovery)} × 유지/kept ${pct(suite.maintenance)} = ${pct(suite.agentness)}`, C_AGENT, 10); } // _drawPairReadRows(pairs, demonstrated, y): the PAIR-GRAIN block (2026-07-28) — one row per // conflict pair off row.orderRead.pairs, state classified by the ONE shared policy // (C._parkPairState, never re-derived here). 'read' prints the inequality plus a per-pair ✓/✗ // against the demonstrated direction (the ✓/✗ is computed HERE off row.demonstrated — the blind // read itself stays persona-free, C1). 'tied' and 'unposed' print NO verdict — same honesty rule // as the Task 9 headline, now at pair grain. Returns the advanced y. function _drawPairReadRows(pairs, demonstrated, y) { const lab = (ax) => PARK_AXIS[ax] ? PARK_AXIS[ax].label : '?'; for (const q of pairs) { q.axes.forEach((ax, j) => dotH(32 + j * 5, y - 3, PARK_AXIS[ax].color, 2.5)); const st = C._parkPairState(q); if (st === 'read') { const ok = demonstrated && demonstrated.indexOf(q.hiAxis) < demonstrated.indexOf(q.loAxis); txtH(46, y, `${lab(q.hiAxis)} > ${lab(q.loAxis)} ${ok ? '✓' : '✗'}`, ok ? C_STAR : '#e0594f', 10); barH(186, y - 7, 30, 6, q.margin, ok ? C_STAR : '#e0594f'); // confidence, display-only } else if (st === 'tied') { txtH(46, y, `${lab(q.axes[0])} × ${lab(q.axes[1])} — 팽팽함`, '#8a90a0', 9); hatchSlot(186, y - 7, 30, 6); } else { txtH(46, y, `${lab(q.axes[0])} × ${lab(q.axes[1])} — 부딪힌 장면 없음`, '#6f7480', 9); } y += 12; } txtH(46, y, '✓/✗ vs demonstrated · 팽팽 tied · 장면 없음 never met', '#5f636f', 8); y += 13; return y; } // drawParkTaskReport(): the hud panel for the just-finished episode. P4 legibility rebuild // (2026-07-05, flow judge): the PRIMARY readout is plain + fully bilingual — "how you did" at // a glance (recovered order vs demonstrated order: match or not; kept-its-order %; pursuit %). // The research jargon (posterior MAP, per-pair σ, discovery n*/n, the kind code) is demoted to // a clearly-labelled '분석 상세 / analyst detail' section below, each term defined in situ. // // TASK 9 §B — THE HONESTY GATE. This headline used to ALWAYS render a verdict: it read // row.posterior.map (a posterior always names a MAP, even at a dead tie), compared it to the // demonstrated order, and stamped ✓ 순서 일치 / ✗ 순서 다름 on the result. On xp the blind // posterior is three personas TIED at 1/3 each — the "recovered order" was decided by object key // order, and 2 of xp's 6 matches were coin flips. The engine never lied (parkRecoverOrder returns // null when the trajectory does not determine an order); only this screen did. // Now: when row.orderRead.determined is false — the trajectory leaves a pair undecided, or the // MAP is tied with another persona at the same mass — the headline prints 읽을 수 없음 / // undetermined, NO ✓/✗ and NO recovered order, plus one line naming WHY in the game's own words // (which conflict pair never occurred, off the row's computed orderRead.missingPairs — never // hardcoded per slot). The demonstrated order still prints (that we DO know), and 자기 지킴 / // 목표 추구 still print (they are per-turn channels and do not depend on the order being // recoverable). The analyst-detail block below keeps the raw posterior mass — it is honest there // because it is LABELLED as a posterior, sitting next to its own confidence bar. // This fixes the LEGACY live cells (xp/xs/x4/x7) as much as the five previews (y12/y14/y8/y6/y10). function drawParkTaskReport() { hx.clearRect(0, 0, hud.width, hud.height); const run = G.campaign, t = run.park.task; const row = run.park.results[t.tile.id]; if (!row) return; const labKR = (o) => (o || []).map(ax => PARK_AXIS[ax] ? PARK_AXIS[ax].label : '?').join('>'); const labEN = (o) => (o || []).map(ax => PARK_AXIS_EN[ax] || '?').join('>'); const labB = (o) => labEN(o) + ' (' + labKR(o) + ')'; // self-contained bilingual order token const pct = (v) => v == null ? '—' : Math.round(v * 100) + '%'; // the trajectory DETERMINES an order (an old row without orderRead — a report drawn off a // pre-Task-9 stored row — keeps the legacy behavior rather than silently reading undetermined). const readable = !row.orderRead || row.orderRead.determined; // WHICH ORDER THE HEADLINE IS ABOUT. Normally the posterior's MAP, exactly as before. On a // CLOSURE row (2026-08-02 — two comparisons that chain, the third implied) the row is determined // by its own decided pairs and not by the posterior, so the verdict must be about the order // those pairs pin. Printing posterior.map there would compare the demonstrated order against a // number this row did not use to decide it was readable at all. const viaClosure = !!(row.orderRead && row.orderRead.viaClosure); const readOrder = viaClosure ? row.orderRead.recovered : row.posterior.map; const matched = readable && row.demonstrated && readOrder && row.demonstrated.join('>') === readOrder.join('>'); const kept = _rowMaintenance(row.conflicts); // honored/tempted pooled = kept-its-order txtH(20, 18, t.transfer ? '결과 — 전이 판독' : '결과 — 과제 판독', C_AGENT, 15); const endTxt = _PARK_END_KO[row.game.reason] || '턴 소진'; let y = 50; // §4.3 layout: the noise label ('무효 입력 한도 초과 / invalid-input limit reached') outgrows // the title row's right slot — give it its own right-aligned rows instead of overlapping. if (row.game.reason === 'noise') { txtH(20, 31, t.transfer ? 'Transfer readout' : 'Task readout', '#6f7480', 9); txtH(221, 31, endTxt, '#9aa0ac', 9, 'right'); txtH(221, 43, _PARK_END_EN.noise, '#6f7480', 9, 'right'); y = 58; } else { txtH(221, 18, endTxt, row.game.reason === 'death' ? '#e0594f' : '#9aa0ac', 10, 'right'); txtH(20, 31, (t.transfer ? 'Transfer readout · ' : 'Task readout · ') + (_PARK_END_EN[row.game.reason] || 'out of turns'), '#6f7480', 9); } // ================= PRIMARY: 얼마나 잘했나 / how you did ================= // The plain result is the HEADLINE — big verdict, bilingual order tokens, co-located transfer // distance, kept/pursuit at read-me size. Everything the analyst wants is demoted below. txtH(20, y, '얼마나 잘했나 / how you did', C_AGENT, 12); y += 20; // Task 9: a PREVIEW crossing says so BEFORE its numbers — the same claim its picker tile made // (the struck-through eye). It is a statement about the CELL, not about this walk: the board is // known not to pose all three conflicts, so it cannot reliably read anyone. Whether THIS walk // was readable is the verdict's own business, one line below. if (row.preview) { _parkNoReadGlyph(hx, 26, y - 4, 5, C_UNREAD); txtH(38, y, '미리보기 — 아직 읽지 못하는 칸', C_UNREAD, 10); y += 11; txtH(38, y, 'preview — cannot read you yet', '#6f7480', 9); y += 17; // fits the 222px hud column } if (t.observer) { // M7 has no demonstrated-order recovery — its "how you did" is the continuation match. txtH(20, y, '이어가기 일치 / continuation match', matched ? C_STAR : '#cfe0ff', 15); y += 20; txtH(28, y, `${row.observer.match} / ${row.observer.total} = ${pct(row.discovery)}`, '#cfe0ff', 13); y += 22; } else { // HEADLINE verdict — recovered-vs-demonstrated order match, largest + highest-contrast. // ... unless the walk does not DETERMINE an order, in which case there is no verdict to make // and the screen says so (§B honesty gate — see the function header). txtH(20, y, !readable ? '읽을 수 없음 / undetermined' : matched ? '✓ 순서 일치 / order match' : '✗ 순서 다름 / order differs', !readable ? C_UNREAD : matched ? C_STAR : '#e0594f', 14); y += 21; // transfer distance CO-LOCATED with the verdict (the "different world, same self" context). if (t.transfer) { // the same scale the board half draws, off the row the episode wrote — never re-derived here // (that second derivation is what printed a distance of 3 out of a maximum of 2). const maxD = (row.transferAxes || t.transfer.axes || []).length; txtH(28, y, `전이 거리 / transfer distance ${row.transferDistance}/${maxD}`, '#cfe0ff', 11); y += 12; txtH(28, y, '높을수록 더 다름 / higher = more different', '#6f7480', 9); y += 17; } // HOW IT KNOWS, when it knows it the harder way. A closure row saw only TWO of the three // comparisons; the third is implied because a priority order is a total order and the two it // saw chain. That is a real reading and it deserves a verdict — but it is a different KIND of // reading from three observed comparisons, and a screen that hid the difference would be // making the same mistake the honesty gate was built to stop: presenting two grades of // evidence as one. The pair-grain block further down still shows which comparison was absent. if (readable && viaClosure) { txtH(28, y, '두 비교로 좁힘 (나머지는 순서의 성질)', '#9aa0ac', 9); y += 11; txtH(28, y, 'narrowed by two comparisons; the third follows', '#6f7480', 9); y += 15; } txtH(28, y, '시연한 순서 / demonstrated', '#9aa0ac', 10); y += 13; txtH(36, y, labB(row.demonstrated), '#cfe0ff', 11); y += 16; if (!readable) { // WHY, in the game's own vocabulary — the conflict pair(s) the walk never posed, computed // from the row's orderRead (parkPairExpressed == 0 on a fresh board), never hardcoded per // slot. When every pair DID occur but the evidence still decides nothing (a tied posterior / // cyclic majorities), say THAT instead — it is a different fact and it deserves its own line. const mp = (row.orderRead && row.orderRead.missingPairs) || []; // KOREAN PARTICLES. The axis labels are 목표 / 안전 / 배려, and 안전 carries a 받침 (final // consonant) while the other two do not — so NEITHER particle is constant, and hardcoding one // misspells whichever pair the screen happens to print. Both are picked off the last // syllable's jongseong: 와/과 joins the two axes, 이/가 marks the subject of the sentence. const jong = (s) => { const c = s.charCodeAt(s.length - 1) - 0xac00; return c >= 0 && c < 11172 && c % 28 !== 0; // has a final consonant? }; const lab = (ax) => PARK_AXIS[ax].label; const pairKR = (p) => `${lab(p[0])}${jong(lab(p[0])) ? '과' : '와'} ${lab(p[1])}`; const pairEN = (p) => `${PARK_AXIS_EN[p[0]]} and ${PARK_AXIS_EN[p[1]]}`; // the subject particle agrees with the LAST label printed (the joined list's tail). const subj = mp.length && jong(lab(mp[mp.length - 1][1])) ? '이' : '가'; txtH(28, y, '되읽은 순서 / recovered', '#9aa0ac', 10); y += 13; txtH(36, y, '— 이 걸음은 순서를 정하지 못해요', C_UNREAD, 11); y += 12; txtH(36, y, ' this walk does not determine one', '#6f7480', 9); y += 15; if (row.orderRead && row.orderRead.pairs) { // PAIR-GRAIN (2026-07-28): the per-pair rows subsume the pooled prose — WHICH inequality // is readable / tied / never-met is shown per row. Legacy rows (pre-pair-read storage, // no `pairs` field) keep the pooled sentence below, byte-identical. y = _drawPairReadRows(row.orderRead.pairs, row.demonstrated, y); } else if (mp.length) { txtH(28, y, `${mp.map(pairKR).join(' · ')}${subj} 부딪히는 장면이 없었어요`, '#8a90a0', 9); y += 11; txtH(28, y, `${mp.map(pairEN).join(' · ')} never met`, '#6f7480', 9); y += 15; } else { txtH(28, y, '증거가 팽팽해서 어느 순서도 앞서지 못했어요', '#8a90a0', 9); y += 11; txtH(28, y, 'the evidence ties — no order comes out ahead', '#6f7480', 9); y += 15; } y += 3; } else { txtH(28, y, '되읽은 순서 / recovered', '#9aa0ac', 10); y += 13; txtH(36, y, labB(row.posterior.map), matched ? C_STAR : '#e0594f', 11); y += 16; // PAIR-GRAIN (2026-07-28): even a determined walk shows WHERE the evidence came from — // which scenes carried the read, which never occurred. The headline above is unchanged. if (row.orderRead && row.orderRead.pairs) y = _drawPairReadRows(row.orderRead.pairs, row.demonstrated, y); else y += 2; } txtH(28, y, '자기 지킴 / kept its order', '#9aa0ac', 10); txtH(216, y, pct(kept), '#cfe0ff', 13, 'right'); y += 17; txtH(28, y, '목표 추구 / pursuit', '#9aa0ac', 10); txtH(216, y, pct(row.pursuit), '#cfe0ff', 13, 'right'); y += 20; } // hearts as GLYPHS (fix R2 #2): filled = remaining, cracked-empty = spent — same grammar // as the live HUD hearts, so all three surfaces read as one meter. const hMax = t.game.P.heartsMax || 3; _reportHearts(20, y - 4, row.game.hearts, hMax); // P8.5 §3.1 CUT #8: the '♥ 남은 목숨 / hearts left · 턴 / turns' gloss line is gone — // the heart grammar is the live HUD's, and the turn count is printed right here. txtH(20 + hMax * 16 + 8, y, `${row.game.turns}턴 / turns`, '#cfe0ff', 11); y += 18; // ================= ANALYST DETAIL (subordinate) ================= // Divider rule + dimmer/smaller type: the research channel sits clearly UNDER the plain // summary, never at equal weight. Every term stays (analysts use it), just de-weighted. hx.fillStyle = '#2c2f38'; hx.fillRect(20, y - 6, 202, 1); txtH(20, y, '▸ 분석 상세 / analyst detail', '#5f636f', 9); y += 15; drawKindPulls(hx, 24, y - 4, row.kind, 4); txtH(52, y, row.kind === 'park' ? '공원 / park' : row.kind.toUpperCase() + (t.observer ? ' · 타인 이어가기 / continuation' : ''), '#8a90a0', 9); y += 13; txtH(28, y, `블라인드 사후확률 / posterior MAP ${pct(row.posterior.mass)}`, '#8a90a0', 9); y += 12; txtH(28, y, `발견 / discovery ${pct(row.discovery)}`, '#8a90a0', 9); y += 12; txtH(28, y, `위반 / violations ${row.game.violations}`, '#8a90a0', 9); y += 12; txtH(28, y, 'σ 위반 크기 / violation size', '#8a90a0', 9); y += 11; txtH(36, y, `G×S ${pct(row.sigma.gc)} · G×C ${pct(row.sigma.gk)} · S×C ${pct(row.sigma.ck)}`, '#8a90a0', 9); y += 13; // §B.3 TRANSFER row: discoveryEff = n*/n (ideal observer's effective decisions vs the player's). if (t.transfer) { txtH(28, y, `발견 효율 / discovery eff n*/n ${row.discoveryEff == null ? '—' : Math.round(row.discoveryEff * 100) + '%'}`, '#8a90a0', 9); y += 11; txtH(36, y, `(이상/ideal ${row.nIdeal == null ? '—' : row.nIdeal} · 당신/you ${row.nAgent == null ? '—' : row.nAgent})`, '#8a90a0', 9); y += 13; } drawParkSuiteRows(y + 16); } /* ==== PARK SESSION SURFACES (P8.6 §B.2 interstitial + §B.3 scorecard) ==== */ // drawParkInterstitial(): the ONE mid-session glyph line (§B.2) — ending glyph + discovery // pip + N-of-10 counter pips, ~1.5s, Enter skips. GLYPH-ONLY (zero fillText — this is a // live between-episode surface, not a report): the full readout waits for the scorecard. function drawParkInterstitial() { const it = G.parkInter; bx.clearRect(0, 0, board.width, board.height); bx.fillStyle = ARC.bg; bx.fillRect(0, 0, board.width, board.height); if (!it) return; const cy = board.height / 2; _parkEndGlyph(board.width / 2 - 156, cy, 18, it.reason); // HOW the walk ended bx.save(); // discovery pip (order recovered?) bx.beginPath(); bx.arc(board.width / 2 - 96, cy, 9, 0, 7); if (it.disc) { bx.fillStyle = C_DISC; bx.fill(); } else { bx.strokeStyle = _alpha(C_DISC, 0.55); bx.lineWidth = 2; bx.stroke(); } bx.restore(); bx.save(); // N-of-10 counter pips const x0 = board.width / 2 - 48, step = 24; for (let i = 0; i < it.n; i++) { bx.beginPath(); bx.arc(x0 + i * step, cy, 6, 0, 7); if (i < it.idx) { bx.fillStyle = '#cfe0ff'; bx.fill(); } else { bx.strokeStyle = 'rgba(230,236,245,0.35)'; bx.lineWidth = 1.5; bx.stroke(); } } bx.restore(); } // _sessionStripBoard(e): the mini-board for one scorecard strip slot — the CACHED finished // runtime (exact board + walked path) when this browser session played it; else a lazy // rebuild from the row's PUBLIC playCtx bytes (board only, no path). View-only cache. function _sessionStripBoard(e) { G.sessionStrip = G.sessionStrip || {}; let sc = G.sessionStrip[e.id]; if (!sc) { try { sc = { st: E.makeParkTask(e.kind, { ...e.playCtx }), path: null }; } catch (err) { sc = { st: null, path: null }; } G.sessionStrip[e.id] = sc; } return sc; } // drawParkScorecard(): the final SESSION SCORECARD (§B.3 — the report channel, text // allowed). Every aggregate comes from C.parkSessionReport — a PURE READ over the N stored // episode rows (no new scoring machinery): 발견 (mean discoveryEff, per-distance table), // 유지 (pooled faced->resisted over ALL judged turns), 추구 (goal-progress ratio, HONESTLY // labeled the demo's pursuit proxy), 결말 분포 (never hidden), and the N-episode mini-board // strip with trajectories + ending glyphs (the existing readout primitives). function drawParkScorecard() { const run = G.campaign; const rep = _sessReport(run); bx.clearRect(0, 0, board.width, board.height); bx.fillStyle = ARC.bg; bx.fillRect(0, 0, board.width, board.height); if (!rep) return; const pct = (v) => v == null ? '—' : Math.round(v * 100) + '%'; bx.textAlign = 'center'; bx.fillStyle = '#cfe0ff'; bx.font = '15px ui-monospace, monospace'; bx.fillText(`세션 결과 / ${_sessRulerName(rep.crossing)} — ${rep.played}/${rep.n} 에피소드`, board.width / 2, 26); bx.textAlign = 'left'; const bar = (x, y, w, frac, color) => { bx.fillStyle = '#23252c'; bx.fillRect(x, y, w, 8); if (frac != null) { bx.fillStyle = color; bx.fillRect(x, y, w * clamp01(frac), 8); } }; // ---- headline aggregates (left column) ---- let y = 56; bx.font = '12px ui-monospace, monospace'; bx.fillStyle = '#9aa0ac'; bx.fillText('발견 / discovery (효율 n*/n)', 24, y); bx.fillStyle = '#cfe0ff'; bx.font = '14px ui-monospace, monospace'; bx.fillText(pct(rep.discovery.eff), 250, y); bar(24, y + 8, 200, rep.discovery.eff, C_DISC); // per-distance mini-curve (§B.3): d1..d3 discovery efficiency const dks = Object.keys(rep.discovery.byDistance).sort(); bx.font = '10px ui-monospace, monospace'; bx.fillStyle = '#8a90a0'; bx.fillText('거리별 / by distance: ' + (dks.length ? dks.map(d => `d${d} ${pct(rep.discovery.byDistance[d].discoveryEff)}`).join(' · ') : '—'), 24, y + 30); y += 52; bx.font = '12px ui-monospace, monospace'; bx.fillStyle = '#9aa0ac'; bx.fillText('유지 / maintenance (직면→지킴)', 24, y); bx.fillStyle = '#cfe0ff'; bx.font = '14px ui-monospace, monospace'; bx.fillText(`${rep.maintenance.resisted}/${rep.maintenance.faced} = ${pct(rep.maintenance.rate)}`, 250, y); bar(24, y + 8, 200, rep.maintenance.rate, C_MAINT); y += 42; bx.font = '12px ui-monospace, monospace'; bx.fillStyle = '#9aa0ac'; bx.fillText('추구 / pursuit — 프록시', 24, y); bx.fillStyle = '#cfe0ff'; bx.font = '14px ui-monospace, monospace'; bx.fillText(pct(rep.pursuit.value), 250, y); bar(24, y + 8, 200, rep.pursuit.value, C_SCORE); bx.font = '10px ui-monospace, monospace'; bx.fillStyle = '#8a90a0'; bx.fillText('게이지·사슬 진행 근사치 / goal-progress proxy (총점/C*는 본채점계)', 24, y + 30); // ---- 결말 분포 (right column; never hidden) ---- bx.font = '12px ui-monospace, monospace'; bx.fillStyle = '#9aa0ac'; bx.fillText('결말 / endings', 400, 56); const ends = [['complete', '완주'], ['cap', '턴소진'], ['death', '탈락'], ['noise', '무효']]; ends.forEach(([k, ko], i) => { const ey = 78 + i * 26; _parkEndGlyph(412, ey, 8, k); bx.font = '11px ui-monospace, monospace'; bx.fillStyle = '#cfd4dc'; bx.fillText(`${ko} ${rep.reasons[k] || 0}`, 430, ey + 4); }); // ---- the N-episode mini-board strip (2 x 5): trajectory + §4.3 ending glyph ---- const cols = 5, gap = 14, w = (board.width - 48 - (cols - 1) * gap) / cols; const sy0 = 208, rowH = w + 32; rep.episodes.forEach((e, i) => { const x = 24 + (i % cols) * (w + gap), yy = sy0 + ((i / cols) | 0) * rowH; const sc = _sessionStripBoard(e); if (sc.st) { _parkReportMini(sc.st, sc.path ? [{ path: sc.path, color: '#7fce97' }] : [], x, yy, w, `${i + 1} · d${e.distance == null ? '?' : e.distance}`, e.reason); } }); // ---- THE OTHER RULER, side by side and never summed. parkCrossingSessionReport's contract: // "the same computation so the two are readable side by side, a different function so no caller // can accidentally sum them." This is the reading half. The other row is printed DIM and on its // own line with its own episode count — there is deliberately no combined figure anywhere on // this screen, because a combined figure would answer a question nobody asked: the two rulers // measure different things (transfer distance vs designed-cell difficulty). const other = _sessOtherReport(run); const oy = 470; bx.strokeStyle = '#23252c'; bx.lineWidth = 1; bx.beginPath(); bx.moveTo(24, oy - 14); bx.lineTo(board.width - 24, oy - 14); bx.stroke(); bx.font = '10px ui-monospace, monospace'; bx.fillStyle = '#6f7480'; bx.fillText(`다른 자 / ${_sessRulerName(!rep.crossing)}`, 24, oy); if (other && other.played) { bx.fillStyle = '#8a90a0'; bx.fillText(`발견 ${pct(other.discovery.eff)} · 유지 ${pct(other.maintenance.rate)} · ` + `${other.played}/${other.n} 에피소드`, 24, oy + 15); bx.fillStyle = '#5c6070'; bx.fillText('두 자는 다른 것을 잽니다 — 합산하지 않습니다.', 24, oy + 30); } else { bx.fillStyle = '#5c6070'; bx.fillText('아직 기록 없음 / not run yet', 24, oy + 15); } bx.textAlign = 'center'; bx.font = '11px ui-monospace, monospace'; bx.fillStyle = '#9aa0ac'; bx.fillText('Enter = 새 세션 / new session Esc · 우상단 칩 = 연습·열람 허브 / practice hub', board.width / 2, board.height - 12); bx.textAlign = 'left'; drawHubCornerChip(); } // drawParkScorecardPanel(): the hud-side per-episode table (report channel) — one compact // row per episode (# / kind / distance / discovery eff / kept / ending) off the SAME pure // C.parkSessionReport read the board scorecard consumes. function drawParkScorecardPanel() { hx.clearRect(0, 0, hud.width, hud.height); const run = G.campaign; const rep = _sessReport(run); if (!rep) return; const pct = (v) => v == null ? '—' : Math.round(v * 100) + '%'; txtH(20, 18, `세션 판독 / ${_sessRulerName(rep.crossing)}`, C_AGENT, rep.crossing ? 11 : 14); txtH(20, 34, `에피소드 ${rep.played}/${rep.n} · 발견 ${pct(rep.discovery.eff)} · 유지 ${pct(rep.maintenance.rate)}`, '#9aa0ac', 9); let y = 56; txtH(20, y, '# 과제 거리 발견효율 결말', '#6f7480', 9); y += 14; const endKo = { complete: '완주', cap: '턴소진', death: '탈락', noise: '무효' }; for (const [i, e] of rep.episodes.entries()) { if (y > hud.height - 40) break; txtH(20, y, String(i + 1), '#cfd4dc', 10); txtH(40, y, (e.kind || '').toUpperCase(), '#cfe0ff', 10); txtH(78, y, 'd' + (e.distance == null ? '?' : e.distance), '#9aa0ac', 10); txtH(112, y, pct(e.discoveryEff == null ? null : Math.min(1, e.discoveryEff)), '#cfe0ff', 10); txtH(168, y, endKo[e.reason] || '—', e.reason === 'complete' ? C_MAINT : '#e0594f', 10); y += 15; } y += 8; txtH(20, y, `추구(프록시) ${pct(rep.pursuit.value)}`, '#9aa0ac', 10); } /* ==== PARK HUB (task-select, ARC-AGI-3 style — LIVE canvas, ZERO-TEXT) ==== A tile grid on the board canvas: each tile is a live miniature render of that task's PUBLIC board (the existing terrain/gem paint path at small scale, hazard-tinted) on a card — kind-colored top accent bar, static keyline (the "this is a button" affordance), hover glow + pointer cursor, badge band with LARGE goal/hazard/pull glyphs. The park capstone is the FIRST, LARGER tile. Played tiles carry a bold completion badge — the task's Maintenance rate as an arc on a dark disc (no numerals). The hud canvas carries the zero-text suite rail (drawParkHubPanel). Everything painted is a pure function of the PUBLIC cell/board or the player's own finished trajectory readout — never the hidden persona (C1). Arrow+Enter, click, or hover+click. */ const HUB_M = 18, HUB_GAP = 12, HUB_COLS = 5, HUB_ROWS = 4; // per-kind accent hue (public kind id only — C1): the card's top bar + the suite rail // chips, so same-kind variants read as one family (audit fix: monochrome sameness). const HUB_KIND_HUE = { park: '#a78bfa', m1: '#e8c14a', m2: '#c85ce0', m3: '#D55E00', m4: '#7fce97', m5: '#9fc0ff' }; // the 17 tile rects: capstone spans 2x2 grid units at the top-left; the 16 minigames fill // the remaining 5x4 grid cells exactly. Vertically centered (audit fix: the leftover // bottom band read as unfinished UI). Pure geometry of the canvas size. function hubTileRects(tiles) { const u = (board.width - 2 * HUB_M - (HUB_COLS - 1) * HUB_GAP) / HUB_COLS; const y0 = (board.height - HUB_ROWS * u - (HUB_ROWS - 1) * HUB_GAP) / 2; const rects = [{ x: HUB_M, y: y0, w: u * 2 + HUB_GAP, h: u * 2 + HUB_GAP }]; const cells = []; for (let rw = 0; rw < HUB_ROWS; rw++) for (let cl = 0; cl < HUB_COLS; cl++) { if (rw < 2 && cl < 2) continue; // the capstone footprint cells.push([cl, rw]); } tiles.slice(1).forEach((t, i) => { const [cl, rw] = cells[i]; rects.push({ x: HUB_M + cl * (u + HUB_GAP), y: y0 + rw * (u + HUB_GAP), w: u, h: u }); }); return rects; } // _hubBoard(pk, tile): the tile's thumbnail board, built lazily under a per-frame budget of // ONE (makeParkTask sweeps admissibility on first build, ~25-130ms/tile) — unbuilt tiles // paint a placeholder plate and fill in over the next frames (pulseLoop redraws the hub). function _hubBoard(pk, tile) { const bs = G.hub.boards; if (bs[tile.id] !== undefined) return bs[tile.id]; if (G.hub.budget <= 0) return null; G.hub.budget--; if (!tile.kind || !tile.cell) { bs[tile.id] = null; return null; } // boardless slot (defensive — none since P13) // SHIPPED crossings carry a filter-accepted PLAY cell and build through the crossing // convention (C._parkCrossBoard: static -> makeParkTask, the P11 filter pre-admits it, 0 // fallbacks; phase/relational -> candidate 0; P13 push/slide -> the verb module's own build // — the exact board the gate measured and the seated game plays). All P13 tiles are LIVE; // a regressed-to-coming WALK tile keeps the candidate-0 path (loud generator-fallback // counter never touched by a thumbnail) and a regressed VERB tile paints the placeholder // plate (its module generator is the only build path, and that counter must stay quiet). // A FIELD-module tile (y12/y14/y6/y10 — the Task 9 open PREVIEWS) is not shipped, but its module // builder IS a candidate-0 build (no k-sweep, so no loud generator counter to disturb) — so it // gets a real thumbnail off the SAME _parkCrossBoard convention the seated game plays, which is // what a preview needs: the tile you click is the board you get. The stream and its stones are // the whole point of the tile. bs[tile.id] = tile.ship ? C._parkCrossBoard(tile.kind, tile.cell) : (tile.cell.mech && tile.cell.mech.fieldMech) ? C._parkCrossBoard(tile.kind, tile.cell) : (tile.cell.mech && tile.cell.mech.moveMech) ? null : E._parkTaskBuild(tile.kind, tile.cell, 0); return bs[tile.id]; } // _parkGoalBadge(ctx, x, y, s, gv): the goal-grammar glyph for the hub surfaces (spec §C.3) — // harvest = gold gem, deliver = gem -> arrow -> basket (R2 goal fix #9: the same carry-flow the // board draws), reach = ringed destination pad, collect = a row of typed gems, boxpad (P13 // push verb) = cargo crate beside the pad ring (the same crate + pad vocabulary the push board // draws — "the crate belongs on the pad"). Telegraphs the // goal at tile size on ANY context (tile disc via bx, suite rail via hx). ZERO-TEXT. function _parkGoalBadge(ctx, x, y, s, gv) { if (gv === 'reach') { _parkPad(ctx, x, y, s * 1.02); return; } if (gv === 'boxpad') { // P13 push: crate -> pad const bs = s * 1.16, bx0 = x - s * 1.5, by0 = y - bs / 2; ctx.save(); ctx.fillStyle = '#a8794a'; ctx.fillRect(bx0, by0, bs, bs); ctx.strokeStyle = '#5f4527'; ctx.lineWidth = Math.max(1, s * 0.18); ctx.lineJoin = 'round'; ctx.strokeRect(bx0, by0, bs, bs); ctx.beginPath(); ctx.moveTo(bx0, by0); ctx.lineTo(bx0 + bs, by0 + bs); ctx.moveTo(bx0 + bs, by0); ctx.lineTo(bx0, by0 + bs); ctx.stroke(); ctx.restore(); _parkPad(ctx, x + s * 0.7, y, s * 0.9); return; } if (gv === 'collect') { for (let i = 0; i < 3; i++) _parkGemType(ctx, x + (i - 1) * s * 0.86, y, s * 0.62, i); return; } if (gv === 'deliver') { // the SAME basket glyph the board shows // (R1 goal fix #14: the old gem-over-a-line shared no vocabulary with the in-board // open-top bucket, so hub badge and board goal never linked.) _parkGem(ctx, x - s * 1.05, y, s * 0.5); ctx.save(); ctx.strokeStyle = '#ffffff'; ctx.lineWidth = Math.max(1.2, s * 0.14); ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(x - s * 0.4, y); ctx.lineTo(x - s * 0.02, y); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x - s * 0.22, y - s * 0.2); ctx.lineTo(x - s * 0.02, y); ctx.lineTo(x - s * 0.22, y + s * 0.2); ctx.stroke(); ctx.restore(); _parkDropZone(ctx, x + s * 0.6, y + s * 0.08, s * 0.8); return; } _parkGem(ctx, x, y, s); } // _parkSlideGlyph(ctx, x, y, s): the P13 SLIDE movement-verb glyph — a long momentum chevron // with two trailing speed dashes in the ice hue (the same "one input glides on" statement the // board's ice floor + lamp-posts make). Rides the mech identity strips beside the play goal // glyph. ZERO-TEXT. function _parkSlideGlyph(ctx, x, y, s) { ctx.save(); ctx.strokeStyle = '#bfe3f5'; ctx.lineWidth = Math.max(1.2, s * 0.3); ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(x - s * 1.2, y); ctx.lineTo(x + s * 0.9, y); ctx.moveTo(x + s * 0.3, y - s * 0.55); ctx.lineTo(x + s * 0.9, y); ctx.lineTo(x + s * 0.3, y + s * 0.55); ctx.stroke(); ctx.globalAlpha = 0.55; ctx.beginPath(); ctx.moveTo(x - s * 1.2, y - s * 0.55); ctx.lineTo(x - s * 0.55, y - s * 0.55); ctx.moveTo(x - s * 1.2, y + s * 0.55); ctx.lineTo(x - s * 0.55, y + s * 0.55); ctx.stroke(); ctx.restore(); } // _parkArchGlyph(cx, cy, s, arch): the topology-archetype silhouette (spec §B.1/§C.3) — a tiny // public icon telegraphing the board family (ring/cross = park, S-lane = serpent, dotted islands // = islands, holed yard = pools). Drawn as a corner tag on the hub thumbnail. ZERO-TEXT. function _parkArchGlyph(cx, cy, s, arch) { bx.save(); bx.globalAlpha = 0.92; bx.lineCap = 'round'; bx.lineJoin = 'round'; bx.strokeStyle = '#0e0f13'; bx.lineWidth = s * 0.5; const stroke = (fn) => { fn(); bx.stroke(); bx.strokeStyle = '#e6ecf5'; bx.lineWidth = s * 0.24; fn(); bx.stroke(); bx.strokeStyle = '#0e0f13'; bx.lineWidth = s * 0.5; }; const h = s * 0.6; if (arch === 'pushframe') { // P13 push module: crate inside its yard stroke(() => { bx.beginPath(); bx.rect(cx - h, cy - h, h * 2, h * 2); }); bx.fillStyle = '#0e0f13'; bx.fillRect(cx - h * 0.38, cy - h * 0.38, h * 0.76, h * 0.76); bx.fillStyle = '#e6ecf5'; bx.fillRect(cx - h * 0.2, cy - h * 0.2, h * 0.4, h * 0.4); } else if (arch === 'serpent') { stroke(() => { bx.beginPath(); bx.moveTo(cx - h, cy - h); bx.bezierCurveTo(cx + h, cy - h * 0.4, cx - h, cy + h * 0.4, cx + h, cy + h); }); } else if (arch === 'islands') { bx.fillStyle = '#0e0f13'; for (const [dx, dy] of [[-h * 0.7, -h * 0.5], [h * 0.6, -h * 0.2], [-h * 0.1, h * 0.6]]) { bx.beginPath(); bx.arc(cx + dx, cy + dy, s * 0.42, 0, 7); bx.fill(); bx.fillStyle = '#e6ecf5'; bx.beginPath(); bx.arc(cx + dx, cy + dy, s * 0.24, 0, 7); bx.fill(); bx.fillStyle = '#0e0f13'; } } else if (arch === 'pools') { stroke(() => { bx.beginPath(); bx.rect(cx - h, cy - h, h * 2, h * 2); }); bx.fillStyle = '#0e0f13'; for (const [dx, dy] of [[-h * 0.45, -h * 0.45], [h * 0.45, -h * 0.45], [-h * 0.45, h * 0.45], [h * 0.45, h * 0.45]]) { bx.beginPath(); bx.arc(cx + dx, cy + dy, s * 0.28, 0, 7); bx.fill(); } } else { // park — perimeter ring + cross stroke(() => { bx.beginPath(); bx.rect(cx - h, cy - h, h * 2, h * 2); }); stroke(() => { bx.beginPath(); bx.moveTo(cx, cy - h); bx.lineTo(cx, cy + h); bx.moveTo(cx - h, cy); bx.lineTo(cx + h, cy); }); } bx.restore(); } // drawHubBadges(tile, r): the tile's BADGE BAND — a dedicated dark plate across the card's // bottom (audit fix: the old ~8px corner strip read as rendering noise) hosting glyph slots: // goal grammar (harvest gem / deliver pad / reach ring / collect typed gems — spec §C.3), the // hazard family (the tile terrain's own deep hue + accent + as many hearts as it costs — the // swatch color literally matches the field it summarizes), and the kind's conflict pull-dots. // The archetype silhouette is telegraphed by the thumbnail terrain + its corner glyph. Public // cell fields only (C1). ZERO-TEXT. // BADGE_SPAN: the band's total width as a multiple of its horizontal metric `m`, summed from the // advances drawHubBadges actually makes on its worst row (kind m4, three pull dots, one heart): // lead-in 0.76 + arch 1.10 + swatch 0.62 + heart pad 0.35 + heart step 0.47 = 3.300 // + pull dots 0.22 + 2*(2*0.22 + 0.22*0.55) + 0.22 = 1.562 // total = 4.862 // declared as an UPPER BOUND, so it rounds UP. HUB-BADGE-FIT re-derives this sum from these very // coefficients and fails if the row outgrows the number, so adding a glyph here without re-measuring // is a red gate rather than a silent overhang. (Note the pull gap is a multiple of the dot RADIUS, // not of m — the units bit the gate's own author once already.) const BADGE_SPAN = 4.87; const BADGE_MARGIN = 4; // px of tile kept clear to the right of the row function drawHubBadges(tile, r) { const bh = Math.min(22, Math.max(17, r.h * 0.19)); // the band's HEIGHT — a vertical concern // HORIZONTAL FIT (measured 2026-07-22, and the reason this function was rewritten). Every glyph in // this row sits on a cursor of advances proportional to ONE metric, so the row's width is a fixed // multiple of that metric. bh cannot be it: bh is FLOORED at 17, so below ~68px tiles the row stops // shrinking while the tile keeps going, and it paints across the gutter onto the NEIGHBOURING tile — // 82.7px of content in a 56px tile at 19 slots (+10.7px past the neighbour's edge), and +19.7px at // 23. So the horizontal metric is its own quantity, capped by the width actually on offer. At large // tiles bh binds and the band looks exactly as it always has; only crowded grids compress. const m = Math.min(bh, (r.w - BADGE_MARGIN) / BADGE_SPAN); const by = r.y + r.h - bh, y = by + bh / 2; bx.fillStyle = 'rgba(9,11,15,0.82)'; bx.fillRect(r.x, by, r.w, bh); let x = r.x + m * 0.76; // ARCHETYPE silhouette tag leads the band (R2 goal fix #9): the goal claim moved UP into the // tile's primary disc (star + goal token — the in-board pin at thumbnail scale), so the band // no longer competes with it; the arch tag left the loud top-left disc judges kept reading as // "the" (unmapped) objective icon. _parkArchGlyph(x, y, m * 0.34, (tile.cell && tile.cell.arch) || (tile.playMech && tile.playMech.moveMech === 'push' ? 'pushframe' : 'park')); // P13: the push module's own silhouette x += m * 1.1; if (tile.cell) { const hz = tile.cell.hazard; // hazard tier swatch (tinted like its field) const tint = PARK_HAZARD_TINT[hz.kind] || PARK_HUES; const s = m * 0.62; bx.fillStyle = tint.deep; bx.fillRect(x - s / 2, y - s / 2, s, s); bx.fillStyle = _alpha(tint.ember, 0.95); bx.beginPath(); bx.moveTo(x, y - s * 0.38); bx.lineTo(x - s * 0.3, y + s * 0.34); bx.lineTo(x + s * 0.3, y + s * 0.34); bx.closePath(); bx.fill(); bx.fillStyle = '#e0594f'; for (let h = 0; h < hz.damage; h++) { // the tier's body cost as heart pips _heartPath(bx, x + s / 2 + m * 0.35 + h * m * 0.47, y, m * 0.2); bx.fill(); } x += s + (hz.damage > 0 ? m * 0.35 + hz.damage * m * 0.47 : m * 0.47); } if (tile.kind) drawKindPulls(bx, x, y, tile.kind, m * 0.22); } // P10 (spec §4): the PICKER (11 slots — 6 shipped, 5 open previews) — the park's first-class hub, // and an UNSCORED one (parkHubEnter: the 연습·열람 corner; ▶ = the scored session). // Tiles = the run's mechanism-transfer CROSSINGS (pk.crossings): one LIVE tile per SHIPPED // crossing (a distinct demo-mechanism -> play-mechanism game with its own thumbnail — the P13 // lineup x4/x5 doubles + xp PUSH verb + xs SLIDE verb + x7 double, every slot swapping >= 2 // mechanism axes; + the field-mechanism branch's y3 DOWNED). The five field cells that measured // short of the 6/6 ship bar (y12 stones, y14 toll, y8 log, y6 duck, y10 flood) are OPEN PREVIEWS // (Task 9; y8 joined them in Task 10, when the ship bar was re-measured under the calibration the // readout itself applies and its 6/6 became 0/6): they seat and play like any other tile, and they // are MARKED as unreadable — a struck-through eye on the tile and on the rail, and a readout that // says 읽을 수 없음 rather than inventing an order. Every one of the 11 is clickable; the old inert // path stays live for a slot that is neither shipped nor open (there is none today). // Pure geometry of the canvas; the tile grid and slot rail flow from PARK_CROSSINGS.length // (3 tiles per row / one rail row per slot — no fixed count). const _compareGameId = (a, b) => a.localeCompare(b, 'en', { numeric: true }); const _pickerTiles = () => [...(G.campaign.park.crossings || [])] .sort((a, b) => _compareGameId(a.id, b.id)); function pickerTileRects(tiles) { const n = tiles.length, cols = 3, rows = Math.ceil(n / cols); const gap = 16, m = 26; const u = Math.min((board.width - 2 * m - (cols - 1) * gap) / cols, (board.height - 2 * m - (rows - 1) * gap) / rows); const totalH = rows * u + (rows - 1) * gap; const y0 = (board.height - totalH) / 2; const rects = []; tiles.forEach((t, i) => { const rw = Math.floor(i / cols), cl = i % cols; const inRow = Math.min(cols, n - rw * cols); const rowW = inRow * u + (inRow - 1) * gap; const rx0 = (board.width - rowW) / 2; rects.push({ x: rx0 + cl * (u + gap), y: y0 + rw * (u + gap), w: u, h: u }); }); return rects; } // drawParkPhasePip(ctx, st, cx, cy, r): the PUBLIC per-seat phase-clock ring (spec §3) — segN // pips around a small ring, the RED (last, dangerous) segment filled in the hazard-ember hue, // the others hollow; the CURRENT segment (st.clock[0].seg, single-state readable pre-move) wears // an outer keyline cursor. Glyph-only (ZERO-TEXT). Reads only the public clock/phase (C1). function drawParkPhasePip(ctx, st, cx, cy, r) { const ph = st && st.park && st.park.phase; if (!ph) return; const segN = ph.segN, red = ph.red; const seg = (st.clock && st.clock[0]) ? st.clock[0].seg : 0; ctx.save(); for (let i = 0; i < segN; i++) { const a = -Math.PI / 2 + (i / segN) * 2 * Math.PI; const px = cx + Math.cos(a) * r, py = cy + Math.sin(a) * r, pr = r * 0.44; ctx.beginPath(); ctx.arc(px, py, pr, 0, 7); if (i === red) { ctx.fillStyle = '#e0594f'; ctx.fill(); } else { ctx.fillStyle = 'rgba(9,11,15,0.72)'; ctx.fill(); ctx.strokeStyle = 'rgba(230,236,245,0.6)'; ctx.lineWidth = 1.2; ctx.stroke(); } if (i === seg) { ctx.strokeStyle = '#e6ecf5'; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.arc(px, py, pr + 2.4, 0, 7); ctx.stroke(); } } ctx.restore(); } // drawCrossingMechStrip(tile, r): the crossing's IDENTITY band along the tile top — the demo // goal-mechanism glyph, an arrow, the PLAY goal-mechanism glyph (A -> B), so a live tile reads as // "learn on A, play on B" at a glance. Mechanics-only (goal grammar glyphs), ZERO-TEXT, no order // leak. The phase crossing appends its clock pip (temporal safety mechanism) after the arrow; // a DOUBLE-AXIS crossing (P12: goal AND safety both change) shows the play GOAL glyph AND the // clock pip — hiding either would misstate what the play leg changes. function drawCrossingMechStrip(tile, r) { if (!tile.demoMech) return; const y = r.y + 15, s = 6.5; const cx0 = r.x + r.w / 2; bx.save(); bx.fillStyle = 'rgba(9,11,15,0.78)'; bx.fillRect(r.x, r.y, r.w, 26); _parkGoalBadge(bx, cx0 - r.w * 0.26, y, s, tile.demoMech.goalMech); bx.strokeStyle = '#e6ecf5'; bx.lineWidth = 1.6; bx.lineCap = 'round'; bx.beginPath(); bx.moveTo(cx0 - r.w * 0.09, y); bx.lineTo(cx0 + r.w * 0.06, y); bx.lineTo(cx0 + r.w * 0.01, y - 3.5); bx.moveTo(cx0 + r.w * 0.06, y); bx.lineTo(cx0 + r.w * 0.01, y + 3.5); bx.stroke(); if (tile.mechanic === 'phase') { const st = _hubBoard(G.campaign.park, tile); if (st) drawParkPhasePip(bx, st, cx0 + r.w * 0.24, y, 5.2); else { _parkGoalBadge(bx, cx0 + r.w * 0.24, y, s, tile.playMech.goalMech); } } else { _parkGoalBadge(bx, cx0 + r.w * 0.24, y, s, tile.playMech.goalMech); if (tile.playMech && tile.playMech.safetyMech === 'phase') { const st = _hubBoard(G.campaign.park, tile); if (st) drawParkPhasePip(bx, st, cx0 + r.w * 0.4, y, 4.6); } // P13 SLIDE verb tag: the movement-verb axis changes too — the strip must say so (the // push slot's boxpad goal glyph already carries its verb; slide keeps the walk goal // glyphs, so it wears the momentum chevron in the pip slot). if (tile.playMech && tile.playMech.moveMech === 'slide') _parkSlideGlyph(bx, cx0 + r.w * 0.4, y, 5); } bx.restore(); } // drawParkHub(): the picker frame (slot count follows PARK_CROSSINGS — 5 live since P13; rects // auto-flow 3 per row). Each LIVE tile = a shipped crossing's PLAY-mechanism // terrain thumbnail + its demo->play mechanism strip (P13: push tiles carry the crate+pad // layer, slide tiles the ice floor + momentum chevron); a non-shipped tile would render the // dimmed hourglass placeholder (fallback path — empty on the P13 all-live lineup). // kind-accent top bar + keyline, hover glow, // pulsing selection keyline; a played crossing tile carries its Maintenance completion badge. function drawParkHub() { const run = G.campaign, pk = run.park; if (!G.hub || G.hub.run !== run) G.hub = { run, sel: 0, hover: null, boards: {} }; G.hub.budget = 2; bx.clearRect(0, 0, board.width, board.height); bx.fillStyle = ARC.bg; bx.fillRect(0, 0, board.width, board.height); const tiles = _pickerTiles(); const rects = pickerTileRects(tiles); const g = _pulseGlow(); tiles.forEach((tile, i) => { const r = rects[i]; bx.fillStyle = '#1a1d25'; bx.fillRect(r.x - 3, r.y - 3, r.w + 6, r.h + 6); // card plate const st = _hubBoard(pk, tile); if (st) { const cell = r.w / st.N; _paintParkTerrain(st, r.x, r.y, cell, 0.62); // GOAL DISC (R2 goal fix #9): the tile's loudest icon is now the OBJECTIVE — the same // star-over-token pin vocabulary the live board shows, at thumbnail scale — instead of // the archetype silhouette (judges read that unmapped icon set as the objective key and // found no mapping to the four goal types). The arch tag moved into the badge band. const ag = Math.max(10, r.w * 0.13); const gdx = r.x + ag * 1.5, gdy = r.y + ag * 1.5; bx.fillStyle = 'rgba(9,11,15,0.82)'; // dark disc so the pin reads at tile size bx.beginPath(); bx.arc(gdx, gdy, ag * 1.5, 0, 7); bx.fill(); bx.strokeStyle = 'rgba(236,242,250,0.3)'; bx.lineWidth = 1.5; bx.stroke(); _parkStar(bx, gdx, gdy - ag * 0.6, ag * 0.45, 0.4); _parkGoalBadge(bx, gdx, gdy + ag * 0.5, ag * 0.55, tile.capstone ? 'harvest' : (tile.cell.goalVariant || 'harvest')); // deliver drop token (public chain + goalVariant) telegraphed distinctly at thumbnail size const hDrop = (!tile.capstone && tile.cell.goalVariant === 'deliver' && st.park.chain.length) ? st.park.chain[st.park.chain.length - 1] : -1; st.tokens.forEach((t, ti) => { // goal-grammar dots: basket / pad ring / typed / gold const cx = r.x + (t.x + 0.5) * cell, cy = r.y + (t.y + 0.5) * cell; if (ti === hDrop) { // deliver basket: open gold cradle glyph bx.strokeStyle = '#e8c14a'; bx.lineWidth = Math.max(1.4, cell * 0.16); bx.lineJoin = 'round'; bx.beginPath(); bx.moveTo(cx - cell * 0.32, cy - cell * 0.2); bx.lineTo(cx + cell * 0.32, cy - cell * 0.2); bx.lineTo(cx + cell * 0.22, cy + cell * 0.26); bx.lineTo(cx - cell * 0.22, cy + cell * 0.26); bx.closePath(); bx.fillStyle = _alpha('#e8c14a', 0.3); bx.fill(); bx.stroke(); } else if (t.pad) { bx.strokeStyle = PARK_PAD_HUE; bx.lineWidth = Math.max(1, cell * 0.14); bx.beginPath(); bx.arc(cx, cy, cell * 0.3, 0, 7); bx.stroke(); bx.fillStyle = PARK_PAD_HUE; bx.beginPath(); bx.arc(cx, cy, cell * 0.12, 0, 7); bx.fill(); } else { bx.fillStyle = t.gtype != null ? PARK_GEM_TYPES[t.gtype % 3] : SPRITE_HUE.reward; bx.beginPath(); bx.arc(cx, cy, cell * 0.3, 0, 7); bx.fill(); } }); // P13 PUSH verb layer at thumbnail scale: cargo crate + destination pad (the slide // tile's ice floor + lamp-posts already ride _paintParkTerrain off park.slide). _drawParkPushLayer(st, r.x, r.y, cell); for (const [seat, hue] of [[0, SPRITE_HUE.agent], [1, PARK_HUES.companion]]) { const p = st.pos[seat]; if (!p) continue; // the two actors as class discs bx.fillStyle = hue; bx.beginPath(); bx.arc(r.x + (p.x + 0.5) * cell, r.y + (p.y + 0.5) * cell, cell * 0.42, 0, 7); bx.fill(); } // PHASE-carrying thumbnail: overlay the public clock pip ring (temporal safety mechanism — // the mechanism-distinct visual for x1, and for the P12 double-axis x4/x5 whose play leg // is also phase). Keyed on the BOARD's own public phase geometry (ZERO-TEXT/C1). if (st.park && st.park.phase) drawParkPhasePip(bx, st, r.x + r.w - 20, r.y + r.h - 40, 8); } else { bx.fillStyle = '#1b1d24'; bx.fillRect(r.x, r.y, r.w, r.h); // still building (budgeted) / regressed-slot placeholder plate } if (tile.demoMech) drawCrossingMechStrip(tile, r); // the demo->play mechanism identity band drawHubBadges(tile, r); // TASK 9 — THREE TILE STATES, three marks, all glyph (the picker never puts prose on a tile): // ship LIVE + MEASURED. Bright, unveiled, hover-lit. Unchanged. // preview PLAYABLE, but its measurement is known broken. A LIGHT veil (it is not // blocked, so it must not read as blocked) + the struck-through EYE: you can // walk this one, it just will not be able to read you. Its readout keeps that // promise — 읽을 수 없음 rather than a coin-flip order. // neither INERT (runParkCrossing still returns null). The old heavy veil + hourglass, // kept working for future slots. No slot is in this state today. const clickable = tile.ship || tile.preview; if (tile.preview) { bx.save(); bx.fillStyle = 'rgba(9,11,15,0.30)'; bx.fillRect(r.x, r.y, r.w, r.h); _parkNoReadGlyph(bx, r.x + r.w / 2, r.y + r.h / 2, Math.min(19, r.w * 0.19), '#e6ecf5'); bx.restore(); } else if (!tile.ship) { bx.save(); bx.fillStyle = 'rgba(9,11,15,0.58)'; bx.fillRect(r.x, r.y, r.w, r.h); const hx0 = r.x + r.w / 2, hy0 = r.y + r.h / 2, hs = Math.min(20, r.w * 0.2); bx.strokeStyle = 'rgba(230,236,245,0.72)'; bx.lineWidth = 2.2; bx.lineJoin = 'round'; bx.beginPath(); bx.moveTo(hx0 - hs * 0.6, hy0 - hs); bx.lineTo(hx0 + hs * 0.6, hy0 - hs); bx.lineTo(hx0 - hs * 0.6, hy0 + hs); bx.lineTo(hx0 + hs * 0.6, hy0 + hs); bx.closePath(); bx.stroke(); bx.fillStyle = 'rgba(230,236,245,0.5)'; bx.beginPath(); bx.moveTo(hx0 - hs * 0.4, hy0 + hs * 0.9); bx.lineTo(hx0 + hs * 0.4, hy0 + hs * 0.9); bx.lineTo(hx0, hy0 + hs * 0.15); bx.closePath(); bx.fill(); bx.restore(); } bx.fillStyle = HUB_KIND_HUE[tile.kind] || '#6b7280'; // kind-accent top bar (family color) bx.fillRect(r.x - 3, r.y - 3, r.w + 6, 3); // keyline: CLICKABLE cards (live AND preview) read as buttons and light on hover; only an // inert card is dimmer-framed. A preview sits BETWEEN the two at rest — brighter than inert, // dimmer than live — so the rail's three states are legible on the grid too. bx.strokeStyle = !clickable ? 'rgba(230,236,245,0.14)' : i === G.hub.hover ? 'rgba(230,236,245,0.85)' : tile.ship ? 'rgba(230,236,245,0.28)' : 'rgba(230,236,245,0.20)'; bx.lineWidth = (clickable && i === G.hub.hover) ? 2 : 1; bx.strokeRect(r.x - 3.5, r.y - 3.5, r.w + 7, r.h + 7); // COMPLETION BADGE: played crossing -> its Maintenance rate as an arc on a dark disc. const row = pk.results[tile.id]; if (row) { const rr = 10, cx = r.x + r.w - rr - 7, cy = r.y + rr + 7; const m = _rowMaintenance(row.conflicts); bx.save(); bx.fillStyle = 'rgba(9,11,15,0.88)'; bx.beginPath(); bx.arc(cx, cy, rr + 4.5, 0, 7); bx.fill(); bx.strokeStyle = 'rgba(230,236,245,0.3)'; bx.lineWidth = 3.5; bx.beginPath(); bx.arc(cx, cy, rr, 0, 7); bx.stroke(); bx.strokeStyle = C_MAINT; bx.beginPath(); bx.arc(cx, cy, rr, -Math.PI / 2, -Math.PI / 2 + (m == null ? 2 : m * 2) * Math.PI); bx.stroke(); bx.fillStyle = C_MAINT; // filled core = "finished", unmistakably bx.beginPath(); bx.arc(cx, cy, rr * 0.42, 0, 7); bx.fill(); bx.restore(); } if (i === G.hub.sel) { // selection keyline (pulsing) bx.strokeStyle = _alpha('#e6ecf5', 0.5 + 0.45 * g); bx.lineWidth = 3; bx.strokeRect(r.x - 5.5, r.y - 5.5, r.w + 11, r.h + 11); } }); drawHubReplayChip(); // P3a §1: the practice-yard replay affordance (top-right, icon-only) } // drawParkHubPanel(): the picker's hud-canvas SLOT RAIL — one slot per CROSSING (11 today: 7 // shipped, 4 open previews): the kind-hue chip, the demo->play goal-mechanism glyph pair (the // crossing's identity; phase pip / P13 slide chevron after the arrow), a STATE MARK, and the // played-crossing Maintenance arc. ZERO-TEXT; public cells + own finished rows only (C1). // The state mark is the hub's whole glyph legend (there is no prose surface anywhere in the // picker), so Task 9's third state is spelled out here as well as on the tile — same glyph, so // the rail and the grid say ONE thing: // bright hollow ring — SHIPPED + unplayed: measured, ready, readable. // struck-through eye — PREVIEW: playable, but this cell cannot read you (the tile's mark). // dim hourglass tick — INERT: not playable at all (the regressed/reserved-slot fallback). // Maintenance arc — played (any state): the row it earned. function drawParkHubPanel() { const run = G.campaign, pk = run.park; const tiles = _pickerTiles(); hx.clearRect(0, 0, hud.width, hud.height); hx.fillStyle = '#14161c'; hx.fillRect(8, 8, hud.width - 16, hud.height - 16); const rows = tiles.length; const rh = Math.min(40, (hud.height - 40) / rows); const y0 = 34; tiles.forEach((tile, i) => { const y = y0 + i * rh; if (i === G.hub.sel) { // selection sync with the grid hx.fillStyle = 'rgba(230,236,245,0.10)'; hx.fillRect(14, y - rh * 0.42, hud.width - 28, rh * 0.84); hx.strokeStyle = 'rgba(230,236,245,0.55)'; hx.lineWidth = 1; hx.strokeRect(14, y - rh * 0.42, hud.width - 28, rh * 0.84); } // kind chip: full family hue when SHIPPED, the hue at half strength for a PREVIEW (it is a // real, playable game of that family — just not a measured one), flat gray when inert. hx.fillStyle = tile.ship ? (HUB_KIND_HUE[tile.kind] || '#e6ecf5') : tile.preview ? _alpha(HUB_KIND_HUE[tile.kind] || '#e6ecf5', 0.5) : '#5a606b'; hx.fillRect(24, y - 6, 12, 12); // the crossing's demo->play goal-mechanism identity (glyph A -> glyph B); the phase slot // shows its clock pip after the arrow; a P12 double-axis slot shows the play goal glyph // AND the pip (both mechanism axes change). if (tile.demoMech) { _parkGoalBadge(hx, 54, y, 4.6, tile.demoMech.goalMech); hx.strokeStyle = tile.ship ? '#e6ecf5' : 'rgba(230,236,245,0.4)'; hx.lineWidth = 1.4; hx.beginPath(); hx.moveTo(66, y); hx.lineTo(78, y); hx.stroke(); if (tile.mechanic === 'phase') { const st = _hubBoard(pk, tile); if (st) drawParkPhasePip(hx, st, 90, y, 5); else _parkGoalBadge(hx, 90, y, 4.6, tile.playMech.goalMech); } else { _parkGoalBadge(hx, 90, y, 4.6, tile.playMech.goalMech); if (tile.playMech && tile.playMech.safetyMech === 'phase') { const st = _hubBoard(pk, tile); if (st) drawParkPhasePip(hx, st, 106, y, 4.2); } if (tile.playMech && tile.playMech.moveMech === 'slide') _parkSlideGlyph(hx, 106, y, 4.2); // P13 slide verb tag (see the strip note) } } const row = pk.results[tile.id]; const cx = hud.width - 34; if (row) { const m = _rowMaintenance(row.conflicts); hx.fillStyle = C_MAINT; hx.beginPath(); hx.arc(cx, y, 3.2, 0, 7); hx.fill(); hx.strokeStyle = C_MAINT; hx.lineWidth = 2.5; hx.beginPath(); hx.arc(cx, y, 7, -Math.PI / 2, -Math.PI / 2 + (m == null ? 2 : m * 2) * Math.PI); hx.stroke(); } else if (tile.ship) { // LIVE + unplayed: a hollow ready ring hx.strokeStyle = 'rgba(230,236,245,0.5)'; hx.lineWidth = 1.5; hx.beginPath(); hx.arc(cx, y, 5, 0, 7); hx.stroke(); } else if (tile.preview) { // PREVIEW: playable, but it cannot read you _parkNoReadGlyph(hx, cx, y, 5.5, 'rgba(230,236,245,0.62)'); } else { // inert: a dim hourglass tick hx.strokeStyle = 'rgba(230,236,245,0.28)'; hx.lineWidth = 1.4; hx.beginPath(); hx.moveTo(cx - 4, y - 5); hx.lineTo(cx + 4, y - 5); hx.lineTo(cx - 4, y + 5); hx.lineTo(cx + 4, y + 5); hx.closePath(); hx.stroke(); } }); } // hubMoveSel(d): arrow navigation — the nearest tile center in the pressed direction. function hubMoveSel(d) { if (!G.hub) return; const rects = pickerTileRects(_pickerTiles()); const c = rects[G.hub.sel], ccx = c.x + c.w / 2, ccy = c.y + c.h / 2; let best = -1, bd = Infinity; rects.forEach((r, i) => { if (i === G.hub.sel) return; const px = r.x + r.w / 2 - ccx, py = r.y + r.h / 2 - ccy; const along = px * d.x + py * d.y; if (along <= 0) return; const off = Math.abs(px * d.y) + Math.abs(py * d.x); const score = along + off * 2; if (score < bd) { bd = score; best = i; } }); if (best >= 0) { G.hub.sel = best; draw(); } } // the hub's TUTORIAL REPLAY chip (P3a §1: the practice yard stays replayable): a small card // with the d-pad glyph, top-right above the tile grid. Zero-text; clicking it re-enters the // practice yard (view-level only — the run is untouched). function _hubReplayRect() { return { x: board.width - 56, y: 18, w: 44, h: 30 }; } // the HUB DOOR chip (spec 2026-07-05 §B.2): random-transfer surfaces (watch + readout) keep the // deliberate hub reachable — a 2x2 tile-grid pictogram (the hub's own visual: a grid of tiles) in // the SAME top-right corner slot the hub's replay chip occupies, so "top-right chip = mode door" // stays one vocabulary. Icon-only (zero-text safe on the live watch frame). View geometry only. function _hubChipRect() { return { x: board.width - 56, y: 18, w: 44, h: 30 }; } function drawHubCornerChip() { const r = _hubChipRect(); bx.save(); bx.fillStyle = '#1a1d25'; bx.fillRect(r.x, r.y, r.w, r.h); bx.strokeStyle = 'rgba(230,236,245,0.35)'; bx.lineWidth = 1; bx.strokeRect(r.x + 0.5, r.y + 0.5, r.w - 1, r.h - 1); const cx = r.x + r.w / 2, cy = r.y + r.h / 2, s = 6, g = 3; bx.fillStyle = '#9fc0ff'; for (const dx of [-1, 1]) for (const dy of [-1, 1]) bx.fillRect(cx + (dx < 0 ? -(s + g / 2) : g / 2), cy + (dy < 0 ? -(s + g / 2) : g / 2), s, s); bx.restore(); } function drawHubReplayChip() { const r = _hubReplayRect(); bx.save(); bx.fillStyle = '#1a1d25'; bx.fillRect(r.x, r.y, r.w, r.h); bx.strokeStyle = 'rgba(230,236,245,0.35)'; bx.lineWidth = 1; bx.strokeRect(r.x + 0.5, r.y + 0.5, r.w - 1, r.h - 1); _dpadGlyph(bx, r.x + r.w / 2, r.y + r.h / 2, 8, '#9fc0ff'); bx.restore(); } // parkHubEnter(): the hub view — P8.6 §B.4 DEMOTED to the 연습·열람 corner mode (practice // browsing, unscored; ▶ = the scored session only). The campaign task slot is cleared // through the capstone route of C.runParkTask (app.js mutates no campaign state directly). // Entry points: the corner chip on watch/readout/scorecard surfaces + practice readouts. const parkHubEnter = () => { const run = G.campaign; if (!run || !run.park) return; clearTimers(); C.runParkTask(run, 'park'); // capstone route = task slot cleared, nothing else touched G.parkAnim = null; G.parkInter = null; // a stale interstitial never survives into the hub G.stage = null; G.parkView = 'hub'; // a stale handoff crossfade never composites over the hub (the fading old task/demo frame // read as a stray floating sprite on the task-select screen; presentation-only). G.xfade = null; draw(); }; // parkHubSelect(tileId): pick a picker slot. A SHIPPED crossing OR an OPEN PREVIEW -> seat it // (C.runParkCrossing: oracle DEMO on mechanism A, then interactive PLAY on the DIFFERENT mechanism // B) and enter its watch phase. Task 9: all 11 slots seat — a preview launches the same real game, // and its honesty is carried by the MARK (the struck-through eye) and by the readout's refusal to // name an order it cannot support, NOT by blocking the click. A slot that is neither shipped nor // open is still inert (runParkCrossing returns null) — a no-op, and there is no such slot today. // tileId matches the crossing's 'cx:' id. const parkHubSelect = (tileId) => { const run = G.campaign; if (!run || !run.park || G.parkView !== 'hub') return; const t = C.runParkCrossing(run, tileId); if (!t) return; // coming/boardless slot -> no-op G.parkView = 'demo'; startParkTaskDemo(t); }; /* ==== ATTRACT LOOP (P8.5 §5.1) — the wordless first-screen identity vignette ==== A ~3s loop on the board canvas, before any run exists: a walker (the park actor disc + eyes) leaves a breadcrumb wake, reaches a hazard fork, and THREE ghost paths diverge — goal = park gold straight across the deep, care = companion magenta toward the little companion, safety = calibration mint on the long detour around the deep. The game's thesis ("watch a walk, read its hidden priorities") shown, not told. Repeats until any key/click. Pre-measurement UI on a NON-measured, hand-laid vignette board (no engine state, no persona anywhere) and zero fillText — glyph primitives only — so C1 and PARK-ZERO-TEXT hold by construction. */ const ATTRACT_MS = 3000; const _attractT0 = Date.now(); // the vignette's three ghost trajectories (grid cells; N=9 hand-laid scene). const _ATTRACT_DEEP = [[5, 3], [6, 3], [5, 4], [6, 4], [5, 5], [6, 5]]; const _ATTRACT_WALK = [[1, 4], [2, 4], [3, 4]]; // the walker's opening steps const _ATTRACT_GHOSTS = [ { color: '#e8c14a', path: [[3, 4], [4, 4], [5, 4], [6, 4], [7, 4]] }, // goal: straight across the deep { color: '#c85ce0', path: [[3, 4], [3, 5], [3, 6], [3, 7]] }, // care: toward the companion { color: '#7fce97', path: [[3, 4], [3, 3], [3, 2], [3, 1], [4, 1], [5, 1], [6, 1], [7, 1], [7, 2], [7, 3], [7, 4]] }, // safety: the long detour ]; function _attractPartial(path, frac, cw) { // stroke `frac` of a cell-path (dashed ghost line) const pts = path.map(([x, y]) => [(x + 0.5) * cw, (y + 0.5) * cw]); const total = (pts.length - 1) * frac; const full = Math.floor(total), rem = total - full; bx.beginPath(); bx.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i <= full && i < pts.length; i++) bx.lineTo(pts[i][0], pts[i][1]); if (full + 1 < pts.length && rem > 0) { const [ax, ay] = pts[full], [bxx, byy] = pts[full + 1]; bx.lineTo(ax + (bxx - ax) * rem, ay + (byy - ay) * rem); } bx.stroke(); const k = Math.min(pts.length - 1, total); const i0 = Math.min(pts.length - 2, Math.floor(k)), f = k - i0; return [pts[i0][0] + (pts[i0 + 1][0] - pts[i0][0]) * f, pts[i0][1] + (pts[i0 + 1][1] - pts[i0][1]) * f]; // the live tip } function drawParkAttract() { const W = board.width, n = 9, cw = W / n; const t = ((Date.now() - _attractT0) % ATTRACT_MS) / ATTRACT_MS; const g = _pulseGlow(); const deep = new Set(_ATTRACT_DEEP.map(([x, y]) => y * n + x)); const verge = new Set(); for (const [dx0, dy0] of _ATTRACT_DEEP) for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { const x = dx0 + dx, y = dy0 + dy, kk = y * n + x; if (x > 0 && y > 0 && x < n - 1 && y < n - 1 && !deep.has(kk)) verge.add(kk); } bx.clearRect(0, 0, W, W); for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) { // terrain tiles const kk = y * n + x; if (x === 0 || y === 0 || x === n - 1 || y === n - 1) bx.fillStyle = PARK_HUES.wall; else if (deep.has(kk)) bx.fillStyle = PARK_HUES.deep; else bx.fillStyle = PARK_HUES.walkway; bx.fillRect(x * cw, y * cw, cw + 0.5, cw + 0.5); if (verge.has(kk)) { // the live verge wash (rgba over walkway) bx.fillStyle = PARK_HUES.verge; bx.fillRect(x * cw, y * cw, cw + 0.5, cw + 0.5); } if (deep.has(kk)) { // ember flecks (texture) bx.fillStyle = _alpha(PARK_HUES.ember, 0.6); bx.beginPath(); bx.arc(x * cw + cw * 0.32, y * cw + cw * 0.62, cw * 0.08, 0, 7); bx.fill(); bx.beginPath(); bx.arc(x * cw + cw * 0.68, y * cw + cw * 0.3, cw * 0.06, 0, 7); bx.fill(); } } _parkGem(bx, 7.5 * cw, 4.5 * cw, cw * 0.24); // the prize beyond the deep bx.save(); // the little companion + halo bx.globalAlpha = 0.28 + 0.2 * g; bx.strokeStyle = PARK_HUES.companion; bx.lineWidth = 2; bx.beginPath(); bx.arc(3.5 * cw, 7.5 * cw, cw * 0.34, 0, 7); bx.stroke(); bx.restore(); _parkActor(bx, 3.5 * cw, 7.5 * cw, cw * 0.16, PARK_COMPANION_SOFT, 0, -1); // PHASE A (0 → .35): the walker steps toward the fork, leaving its breadcrumb wake. const wa = Math.min(1, t / 0.35); const wk = Math.min(_ATTRACT_WALK.length - 1, wa * (_ATTRACT_WALK.length - 1)); const wi = Math.min(_ATTRACT_WALK.length - 2, Math.floor(wk)), wf = wk - wi; const wx = (_ATTRACT_WALK[wi][0] + (_ATTRACT_WALK[wi + 1][0] - _ATTRACT_WALK[wi][0]) * wf + 0.5) * cw; const wy = (_ATTRACT_WALK[wi][1] + (_ATTRACT_WALK[wi + 1][1] - _ATTRACT_WALK[wi][1]) * wf + 0.5) * cw; bx.save(); // breadcrumb wake (fading dots) bx.fillStyle = '#9fc0ff'; for (let i = 0; i < wk; i++) { const tt = (i + 1) / _ATTRACT_WALK.length; bx.globalAlpha = 0.12 + 0.35 * tt; bx.beginPath(); bx.arc((_ATTRACT_WALK[i][0] + 0.5) * cw, (_ATTRACT_WALK[i][1] + 0.5) * cw, cw * (0.07 + 0.06 * tt), 0, 7); bx.fill(); } bx.restore(); // PHASE B (.4 → .85): three ghost paths diverge from the fork (dashed, progressive reveal), // each ending in the hollow dashed phantom-disc idiom (the path-not-taken vocabulary). const gr = clamp01((t - 0.4) / 0.45); if (gr > 0) { for (const gh of _ATTRACT_GHOSTS) { bx.save(); bx.strokeStyle = gh.color; bx.lineWidth = 3; bx.lineJoin = 'round'; bx.setLineDash([5, 5]); bx.globalAlpha = 0.55; const tip = _attractPartial(gh.path, gr, cw); bx.setLineDash([3, 3]); bx.lineWidth = 2; // phantom disc at the live tip bx.globalAlpha = gr >= 1 ? 0.4 + 0.35 * g : 0.5; bx.beginPath(); bx.arc(tip[0], tip[1], cw * 0.26, 0, 7); bx.stroke(); bx.restore(); } } _parkActor(bx, wx, wy, cw * 0.24, SPRITE_HUE.agent, 1, 0); // the walker (facing the fork) } /* ============================== MAIN DRAW =============================== */ // syncParkChrome(): P8.5 §3.1 CUT #2 — the legacy #metarail canvas is HIDDEN whenever the // page is park-shaped (a park run, or the pre-run attract screen of the park build); it // stays visible only for a live LEGACY (non-park) run. DOM-level display toggle only. function syncParkChrome() { const run = G.campaign; const off = !run || !!run.park; const rail = document.getElementById('metarail'); if (rail) rail.style.display = off ? 'none' : ''; } function draw() { setSteps(); syncParkChrome(); updateScenario(); // human-facing scenario narration (C1-safe; cache-guarded no-op when unchanged) // P4 §C DOM annotation layers (chips + tutorial cards): synced EVERY frame so their // visibility is always a pure function of the current phase (ANNOT-DEMO-ONLY §C.3) — // they hide themselves on game/hub/report/legacy frames. No-ops without the DOM layer. syncParkAnnot(); syncParkTutCards(); syncParkHandoffCard(); // R2 #6: the one handoff card (ceremony-only; gone on the first input) const run = G.campaign; const sk = stageKey(); // P8.5 §5.1 ATTRACT LOOP: the pre-run FIRST SCREEN is a wordless ~3s canvas loop (a walker, // its breadcrumb wake, a hazard fork, three diverging ghost paths) repeating until any // key/click — the game's thesis before any text. Zero fillText by construction (glyph // primitives only); pre-measurement UI on a non-measured board, so C1/PARK-ZERO-TEXT hold. if (!run && sk === 'idle') { drawParkAttract(); hx.clearRect(0, 0, hud.width, hud.height); return; } // PARK PERSONA GRIDWORLD: under parkMode (run.park present) the demo/play/report stages render // as the zero-text park surface, NOT the legacy busy board. Intercept FIRST — the whole legacy // render/HUD path below is never reached in park mode, and never touched when run.park is absent // (byte-identical off-path). LIVE frames are zero-text; the report is the analyst channel. if (run && run.park && (sk === 'tutorial' || sk === 'hub' || sk === 'demo' || sk === 'play' || sk === 'report' || sk === 'interstitial' || sk === 'scorecard')) { _demoEmphasis = false; _playPulse = false; if (sk === 'tutorial') { drawParkTutorial(); // bx: the practice-yard vignette (zero-text, P3a §1) drawParkTutorialHUD(); // hx: practice hearts + gauge (zero-text) } else if (sk === 'hub') { drawParkHub(); // bx: the zero-text task-select tile grid drawParkHubPanel(); // hx: the zero-text suite rail (roster + pooled bars) } else if (sk === 'interstitial') { drawParkInterstitial(); // bx: the §B.2 mid-session glyph line (GLYPH-ONLY) hx.clearRect(0, 0, hud.width, hud.height); } else if (sk === 'scorecard') { drawParkScorecard(); // bx: the §B.3 session scorecard (report channel) drawParkScorecardPanel(); // hx: the per-episode session table } else if (sk === 'report') { if (run.park.task) { drawParkTaskReportBoard(); // bx: the finished MINIGAME's trajectory overlay (text allowed) drawParkTaskReport(); // hx: its blind row + the suite rows/headline } else { drawParkReportBoard(); // bx: the demo-vs-game trajectory overlay picture (text allowed) drawHUD(); // hx: drawParkReport (inferred vs demonstrated + denominators) } } else { // 미니 시연 창은 drawParkFrame **앞**이다: drawParkScene 이 CELL(let 전역)을 호출마다 // 다시 쓰므로, 플레이 판을 나중에 그려야 프레임 끝에 CELL 이 플레이 값으로 남는다. // 플레이 단계 전용 — 허브·리포트·튜토리얼은 자기 HUD 문법이 따로 있다. const replay = sk === 'play' && _parkReplayTick(); drawParkFrame(); // bx: the live zero-text park stage drawParkHUD(); // hx: ♥ glyphs + gem gauge only — zero text if (replay) _parkReplayBlit(); // hx: 옆판의 미니 시연 (drawParkHUD 가 hx 를 지운 뒤) } paintXfadeOverlay(); // P8.5 §3.1 CUT #2: the metarail is display:none under park mode (syncParkChrome), so // the old per-frame clearRect is gone with it. return; } // EMPHASIS flag: on iff this is a decisive value-demo frame. Reset every draw() so play is // byte-identical (flag false) and routine demo frames don't pulse. _demoEmphasis = !!(sk === 'demo' && G.demoAnim && G.demoAnim.valueDemo && (G.demoAnim.scenePair || G.demoAnim.foregone)); // PLAY PULSE: gentle always-on active-seat breathe during play only (time-based, no state). _playPulse = (sk === 'play'); if (sk === 'demo' && G.demoAnim) { // DEMO: render the separate TUTORIAL board (the live cumulative board is // untouched). The tutorial board carries a single newcomer at seat 0; only it // self-demonstrates (display-only) on the small education grid. const a = G.demoAnim; // the newcomer lives at the tutorial board's seat 0 but is rendered with ITS // party-seat identity (shape/color by a.seatId) — the same glyph the player will // see it carry once it joins the round-robin. // the newcomer's facing for the directional AGENT sprite = its last demo step's // direction (display-only; no rule key). Defaults 'up' before the first step. const dface = (a.lastTo && a.lastTo.from && a.lastTo.to) ? faceOf(a.lastTo.to.x - a.lastTo.from.x, a.lastTo.to.y - a.lastTo.from.y) : null; const seats = [{ id: 0, active: true, vid: a.seatId, face: dface }]; // COMPANION (seat 1) rendered CONTINUOUSLY so the other agent is a STABLE moving figure rather than // blinking in/out per step. The claim leash/ring cue (drawDemoCues) still highlights it when its // token is the contested claim. (On the value-demo board the only other seat is 1.) if (a.valueDemo && a.disp && a.disp.pos[1]) seats.push({ id: 1, active: false, vid: 1 }); // ROLE demo: clean stage (no terrain), the FULL accumulated path as a solid trail in // the newcomer's identity color (intention reads from goal-directed motion), and a // faint highlight on the contrastive beat. No violation flash (the role own-walk is // clean). Non-role demos keep the legacy single-step trail + red violation flash. // VALUE DEMO: a SOLID trail on the chosen oracle move (the agent FOLLOWING the winning // concern this scene), colored by the WINNER concern's hue so the move reads as "this // concern won". The chosen destination cell is ringed (beatHighlight) so the in-tension // resolution is visible on the board. ROLE/legacy demos keep their existing trail vocab. const winColor = (a.valueDemo && a.scenePair && CONCERN[a.scenePair.hi]) ? CONCERN[a.scenePair.hi].color : null; const roleTrails = a.valueDemo ? (a.lastTo && (a.lastTo.from.x !== a.lastTo.to.x || a.lastTo.from.y !== a.lastTo.to.y) ? [{ mv: a.lastTo, color: _alpha(winColor || '#cfe0ff', 0.9) }] : null) : (a.role ? a.trail.map(mv => ({ mv, color: _alpha(seatColor(a.seatId), 0.85) })) : (a.lastTo ? [{ mv: a.lastTo, color: 'rgba(63,125,246,0.55)' }] : null)); drawGrid(a.disp, Object.assign({ demoBand: true, seats, // SPRITE regime: a value-cycle demo renders the approved 7x7 silhouettes (the live // game is the persona/ordering game). Gated on the live run being a value cycle so // every legacy/role/terrain demo keeps the byte-identical glyph vocabulary. sprites: isValueCycle(run), // CLEAN STAGE for a role/value board: terrain paint skipped (intention reads from the // conflict + motion, not a terrain backdrop). engine still seeds it inert. cleanStage: a.role || a.valueDemo, // §A SPARSE CLUES: on a value-demo scene, FOREGROUND only the load-bearing entities of // the engaged pair and MUTE the rest (conflict-irrelevant tokens/zone) so the divergence // reads clean. Render-layer only (the engine board is byte-untouched). drawGrid keeps the // hazards via cleanStage already (terrain unpainted); this gates the tokens + zone disc. // FULL board furniture (not the per-step conflict subset) so every token + hazard stays drawn and // FIXED across the long walk — the per-step `relevant` subset made the board flicker (no continuity). relevant: a.valueDemo ? _fullRelevant(a.disp) : null, // GHOSTS on the tutorial board (display-only relational reference markers). ghosts: true, // PHASE-CLOCK on the tutorial board: the tutorial board carries its own st.clock // keyed by tutorial seat 0; surface it under the newcomer's vid so the demo pip // matches the seat the newcomer will render with. Public seg/segN only (C1). clock: demoClockState(a.disp, a.seatId), trails: roleTrails, // VALUE DEMO: ring the CHOSEN destination cell (the resolved-in-favor-of-winner move); // ROLE: faint contrastive-beat highlight. Neither is a violation flash or a rule label. beatHighlight: a.valueDemo ? (a.lastTo ? a.lastTo.to : null) : (a.role ? a.beat : null), // FOREGONE ARROW REMOVED (user directive 2026-06-26 "불필요한 화살표 최대한 줄이고"): the // declined reward-greedy ghost arrow + chevron + X was visual clutter that inverted the read. // The forgone reward is now legible WITHOUT an arrow: the reward token stays visible on the // board (yellow) and the GOAL GAUGE (demoGoalOpts below) does NOT advance when the persona // declines it for safety/morality, while the winner-COLORED chosen move shows which concern won. foregone: null, flash: (!a.role && !a.valueDemo && a.flash && a.lastTo) ? a.lastTo.to : null, }, // GOAL ON THE DEMO BOARD (user directive 2026-06-26 "시연에도 목표 보여줘"): a value demo now // renders the harvest goal gauge reflecting a.disp's token state, so the viewer sees the goal the // persona pursues and SEES it forgo reward when safety/morality outranks goal. Display-only (reads // the demo board's own tokens); scored core untouched. a.valueDemo ? demoGoalOpts(a.disp) : {})); // LEGIBILITY OVERLAY: the road not taken + the contested entity, so each decision reads as a // TRADEOFF ("chose reward, declined the safe/claimed cell"), not just motion. CELL is set by the // drawGrid call just above, so these overlays land on the same demo board. if (a.valueDemo) drawDemoCues(a); } else if (sk === 'play' && run) { const seats = run.party.map(ag => ({ id: ag.id, active: ag.id === run.turnSeat })); // LIVE OPPONENT (design 2026-06-30 §3): render the escapability-preserving mover as a // non-active companion seat. It is a board occupant at id run.party.length (NOT a party // member); its identity (shape+hue) is keyed on the seat INDEX only, never the hidden rule // (C1-safe, exactly like every other seat). Render-only — its motion is driven engine-side. if (run.config && run.config.liveOpponent && run.board && run.board.pos[run.party.length]) { seats.push({ id: run.party.length, active: false }); } drawGrid(run.board, Object.assign({ seats, keyLegend: true, clock: C.clockState(run), cleanStage: isRoleCycle(run), sprites: isValueCycle(run), // hazards RECEDE to a dark floor on the play board so the // actors are the salient class (logical set untouched, C1). recessiveHazard: true, foregone: playForegone(run), trails: playTrails(run), // FIX3 FOCAL FRAME: mark the ACTIVE seat's CELL (run.board.pos // [run.turnSeat]) so the controlled agent carries a crisp focal // frame each turn (the demo's focal cue, carried onto play). C1: // marks the agent CELL, never an ordering. Set ONLY in play. focal: (run.turnSeat != null && run.board.pos[run.turnSeat]) ? run.board.pos[run.turnSeat] : null, flash: G.flash ? G.flash.cell : null }, goalGaugeOpts())); } else if (sk === 'report' && run) { // FIX(B) REPORT-FRAME SELF-MARKER: mark the controlled seat active on the report board // too (was active:false for ALL seats, so scored report frames rendered NO self-marker) // — same key as play (ag.id === run.turnSeat). Now every scored frame carries the blue // "you"-class marker. Keyed ONLY on the PUBLIC turnSeat, never the rule (C1). const seats = run.party.map(ag => ({ id: ag.id, active: ag.id === run.turnSeat })); drawGrid(run.board, Object.assign({ seats, clock: C.clockState(run), cleanStage: isRoleCycle(run), sprites: isValueCycle(run), recessiveHazard: true }, goalGaugeOpts())); // FINALE for a full clear (정복 완료) vs the plain death banner. if (G.lastEnding) { if (G.lastEnding.status === 'cleared_cap') drawFinaleBanner(run); else drawEndingBanner(G.lastEnding.status); } } else { bx.clearRect(0,0,board.width,board.height); bx.fillStyle = '#2a2d36'; const cx = board.width/2, cy = board.height/2, s = 26; bx.beginPath(); bx.moveTo(cx-s*0.5, cy-s); bx.lineTo(cx-s*0.5, cy+s); bx.lineTo(cx+s, cy); bx.closePath(); bx.fill(); } // ONE-LONG-BOARD CONTINUITY: composite the crossfade overlay (frozen old frame fading out + // optional join pulse) on TOP of the freshly-painted board, so a cycle / demo-segment handoff // reads as the SAME stage continuing. Presentation-only; no-op when no crossfade is live. paintXfadeOverlay(); drawHUD(); updatePanel(); } // INTENT CUE — playTrails(run): the active seat's last ACTUAL move (G.lastMove) as a SOLID // seat-colored arrow/trail, so the persona's CHOICE is visible from the board (matching the // demo's chosen-move trail). Display-only; reuses drawTrail via opts.trails. Returns null when // the active seat has not moved yet (no trail to show). C1: shows the MOVE, never the rule. function playTrails(run) { if (!run || run.turnSeat == null) return null; const seat = run.turnSeat; const mv = G.lastMove[seat]; if (!mv) return null; // FIX3 CHOSEN MOVE: render the active seat's last move as a BOLD bright arrow in the active "you" // cyan/blue class (SPRITE_HUE.agent, matching FIX1's agent color) — not the seat identity hue — so // the chosen direction reads as the CONTROLLED agent's intent, mirroring the demo's chosen arrow. // Per-turn via G.lastMove[seat]. C1: shows the MOVE, never the rule. return [{ mv, color: _alpha(SPRITE_HUE.agent, 0.95) }]; } // INTENT CUE — playForegone(run): the reward-greedy move the active seat DECLINED, computed // RULE-BLIND so it leaks nothing about the hidden ordering (C1). For the active seat at its // current cell: among E.legalMoves (rule-blind) pick the cand minimizing Manhattan distance to // the nearest ALIVE token (pure reward-greedy, ordering ignored). If that greedy cell differs // from the chosen move (G.lastMove.to), the persona VISIBLY forwent reward to honor its rule → // return {to} so drawGrid's foregone-ghost paints the declined-greedy arrow. Returns null when // there is no divergence (greedy == chosen), no last move yet, or no alive token. The ghost // shows only a DESTINATION CELL (a move), never any concern/ordering name. function playForegone(run) { if (!run || !run.board || run.turnSeat == null || !E || !E.legalMoves) return null; const seat = run.turnSeat; const mv = G.lastMove[seat]; if (!mv) return null; // no chosen move recorded yet → nothing to contrast const st = run.board; const from = mv.from; // contrast against the cell the chosen move LEFT // nearest alive token (rule-blind, pure reward-greedy target). let best = null, bestD = Infinity; for (const t of st.tokens) { if (!t.alive) continue; const d = Math.abs(t.x - from.x) + Math.abs(t.y - from.y); if (d < bestD) { bestD = d; best = t; } } if (!best) return null; // greedy legal step toward that token (rule-blind: E.legalMoves never consults the rule). const cands = E.legalMoves(st, seat); let greedy = null, gD = Infinity; for (const c of cands) { const d = Math.abs(best.x - c.x) + Math.abs(best.y - c.y); if (d < gD) { gD = d; greedy = c; } } if (!greedy) return null; // only a DIVERGENCE is interesting: greedy != the chosen destination → a forgone-reward step. if (greedy.x === mv.to.x && greedy.y === mv.to.y) return null; return { from: { ...from }, to: { x: greedy.x, y: greedy.y } }; } // GOAL-GAUGE opts for the live/report board: how full is the party's harvest // toward the cycle quota? The quota + fill are READ from campaign (C.goalProgress, // the single source of truth — the rebalanced threshold ceil(0.30 * min(present, // compliant-reachable)) cannot be re-derived here without the compliant rollout). // A pure read of the public goal gauge (keyed on the public goal alone, C1). // demoGoalOpts(disp): the harvest goal gauge for the DEMO board (display-only). The value-demo board // is a harvest_max board with a few reward tokens; the gauge fills as the oracle walk takes them and // STALLS when the persona declines a reachable reward for a higher concern — that stall (not an arrow) // is how the viewer reads "goal was outranked here". quota/filled key on PUBLIC token aliveness only, // never the rule (C1). Per-segment (re-derived from the current disp board each frame). // _claimedTokenFor(st, seat): the alive token some OTHER seat is STRICTLY closest to (what RESPECT // would leave untaken) + that claimant seat id. Mirrors PRO_ATTITUDES.N's strict-claimant read on // PUBLIC positions (display-only; used to mark the contested token + show the claimant in the demo). function _claimedTokenFor(st, seat) { const from = st.pos[seat]; if (!from || !st.tokens) return null; const others = Object.keys(st.pos).map(Number).filter(id => id !== seat); for (const tok of st.tokens) { if (!tok.alive) continue; const tk = { x: tok.x, y: tok.y }; let claimD = E.manhattan(from, tk), claimant = null, strict = false; for (const id of others) { const dd = E.manhattan(st.pos[id], tk); if (dd < claimD) { claimD = dd; claimant = id; strict = true; } else if (dd === claimD) strict = false; } if (strict && claimant != null) return { token: tk, seat: claimant }; } return null; } // _nearestDarkCell(st, from): the closest hazard ('dark') cell to `from` as {x,y}, or null. Used to // emphasize the danger the agent braved on a SAFETY (C) loss. Reads the public hazard set only. function _nearestDarkCell(st, from) { if (!st.hazard || !st.hazard.size) return null; const n = st.N || (board.width / CELL) | 0; let best = null, bd = Infinity; for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) { if (!st.hazard.has(E.keyN({ x, y }, n))) continue; const d = E.manhattan(from, { x, y }); if (d < bd) { bd = d; best = { x, y }; } } return best; } // _fullRelevant(disp): the WHOLE board's furniture (every alive token + every hazard) as the demo's // `relevant` set. The old per-step `relevant` was a CHANGING subset (only the current conflict's // entities), so on a long continuous board the tokens/hazards flickered in and out every step = no // continuity. Passing the FULL set keeps every token/hazard drawn and FIXED (a token only disappears // when actually harvested); the cleanStage demo still needs `relevant.hazards` to draw hazards at all. function _fullRelevant(disp) { if (!disp) return null; const tokens = new Set(); (disp.tokens || []).forEach(t => { if (t.alive) tokens.add(t.x + ',' + t.y); }); const hazards = new Set(disp.hazard ? [...disp.hazard] : []); return { tokens, hazards }; } function demoGoalOpts(disp) { if (!disp || !disp.tokens || !disp.tokens.length) return {}; const quota = disp.tokens.length; const filled = disp.tokens.filter(t => !t.alive).length; return { goal: 'harvest_max', goalQuota: quota, goalFilled: filled, goalFrac: quota ? filled / quota : 0 }; } function goalGaugeOpts() { const run = G.campaign; if (!run || !run.board) return {}; const gp = C.goalProgress(run); // gp also carries the new-goal state (seatReached / recipe+kindDone) and the §3 // engagement floor (bySeat done/need) — all keyed on PUBLIC goal + seat id + counts // only (never the rule, C1). Pass the whole progress object as `goalExtra` so the // gauge + on-board destination overlay + engagement pips all read from ONE query. return { goal: gp.goal, goalQuota: gp.quota, goalFilled: gp.filled, goalFrac: gp.frac, goalExtra: gp }; } // SLICE2 §3 demo phase-clock view: the tutorial board carries its OWN st.clock keyed by // the tutorial seat (0); surface it in the same {bySeat:{[id]:{seg,segN}}} shape the live // C.clockState query uses, re-keyed under the newcomer's vid so the demo pip is found for // the seat the newcomer renders with. PURE public read of seg/segN (never the rule, C1). // Returns empty bySeat when the tutorial board carries no clock (non-phase rules). function demoClockState(disp, vid) { if (!disp || !disp.clock) return { bySeat: {} }; const c = disp.clock[0]; // the tutorial board's single seat if (!c) return { bySeat: {} }; return { bySeat: { [vid]: { seg: c.seg, segN: c.segN, advanceOn: c.advanceOn } } }; } /* =============================== CONTROLS =============================== */ // ---- 부팅 로딩 게이지 (2026-08-05). 부팅은 실제로 12~16초 멈춘다: 측정상 createRun // 한 번이 12.7초이고 그 중 parkCrossings 가 16.3초 — 92% — 이며 그것은 23칸 map // 루프다. 단일 동기 호출이라 그동안 브라우저는 한 프레임도 못 그린다: 지금 자리에 // 바를 얹으면 0%에서 얼어붙은 채 12초 있다가 사라진다. 그래서 이 일의 본체는 바를 // 그리는 것이 아니라 부팅을 24조각으로 쪼개는 것이다. // // 칸 비용은 고르지 않다(실측 410ms ~ 5,761ms, 14배). 그래서 바는 균등하게 차지 않고 // 무거운 칸에서 머문다. 그래도 칸을 센다 — 시간을 세면 느린 기기에서 100%에 버티고 // 빠른 기기에서 중간에 끝나, 판독 화면이 지키는 "모르는 것을 아는 척하지 않는다"를 깬다. let _bootGauge = null; function _bootGaugeTotal(stepper) { return stepper.total + 1; } function bootGaugeShow(total) { const el = document.getElementById('bootbar'); _bootGauge = { total, done: 0 }; if (el) { el.classList.add('on'); const f = el.firstElementChild; if (f) f.style.width = '0%'; } } function bootGaugeTick() { if (!_bootGauge) return; _bootGauge.done++; const el = document.getElementById('bootbar'); const f = el && el.firstElementChild; if (f) f.style.width = Math.min(100, _bootGauge.done / _bootGauge.total * 100) + '%'; } function bootGaugeHide() { const el = document.getElementById('bootbar'); if (el) el.classList.remove('on'); _bootGauge = null; } // REENTRANCY GUARD + FAILURE RECOVERY (2026-08-05, critical fix — 리뷰 라운드 1). start() 가 // 프레임에 걸쳐 도는 동안(=bootGaugeShow~bootGaugeHide 사이) 클릭 가능한 몇 초 창이 열린다. // board 의 click, keydown, startBtnRoute() 세 진입점이 이 창에서 다시 start() 를 부르면: // 1) 두 번째 부팅이 첫 번째의 G.campaign 을 자기 시드로 덮어써, 먼저 시작한 pump 가 나중에 // 도착해 엉뚱한 시드로 계산한 crossings 를 얹을 수 있다(campaign.js 가 못박는 "crossings // 는 run seed 의 순수 함수" 불변식 위반). // 2) after(400, parkBootRoute) 가 중복 예약돼 startParkTutorial/startDemoAnim 이 두 번 뜬다. // _bootGauge 는 이미 bootGaugeShow~bootGaugeHide 사이에서만 non-null 이므로 그 자체를 // in-flight 표지로 재사용한다 — 별도 플래그를 새로 두면 두 상태가 어긋날 여지만 늘어난다. // // 실패 경로도 갇히면 안 된다: rAF 콜백 안에서 던지면 아무도 못 잡아 바가 화면에 박제되고 // _bootGauge 가 영영 안 풀려 위 재진입 가드까지 죽는 완전한 데드엔드가 된다. 옛 동기 코드는 // 예외가 나면 G.campaign 이 null 로 남아 다음 클릭/키가 재시도할 수 있었다 — 그 성질을 // 되살린다. 조용히 삼키지 않는다: console.error 로 남기고, 게이지를 내리고, G.campaign 을 // null 로 되돌려 다음 입력이 처음부터 다시 시도하게 한다. function _bootFail(err) { console.error('[boot] 부팅 체인이 실패했다 — 다음 클릭/키가 재시도한다', err); G.campaign = null; bootGaugeHide(); } function start() { // 부팅 중 재진입 가드 (2026-08-05, critical fix): _bootGauge 가 non-null 이면 이미 부팅이 // 진행 중이다 — 두 번째 호출은 clearTimers/G 리셋조차 없이 완전한 no-op 으로 끝난다. if (_bootGauge) return; clearTimers(); G.lastEnding = null; G.flash = null; G.demoAnim = null; G.parkAnim = null; // stale park state never survives a restart G.parkReplay = null; // stale mini demo replay never survives a restart G.parkView = null; // stale hub/task view never survives a restart G.parkTut = null; // stale practice-yard state never survives a restart G.parkAnnot = null; // stale demo highlights never survive a restart G.parkInter = null; // stale session interstitial never survives a restart (P8.6) G.sessionStrip = null; // stale scorecard strip cache never survives a restart (P8.6) G.hub = null; // stale hub thumbnails/selection never survive a restart G.facing = {}; // stale facings/trails never survive a restart G.lastMove = {}; G.generate = null; // GENERATE sub-mode never survives a restart G.xfade = null; // a stale crossfade overlay never survives a restart (presentation-only) G.stage = null; // build a fresh run (campaign owns ALL run/cycle/party/score state). The first // cycle is begun inside createRun (stage='demo'); kick off the demo animation. // SLICE2: the LIVE game runs the difficulty-by-depth schedule (relational lever A + // phase/memory lever D families) — its escapability / C1 / total<=C* soundness is // verified by the engine + central-invariant gates + the independent geometry/escape // probes before this flag was enabled here. // daBattery:true seats the W1.1 four-concern value-laden persona (REWARD/SAFETY/YIELD/ // RESPECT) into the live pool so an ordering cycle (and the concern legend + tension HUD) // is reachable — the design intent (campaign.js §config) of the LIVE campaign. The path // is fully wired (lexFilter/lexicalOracle ceiling) and gate-verified; flag-only, no // engine behavior change. // SHOWCASE: daBattery seats the value persona into the pool but does not guarantee // cycle 1 draws it; scan a few nearby seeds for a run whose FIRST cycle is a value-laden // ordering cycle so the concern legend + tension HUD are visible on load. Pure app-side // seed selection (no engine/campaign behavior change); falls back to the raw seed. // LIVE-ONLY createRun params (the legacy/test path never passes these, so engine + // campaign behavior on the green-gate path is byte-identical): a 16x16 board and // daBatteryValueFirst (pin the value persona to cycle 0 so the four-concern ordering // game — and its concern HUD — is the LIVE game from load). Value rules land tier 2/3 // (cycle ~12+) without the pin, so the old seed-scan could never find a cycle-0 ordering // run and silently fell back to a terrain cycle (the prior concern-HUD-never-fired bug). // GAME TASK A — daBatteryDiverseOrderings:true seats a DIFFERENT lexical persona (drawn // deterministically from the 24 permutations via _orderingForSeed) per ordering cycle, so the // LIVE game surfaces the 24-ordering space across cycles instead of the ONE canonical // DEFER-FIRST persona. personaView already renders run.orderings[seat], so the concern legend // + tension HUD vary per cycle with NO render change. C* / oracle / escapability recompute per // run.orderings (all 24 dominating-proven), so realized<=C* + escapability hold. Live-only flag // (no test/gate path sets it -> _orderingsForRun byte-identical off the live path). // SHARED with the headless blind probe (agent_harness.playRun) via campaign.LIVE_OPTS, so // the canvas and the probe play the SAME campaign and cannot drift (dual-surface parity). // Falls back to the inline literal only if an older campaign.js bundle lacks the export. const LIVE_OPTS = C.LIVE_OPTS || { slice2Families: true, daBattery: true, daBatteryValueFirst: true, daBatteryDiverseOrderings: true, boardN: 16 }; const seed0 = (Date.now() % 100000) | 0; // 부팅을 프레임에 걸쳐 편다 (2026-08-05). deferCrossings 는 여기서만 붙인다 — // LIVE_OPTS 에 넣으면 agent_harness 와 기존 게이트가 크로싱 없는 런을 받는다. const probe = C.parkCrossingsStepper(seed0); bootGaugeShow(_bootGaugeTotal(probe)); const frame = (fn) => (window.requestAnimationFrame ? window.requestAnimationFrame(fn) : after(16, fn)); frame(() => { // 실패 복구 (2026-08-05, critical fix): 이 콜백은 rAF 안에서 돈다 — 여기서 던지면 아무도 // 못 잡아 게이지가 박제되고 _bootGauge 가 영영 안 풀린다. try/catch 로 감싸 _bootFail 로 // 되돌린다(콘솔 기록 + 게이지 해제 + G.campaign=null 로 다음 입력이 재시도하게). try { // 틱 1: 런을 짓는다. 크로싱은 빠져 있으므로 실측 ~1.1초. let run = null; for (let i = 0; i < 120; i++) { const cand = C.createRun(Object.assign({ seed: (seed0 + i) % 100000, deferCrossings: true }, LIVE_OPTS)); if (C._isOrderingCycle && C._isOrderingCycle(cand.ruleSet)) { run = cand; break; } } G.campaign = run || C.createRun(Object.assign({ seed: seed0, deferCrossings: true }, LIVE_OPTS)); bootGaugeTick(); // 틱 2..N: 크로싱을 한 프레임에 한 칸씩. 스테퍼는 실제로 쓰이는 런의 시드로 다시 // 만든다 — 시드 스캔이 seed0 이 아닌 후보를 골랐을 수 있다. const st = C.parkCrossingsStepper(G.campaign.seed); const pump = () => { // pump 는 매 프레임 새로 예약되는 별도 콜백이라 위 try 하나로는 못 덮는다 — 자기 몫을 // 스스로 감싼다. 같은 이유·같은 복구(_bootFail)다. try { if (st.step()) { bootGaugeTick(); return frame(pump); } G.campaign.park.crossings = st.result; // 부착. 이 뒤에야 라우팅이 열린다. bootGaugeHide(); setHint('① 시연 — 새 동료가 자기 규칙을 시연합니다(번쩍 = 단서, 점수 미반영). (자세히: ? 안내)'); draw(); // PARK ROUTING (P8.6 §B.4, EVERY-VISIT 2026-07-10): ▶ (or any key/click on the attract // screen) goes STRAIGHT to the practice-yard tutorial on EVERY boot — the localStorage // return-skip branch is REMOVED (any stale 'aa_park_tutorial_done' key is simply ignored; // nothing reads it anymore). Never the text wall (the ? guide opens only via ?). The // PROMINENT skip chip on the first tutorial frame — plus Escape — whole-skips to the // picker (finishParkTutorial, P10 §4). The legacy path keeps the direct demo kickoff // (byte-identical off-park). parkBootRoute below is the named module-scope route so the // TUTORIAL-EVERY-VISIT gate can drive the exact boot decision. // // parkBootRoute 는 크로싱 부착 뒤에만 걸린다 — 그래야 허브/세션이 빈 목록을 // 보는 창이 없다. after(400, parkBootRoute); } catch (err) { _bootFail(err); } }; frame(pump); } catch (err) { _bootFail(err); } }); } // the boot destination decision (extracted from start()'s after(400) closure, 2026-07-10): // park -> tutorial Act 1 on EVERY visit; legacy -> the direct demo kickoff. No localStorage. function parkBootRoute() { if (!(G.campaign && G.campaign.park)) return startDemoAnim(); return startParkTutorial(); } // startBtnRoute(): what the header ▶ does, decided by the screen it is pressed on. Named at // module scope for the same reason parkBootRoute above is — so PARK-SESSION-DOOR can drive the // exact decision without a DOM. // // THE HUB BRANCH IS THE SCORED SESSION'S ONLY DOOR (P8.6 §B.4). Until 2026-07-29 this listener // was bound straight to start(), and parkSessionBegin — the only caller of C.startParkSession — // was reachable from exactly one place: the `psk === 'scorecard'` key branch, i.e. "begin ANOTHER // session once one has finished". Since a scorecard can only be produced by finishing a session, // the entry point was circular and the scored session was unreachable by any human: ▶ on the hub // silently re-ran start() and dropped the player back in the practice-yard tutorial, throwing // nothing. The whole suite stayed green (the chain itself is fine — PARK-SESSION-SMOKE plays it // 10/10 on seeds 7/11/23); what was missing was the person's way in. // // This restores a DOCUMENTED door rather than inventing one — three places in this file already // assert it: the session block header at the top ("▶ starts a SESSION"), parkHubEnter's header // ("the hub is ... unscored; ▶ = the scored session only"), and — decisively — the hub's own // on-screen caption in updateScenario, '연습·열람 — 미채점 · ▶ = 세션', which promises the player // in so many words that ▶ starts the session. The button now keeps that promise. // // Everywhere ELSE ▶ stays the full restart, unchanged: the cold attract screen, the tutorial, a // live episode, and any legacy (non-park) run all still route to start(). function startBtnRoute() { if (G.campaign && G.campaign.park && stageKey() === 'hub') return parkSessionBegin(); return start(); } document.getElementById('startBtn').addEventListener('click', startBtnRoute); // HOW-TO / intro panel. P8.5 §5.2 (CUT #6): NEVER default-open — the first screen is the // wordless attract loop, and the guide opens ONLY via the header ? button (dismissible via // ✕ or ? again). No rule text. // The ? button is CONTEXT-AWARE (2026-07-28): on a readout screen it opens the readout's own // legend (#readguide) instead of the general park guide, because that is the screen whose // vocabulary a reader cannot look up anywhere — the axis dots are PARK_AXIS colours and nothing // named them. Which card is "the help" is decided by stageKey() === 'report', which covers BOTH // readouts (a task/crossing episode parks G.parkView='report'; the capstone sets G.stage) so // preview, live and transfer episodes are all served by one condition. Only ever ONE card is // open: switching screens with the guide open would otherwise leave the wrong one showing. const _helpPanels = () => ({ howto: document.getElementById('howto'), read: document.getElementById('readguide') }); // helpPanelOpen(): does ANY help card currently cover the game? The keydown handler reads this // to leave the reader alone (a key while the guide is open is reading, not playing). function helpPanelOpen() { const p = _helpPanels(); return !!((p.howto && !p.howto.classList.contains('hidden')) || (p.read && !p.read.classList.contains('hidden'))); } (function wireHowto() { const { howto, read } = _helpPanels(); const helpBtn = document.getElementById('helpBtn'); if (!howto && !read) return; const hideAll = () => { for (const p of [howto, read]) if (p) p.classList.add('hidden'); }; // the card this screen's ? should open: the readout legend on a readout, the park guide elsewhere. const forStage = () => (read && stageKey() === 'report') ? read : howto; hideAll(); for (const [btnId, panel] of [['howtoClose', howto], ['readguideClose', read]]) { const b = document.getElementById(btnId); if (b && panel) b.addEventListener('click', () => panel.classList.add('hidden')); } if (helpBtn) helpBtn.addEventListener('click', () => { const want = forStage(); const show = !!want && want.classList.contains('hidden'); hideAll(); // never two cards at once if (show && want) want.classList.remove('hidden'); // DROP THE FOCUS. Left focused, the ? button keeps eating Enter — and on the readout Enter is // "next episode", so the same key meant two things depending on where the last click landed. helpBtn.blur(); }); // Escape closes whichever card is open, and stops there: the readout's own Escape is "back to // the hub", and a reader dismissing a guide is not asking to leave the screen it explains. // stopImmediatePropagation, not stopPropagation — the game's key handler is another listener on // this same document, and by the time it ran the card would already be hidden. document.addEventListener('keydown', (e) => { if (e.key !== 'Escape' || !helpPanelOpen()) return; hideAll(); e.preventDefault(); e.stopImmediatePropagation(); }); })(); // V3 FREE-SWITCH CONTROL: the ARROW keys move the ONE active agent one cell per // press (it may move repeatedly). SPACE and TAB SWITCH the active agent to the next // party seat (round-robin, wrap) via C.switchSeat — they NO LONGER pass. The '.' key // is the explicit PASS/WAIT (a legal to===from move that fills the §3 engagement floor // and costs no ♥ when staying is compliant). Clicking an ADJACENT cell moves the active // agent; clicking the active agent's OWN cell is the same harmless stay/wait — so a seat // cornered into "stay is the only compliant option" can wait via '.' or an own-cell // click, while Space is reserved for switching. const KEYDIR = { ArrowUp:{x:0,y:-1}, ArrowDown:{x:0,y:1}, ArrowLeft:{x:-1,y:0}, ArrowRight:{x:1,y:0} }; // switchSeat keys: Space and Tab rotate the active agent round-robin. function switchSeat() { const run = G.campaign; if (!run || run.stage !== 'play' || run.status !== 'running') return; C.switchSeat(run); G.generate = null; // the focal changed — the per-focal GENERATE target is stale; reset draw(); } document.addEventListener('keydown', e => { // P8.5 §5.1: the attract loop repeats until ANY key/click — a plain key on the first // screen starts the run exactly like ▶ (the tutorial, on EVERY visit — 2026-07-10). // Modifier chords (browser shortcuts) and Escape stay inert, and keys are ignored while // the ? guide is open (the reader is reading, not playing). if (!G.campaign) { if (e.ctrlKey || e.metaKey || e.altKey || e.key === 'Escape') return; if (helpPanelOpen()) return; // either help card counts (2026-07-28) e.preventDefault(); start(); return; } // PARK HUB / REPORT keys (parkMode only): the hub is arrow+Enter task-select; ANY park // report returns to the hub (the back-to-hub loop). Legacy (run.park absent) never enters. if (G.campaign && G.campaign.park) { const psk = stageKey(); // PRACTICE-YARD TUTORIAL (P3a §1): every key routes to the action-gated beat machine; // Escape skips the whole vignette. View-level only — the run is untouched. if (psk === 'tutorial') { if (KEYDIR[e.key] || e.key === ' ' || e.key === '.' || e.key === 'Enter' || e.key === 'Escape') e.preventDefault(); parkTutorialInput(e.key === 'Enter' ? ' ' : e.key); return; } // TASK 11 DEMO SKIP — the ONLY keys the watch stage has ever had (it had none), so nothing is // displaced. S1: hold Shift or Space to fast-forward (release restores the pace). S2: Esc runs // the demo to its end and holds the STATIC whole-trajectory frame — Esc is the tutorial's own // skip idiom, reused rather than reinvented. On that static frame Enter/Space CONFIRM into play // (the interstitial/scorecard idiom); nothing auto-advances, so the trajectory can be read for // as long as the viewer wants. Escape NEVER hands off by itself: skipping skips time, not the // question. if (psk === 'demo' && G.parkAnim && G.parkAnim.mode === 'demo') { const a = G.parkAnim; if (a.end) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); parkDemoConfirm(); } return; } if (e.key === 'Escape') { e.preventDefault(); parkDemoToEnd(); return; } if (e.key === 'Shift' || e.key === ' ') { e.preventDefault(); parkDemoSetFF(true); } return; } if (psk === 'hub') { const d = KEYDIR[e.key]; if (d) { e.preventDefault(); hubMoveSel(d); return; } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); const tiles = _pickerTiles(); if (G.hub && tiles[G.hub.sel]) parkHubSelect(tiles[G.hub.sel].id); } // C = the CROSSING session (design 2026-07-31). The header's ▶ keeps its documented promise // — it starts the TRANSFER session — so the second ledger needs its own door rather than a // mode toggle on the first. Checked for collisions: no other handler consumes 'c'/'C'. if (e.key === 'c' || e.key === 'C') { e.preventDefault(); parkSessionBegin(true); } return; } // P8.6 §B.2: the mid-session interstitial auto-advances after ~1.5s; Enter/Space skip. if (psk === 'interstitial') { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); parkSessionNext(); } return; } // P8.6 §B.3: the scorecard — Enter = a NEW session; Escape = the 연습·열람 hub. if (psk === 'scorecard') { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); parkSessionBegin(); } if (e.key === 'Escape') { e.preventDefault(); parkHubEnter(); } return; } if (psk === 'report') { // A CROSSING readout (P10 §4) returns to the PICKER on any key. A RANDOM TRANSFER readout // (spec §B.1) chains Enter/Space to the next episode; Escape is the hub door. // ...unless the ? legend is open (2026-07-28): every key on this screen LEAVES it, so a // reader who opens the legend and taps a key would lose the very readout it explains. if (helpPanelOpen()) return; const tt = G.campaign.park.task; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (tt && tt.crossing) parkHubEnter(); else if (tt && tt.transfer) startParkTransfer(); else parkHubEnter(); } if (e.key === 'Escape' && tt && (tt.transfer || tt.crossing)) { e.preventDefault(); parkHubEnter(); } return; } } if (stageKey() !== 'play') return; // demo ignores keys; report frozen // PARK GAME: under parkMode the play stage is the interactive park game; route directional keys // (and '.'/Space = stay — the park has ONE seat, so Space is free for waiting) to the park // resolver (C.parkGameMove, inside campaign) and swallow the legacy play keys. Only reached when // run.park is present (byte-identical off-path). if (G.campaign && G.campaign.park) { if (G.parkAnim && G.parkAnim.mode === 'game') { const mv = PARK_KEYMOVE[e.key] || ((e.key === '.' || e.key === ' ') ? 'stay' : null); if (mv) { e.preventDefault(); parkGameInput(mv); } } return; } if (e.key === ' ' || e.key === 'Tab') { e.preventDefault(); switchSeat(); return; } // GENERATE / seat-swap toggle (section1:17 third pillar): 'g' makes the active seat act // ON the next peer's GIVEN ordering (value cycles only; no-op elsewhere). Live-only. if (e.key === 'g' || e.key === 'G') { e.preventDefault(); toggleGenerate(); return; } // §6 explicit PASS: '.' waits in place (a legal to===from move). Routes through the // SAME C.playerMove site (inside playMove) so it counts the §3 engagement floor and // never charges ♥ (staying is compliant for all shipped rules — escapability §1). if (e.key === '.') { e.preventDefault(); playMove({ x: 0, y: 0 }); return; } const d = KEYDIR[e.key]; if (!d) return; e.preventDefault(); playMove(d); // arrows drive the ACTIVE agent one cell }); // S1 RELEASE (task 11): fast-forward is a HOLD, so it must end on the key going up — and on the // window losing focus, or a tab-switch would leave the demo stuck at 6x with the key "held" // forever. parkDemoSetFF is a no-op whenever no demo is running, so these are inert elsewhere. document.addEventListener('keyup', e => { if (e.key === 'Shift' || e.key === ' ') parkDemoSetFF(false); }); window.addEventListener('blur', () => parkDemoSetFF(false)); // S1 by POINTER: press-and-hold the ▶▶ chip. Same hold semantics as the key (mouseup anywhere — // including outside the canvas — releases), so the affordance is not a lie for a mouse-only reader. board.addEventListener('mousedown', e => { const a = G.parkAnim; if (!G.campaign || !G.campaign.park || stageKey() !== 'demo' || !a || a.mode !== 'demo' || a.end) return; const r0 = board.getBoundingClientRect(); const px = (e.clientX - r0.left) / r0.width * board.width; const py = (e.clientY - r0.top) / r0.height * board.height; const fr = _parkFFRect(); if (px >= fr.x && px <= fr.x + fr.w && py >= fr.y && py <= fr.y + fr.h) { e.preventDefault(); parkDemoSetFF(true); } }); document.addEventListener('mouseup', () => parkDemoSetFF(false)); board.addEventListener('mousemove', e => { // PARK HUB hover affordance (audit fix: nothing signaled the tiles are clickable): track // the hovered tile + a pointer cursor; drawParkHub brightens its keyline (the ~12fps hub // pulse loop repaints). Hub-only; every other stage clears the state. Render-only (C1). const inHub = G.campaign && G.campaign.park && stageKey() === 'hub' && G.hub; let hover = null; if (inHub) { const r0 = board.getBoundingClientRect(); const px = (e.clientX - r0.left) / r0.width * board.width; const py = (e.clientY - r0.top) / r0.height * board.height; const i = pickerTileRects(_pickerTiles()) .findIndex(r => px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h); hover = i >= 0 ? i : null; } if (board.style) board.style.cursor = hover != null ? 'pointer' : ''; if (G.hub && G.hub.hover !== hover) G.hub.hover = hover; }); // _parkLiveP(run): the live P behind the board on screen. A HUB TASK cell keeps its game one level // deeper than a plain crossing cell, and a command aimed at the wrong one silently drives a // DIFFERENT board — which is why this is named once instead of re-spelt at each call site. function _parkLiveP(run) { return run.park.task ? run.park.task.game.P : run.park.game.P; } // y51 THE PULL — THE CLICK SEAM, AND WHY IT IS A FUNCTION RATHER THAN THREE LINES IN THE LISTENER. // // `parkEscapePull` is a HUMAN-ONLY verb: this handler is its one caller and the oracle never // reaches it. So the module admission bar, the crossing pairing bar and the mimic bar are all // STRUCTURALLY blind to this wiring — a branch aimed at the wrong P, or one that swallowed a click // it should have passed through to the move read, would leave every one of them green. That is not // hypothetical: on 2026-07-27 a companion SUMMON shipped with no release path, the companion's // homecoming fell 24/24 -> 0/24 on two seated cells, and every gate stayed green throughout. So the // DECISION lives here, as a function of (run, st, clicked cell) that node can drive with no browser, // and ESCAPE-CLICK-SEAM in engine.test.js is the only eyes on it. // // THE CONTRACT IS THREE-VALUED, and that is load-bearing: // null — NOT MINE. The caller MUST fall through to the adjacent-move read (a click on the walker // is 'stay', a click on a neighbour is that step, and neither is this verb). // false — MINE, and nothing changed: the engine refused (he is frozen, he is out of reach, or // there is no cell to put him on). The click is CONSUMED — a refused pull must never also // become a step INTO him — but there is nothing to repaint. // true — MINE, and the world moved. Repaint. // A boolean cannot say "consumed but inert", and collapsing the two would turn every refused pull // into a walk onto his cell: exactly the input collision the y29 summon branch exists to prevent. function _parkEscapeClick(run, st, px, py) { if (!st || !st.park || !st.park.escape) return null; // not an escape board: never ours const mate = st.pos && st.pos[1]; if (!mate || px !== mate.x || py !== mate.y) return null; // only HIS OWN cell is the button return !!E.parkEscapePull(_parkLiveP(run)); } // _parkGameClick(run, st, px, py): THE WHOLE IN-GAME CLICK ROUTE, in one runnable place. It returns // the KIND of thing the click turned out to be ('summon' | 'pull' | 'stay' | 'move' | 'inert') and // performs it; the listener below is a four-line dispatcher that computes the cell and calls this. // // WHY THE LISTENER NO LONGER OWNS THE ROUTE. A source-level gate can pin the TEXT of a branch — it // cannot see whether that branch is REACHABLE. Wrapping the shipped pull branch in `if (false)` // left the source byte-identical and the pull dead, and the gate stayed green; so would an early // `return` slipped in above it, or a NEW companion's-cell branch inserted before it (exactly the // relationship the pull already has to the move read). Every one of those is the 2026-07-27 summon // bug's shape: present in source, non-functional at runtime, every bar green. With the route lifted // here, ESCAPE-CLICK-SEAM cuts THIS function out of app.js and RUNS it, so reachability is an // executed assertion and shadowing is caught by the assertion that the pull still fires. What is // left textual is the single call above — a four-line surface, pinned by name and by position. // // The y29 summon branch below is MOVED, not rewritten: its condition, its draw() and its early // exit are byte-for-byte what they were in the listener. Its inline task-vs-capstone P expression // is NOT — it now calls `_parkLiveP` instead of re-spelling the ternary, which is what that // helper's own docstring already claimed was true (it was not, until this pass). function _parkGameClick(run, st, px, py) { // y29 SUMMON: a click on the companion's OWN cell is the call — routed before the // adjacent-move read so the two can never collide on his cell (walking onto him was // input noise anyway). No turn is spent; the engine command validates reach, stun and // the destination itself, and a refused call is simply inert. // NOT WHEN SHE IS ASLEEP (y46 v2). On that board walking onto her is no longer input noise — it is // the SHOVE (engine legalAdd/onEnter), the verb that replaced the call. Swallowing the click here // would consume the only click that means anything on her cell and answer it with an engine command // that now refuses on D.asleep: a dead branch eating a live input. So the guard lets the click fall // through to the move read below, which is where the push lives. (`dyn` may be absent on the // stub-shaped runs the click-seam gate drives — an unknown state is not asleep, so summon stands.) const _mateD = st.park.dyn && st.park.dyn.statue; if (st.park.statue && !(_mateD && _mateD.asleep) && px === st.pos[1].x && py === st.pos[1].y) { const PP = _parkLiveP(run); if (E.parkStatueSummon(PP)) draw(); return 'summon'; } // y51 PULL: the same routing law as the summon above, for the other companion verb — his own // cell is the button, read BEFORE the adjacent-move test so the two can never collide on it. // Three-valued (see _parkEscapeClick): null falls through to the move read, false is // consumed-and-inert, true repaints. No turn is spent; the engine command owns every refusal. const pulled = _parkEscapeClick(run, st, px, py); if (pulled !== null) { if (pulled) draw(); return 'pull'; } const ddx = px - st.pos[0].x, ddy = py - st.pos[0].y; if (ddx === 0 && ddy === 0) { parkGameInput('stay'); return 'stay'; } if (Math.abs(ddx) + Math.abs(ddy) === 1) { parkGameInput(ddy < 0 ? 'U' : ddy > 0 ? 'D' : ddx < 0 ? 'L' : 'R'); return 'move'; } return 'inert'; // a click on far ground: the old fall-through } board.addEventListener('click', e => { // P8.5 §5.1: a click on the attract-loop first screen starts the run (same route as ▶). if (!G.campaign) { start(); return; } // PARK HUB / REPORT clicks (parkMode only): a hub tile click selects+runs it; a report // click returns to the hub. Same routes as the keys above; legacy path untouched. if (G.campaign && G.campaign.park) { const psk = stageKey(); if (psk === 'tutorial') { // skip chip click = skip the whole vignette; a completed beat advances on ANY click; // otherwise an adjacent-cell click is that directional move (own cell = stay). const tut = G.parkTut; if (!tut) return; const r0 = board.getBoundingClientRect(); const px = (e.clientX - r0.left) / r0.width * board.width; const py = (e.clientY - r0.top) / r0.height * board.height; const sr = _tutSkipRect(); if (px >= sr.x && px <= sr.x + sr.w && py >= sr.y && py <= sr.y + sr.h) return finishParkTutorial(); if (tut.beat === 4) { parkTutorialInput(' '); return; } const st = tut.P.st; const cxx = (px / board.width * st.N) | 0, cyy = (py / board.height * st.N) | 0; const ddx = cxx - st.pos[0].x, ddy = cyy - st.pos[0].y; if (ddx === 0 && ddy === 0) parkTutorialInput(' '); else if (Math.abs(ddx) + Math.abs(ddy) === 1) parkTutorialInput(ddy < 0 ? 'ArrowUp' : ddy > 0 ? 'ArrowDown' : ddx < 0 ? 'ArrowLeft' : 'ArrowRight'); return; } if (psk === 'hub') { if (!G.hub) return; const r0 = board.getBoundingClientRect(); const px = (e.clientX - r0.left) / r0.width * board.width; const py = (e.clientY - r0.top) / r0.height * board.height; const rr = _hubReplayRect(); // the practice-yard replay chip (P3a §1) if (px >= rr.x && px <= rr.x + rr.w && py >= rr.y && py <= rr.y + rr.h) return startParkTutorial(); const tiles = _pickerTiles(); const rects = pickerTileRects(tiles); const i = rects.findIndex(r => px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h); if (i >= 0 && tiles[i]) { G.hub.sel = i; parkHubSelect(tiles[i].id); } return; } // RANDOM TRANSFER watch phase (spec §B.2): the corner chip is the hub door — reachable // without finishing the episode. Every other watch click stays inert (demo is passive). if (psk === 'demo') { const a = G.parkAnim; if (!a || a.mode !== 'demo') return; const tt = G.campaign.park.task; const r0 = board.getBoundingClientRect(); const px = (e.clientX - r0.left) / r0.width * board.width; const py = (e.clientY - r0.top) / r0.height * board.height; // the hub door keeps priority over everything else on this surface (unchanged route). if (tt && tt.transfer) { const hc = _hubChipRect(); if (px >= hc.x && px <= hc.x + hc.w && py >= hc.y && py <= hc.y + hc.h) return parkHubEnter(); } // TASK 11: on the S2 static end-frame a click is the CONFIRM (the pulsing d-pad chip is the // affordance, but any click takes it — a mouse reader should not have to hunt a 44px target). // While the demo is still WALKING every other click stays inert exactly as before (a stray // click must never abort the watch stage — the ▶▶ chip is press-and-HOLD, handled in mousedown). if (a.end) return parkDemoConfirm(); return; } // P8.6 §B.2: a click on the interstitial skips its dwell (same as Enter). if (psk === 'interstitial') { parkSessionNext(); return; } // P8.6 §B.3 scorecard: only the corner chip acts (-> the 연습·열람 hub); other clicks // stay inert so a stray click never discards the one aggregate readout. if (psk === 'scorecard') { const r0 = board.getBoundingClientRect(); const px = (e.clientX - r0.left) / r0.width * board.width; const py = (e.clientY - r0.top) / r0.height * board.height; const hc = _hubChipRect(); if (px >= hc.x && px <= hc.x + hc.w && py >= hc.y && py <= hc.y + hc.h) parkHubEnter(); return; } if (psk === 'report') { // TRANSFER readout: the corner chip returns to the deliberate hub; any other click // chains to the NEXT random episode (spec §B.1). Hub-launched tasks keep back-to-hub. // The ? legend holds the screen still while it is open, exactly as the keys do. if (helpPanelOpen()) return; const tt = G.campaign.park.task; if (tt && tt.transfer && !tt.crossing) { const r0 = board.getBoundingClientRect(); const px = (e.clientX - r0.left) / r0.width * board.width; const py = (e.clientY - r0.top) / r0.height * board.height; const hc = _hubChipRect(); if (px >= hc.x && px <= hc.x + hc.w && py >= hc.y && py <= hc.y + hc.h) return parkHubEnter(); return startParkTransfer(); } parkHubEnter(); // a crossing readout (P10 §4) returns to the picker on any click return; } } if (stageKey() !== 'play') return; // demo passive; report frozen const run = G.campaign; // PARK GAME: an adjacent click on the agent's neighbour = that directional move (routes to the // park resolver); clicking the agent's own cell = stay. Park-mode only (its own geometry — // the capstone N=20 park or the active minigame's smaller board). if (run.park) { if (G.parkAnim && G.parkAnim.mode === 'game') { const st = _parkLiveP(run).st; const r0 = board.getBoundingClientRect(); const px = ((e.clientX - r0.left) / r0.width * st.N) | 0; const py = ((e.clientY - r0.top) / r0.height * st.N) | 0; _parkGameClick(run, st, px, py); // the whole routing decision, lifted so it can be RUN } return; } const N = run.board.N; const r = board.getBoundingClientRect(); const cx = ((e.clientX - r.left) / r.width * N) | 0; const cy = ((e.clientY - r.top) / r.height * N) | 0; const from = run.board.pos[run.turnSeat]; const dx = cx - from.x, dy = cy - from.y; if (dx === 0 && dy === 0) { playMove({ x: 0, y: 0 }); return; } // click own cell = harmless stay/wait if (Math.abs(dx) + Math.abs(dy) !== 1) return; playMove({ x: dx, y: dy }); // adjacent click moves the active agent }); setHint('▶ 를 눌러 시작하세요. (시연 → play 라운드로빈 유지 → 사이클 반복 → 리포트. ' + '새 동료가 누적되고, 사이클별 준수 점수의 평균(추구)이 헤드라인입니다 — 도달(클리어 사이클 수)은 보조 지표.)'); draw(); // DEBUG SURFACE (headless verification only): expose the thin view-model so the // playwright render check can read the live board N / stage / which render path // fired. Read-only handle; touches no game state and leaks no rule. Harmless in a // browser (an unused global); the headless live-render gate is its live caller. if (typeof window !== 'undefined') window.__AA__ = G; // Test/capture-only render hooks (read-only handles to the render + continuous-demo tick): the // deterministic legibility-baseline capture (_val_capture2.js) pins the demo to one step, redraws // once, and resumes the chain. Exposing these leaks no rule (they only render existing demoAnim // state) and changes no game state; production play never calls them via window. if (typeof window !== 'undefined') { window.draw = draw; window.continuousReplayTick = continuousReplayTick; // continuity-capture-only hooks: playMove drives the REAL play->cycle-handoff path (so the real // captureXfade(true) join morph + scheduled startDemoAnim fire), startDemoAnim rebuilds the next // cycle's continuous demo, finishDemo hands the demo off. Read/drive existing code paths only — // they leak no rule and production play never invokes them via window. */ window.playMove = playMove; window.startDemoAnim = startDemoAnim; window.finishDemo = finishDemo; // park capture/test hooks (same discipline: read/drive existing display paths only, no rule leak): // startParkDemo builds the demo replay, parkDemoFrame advances ONE exact live frame (the capture // harness pins _frozen and steps it). The GAME-DRIVING hooks (finishParkDemo / parkGameInput / // parkGameOver) are CAPTURE-GATED (spec P2 §E): the capture harness / zero-text gate set // window.__PARK_CAPTURE__ BEFORE app.js loads; a production browser console gets no game levers. window.startParkDemo = startParkDemo; window.parkDemoFrame = parkDemoFrame; if (window.__PARK_CAPTURE__) { window.finishParkDemo = finishParkDemo; window.parkGameInput = parkGameInput; window.parkGameOver = parkGameOver; // P2b hub/task drivers (same capture-only discipline: drive existing view paths, no leak) window.parkHubEnter = parkHubEnter; window.parkHubSelect = parkHubSelect; // 미니 시연 창을 손으로 미는 훅 (얼린 시계에서 중반 프레임을 찍을 때). 캡처 게이트 안이라 // 프로덕션 콘솔에는 안 뜬다. 표시 상태만 만지고 채점 경로는 안 탄다. window._parkReplayAdvance = _parkReplayAdvance; window.finishParkTaskDemo = finishParkTaskDemo; window.parkTaskOver = parkTaskOver; // P4 random-transfer driver (spec 2026-07-05 §B.1; same capture-only discipline) window.startParkTransfer = startParkTransfer; // P8.6 session drivers (§B — same capture-only discipline: drive existing view paths) window.parkSessionBegin = parkSessionBegin; window.parkSessionNext = parkSessionNext; window.parkTutorialWatchTick = parkTutorialWatchTick; window._tutWatchDone = _tutWatchDone; // full-cycle vignette act sequencer (design 2026-07-10; the TUTORIAL-FULLCYCLE gate // drives per-act skips through it — same view-level-only discipline) window._tutActNext = _tutActNext; // P3a practice-yard tutorial drivers (view-level only; the TUTORIAL-* gates walk these) window.startParkTutorial = startParkTutorial; window.parkTutorialInput = parkTutorialInput; window.parkTutorialTick = parkTutorialTick; window.parkTutorialRegen = parkTutorialRegen; window.finishParkTutorial = finishParkTutorial; // EVERY-VISIT boot route + the skip/replay chips' hit rects (2026-07-10): the // TUTORIAL-EVERY-VISIT gate drives the exact boot decision and clicks the chips' // real hit targets (same view-level-only discipline — the route/rects leak no rule). window.parkBootRoute = parkBootRoute; window._tutSkipRect = _tutSkipRect; window._hubReplayRect = _hubReplayRect; // TASK 11 demo-skip drivers (same capture-only discipline: they drive the EXISTING demo // frame chain — S1 only rescales the dwell, S2 only exhausts parkDemoFrame and parks the // result as a static read; neither touches campaign/engine state, and the handoff still // goes through finishParkDemo/finishParkTaskDemo). window.parkDemoSetFF = parkDemoSetFF; window.parkDemoToEnd = parkDemoToEnd; window.parkDemoConfirm = parkDemoConfirm; // P4 §C highlight-layer probes (read-only scope predicate + the mechanics-only label // table + the card driver) — the ANNOT-DEMO-ONLY gate and the capture harness walk these. window.annotLayerActive = annotLayerActive; window.PARK_ANNOT_LABELS = PARK_ANNOT_LABELS; window.parkTutCardNext = parkTutCardNext; // 부팅 게이지 (2026-08-05): 캡처 하네스가 바가 걸린 프레임을 찍을 수 있게. window._bootGaugeTotal = _bootGaugeTotal; window.bootGaugeShow = bootGaugeShow; window.bootGaugeTick = bootGaugeTick; window.bootGaugeHide = bootGaugeHide; } } // TEXT-FREE GOAL BEACON pulse driver: a single rAF loop that re-renders the board at // ~12fps ONLY during the live play stage, so the on-board goal-target beacons (socket / // unreached destinations) pulse and visually bind to the top goal flag. Idle (no // redraw) outside play, so it never fights the demo/report timers. Pure render — it // reads state and repaints; it changes no game state and leaks no rule (C1). let _lastPulse = 0; function pulseLoop() { // live play: beacon pulse (unchanged). demo: ALSO redraw, but ONLY on a decisive value-demo frame // (scenePair || foregone) so the agent ring + foregone ghost pulse through the dwell; routine // fast-forward frames stay static (no redraw) so pulsing never fights the micro-dwell timer. const sk = stageKey(); const demoDecisive = (sk === 'demo' && G.demoAnim && G.demoAnim.valueDemo && (G.demoAnim.scenePair || G.demoAnim.foregone)); // PARK: keep the deep-field slow pulse + deliberate-pause ring + beacons breathing during the // park demo/game (gated on G.parkAnim → park mode only, so the legacy pulse cadence is untouched). // The HUB rides the same loop: the selection keyline pulses AND the ~12fps redraw progressively // builds the budgeted-one-per-frame tile thumbnails. const parkLive = !!(G.campaign && G.campaign.park && ((G.parkAnim && (sk === 'demo' || sk === 'play')) || sk === 'hub' || sk === 'tutorial')); // tutorial chevrons/pips breathe on the same loop // P8.5 §5.1: the pre-run ATTRACT LOOP animates on the same ~12fps cadence. const attract = !G.campaign && sk === 'idle'; if (sk === 'play' || demoDecisive || parkLive || attract) { const now = Date.now(); if (now - _lastPulse > 80) { _lastPulse = now; draw(); } } requestAnimationFrame(pulseLoop); } requestAnimationFrame(pulseLoop);