- commit
- 17912082fe4a9ac69b81dc7ad9e1bea71715c619
- parent
- db5a1fe0408d7e61f6e21c74b48ee1c8860a83d8
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 151 | ++++++++++++++++++++++++++++++ |
| A | src/ | 110 | ++++++++++++++++++++++ |
| M | src/ | 91 | ++++++++---------- |
| A | tests/ | 63 | +++++++++++++ |
@@ -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;@@ -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;@@ -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));@@ -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+});