clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

Separate turn lifecycle and developer view from app bootstrap

Dami committed 1791208
commit
17912082fe4a9ac69b81dc7ad9e1bea71715c619
parent
db5a1fe0408d7e61f6e21c74b48ee1c8860a83d8
author
Dami <dami.etoile@gmail.com>
4 files changed, +365 -50
Asrc/app/dev-view.js151++++++++++++++++++++++++++++++
Asrc/app/turns.js110++++++++++++++++++++++
Msrc/main.js91++++++++----------
Atests/turns.test.js63+++++++++++++
A src/app/dev-view.js +151 -0
@@ -0,0 +1,151 @@
1+import { drawBoard } from '../render/board.js';2+3+const DEFAULT_COLORS = Object.freeze({4+ YOU: '#f06d5f',5+ PUSH: '#efb34a',6+ STOP: '#625e88',7+ WIN: '#2d9c88',8+ SINK: '#6f9ca8',9+ DEFEAT: '#b45273',10+});11+12+function drawRounded(ctx, x, y, width, height, radius, fill, stroke) {13+ ctx.beginPath();14+ ctx.roundRect(x, y, width, height, radius);15+ ctx.fillStyle = fill;16+ ctx.fill();17+ if (stroke) {18+ ctx.strokeStyle = stroke;19+ ctx.lineWidth = 1;20+ ctx.stroke();21+ }22+}23+24+/**25+ * Development canvas view for the DOM shell.26+ *27+ * The fullscreen player is rendered by Stage. This view exists for `?dev`,28+ * where the app shell exposes the state inspector and level controls.29+ */30+export function createDevView({31+ canvas,32+ ctx = canvas?.getContext?.('2d'),33+ getGame,34+ getPalette = () => ({}),35+ glyphs = {},36+ colors = DEFAULT_COLORS,37+ isPlayer = () => document.body.classList.contains('stage-player'),38+} = {}) {39+ function render(state = getGame()?.getState()) {40+ if (!state || !ctx || !canvas) return;41+ const width = canvas.width;42+ const height = canvas.height;43+ const pad = 28;44+ const cell = Math.min(45+ (width - pad * 2) / state.width,46+ (height - pad * 2) / state.height,47+ );48+ const originX = (width - cell * state.width) / 2;49+ const originY = (height - cell * state.height) / 2;50+ const game = getGame();51+ const palette = getPalette();52+53+ if (isPlayer()) {54+ ctx.fillStyle = '#0F0E0D';55+ ctx.fillRect(0, 0, width, height);56+ drawBoard(ctx, state, { x: originX, y: originY, cell, game, palette, glyphs });57+ return;58+ }59+60+ ctx.clearRect(0, 0, width, height);61+ ctx.fillStyle = '#fbf7ef';62+ ctx.fillRect(0, 0, width, height);63+ ctx.strokeStyle = 'rgba(102,91,72,.12)';64+ ctx.lineWidth = 1;65+ for (let x = 0; x <= state.width; x++) {66+ ctx.beginPath();67+ ctx.moveTo(originX + x * cell, originY);68+ ctx.lineTo(originX + x * cell, originY + state.height * cell);69+ ctx.stroke();70+ }71+ for (let y = 0; y <= state.height; y++) {72+ ctx.beginPath();73+ ctx.moveTo(originX, originY + y * cell);74+ ctx.lineTo(originX + state.width * cell, originY + y * cell);75+ ctx.stroke();76+ }77+78+ const stacks = new Map();79+ for (const object of state.objects) {80+ const key = `${object.x},${object.y}`;81+ if (!stacks.has(key)) stacks.set(key, []);82+ stacks.get(key).push(object);83+ }84+ const objects = [...state.objects].sort((a, b) =>85+ (a.kind === 'word' ? 1 : 0) - (b.kind === 'word' ? 1 : 0),86+ );87+ for (const object of objects) {88+ const stack = stacks.get(`${object.x},${object.y}`);89+ const stackIndex = stack.indexOf(object);90+ const stackOffset = stack.length > 191+ ? (stackIndex - (stack.length - 1) / 2) * cell * 0.1192+ : 0;93+ const x = originX + object.x * cell + stackOffset;94+ const y = originY + object.y * cell - stackOffset * 0.35;95+ const isWord = object.kind === 'word';96+ const name = object.noun || object.text;97+ const properties = game.properties(object);98+ const accent = [...properties].map(property => colors[property]).find(Boolean) || '#766f64';99+100+ if (isWord) {101+ drawRounded(ctx, x + cell * 0.08, y + cell * 0.18, cell * 0.84, cell * 0.62, cell * 0.11, '#fffdf8', accent);102+ ctx.fillStyle = accent;103+ ctx.font = `700 ${Math.max(14, cell * 0.2)}px ui-monospace, monospace`;104+ ctx.textAlign = 'center';105+ ctx.textBaseline = 'middle';106+ ctx.fillText(object.text, x + cell / 2, y + cell * 0.49);107+ ctx.fillStyle = 'rgba(80,70,55,.25)';108+ ctx.font = `600 ${Math.max(8, cell * 0.09)}px ui-monospace, monospace`;109+ ctx.fillText('WORD', x + cell / 2, y + cell * 0.84);110+ continue;111+ }112+113+ const emoji = palette[name] || glyphs[name];114+ ctx.shadowColor = properties.includes('YOU') ? 'rgba(240,109,95,.38)' : 'transparent';115+ ctx.shadowBlur = properties.includes('YOU') ? 10 : 0;116+ ctx.font = `${Math.max(24, cell * 0.52)}px "Apple Color Emoji","Segoe UI Emoji",sans-serif`;117+ ctx.textAlign = 'center';118+ ctx.textBaseline = 'middle';119+ if (emoji) ctx.fillText(typeof emoji === 'object' ? emoji.glyph : emoji, x + cell / 2, y + cell * 0.52);120+ else {121+ ctx.fillStyle = accent;122+ ctx.font = `700 ${Math.max(13, cell * 0.18)}px 'DM Mono',monospace`;123+ ctx.fillText(name, x + cell / 2, y + cell * 0.52);124+ }125+ ctx.shadowBlur = 0;126+ ctx.fillStyle = accent;127+ ctx.font = `700 ${Math.max(8, cell * 0.1)}px ui-monospace,monospace`;128+ ctx.fillText(name, x + cell / 2, y + cell * 0.87);129+ if (properties.length) {130+ ctx.strokeStyle = accent;131+ ctx.lineWidth = 2;132+ ctx.beginPath();133+ ctx.arc(x + cell / 2, y + cell / 2, cell * 0.36, 0, Math.PI * 2);134+ ctx.stroke();135+ }136+ if (stack.length > 1) {137+ ctx.fillStyle = '#fffaf2';138+ ctx.beginPath();139+ ctx.arc(x + cell * 0.82, y + cell * 0.2, cell * 0.12, 0, Math.PI * 2);140+ ctx.fill();141+ ctx.fillStyle = accent;142+ ctx.font = `700 ${Math.max(9, cell * 0.1)}px 'DM Mono',monospace`;143+ ctx.fillText(String(stack.length), x + cell * 0.82, y + cell * 0.2);144+ }145+ }146+ }147+148+ return Object.freeze({ render });149+}150+151+export default createDevView;
A src/app/turns.js +110 -0
@@ -0,0 +1,110 @@
1+/**2+ * Coordinates a settled engine action with presentation and shell effects.3+ *4+ * The engine remains the source of truth. This adapter owns the ordering that5+ * must stay consistent for movement, waits, undo, and restart: reconcile old6+ * tweens, invoke the action, consume the event, then refresh the shell.7+ */8+export function createTurnActions(options = {}) {9+ const {10+ getGame,11+ getStage,12+ getJuice,13+ getNow = () => performance.now(),14+ accepted = result => typeof result === 'object' && result !== null ? result.accepted : result,15+ setFeedback = () => {},16+ clearFeedback = () => setFeedback(''),17+ updateUI = () => {},18+ render = () => {},19+ paintBoardFrame = () => {},20+ animateBoard = () => {},21+ playSound = () => {},22+ playTurnSounds = () => {},23+ announceTurn = () => {},24+ onWin = () => {},25+ onRestart = () => {},26+ onUndo = () => {},27+ } = options;28+29+ function execute(action) {30+ const game = getGame();31+ if (!game || game.getState().won) return null;32+ const now = getNow();33+ const juice = getJuice();34+ juice.syncState(game.getState(), now);35+ juice.beginInput(now);36+ const result = action(game);37+ juice.consume(result, now, game.getState());38+ if (accepted(result)) {39+ clearFeedback();40+ playTurnSounds(result);41+ announceTurn(result);42+ if (game.getState().won) onWin(now, result);43+ updateUI();44+ render();45+ paintBoardFrame(now);46+ animateBoard();47+ } else if (result?.blocked) {48+ playSound(result.blocked.reason === 'bounds' ? 'blockedBounds' : 'blockedStop');49+ setFeedback('BLOCKED / try another sentence');50+ paintBoardFrame(now);51+ animateBoard(180);52+ }53+ return result;54+ }55+56+ function move(dx, dy) {57+ return execute(game => game.step(dx, dy));58+ }59+60+ function wait() {61+ return typeof getGame()?.wait === 'function' ? execute(game => game.wait()) : null;62+ }63+64+ function restart() {65+ const game = getGame();66+ if (!game) return null;67+ const now = getNow();68+ const juice = getJuice();69+ juice.syncState(game.getState(), now);70+ const result = game.reset();71+ juice.consume(result, now, game.getState());72+ juice.syncState(game.getState(), now);73+ clearFeedback();74+ playSound('restart');75+ announceTurn(result);76+ updateUI();77+ render();78+ getStage()?.show('proof');79+ onRestart(now, result);80+ paintBoardFrame(now);81+ animateBoard(300);82+ return result;83+ }84+85+ function undo() {86+ const game = getGame();87+ if (!game) return null;88+ const now = getNow();89+ const juice = getJuice();90+ juice.syncState(game.getState(), now);91+ const result = game.undo();92+ juice.consume(result, now, game.getState());93+ if (accepted(result)) {94+ onUndo();95+ clearFeedback();96+ playSound('undo');97+ announceTurn(result);98+ updateUI();99+ render();100+ getStage()?.show('proof');101+ paintBoardFrame(now);102+ animateBoard(300);103+ } else playSound('undoEmpty');104+ return result;105+ }106+107+ return Object.freeze({ execute, move, wait, restart, undo });108+}109+110+export default createTurnActions;
M src/main.js +41 -50
@@ -5,7 +5,6 @@ import gardenBasicsSource from '../mods/garden-basics.cjs?raw';
55 import wordLabSource from '../mods/word-lab.cjs?raw';66 import { prepare, layout } from '@chenglou/pretext';77 import { createStage } from './stage.js';8-import { drawBoard } from './render/board.js';98 import { createInput } from './input.js';109 import { createPackService } from './pack-service.js';1110 import { createPackLibrary } from './pack-library.js';
@@ -15,12 +14,13 @@ import { createIndexLayout, decorateIndex, createProgressStore, createIndexNavig
1514 import { createGiftScene } from './scenes/gift.js';1615 import { createWorkshopScene } from './scenes/workshop.js';1716 import { createTitleScene } from './scenes/title.js';17+import { createTurnActions } from './app/turns.js';18+import { createDevView } from './app/dev-view.js';1819 import './style.css';1920 import './overrides.css';2021 import './font.css';2122 2223 const canvas = document.querySelector('#game-canvas');23-const ctx = canvas.getContext('2d');2424 const levelTitle = document.querySelector('#level-title');2525 const levelIndex = document.querySelector('#level-index');2626 const levelList = document.querySelector('#level-list');
@@ -89,7 +89,6 @@ function refreshIndex(focusId) {
8989 }9090 function activePalette() { return currentLevels()[levelNo]?.palette || {}; }9191 function activePackId() { return currentLevels()[levelNo]?.packId || 'core'; }92-function packData(packId = activePackId()) { return catalog.getPack(packId); }9392 function showModStatus(message, detail = '') { modStatus.textContent = message; modStatus.title = detail || message; }9493 function registerBundledPacks() { for (const [filename, source] of [['garden-basics.cjs', gardenBasicsSource], ['word-lab.cjs', wordLabSource]]) { try { loadAndRegister(source, filename); } catch (error) { showModStatus(`Could not load ${filename}`, error.message); } } }9594 function loadAndRegister(source, filename) { const mod = loadMod(source, { filename }); return catalog.register(mod, { origin: 'bundled' }); }
@@ -144,20 +143,11 @@ function updateUI() {
144143 statusDot.classList.toggle('won', lastState.won); statusDot.classList.toggle('blocked', Boolean(feedback)); winToast.hidden = !lastState.won;145144 boardDescription.textContent = `${lastState.title}. ${lastState.width} columns by ${lastState.height} rows. ${lastState.moves} moves. ${lastState.won ? 'Won.' : 'Playing.'} Active rules: ${lastState.rules.map(rule => rule.text).join('; ') || 'none'}. Entities: ${lastState.objects.map(object => `${object.kind === 'word' ? `word ${object.text}` : object.noun} at column ${object.x + 1}, row ${object.y + 1}`).join('; ')}.`;146145 }147-function drawRounded(x, y, w, h, r, fill, stroke) { ctx.beginPath(); ctx.roundRect(x, y, w, h, r); ctx.fillStyle = fill; ctx.fill(); if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 1; ctx.stroke(); } }146+const devView = createDevView({ canvas, getGame: () => game, getPalette: activePalette, glyphs, colors });148147 function render() {149- if (!game) return; lastState = game.getState(); const W = canvas.width, H = canvas.height; const pad = 28; const cell = Math.min((W - pad * 2) / lastState.width, (H - pad * 2) / lastState.height); const ox = (W - cell * lastState.width) / 2, oy = (H - cell * lastState.height) / 2;150- if (document.body.classList.contains('stage-player')) { ctx.fillStyle = '#0F0E0D'; ctx.fillRect(0, 0, W, H); drawBoard(ctx, lastState, { x: ox, y: oy, cell, game, palette: activePalette(), glyphs }); return; }151- ctx.clearRect(0,0,W,H); ctx.fillStyle='#fbf7ef'; ctx.fillRect(0,0,W,H);152- ctx.strokeStyle='rgba(102,91,72,.12)'; ctx.lineWidth=1;153- for(let x=0;x<=lastState.width;x++){ctx.beginPath();ctx.moveTo(ox+x*cell,oy);ctx.lineTo(ox+x*cell,oy+lastState.height*cell);ctx.stroke()}154- for(let y=0;y<=lastState.height;y++){ctx.beginPath();ctx.moveTo(ox,oy+y*cell);ctx.lineTo(ox+lastState.width*cell,oy+y*cell);ctx.stroke()}155- const stacks = new Map(); lastState.objects.forEach(object => { const cellKey = `${object.x},${object.y}`; if (!stacks.has(cellKey)) stacks.set(cellKey, []); stacks.get(cellKey).push(object); });156- const sorted=[...lastState.objects].sort((a,b)=>(a.kind==='word'?1:0)-(b.kind==='word'?1:0));157- sorted.forEach(object => { const stack=stacks.get(`${object.x},${object.y}`); const stackIndex=stack.indexOf(object); const stackOffset=(stack.length>1 ? (stackIndex-(stack.length-1)/2)*cell*.11 : 0); const x=ox+object.x*cell+stackOffset, y=oy+object.y*cell-stackOffset*.35; const isWord=object.kind==='word'; const name=object.noun || object.text; const props=game.properties(object); const accent=([...props].map(p=>colors[p]).find(Boolean)||'#766f64');158- if(isWord){ drawRounded(x+cell*.08,y+cell*.18,cell*.84,cell*.62,cell*.11,'#fffdf8',accent); ctx.fillStyle=accent; ctx.font=`700 ${Math.max(14,cell*.2)}px ui-monospace, monospace`; ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText(object.text,x+cell/2,y+cell*.49); ctx.fillStyle='rgba(80,70,55,.25)'; ctx.font=`600 ${Math.max(8,cell*.09)}px ui-monospace, monospace`; ctx.fillText('WORD',x+cell/2,y+cell*.84);159- } else { const emoji=activePalette()[name] || glyphs[name]; ctx.shadowColor=props.includes('YOU') ? 'rgba(240,109,95,.38)' : 'transparent'; ctx.shadowBlur=props.includes('YOU') ? 10 : 0; ctx.font=`${Math.max(24,cell*.52)}px "Apple Color Emoji","Segoe UI Emoji",sans-serif`; ctx.textAlign='center'; ctx.textBaseline='middle'; if (emoji) ctx.fillText(emoji,x+cell/2,y+cell*.52); else { ctx.fillStyle=accent; ctx.font=`700 ${Math.max(13,cell*.18)}px 'DM Mono',monospace`; ctx.fillText(name,x+cell/2,y+cell*.52); } ctx.shadowBlur=0; ctx.fillStyle=accent; ctx.font=`700 ${Math.max(8,cell*.1)}px ui-monospace,monospace`; ctx.fillText(name,x+cell/2,y+cell*.87); if(props.length){ctx.strokeStyle=accent;ctx.lineWidth=2;ctx.beginPath();ctx.arc(x+cell/2,y+cell/2,cell*.36,0,Math.PI*2);ctx.stroke()} if(stack.length>1){ctx.fillStyle='#fffaf2';ctx.beginPath();ctx.arc(x+cell*.82,y+cell*.2,cell*.12,0,Math.PI*2);ctx.fill();ctx.fillStyle=accent;ctx.font=`700 ${Math.max(9,cell*.1)}px 'DM Mono',monospace`;ctx.fillText(String(stack.length),x+cell*.82,y+cell*.2)}}160- });148+ if (!game) return;149+ lastState = game.getState();150+ devView.render(lastState);161151 }162152 function playSound(name) { if (!soundOn) return; audio.play(name === 'rule' ? 'ruleFormed' : name); }163153 function playTurnSounds(result) {
@@ -212,41 +202,42 @@ function scheduleInitialWin() {
212202 }213203 function paintBoardFrame(now = performance.now()) { stage.setBoard(displayState(),game,activePalette(),glyphs,juice.frame(now)); }214204 function animateBoard(duration = 650) { animationDeadline = Math.max(animationDeadline,performance.now()+duration); if (animationRequest) return; const tick = now => { animationRequest=0; if (stage.scene === 'proof' || stage.scene === 'win') paintBoardFrame(now); if (now < animationDeadline) animationRequest=requestAnimationFrame(tick); }; animationRequest=requestAnimationFrame(tick); }215-function performTurn(action) {216- if (!game || game.getState().won) return;217- const now = performance.now();218- juice.syncState(game.getState(), now);219- juice.beginInput(now);220- const result = action();221- juice.consume(result, now, game.getState());222- if (accepted(result)) {223- feedback = '';224- playTurnSounds(result);225- announceTurn(result);226- if (game.getState().won) {227- const level = currentLevels()[levelNo];228- if (level?.packId && level?.localId) {229- progress.markSolved(level.packId, level.localId);230- refreshIndex(level.id);231- }232- playSound('win');233- clearTimeout(winTimer);234- const cardAt = juice.frame(now).win?.cardAt ?? now;235- winTimer = setTimeout(() => {236- if (game?.getState().won) stage.show('win', { steps: game.getState().moves, nodeId: level?.id });237- }, Math.max(0, cardAt - now));205+const turns = createTurnActions({206+ getGame: () => game,207+ getStage: () => stage,208+ getJuice: () => juice,209+ setFeedback: showFeedback,210+ clearFeedback: () => { feedback = ''; },211+ updateUI,212+ render,213+ paintBoardFrame,214+ animateBoard,215+ playSound,216+ playTurnSounds,217+ announceTurn,218+ onWin: now => {219+ const level = currentLevels()[levelNo];220+ if (level?.packId && level?.localId) {221+ progress.markSolved(level.packId, level.localId);222+ refreshIndex(level.id);238223 }239- updateUI(); render(); paintBoardFrame(now); animateBoard();240- } else if (result.blocked) {241- playSound(result.blocked.reason === 'bounds' ? 'blockedBounds' : 'blockedStop');242- showFeedback('BLOCKED / try another sentence');243- paintBoardFrame(now); animateBoard(180);244- }245-}246-function move(dx, dy) { performTurn(() => game.step(dx, dy)); }247-function waitTurn() { if (typeof game?.wait === 'function') performTurn(() => game.wait()); }248-function restart(){if(!game)return;clearTimeout(winTimer);const now=performance.now();juice.syncState(game.getState(),now);const result=game.reset();juice.consume(result,now,game.getState());juice.syncState(game.getState(),now);feedback='';playSound('restart');announceTurn(result);updateUI();render();stage.show('proof');paintBoardFrame(now);scheduleInitialWin();animateBoard(300);}249-function undo(){if(!game)return;const now=performance.now();juice.syncState(game.getState(),now);const result=game.undo();if(accepted(result)){clearTimeout(winTimer);juice.consume(result,now,game.getState());feedback='';playSound('undo');announceTurn(result);updateUI();render();stage.show('proof');paintBoardFrame(now);animateBoard(300);}else playSound('undoEmpty');}224+ playSound('win');225+ clearTimeout(winTimer);226+ const cardAt = juice.frame(now).win?.cardAt ?? now;227+ winTimer = setTimeout(() => {228+ if (game?.getState().won) stage.show('win', { steps: game.getState().moves, nodeId: level?.id });229+ }, Math.max(0, cardAt - now));230+ },231+ onRestart: () => {232+ clearTimeout(winTimer);233+ scheduleInitialWin();234+ },235+ onUndo: () => clearTimeout(winTimer),236+});237+function move(dx, dy) { turns.move(dx, dy); }238+function waitTurn() { turns.wait(); }239+function restart() { turns.restart(); }240+function undo() { turns.undo(); }250241 function handleIntent(intent) { if (intent.type === 'summary') { announceBoardSummary(); return; } if (stage.scene === 'title') { if(intent.type==='menu'){stage.show('pause');return;} titleController.input(intent.type==='confirm'?'enter':intent.type==='undo'?'z':intent.type==='restart'?'r':intent.dir); stage.render(); return; } if (stage.scene === 'proof' || stage.scene === 'win') { if (intent.type === 'move' && stage.scene === 'proof') ({up: () => move(0,-1), down: () => move(0,1), left: () => move(-1,0), right: () => move(1,0)}[intent.dir])?.(); if (intent.type === 'wait' && stage.scene === 'proof') waitTurn(); if (intent.type === 'undo') undo(); if (intent.type === 'restart') restart(); if (intent.type === 'menu' && stage.scene === 'proof') stage.show('pause'); if (intent.type === 'mute') soundButton.click(); if (intent.type === 'confirm' && stage.scene === 'win') stage.show('index'); return; } if(intent.source !== 'keyboard' && !(intent.source === 'touch' && intent.point)) stage.handleIntent?.(intent); }251242 document.querySelectorAll('[data-dir]').forEach(button=>button.addEventListener('click',()=>({up:move.bind(null,0,-1),down:move.bind(null,0,1),left:move.bind(null,-1,0),right:move.bind(null,1,0)}[button.dataset.dir])()));252243 document.querySelector('#prev-level').addEventListener('click',()=>navigateWithinPack(-1)); document.querySelector('#next-level').addEventListener('click',()=>navigateWithinPack(1));
A tests/turns.test.js +63 -0
@@ -0,0 +1,63 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { createTurnActions } from '../src/app/turns.js';4+5+function harness(result = { accepted: true }) {6+ const events = [];7+ let state = { won: false };8+ const game = {9+ getState: () => state,10+ step: () => { events.push('step'); return result; },11+ wait: () => { events.push('wait'); return result; },12+ reset: () => { events.push('reset'); return { accepted: true, kind: 'reset' }; },13+ undo: () => { events.push('undo'); return result; },14+ };15+ const juice = {16+ syncState: () => events.push('sync'),17+ beginInput: () => events.push('begin'),18+ consume: () => events.push('consume'),19+ frame: () => ({ win: null }),20+ };21+ const actions = createTurnActions({22+ getGame: () => game,23+ getStage: () => null,24+ getJuice: () => juice,25+ getNow: () => 10,26+ setFeedback: message => events.push(`feedback:${message}`),27+ clearFeedback: () => events.push('clear'),28+ updateUI: () => events.push('ui'),29+ render: () => events.push('render'),30+ paintBoardFrame: () => events.push('paint'),31+ animateBoard: duration => events.push(`animate:${duration || 650}`),32+ playSound: sound => events.push(`sound:${sound}`),33+ playTurnSounds: () => events.push('turn-sound'),34+ announceTurn: () => events.push('announce'),35+ onRestart: () => events.push('restart-complete'),36+ onUndo: () => events.push('undo-complete'),37+ });38+ return { actions, events };39+}40+41+test('turn actions keep engine, presentation, and shell lifecycle ordered', () => {42+ const { actions, events } = harness();43+ actions.move(1, 0);44+ assert.deepEqual(events, ['sync', 'begin', 'step', 'consume', 'clear', 'turn-sound', 'announce', 'ui', 'render', 'paint', 'animate:650']);45+});46+47+test('blocked actions report feedback without running accepted turn effects', () => {48+ const { actions, events } = harness({ accepted: false, blocked: { reason: 'bounds' } });49+ actions.move(1, 0);50+ assert.deepEqual(events, ['sync', 'begin', 'step', 'consume', 'sound:blockedBounds', 'feedback:BLOCKED / try another sentence', 'paint', 'animate:180']);51+});52+53+test('wait, undo, and restart use the same adapter boundary', () => {54+ const { actions, events } = harness();55+ actions.wait();56+ actions.undo();57+ actions.restart();58+ assert.deepEqual(events, [59+ 'sync', 'begin', 'wait', 'consume', 'clear', 'turn-sound', 'announce', 'ui', 'render', 'paint', 'animate:650',60+ 'sync', 'undo', 'consume', 'undo-complete', 'clear', 'sound:undo', 'announce', 'ui', 'render', 'paint', 'animate:300',61+ 'sync', 'reset', 'consume', 'sync', 'clear', 'sound:restart', 'announce', 'ui', 'render', 'restart-complete', 'paint', 'animate:300',62+ ]);63+});