diff --git a/frontend/app/__snapshots__/maze.test.ts.snap b/frontend/app/__snapshots__/maze.test.ts.snap index 712105c..cefe493 100644 --- a/frontend/app/__snapshots__/maze.test.ts.snap +++ b/frontend/app/__snapshots__/maze.test.ts.snap @@ -2,10 +2,10 @@ exports[`maze > generates a deterministic maze layout for a fixed random source 1`] = ` { - "finalPosition": [ - 1, - 9, - ], + "finalPosition": { + "x": 9, + "y": 1, + }, "maze": [ [ "|", @@ -151,9 +151,9 @@ exports[`maze > generates a deterministic maze layout for a fixed random source "|", ], ], - "startPosition": [ - 1, - 1, - ], + "startPosition": { + "x": 1, + "y": 1, + }, } `; diff --git a/frontend/app/agent-context.test.ts b/frontend/app/agent-context.test.ts new file mode 100644 index 0000000..9cd6e55 --- /dev/null +++ b/frontend/app/agent-context.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it, vi } from "vitest" + +import { CONFIG } from "./config" +import { executeActionWithFeedback } from "./agent-context" +import type { MazeAction, MoveAction, State, TraversalHistoryEntry } from "./types" + +function visit(row: number, col: number): TraversalHistoryEntry { + return { playerName: "Blue", row, col } +} + +const expectedAgentPrompt = [ + "Your name is Blue.", + "Use traversalHistory entries matching your playerName to review your past moves in order,", + "then use the provided context to predict the next valid moves.", + "Valid moves advance you until the first invalid move stops replay.", + "Every submitted prediction counts toward score decay until the destination is reached.", + "Locate the randomized path between the current position and destination with the highest score retention.", +].join("\n") + +// createState builds a compact agent-facing runtime state for movement feedback tests. +function createState(overrides: Partial = {}): State { + return { + controlMode: CONFIG.runtime.controlModes.agentApi, + level: 4, + mazeDimensions: { length: 2, width: 1 }, + maze: [ + ["|", "---", "|", "---", "|"], + ["|", " ", " ", " ", "|"], + ["|", "---", "|", "---", "|"], + ], + playerPosition: { x: 1, y: 1 }, + traversalHistory: [visit(0, 0)], + finalPosition: { x: 3, y: 1 }, + status: "running", + score: 700, + lastRoundScore: 0, + lastAttemptRetention: null, + bestWinRetention: null, + lastWinRequestCount: null, + bestWinRequestCount: null, + winSummary: "", + canResume: false, + wallWeight: 1, + scoreDecayUnits: 0, + agentRequestCount: 0, + clock: null, + ...overrides, + } +} + +// createClock supplies the pause/resume shape expected by feedback precondition checks. +function createClock(): State["clock"] { + return { + pause: vi.fn(), + resume: vi.fn(), + elapsed: vi.fn(), + blink: vi.fn(), + remaining: vi.fn(), + } as unknown as State["clock"] +} + +// createContext provides the minimal runtime hooks consumed by movement feedback execution. +function createContext(state: State) { + return { + executeCommand: vi.fn(), + state, + handleMove: vi.fn((action: MoveAction) => { + if (action === "MoveRight") { + state.playerPosition = { x: 3, y: 1 } + state.traversalHistory = [visit(0, 0), visit(0, 1)] + } + }), + playerName: "Blue", + } +} + +// moveAction keeps the flattened action payload concise in expectations. +function moveAction(type: MoveAction): MazeAction { + return { type } +} + +// These tests lock down the compact command feedback returned to agent callers. +describe("cmd feedback", () => { + it("reports when movement is unavailable", () => { + const state = createState({ + status: "paused", + clock: createClock(), + }) + const context = createContext(state) + + expect( + executeActionWithFeedback(moveAction("MoveLeft"), context), + ).toEqual({ + currentCell: { row: 0, col: 0 }, + destinationCell: { row: 0, col: 1 }, + traversalHistory: [visit(0, 0)], + playerName: "Blue", + level: 4, + score: 700, + model: "", + stream: false, + format: "json", + status: "paused", + allowedMoves: ["MoveUp", "MoveDown", "MoveLeft", "MoveRight"], + recommendedAvgPredictionLimit: 18, + prompt: expectedAgentPrompt, + expectedResponseFormat: { + validPredictionFormat: { + moves: ["MoveRight", "MoveDown"], + }, + }, + lastMoveStatus: "invalid-move", + submittedMovesIndexBase: 0, + submittedMovesPattern: ":", + submittedMoves: ["0:MoveLeft"], + lastValidMoveIndex: null, + decayedMovesCount: 0, + }) + expect(context.handleMove).not.toHaveBeenCalled() + }) + + it("reports blocked movement when a wall is encountered", () => { + const state = createState({ + maze: [ + ["|", "---", "|", "---", "|"], + ["|", " ", "|", " ", "|"], + ["|", "---", "|", "---", "|"], + ], + }) + const context = createContext(state) + + expect( + executeActionWithFeedback(moveAction("MoveRight"), context), + ).toEqual({ + currentCell: { row: 0, col: 0 }, + destinationCell: { row: 0, col: 1 }, + traversalHistory: [visit(0, 0)], + playerName: "Blue", + level: 4, + score: 700, + model: "", + stream: false, + format: "json", + status: "running", + allowedMoves: ["MoveUp", "MoveDown", "MoveLeft", "MoveRight"], + recommendedAvgPredictionLimit: 18, + prompt: expectedAgentPrompt, + expectedResponseFormat: { + validPredictionFormat: { + moves: ["MoveRight", "MoveDown"], + }, + }, + lastMoveStatus: "invalid-move", + submittedMovesIndexBase: 0, + submittedMovesPattern: ":", + submittedMoves: ["0:MoveRight"], + lastValidMoveIndex: null, + decayedMovesCount: 0, + }) + expect(context.handleMove).not.toHaveBeenCalled() + }) + + it("reports when a move reaches the destination", () => { + const state = createState() + const context = createContext(state) + context.handleMove.mockImplementationOnce((action: MoveAction) => { + if (action === "MoveRight") { + state.playerPosition = { x: 3, y: 1 } + state.traversalHistory = [visit(0, 0), visit(0, 1)] + state.status = "won" + } + }) + + expect( + executeActionWithFeedback(moveAction("MoveRight"), context), + ).toEqual({ + currentCell: { row: 0, col: 1 }, + destinationCell: { row: 0, col: 1 }, + traversalHistory: [visit(0, 0), visit(0, 1)], + playerName: "Blue", + level: 4, + score: 700, + model: "", + stream: false, + format: "json", + status: "won", + allowedMoves: ["MoveUp", "MoveDown", "MoveLeft", "MoveRight"], + recommendedAvgPredictionLimit: 18, + prompt: expectedAgentPrompt, + expectedResponseFormat: { + validPredictionFormat: { + moves: ["MoveRight", "MoveDown"], + }, + }, + lastMoveStatus: "reached-target", + visitedBefore: false, + submittedMovesIndexBase: 0, + submittedMovesPattern: ":", + submittedMoves: ["0:MoveRight"], + lastValidMoveIndex: 0, + decayedMovesCount: 0, + }) + expect(context.handleMove).toHaveBeenCalledWith("MoveRight", "Blue") + }) + + it("keeps traversal history stable when a move revisits an older cell", () => { + const state = createState({ + playerPosition: { x: 3, y: 1 }, + traversalHistory: [visit(0, 0), visit(0, 1)], + }) + const context = createContext(state) + context.handleMove.mockImplementationOnce((action: MoveAction) => { + if (action === "MoveLeft") { + state.playerPosition = { x: 1, y: 1 } + } + }) + + expect( + executeActionWithFeedback(moveAction("MoveLeft"), context), + ).toEqual({ + currentCell: { row: 0, col: 0 }, + destinationCell: { row: 0, col: 1 }, + traversalHistory: [visit(0, 0), visit(0, 1)], + playerName: "Blue", + level: 4, + score: 700, + model: "", + stream: false, + format: "json", + status: "running", + allowedMoves: ["MoveUp", "MoveDown", "MoveLeft", "MoveRight"], + recommendedAvgPredictionLimit: 18, + prompt: expectedAgentPrompt, + expectedResponseFormat: { + validPredictionFormat: { + moves: ["MoveRight", "MoveDown"], + }, + }, + lastMoveStatus: "applied", + visitedBefore: true, + submittedMovesIndexBase: 0, + submittedMovesPattern: ":", + submittedMoves: ["0:MoveLeft"], + lastValidMoveIndex: 0, + decayedMovesCount: 0, + }) + }) +}) diff --git a/frontend/app/agent-context.ts b/frontend/app/agent-context.ts new file mode 100644 index 0000000..a7479ed --- /dev/null +++ b/frontend/app/agent-context.ts @@ -0,0 +1,167 @@ +import { getNavigationProfile } from "./maze" +import { isWonStatus } from "./status" +import { + cellCoordinateFromGridPoint, + isMoveAction, + resolvePlayerMove, +} from "./traversal" +import type { + MazeAction, + MazeActionState, + MoveStatus, + MoveAction, + State, +} from "./types" + +// ALLOWED_MOVE_ACTIONS enumerates the only traversal commands the agent may return. +const ALLOWED_MOVE_ACTIONS: MoveAction[] = ["MoveUp", "MoveDown", "MoveLeft", "MoveRight"] + +// VALID_PREDICTION_FORMAT documents the one supported agent response payload. +const VALID_PREDICTION_FORMAT = { + validPredictionFormat: { + moves: ["MoveRight", "MoveDown"] as MoveAction[], + }, +} as const + +// agentPrompt keeps request guidance compact while naming the active agent for history lookup. +function agentPrompt(playerName: string): string { + return [ + `Your name is ${playerName}.`, + "Use traversalHistory entries matching your playerName to review your past moves in order,", + "then use the provided context to predict the next valid moves.", + "Valid moves advance you until the first invalid move stops replay.", + "Every submitted prediction counts toward score decay until the destination is reached.", + "Locate the randomized path between the current position and destination with the highest score retention.", + ].join("\n") +} + +// recommendedAvgPredictionLimit derives the advisory prediction length from the active navigation profile. +function recommendedAvgPredictionLimit(state: State): number { + if (!state.mazeDimensions) { + return 0 + } + + const profile = getNavigationProfile(state.mazeDimensions) + return profile.__softCorridorLimit + profile.__hardCorridorLimit +} + +// normalizeSubmittedMoves formats replayed commands into the stable stepNumber:MoveAction shape. +function normalizeSubmittedMoves(moves: MoveAction[]): string[] { + return moves.map((move, index) => `${index}:${move}`) +} + +// buildMazeActionState snapshots the live maze state together with the latest agent replay result. +export function buildMazeActionState( + state: State, + playerName: string, + overrides: Partial = {}, +): MazeActionState { + return { + level: state.level, + status: state.status, + score: state.score, + model: "", + stream: false, + format: "json", + playerName, + currentCell: state.playerPosition ? cellCoordinateFromGridPoint(state.playerPosition) : null, + destinationCell: state.finalPosition ? cellCoordinateFromGridPoint(state.finalPosition) : null, + traversalHistory: state.traversalHistory.map(({ playerName, row, col }) => ({ + playerName, row, col, + })), + allowedMoves: [...ALLOWED_MOVE_ACTIONS], + recommendedAvgPredictionLimit: recommendedAvgPredictionLimit(state), + prompt: agentPrompt(playerName), + expectedResponseFormat: { + validPredictionFormat: { + moves: [...VALID_PREDICTION_FORMAT.validPredictionFormat.moves], + }, + }, + submittedMovesIndexBase: 0, + submittedMovesPattern: ":", + submittedMoves: [], + lastMoveStatus: null, + lastValidMoveIndex: null, + decayedMovesCount: 0, + ...overrides, + } +} + +// mergeMazeActionState reapplies transient replay details on top of the latest normalized base payload. +export function mergeMazeActionState( + actionState: MazeActionState, + overrides: Partial = {}, +): MazeActionState { + const playerName = overrides.playerName ?? actionState.playerName + + return { + ...actionState, + allowedMoves: [...actionState.allowedMoves], + expectedResponseFormat: { + validPredictionFormat: { + moves: [...actionState.expectedResponseFormat.validPredictionFormat.moves], + }, + }, + traversalHistory: actionState.traversalHistory.map(({ playerName, row, col }) => ({ + playerName, row, col, + })), + submittedMoves: [...actionState.submittedMoves], + prompt: overrides.prompt ?? agentPrompt(playerName), + ...overrides, + } +} + +type CommandFeedbackContext = { + state: State + playerName: string + executeCommand: (action: MazeAction) => void + handleMove: (action: MoveAction, playerName?: string) => void +} + +// buildReplayState records the result of one replay step using the shared agent payload shape. +function buildReplayState( + state: State, + playerName: string, + command: MoveAction, + status: MoveStatus, + visitedBefore?: boolean, +): MazeActionState { + const lastValidMoveIndex = status === "applied" || status === "reached-target" ? 0 : null + const visitedBeforeState = visitedBefore === undefined ? {} : { visitedBefore } + + return buildMazeActionState(state, playerName, { + lastMoveStatus: status, + submittedMoves: normalizeSubmittedMoves([command]), + lastValidMoveIndex, + ...visitedBeforeState, + }) +} + +// executeActionWithFeedback classifies one requested command and returns feedback when supported. +export function executeActionWithFeedback( + action: MazeAction, + context: CommandFeedbackContext, +): MazeActionState | null { + if (!isMoveAction(action)) { + context.executeCommand(action) + return null + } + + const { state, handleMove, playerName } = context + const move = action.type + const moveEvaluation = resolvePlayerMove(state, move) + if (!moveEvaluation.canMove) { + return buildReplayState(state, playerName, move, "invalid-move") + } + + handleMove(move, playerName) + const finalStatus: MoveStatus = isWonStatus(state.status) ? "reached-target" : "applied" + + return buildReplayState( + state, + playerName, + move, + finalStatus, + moveEvaluation.visitedBefore, + ) +} diff --git a/frontend/app/clock.test.ts b/frontend/app/clock.test.ts index 49178c2..f488337 100644 --- a/frontend/app/clock.test.ts +++ b/frontend/app/clock.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest" import { GameClock } from "./clock" +// These tests keep timing, pause, and blink semantics stable across refactors. describe("GameClock", () => { it("tracks elapsed and remaining time from the level duration", () => { const clock = new GameClock(10_000) diff --git a/frontend/app/clock.ts b/frontend/app/clock.ts index c7afc2b..82cf9e3 100644 --- a/frontend/app/clock.ts +++ b/frontend/app/clock.ts @@ -1,5 +1,6 @@ const blinkIntervalMs = 500 +// GameClock tracks elapsed play time, pause state, and destination blinking cadence. export class GameClock { levelDurationMs: number startedAt: number @@ -13,27 +14,32 @@ export class GameClock { this.pausedDuration = 0 } + // elapsed reports active time only, excluding any paused duration. elapsed(now = performance.now()): number { // Freeze elapsed time while paused so reloads and manual pauses keep the remaining time stable. const effectiveNow = this.pausedAt || now return effectiveNow - this.startedAt - this.pausedDuration } + // remaining returns the still-playable time clamped at zero. remaining(now = performance.now()): number { return Math.max(0, this.levelDurationMs - this.elapsed(now)) } + // blink toggles the destination marker in half-second phases. blink(now = performance.now()): boolean { // Alternate the destination visibility in half-second phases while the round is active. return Math.floor(this.elapsed(now) / blinkIntervalMs) % 2 === 0 } + // pause captures the instant at which active time should stop advancing. pause(now = performance.now()): void { if (!this.pausedAt) { this.pausedAt = now } } + // resume folds the paused span back into the accumulated pause duration. resume(now = performance.now()): void { if (!this.pausedAt) { return diff --git a/frontend/app/config.ts b/frontend/app/config.ts index f67a9d1..a5da2ce 100644 --- a/frontend/app/config.ts +++ b/frontend/app/config.ts @@ -1,139 +1,234 @@ -import type { AppConfig, NavigationProfile, WallWeight } from "./types" +import type { + AppConfig, + NavigationProfile, + WallWeight, +} from "./types" +declare const __TAPOO_BUILD_YEAR__: number + +// NAVIGATION_FRIENDLY_PROFILE defines the easiest corridor settings for small mazes. const NAVIGATION_FRIENDLY_PROFILE: NavigationProfile = { __softCorridorLimit: 8, __hardCorridorLimit: 10, __preferTurnPercent: 90, } +// NAVIGATION_HARDEST_PROFILE defines the tightest supported corridor settings. const NAVIGATION_HARDEST_PROFILE: NavigationProfile = { __softCorridorLimit: 2, __hardCorridorLimit: 3, __preferTurnPercent: 55, } -export const CONFIG: AppConfig = { - // Shared page chrome. - appName: "Tapoo", - appSubtitle: "maze runner (hide & seek)", - appControlsAriaLabel: "Application controls", - moreActionsAriaLabel: "More actions", - footerAriaLabel: "Copyright", - pageVersionTemplate: "v{version} © {year} Tapoo", - contactLabel: "Contact", - contactAriaLabel: "Contact the author", +// VERSION_MAJOR is the semantic major version for the browser SPA runtime. +const VERSION_MAJOR = 1 - // Game page chrome. - gameDocumentTitle: "Tapoo Maze Runner | Game", - gameDescription: - "Tapoo maze runner hide and seek game rendered as a browser-based terminal experience.", - gamePageLabel: "Game", - aiAgentsLabel: "AI Agents", - aiAgentsPageAriaLabel: "AI Agents page", - resetProgressLabel: "Reset Progress", - resetProgressAriaLabel: "Caution reset progress", - terminalAriaLabel: "Tapoo browser terminal", - touchControlsAriaLabel: "Touch game controls", +// VERSION_MINOR is the semantic minor version for the browser SPA runtime. +const VERSION_MINOR = 1 - // AI Agents page chrome. - agentsDocumentTitle: "Tapoo Maze Runner | AI Agents", - agentsDescription: "Tapoo maze runner AI agents page temporarily unavailable.", - agentsPageLabel: "AI Agents", - agentsPageAriaLabel: "AI Agents page temporarily unavailable", - backToGameLabel: "Back To Game", - backToGameAriaLabel: "Back to game", +// VERSION_PATCH is the semantic patch version for the browser SPA runtime. +const VERSION_PATCH = 0 - // Gameplay text. - navigation: - "Use Arrow Keys to guide Blue (Player) to Red. Ctrl+B changes walls.", - navigationCompact: "Guide Blue (Player) to Red. Ctrl+B changes walls.", - touchNavigation: "Use touch controls to guide Blue (Player) to Red.", - touchNavigationCompact: "Guide Blue to Red with touch controls.", - pauseMessage: "Game paused !!!", - successMessage: - "Game over! Congratulations, You won by locating the target on time.", - successCompactMessage: "Game over! You won.", - failedMessage: - "Game over! Ooops!!!, You failed to locate the target on time.", - failedCompactMessage: "Game over! You lost.", - proceedMessage: "Press Enter or Ctrl+P to Proceed", - touchProceedMessage: "Use the buttons below.", - tooSmallMessage: "Level {level} needs more screen room!", - tooSmallActionMessage: "Make more screen room, or use Reset Progress.", - statusTemplate: - "Press Space to Pause. Press Ctrl+B to Change Walls. Level: {level} Scores: {score}", - touchStatusTemplate: "Level: {level} Scores: {score}", - highScoreTemplate: - "Final Level {level} Scores: {score} ({percent}% retention)", - winNoPrevNewRecord: "New scores retention record", - winNoPrevMatchedBest: "Matched best scores retention", - winNoPrevBehindBest: "{delta} behind best scores retention", - winFasterPrevNewRecord: "{delta} faster than previous (new record)", - winFasterPrevMatchedBest: "{delta} faster than previous (matched best)", - winFasterPrevBehindBest: "{delta} faster than previous ({bestDelta} behind best)", - winSlowerPrevNewRecord: "{delta} slower than previous (new record)", - winSlowerPrevMatchedBest: "{delta} slower than previous (matched best)", - winSlowerPrevBehindBest: "{delta} slower than previous ({bestDelta} behind best)", - winMatchedPrevNewRecord: "Matched previous (new record)", - winMatchedPrevBest: "Matched previous (matched best)", - winMatchedPrevBehindBest: "Matched previous ({bestDelta} behind best)", +// APP_VERSION is kept private because only the composed page copyright text is rendered. +const APP_VERSION = `${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}` - // Touch-control labels. - wallsTouchLabel: "Walls", - pauseTouchLabel: "Pause", - proceedTouchLabel: "Proceed", - touchMoveUpAriaLabel: "Move up", - touchMoveLeftAriaLabel: "Move left", - touchMoveRightAriaLabel: "Move right", - touchMoveDownAriaLabel: "Move down", +// BUILD_YEAR is injected by esbuild for production and falls back only for local test imports. +const BUILD_YEAR = + typeof __TAPOO_BUILD_YEAR__ === "number" + ? __TAPOO_BUILD_YEAR__ + : new Date().getFullYear() - // Maze rendering. - playerMarker: "▓", - destinationMarker: "█", - walls: { - 1: ["|", "---", "-"], - 2: ["╏", "╍╍╍", "╍"], - 3: ["║", "===", "="], +// CONFIG centralizes browser-facing copy together with generation and layout constants. +export const CONFIG: AppConfig = { + // Shared branding used by both HTML pages and the footer version tag. + chrome: { + appName: "Tapoo", + appSubtitle: "maze runner (hide & seek)", + pageVersionTemplate: "v{version} © {year} Tapoo", + contactLabel: "Contact", + }, + // Per-page labels and metadata consumed by static page chrome. + pages: { + game: { + documentTitle: "Tapoo Maze Runner | Game", + description: + "Tapoo maze runner hide and seek game rendered as a browser-based terminal experience.", + pageLabel: "Game", + aiAgentsLabel: "AI Agents", + resetProgressLabel: "Reset Progress", + }, + agents: { + documentTitle: "Tapoo Maze Runner | AI Agents", + description: + "Tapoo maze runner played by an HTTP-driven agent with human session controls.", + pageLabel: "AI Agents", + backToGameLabel: "Back To Game", + resetProgressLabel: "Reset Progress", + }, + }, + // Runtime text shown inside the terminal view and overlay states. + messages: { + // Navigation hints swap between full and compact wording by viewport size. + navigation: { + default: + "Use Arrow Keys to guide Blue (Player) to Red. Ctrl+B changes walls.", + compact: "Guide Blue (Player) to Red with touch controls.", + }, + pauseMessage: "Game paused !!!", + successMessage: + "Game over! Congratulations, You won by locating the target on time.", + successCompactMessage: "Game over! You won.", + failedMessage: + "Game over! Ooops!!!, You failed to locate the target on time.", + failedCompactMessage: "Game over! You lost.", + proceedMessage: "Press Enter or Ctrl+P to Proceed", + touchProceedMessage: "Use the buttons below.", + agentAwaitMessage: "No enabled agent API is configured.", + agentAwaitActionMessage: "Configure an agent. ", + tooSmallMessage: "Level {level} needs more screen room!", + tooSmallActionMessage: "Make more screen room, or use Reset Progress.", + statusTemplate: + "Press Space to Pause. Press Ctrl+B to Change Walls. Level: {level} Scores: {score}", + touchStatusTemplate: "Level: {level} Scores: {score}", + highScoreTemplate: + "Final Level {level} Scores: {score} ({percent}% retention)", + // Win-summary variants are selected from game.ts after comparing retention metrics. + winSummary: { + noPrevious: { + newRecord: "New scores retention record", + matchedBest: "Matched best scores retention", + behindBest: "{delta} behind best scores retention", + }, + fasterPrevious: { + newRecord: "{delta} faster than previous (new record)", + matchedBest: "{delta} faster than previous (matched best)", + behindBest: "{delta} faster than previous ({bestDelta} behind best)", + }, + slowerPrevious: { + newRecord: "{delta} slower than previous (new record)", + matchedBest: "{delta} slower than previous (matched best)", + behindBest: "{delta} slower than previous ({bestDelta} behind best)", + }, + matchedPrevious: { + newRecord: "Matched previous (new record)", + matchedBest: "Matched previous (matched best)", + behindBest: "Matched previous ({bestDelta} behind best)", + }, + }, + agentWinSummary: { + noPrevious: { + newRecord: "New lowest request count", + matchedBest: "Matched best request count", + behindBest: "{delta} requests behind best", + }, + fewerPrevious: { + newRecord: "{delta} fewer requests than previous (new record)", + matchedBest: "{delta} fewer requests than previous (matched best)", + behindBest: "{delta} fewer requests than previous ({bestDelta} behind best)", + }, + morePrevious: { + newRecord: "{delta} more requests than previous (new record)", + matchedBest: "{delta} more requests than previous (matched best)", + behindBest: "{delta} more requests than previous ({bestDelta} behind best)", + }, + matchedPrevious: { + newRecord: "Matched previous request count (new record)", + matchedBest: "Matched previous request count (matched best)", + behindBest: "Matched previous request count ({bestDelta} behind best)", + }, + }, + }, + // Touch-control labels used by the browser action pad. + controls: { + touch: { + wallsLabel: "Walls", + pauseLabel: "Pause", + proceedLabel: "Proceed", + }, + }, + // Maze glyphs and geometry shared by generation, traversal, and rendering. + maze: { + playerMarker: "▓", + destinationMarker: "█", + walls: { + 1: ["|", "---", "-"], + 2: ["╏", "╍╍╍", "╍"], + 3: ["║", "===", "="], + }, + cellSpan: 2, + cellPathWidth: 3, + moveStep: 2, + leftPadding: 3, + minDimension: 5, + }, + // Maze-generation tuning controls level growth and navigation difficulty. + generation: { + seed: 60, + diff: 10, + navigation: { + hardestArea: 1600, + friendlyMaxArea: 130, + hardestProfile: NAVIGATION_HARDEST_PROFILE, + friendlyProfile: NAVIGATION_FRIENDLY_PROFILE, + }, + }, + // Score math controls maximum round score and retained-score percentages. + scoring: { + percentScale: 100, + budgetMultiplier: 100, + retentionScale: 1_000_000, + }, + // Timing values drive refresh cadence, score decay, and the slower agent-api pacing. + timing: { + refreshInterval: 250, + scoreDecayRate: 100, + interactiveCoreDecayIntervalPerCellMs: 1_000, // Translates to 1sec + agentApiCoreDecayIntervalPerCellMs: 30_000, // Translates to 30sec + agentApiResponseTimeoutMs: 180_000, // Translates to 3min + }, + // Viewport thresholds translate measured DOM space into logical maze room. + viewport: { + compactWidth: 540, + compactHeight: 520, + minTerminalRows: 20, + minTerminalColumns: 48, + terminalHeightInset: 5, + terminalHeightScale: 4, + terminalWidthInset: 10, + terminalWidthScale: 2, + terminalSampleWidth: 10, + }, + // Runtime settings back persistence validation and agent-mode bootstrapping. + runtime: { + controlModes: { + agentApi: "agent-api", + interactive: "interactive", + }, + storage: { + version: 2, + suffixes: { + gameSetup: "gameSetup", + winMetrics: "winMetrics", + agentConfigs: "agentConfigs", + }, + }, + interactivePlayerName: "Self", + agentApiMistakePenaltyMoves: 5, + missingElementErrorTemplate: "missing required element: {id}", }, - - // Runtime and layout settings. - cellSpan: 2, - cellPathWidth: 3, - moveStep: 2, - scoreMultiplier: 100, - percentScale: 100, - retentionScale: 1_000_000, - refreshInterval: 250, - navigationFriendlyMaxArea: 130, - navigationHardestArea: 1600, - navigationFriendlyProfile: NAVIGATION_FRIENDLY_PROFILE, - navigationHardestProfile: NAVIGATION_HARDEST_PROFILE, - mazeLeftPadding: 3, - seed: 60, - diff: 10, - minMazeDimension: 5, - missingElementErrorTemplate: "missing required element: {id}", - compactViewportWidth: 540, - compactViewportHeight: 520, - terminalHeightInset: 5, - terminalHeightScale: 4, - terminalWidthInset: 10, - terminalWidthScale: 2, } -export const WALL_WEIGHTS = Object.keys(CONFIG.walls) +// PAGE_COPYRIGHT_TEXT is the fully composed footer text shared by static browser pages. +export const PAGE_COPYRIGHT_TEXT = CONFIG.chrome.pageVersionTemplate + .replace("{version}", APP_VERSION) + .replace("{year}", String(BUILD_YEAR)) + +// WALL_WEIGHTS keeps wall-style iteration ordered and type-safe for traversal helpers. +export const WALL_WEIGHTS = Object.keys(CONFIG.maze.walls) .map((weight) => Number(weight)) .sort((left, right) => left - right) as WallWeight[] -export const WALL_WEIGHT_STORAGE_KEY = "tapoo.wallWeight" -export const LEVEL_STORAGE_KEY = "tapoo.level" -export const LAST_ATTEMPT_RETENTION_STORAGE_KEY = "tapoo.lastAttemptRetention" -export const BEST_WIN_RETENTION_STORAGE_KEY = "tapoo.bestWinRetention" -export const ROUND_STORAGE_KEY = "tapoo.round" -export const ROUND_STORAGE_VERSION = 1 -export const TERMINAL_SAMPLE_WIDTH = 10 -export const MIN_TERMINAL_ROWS = 20 -export const MIN_TERMINAL_COLUMNS = 48 -export const STORE_ENCODING_PREFIX = "tapoo:v2:" +// STORE_ENCODING_PREFIX marks the storage schema version embedded in every encoded payload. +export const STORE_ENCODING_PREFIX = `tapoo:v${CONFIG.runtime.storage.version}:` export const STORE_BLEND_KEY = ["tapoo:web/vault", "key|spa.persist"].join(` `) diff --git a/frontend/app/control/agent-api.test.ts b/frontend/app/control/agent-api.test.ts new file mode 100644 index 0000000..5c342c4 --- /dev/null +++ b/frontend/app/control/agent-api.test.ts @@ -0,0 +1,611 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { CONFIG } from "../config" +import type { + AgentApiConfig, + MazeAction, + MazeActionDispatch, + MazeActionState, + TraversalHistoryEntry, +} from "../types" +import { handleAgentTurnLoop } from "./agent-api" + +const agentMovePollIntervalMs = CONFIG.timing.agentApiCoreDecayIntervalPerCellMs +const expectedAgentPrompt = [ + "Your name is Blue.", + "Use traversalHistory entries matching your playerName to review your past moves in order,", + "then use the provided context to predict the next valid moves.", + "Valid moves advance you until the first invalid move stops replay.", + "Every submitted prediction counts toward score decay until the destination is reached.", + "Locate the randomized path between the current position and destination with the highest score retention.", +].join("\n") + +function visit(row: number, col: number): TraversalHistoryEntry { + return { playerName: "Blue", row, col } +} + +function createActionState( + overrides: Partial = {}, +): MazeActionState { + return { + currentCell: { row: 0, col: 0 }, + destinationCell: { row: 0, col: 2 }, + traversalHistory: [visit(0, 0)], + playerName: "Blue", + level: 2, + score: 600, + model: "llama3.2", + stream: false, + format: "json", + status: "running", + allowedMoves: ["MoveUp", "MoveDown", "MoveLeft", "MoveRight"], + recommendedAvgPredictionLimit: 18, + prompt: expectedAgentPrompt, + expectedResponseFormat: { + validPredictionFormat: { + moves: ["MoveRight", "MoveDown"], + }, + }, + submittedMovesIndexBase: 0, + submittedMovesPattern: ":", + submittedMoves: [], + lastMoveStatus: null, + lastValidMoveIndex: null, + decayedMovesCount: 0, + ...overrides, + } +} + +function enabledAgentConfigs(): AgentApiConfig[] { + return [ + { + id: "blue-agent", + playerName: "Blue", + model: "llama3.2", + endpoint: "/api/agent/move", + enabled: true, + }, + ] +} + +function createDisableAgentAfterNetworkError() { + return vi.fn((agent: AgentApiConfig) => { + agent.enabled = false + }) +} + +describe("agent api turn loop", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it("polls only while attached to a running maze", async () => { + const elements = { body: document.createElement("div") } + let actionState = createActionState({ status: "running" }) + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ moves: ["MoveRight"] }), + }) + vi.stubGlobal("fetch", fetchMock) + + const poller = handleAgentTurnLoop({ + __elements: elements, + __dispatch: vi.fn() as MazeActionDispatch, + __dispatchAgentAction: vi.fn(() => + createActionState({ lastMoveStatus: "applied" }), + ), + __commitAgentTurn: vi.fn(() => createActionState()), + __onActionState: vi.fn(), + __disableAgentAfterNetworkError: createDisableAgentAfterNetworkError(), + __readAgentConfigs: enabledAgentConfigs, + __readActionState: () => actionState, + }) + + poller.__scheduleNextAgentTurn() + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + expect(fetchMock).not.toHaveBeenCalled() + + poller.__setAttached(true) + actionState = createActionState({ status: "paused" }) + poller.__scheduleNextAgentTurn() + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + expect(fetchMock).not.toHaveBeenCalled() + + actionState = createActionState({ status: "running" }) + poller.__scheduleNextAgentTurn() + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(elements.body.dataset.agentControl).toBe("active") + }) + + it("moves the game into agent waiting state immediately when no enabled agent exists", () => { + const fetchMock = vi.fn() + vi.stubGlobal("fetch", fetchMock) + const dispatch = vi.fn() as MazeActionDispatch + + const poller = handleAgentTurnLoop({ + __elements: { body: document.createElement("div") }, + __dispatch: dispatch, + __dispatchAgentAction: vi.fn(), + __commitAgentTurn: vi.fn(() => createActionState()), + __onActionState: vi.fn(), + __disableAgentAfterNetworkError: createDisableAgentAfterNetworkError(), + __readAgentConfigs: () => [], + __readActionState: () => createActionState(), + }) + + poller.__setAttached(true) + poller.__scheduleNextAgentTurn() + + expect(dispatch).toHaveBeenCalledWith({ type: "await-agent" }, { playerName: "Blue" }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it("moves into agent waiting state after the final enabled agent has a network error", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new TypeError("network failed")), + ) + + const agentConfigs = enabledAgentConfigs() + const dispatch = vi.fn() as MazeActionDispatch + const disableAgentAfterNetworkError = createDisableAgentAfterNetworkError() + + const poller = handleAgentTurnLoop({ + __elements: { body: document.createElement("div") }, + __commitAgentTurn: vi.fn(() => createActionState()), + __dispatch: dispatch, + __dispatchAgentAction: vi.fn(), + __onActionState: vi.fn(), + __disableAgentAfterNetworkError: disableAgentAfterNetworkError, + __readAgentConfigs: () => agentConfigs, + __readActionState: () => createActionState(), + }) + + poller.__setAttached(true) + poller.__scheduleNextAgentTurn() + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + + expect(disableAgentAfterNetworkError).toHaveBeenCalledWith(agentConfigs[0]) + expect(dispatch).toHaveBeenCalledWith({ type: "await-agent" }, { playerName: "Blue" }) + }) + + it("replays valid predictions and decays score by every submitted move", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + moves: ["MoveRight", "MoveDown", "MoveLeft"], + }), + }) + vi.stubGlobal("fetch", fetchMock) + + const dispatch = vi.fn() as MazeActionDispatch + const dispatchAgentAction = vi + .fn<(action: MazeAction) => MazeActionState>() + .mockReturnValueOnce(createActionState({ + currentCell: { row: 0, col: 1 }, + traversalHistory: [visit(0, 0), visit(0, 1)], + lastMoveStatus: "applied", + visitedBefore: false, + })) + .mockReturnValueOnce(createActionState({ + currentCell: { row: 1, col: 1 }, + traversalHistory: [visit(0, 0), visit(0, 1), visit(1, 1)], + lastMoveStatus: "applied", + visitedBefore: false, + })) + .mockReturnValueOnce(createActionState({ + currentCell: { row: 1, col: 1 }, + traversalHistory: [visit(0, 0), visit(0, 1), visit(1, 1)], + lastMoveStatus: "invalid-move", + visitedBefore: true, + })) + const commitAgentTurn = vi.fn((decayedMovesCount: number) => + createActionState({ + currentCell: { row: 1, col: 1 }, + score: 600 - decayedMovesCount * CONFIG.timing.scoreDecayRate, + }), + ) + const onActionState = vi.fn() + + const poller = handleAgentTurnLoop({ + __elements: { body: document.createElement("div") }, + __commitAgentTurn: commitAgentTurn, + __dispatch: dispatch, + __dispatchAgentAction: dispatchAgentAction, + __onActionState: onActionState, + __disableAgentAfterNetworkError: createDisableAgentAfterNetworkError(), + __readAgentConfigs: enabledAgentConfigs, + __readActionState: () => createActionState(), + }) + + poller.__setAttached(true) + poller.__scheduleNextAgentTurn() + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + + expect(dispatchAgentAction).toHaveBeenNthCalledWith( + 1, + { type: "MoveRight" }, + dispatch, + "Blue", + ) + expect(dispatchAgentAction).toHaveBeenNthCalledWith( + 2, + { type: "MoveDown" }, + dispatch, + "Blue", + ) + expect(dispatchAgentAction).toHaveBeenNthCalledWith( + 3, + { type: "MoveLeft" }, + dispatch, + "Blue", + ) + expect(commitAgentTurn).toHaveBeenCalledWith(3) + expect(onActionState).toHaveBeenCalledWith( + expect.objectContaining({ + currentCell: { row: 1, col: 1 }, + lastMoveStatus: "invalid-move", + submittedMoves: ["0:MoveRight", "1:MoveDown", "2:MoveLeft"], + lastValidMoveIndex: 1, + decayedMovesCount: 3, + }), + ) + }) + + it("rotates through enabled agents configured for the shared maze", async () => { + const agentConfigs: AgentApiConfig[] = [ + { + id: "agent-a", + playerName: "Agent A", + model: "llama3.2", + endpoint: "/api/agents/a/move", + enabled: true, + }, + { + id: "agent-b", + playerName: "Agent B", + model: "gemma4", + endpoint: "/api/agents/b/move", + enabled: true, + }, + { + id: "agent-disabled", + playerName: "Disabled Agent", + model: "disabled-model", + endpoint: "/api/agents/disabled/move", + enabled: false, + }, + { + id: "agent-c", + playerName: "Agent C", + model: "qwen3", + endpoint: "/api/agents/c/move", + enabled: true, + }, + ] + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ moves: ["MoveRight"] }), + }) + vi.stubGlobal("fetch", fetchMock) + + const dispatch = vi.fn() as MazeActionDispatch + const dispatchAgentAction = vi.fn(() => + createActionState({ + currentCell: { row: 0, col: 1 }, + lastMoveStatus: "applied", + }), + ) + const onActionState = vi.fn() + + const poller = handleAgentTurnLoop({ + __elements: { body: document.createElement("div") }, + __commitAgentTurn: vi.fn(() => createActionState()), + __dispatch: dispatch, + __dispatchAgentAction: dispatchAgentAction, + __onActionState: onActionState, + __disableAgentAfterNetworkError: createDisableAgentAfterNetworkError(), + __readAgentConfigs: () => agentConfigs, + __readActionState: () => createActionState({ level: 2 }), + }) + + poller.__setAttached(true) + poller.__scheduleNextAgentTurn() + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "/api/agents/a/move", + expect.objectContaining({ method: "POST" }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "/api/agents/b/move", + expect.objectContaining({ method: "POST" }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + "/api/agents/c/move", + expect.objectContaining({ method: "POST" }), + ) + + const firstRequest = fetchMock.mock.calls[0][1] as RequestInit + const secondRequest = fetchMock.mock.calls[1][1] as RequestInit + const thirdRequest = fetchMock.mock.calls[2][1] as RequestInit + + if ( + typeof firstRequest.body !== "string" || + typeof secondRequest.body !== "string" || + typeof thirdRequest.body !== "string" + ) { + throw new Error("expected agent request bodies to be serialized json") + } + + const firstRequestBody = JSON.parse(firstRequest.body) as MazeActionState + const secondRequestBody = JSON.parse(secondRequest.body) as MazeActionState + const thirdRequestBody = JSON.parse(thirdRequest.body) as MazeActionState + + expect(firstRequestBody.playerName).toBe("Agent A") + expect(firstRequestBody.model).toBe("llama3.2") + expect(firstRequestBody.stream).toBe(false) + expect(firstRequestBody.format).toBe("json") + expect(firstRequestBody.prompt).toContain("Your name is Agent A.") + expect(secondRequestBody.playerName).toBe("Agent B") + expect(secondRequestBody.model).toBe("gemma4") + expect(secondRequestBody.prompt).toContain("Your name is Agent B.") + expect(thirdRequestBody.playerName).toBe("Agent C") + expect(thirdRequestBody.model).toBe("qwen3") + expect(thirdRequestBody.prompt).toContain("Your name is Agent C.") + expect(dispatchAgentAction).toHaveBeenNthCalledWith( + 1, + { type: "MoveRight" }, + dispatch, + "Agent A", + ) + expect(dispatchAgentAction).toHaveBeenNthCalledWith( + 2, + { type: "MoveRight" }, + dispatch, + "Agent B", + ) + expect(dispatchAgentAction).toHaveBeenNthCalledWith( + 3, + { type: "MoveRight" }, + dispatch, + "Agent C", + ) + expect(onActionState).toHaveBeenLastCalledWith( + expect.objectContaining({ playerName: "Agent C" }), + ) + }) + + it("stops replaying predictions after the destination is reached", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + moves: ["MoveRight", "MoveDown"], + }), + }), + ) + + const dispatchAgentAction = vi.fn(() => + createActionState({ + currentCell: { row: 0, col: 1 }, + destinationCell: { row: 0, col: 1 }, + lastMoveStatus: "reached-target", + status: "won", + }), + ) + const onActionState = vi.fn() + + const poller = handleAgentTurnLoop({ + __elements: { body: document.createElement("div") }, + __commitAgentTurn: vi.fn((decayedMovesCount: number) => + createActionState({ + currentCell: { row: 0, col: 1 }, + destinationCell: { row: 0, col: 1 }, + decayedMovesCount, + status: "won", + }), + ), + __dispatch: vi.fn() as MazeActionDispatch, + __dispatchAgentAction: dispatchAgentAction, + __onActionState: onActionState, + __disableAgentAfterNetworkError: createDisableAgentAfterNetworkError(), + __readAgentConfigs: enabledAgentConfigs, + __readActionState: () => createActionState(), + }) + + poller.__setAttached(true) + poller.__scheduleNextAgentTurn() + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + + expect(dispatchAgentAction).toHaveBeenCalledTimes(1) + expect(onActionState).toHaveBeenCalledWith( + expect.objectContaining({ + lastMoveStatus: "reached-target", + submittedMoves: ["0:MoveRight", "1:MoveDown"], + lastValidMoveIndex: 0, + decayedMovesCount: 2, + }), + ) + }) + + it("records malformed responses with the fixed mistake decay", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ moves: ["MoveSideways"] }), + }), + ) + + const dispatchAgentAction = vi.fn() + const commitAgentTurn = vi.fn((decayedMovesCount: number) => + createActionState({ decayedMovesCount }), + ) + const onActionState = vi.fn() + + const poller = handleAgentTurnLoop({ + __elements: { body: document.createElement("div") }, + __commitAgentTurn: commitAgentTurn, + __dispatch: vi.fn() as MazeActionDispatch, + __dispatchAgentAction: dispatchAgentAction, + __onActionState: onActionState, + __disableAgentAfterNetworkError: createDisableAgentAfterNetworkError(), + __readAgentConfigs: enabledAgentConfigs, + __readActionState: () => createActionState(), + }) + + poller.__setAttached(true) + poller.__scheduleNextAgentTurn() + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + + expect(dispatchAgentAction).not.toHaveBeenCalled() + expect(commitAgentTurn).toHaveBeenCalledWith( + CONFIG.runtime.agentApiMistakePenaltyMoves, + ) + expect(onActionState).toHaveBeenCalledWith( + expect.objectContaining({ + lastMoveStatus: "malformed-response", + decayedMovesCount: CONFIG.runtime.agentApiMistakePenaltyMoves, + }), + ) + }) + + it("disables the agent after response timeouts without score decay", async () => { + const fetchMock = vi.fn((_url: string, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("aborted", "AbortError")) + }) + }) + }) + vi.stubGlobal("fetch", fetchMock) + + const commitAgentTurn = vi.fn((decayedMovesCount: number) => + createActionState({ decayedMovesCount }), + ) + const onActionState = vi.fn() + const agentConfigs = enabledAgentConfigs() + const disableAgentAfterNetworkError = createDisableAgentAfterNetworkError() + + const poller = handleAgentTurnLoop({ + __elements: { body: document.createElement("div") }, + __commitAgentTurn: commitAgentTurn, + __disableAgentAfterNetworkError: disableAgentAfterNetworkError, + __dispatch: vi.fn() as MazeActionDispatch, + __dispatchAgentAction: vi.fn(), + __onActionState: onActionState, + __readAgentConfigs: () => agentConfigs, + __readActionState: () => createActionState(), + }) + + poller.__setAttached(true) + poller.__scheduleNextAgentTurn() + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + await vi.advanceTimersByTimeAsync(CONFIG.timing.agentApiResponseTimeoutMs) + + expect(commitAgentTurn).not.toHaveBeenCalled() + expect(disableAgentAfterNetworkError).toHaveBeenCalledWith(agentConfigs[0]) + expect(onActionState).toHaveBeenCalledWith( + expect.objectContaining({ + decayedMovesCount: 0, + lastMoveStatus: "network-error", + playerName: "Blue", + }), + ) + }) + + it("disables the agent after non-ok http responses without score decay", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + json: vi.fn(), + }), + ) + + const commitAgentTurn = vi.fn((decayedMovesCount: number) => + createActionState({ decayedMovesCount }), + ) + const onActionState = vi.fn() + const agentConfigs = enabledAgentConfigs() + const disableAgentAfterNetworkError = createDisableAgentAfterNetworkError() + + const poller = handleAgentTurnLoop({ + __elements: { body: document.createElement("div") }, + __commitAgentTurn: commitAgentTurn, + __disableAgentAfterNetworkError: disableAgentAfterNetworkError, + __dispatch: vi.fn() as MazeActionDispatch, + __dispatchAgentAction: vi.fn(), + __onActionState: onActionState, + __readAgentConfigs: () => agentConfigs, + __readActionState: () => createActionState(), + }) + + poller.__setAttached(true) + poller.__scheduleNextAgentTurn() + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + + expect(commitAgentTurn).not.toHaveBeenCalled() + expect(disableAgentAfterNetworkError).toHaveBeenCalledWith(agentConfigs[0]) + expect(onActionState).toHaveBeenCalledWith( + expect.objectContaining({ + decayedMovesCount: 0, + lastMoveStatus: "network-error", + playerName: "Blue", + }), + ) + }) + + it("disables the agent after fetch failures without score decay", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new TypeError("network failed")), + ) + + const commitAgentTurn = vi.fn((decayedMovesCount: number) => + createActionState({ decayedMovesCount }), + ) + const onActionState = vi.fn() + const agentConfigs = enabledAgentConfigs() + const disableAgentAfterNetworkError = createDisableAgentAfterNetworkError() + + const poller = handleAgentTurnLoop({ + __elements: { body: document.createElement("div") }, + __commitAgentTurn: commitAgentTurn, + __disableAgentAfterNetworkError: disableAgentAfterNetworkError, + __dispatch: vi.fn() as MazeActionDispatch, + __dispatchAgentAction: vi.fn(), + __onActionState: onActionState, + __readAgentConfigs: () => agentConfigs, + __readActionState: () => createActionState(), + }) + + poller.__setAttached(true) + poller.__scheduleNextAgentTurn() + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + + expect(commitAgentTurn).not.toHaveBeenCalled() + expect(disableAgentAfterNetworkError).toHaveBeenCalledWith(agentConfigs[0]) + expect(onActionState).toHaveBeenCalledWith( + expect.objectContaining({ + decayedMovesCount: 0, + lastMoveStatus: "network-error", + playerName: "Blue", + }), + ) + }) +}) diff --git a/frontend/app/control/agent-api.ts b/frontend/app/control/agent-api.ts new file mode 100644 index 0000000..fd0e065 --- /dev/null +++ b/frontend/app/control/agent-api.ts @@ -0,0 +1,319 @@ +import { CONFIG } from "../config" +import { mergeMazeActionState } from "../agent-context" +import { isRunningStatus } from "../status" +import type { + AgentApiConfig, + MazeAction, + MazeActionDispatch, + MazeActionState, + MoveAction, +} from "../types" + +const { runtime, timing } = CONFIG + +type AgentPredictionResponse = { + moves?: unknown +} + +// isMoveAction validates one raw prediction entry against the supported move vocabulary. +function isMoveAction(value: unknown): value is MoveAction { + return ( + value === "MoveUp" || + value === "MoveDown" || + value === "MoveLeft" || + value === "MoveRight" + ) +} + +// parseAgentPrediction extracts the single supported prediction payload from one HTTP response. +function parseAgentPrediction(payload: unknown): MoveAction[] | null { + if (typeof payload !== "object" || payload === null) { + return null + } + + const { moves } = payload as AgentPredictionResponse + if (!Array.isArray(moves) || moves.length === 0) { + return null + } + return moves.every(isMoveAction) ? [...moves] : null +} + +// mergeReplayResult reapplies replay metadata on top of the latest committed base state. +function mergeReplayResult( + actionState: MazeActionState, + overrides: Partial, +): MazeActionState { + return mergeMazeActionState(actionState, overrides) +} + +export type AgentMovePoller = { + __stopPolling: () => void + __shouldPollAgent: () => boolean + __scheduleNextAgentTurn: () => void + __setAttached: (attached: boolean) => void + __setLastActionState: (actionState: MazeActionState | null) => void +} + +type HandleAgentTurnLoopOptions = { + __elements: { body: HTMLElement } + __commitAgentTurn: (decayedMovesCount: number) => MazeActionState + __dispatch: MazeActionDispatch + __dispatchAgentAction: ( + action: MazeAction, + dispatch: MazeActionDispatch, + playerName: string, + ) => MazeActionState + __disableAgentAfterNetworkError: (agent: AgentApiConfig) => void + __readAgentConfigs: () => AgentApiConfig[] + __onActionState: (actionState: MazeActionState) => void + __readActionState: () => MazeActionState +} + +// handleAgentTurnLoop owns the HTTP polling cycle used by the agent-api control mode. +export function handleAgentTurnLoop({ + __commitAgentTurn, __disableAgentAfterNetworkError, __dispatch, + __dispatchAgentAction, __elements, __onActionState, __readActionState, + __readAgentConfigs, +}: HandleAgentTurnLoopOptions): AgentMovePoller { + let attached = false + let scheduledTurn: number | null = null + let activeController: AbortController | null = null + let activeTimeout: number | null = null + let lastActionState: MazeActionState | null = null + let agentCursor = 0 + + // activeActionState returns the most recent replay state, or the live base state before any replay exists. + const activeActionState = (): MazeActionState => lastActionState ?? __readActionState() + + // nextAgent rotates through all enabled agents configured for the shared maze. + const nextAgent = (): AgentApiConfig | null => { + const enabledAgents = __readAgentConfigs().filter((agent) => agent.enabled) + + if (enabledAgents.length === 0) { + return null + } + + const selectedAgent = enabledAgents[agentCursor % enabledAgents.length] + agentCursor += 1 + return selectedAgent + } + + // hasEnabledAgents checks whether polling can produce work before waiting for a timeout. + const hasEnabledAgents = (): boolean => __readAgentConfigs().some((agent) => agent.enabled) + + // awaitAgent immediately moves the game into its no-agent state without spending score. + const awaitAgent = (): void => { + __dispatch({ type: "await-agent" }, { playerName: activeActionState().playerName }) + } + + // clearScheduledTurn stops any queued request cycle. + const clearScheduledTurn = (): void => { + if (scheduledTurn === null) { + return + } + + window.clearTimeout(scheduledTurn) + scheduledTurn = null + } + + // abortActiveRequest cancels the in-flight HTTP request and its timeout watcher. + const abortActiveRequest = (): void => { + activeController?.abort() + activeController = null + if (activeTimeout !== null) { + window.clearTimeout(activeTimeout) + activeTimeout = null + } + } + + // stopPolling clears both queued and active work so callers can reset the loop in one step. + const stopPolling = (): void => { + clearScheduledTurn() + abortActiveRequest() + } + + // shouldPollAgent only keeps the replay loop alive while the live round is actively running. + const shouldPollAgent = (): boolean => attached && isRunningStatus(__readActionState().status) + + // recordAgentNetworkError disables failed agents and records the no-score-decay network state. + const recordAgentNetworkError = (agent: AgentApiConfig | null): void => { + if (!agent) { + return + } + + __disableAgentAfterNetworkError(agent) + const nextState = mergeMazeActionState(activeActionState(), { + playerName: agent.playerName, + lastMoveStatus: "network-error", + decayedMovesCount: 0, + }) + lastActionState = nextState + __onActionState(nextState) + + if (!hasEnabledAgents()) { + awaitAgent() + } + } + + // scheduleNextAgentTurn waits for the derived agent-api poll interval before asking again. + const scheduleNextAgentTurn = (): void => { + clearScheduledTurn() + if (!shouldPollAgent()) { + return + } + + if (!hasEnabledAgents()) { + awaitAgent() + return + } + + const agentMovePollIntervalMs = timing.agentApiCoreDecayIntervalPerCellMs + scheduledTurn = window.setTimeout(() => { + scheduledTurn = null + void requestNextAgentTurn() + }, agentMovePollIntervalMs) + } + + // requestNextAgentTurn submits the current maze state and replays one predicted batch in order. + const requestNextAgentTurn = async (): Promise => { + if (!shouldPollAgent()) { + return + } + + let didTimeout = false + // AbortController lets the timeout watcher cancel slow requests cleanly. + const controller = new AbortController() + let selectedAgent: AgentApiConfig | null = null + activeController = controller + activeTimeout = window.setTimeout(() => { + didTimeout = true + controller.abort() + }, timing.agentApiResponseTimeoutMs) + + try { + const currentActionState = activeActionState() + selectedAgent = nextAgent() + if (!selectedAgent) { + awaitAgent() + return + } + + const requestActionState = mergeMazeActionState(currentActionState, { + model: selectedAgent.model, + playerName: selectedAgent.playerName, + }) + + const response = await fetch(selectedAgent.endpoint, { + body: JSON.stringify(requestActionState), + headers: { + "Content-Type": "application/json", + }, + method: "POST", + signal: controller.signal, + }) + + if (!response.ok) { + // Network-class failures disable this agent without spending score decay. + recordAgentNetworkError(selectedAgent) + return + } + + const submittedMoves = parseAgentPrediction(await response.json()) + + if (!submittedMoves) { + // Malformed payloads spend the fixed mistake decay without replaying any move. + const decayedMovesCount = runtime.agentApiMistakePenaltyMoves + const nextState = mergeMazeActionState( + __commitAgentTurn(decayedMovesCount), + { + playerName: selectedAgent.playerName, + lastMoveStatus: "malformed-response", + }, + ) + lastActionState = nextState + __onActionState(nextState) + return + } + + let lastReplayState: MazeActionState | null = null + let appliedMoveCount = 0 + + for (const move of submittedMoves) { + const replayState = __dispatchAgentAction( + { type: move }, + __dispatch, + selectedAgent.playerName, + ) + lastReplayState = replayState + const status = replayState.lastMoveStatus + + if (status === "reached-target") { + appliedMoveCount += 1 + // The destination was reached, so no later prediction can affect this replay. + break + } + + if (status === "applied") { + appliedMoveCount += 1 + continue + } + + // Stop once replay hits the first invalid move in the batch. + break + } + + if (!lastReplayState) { + return + } + + const decayedMovesCount = submittedMoves.length + + // Every successfully parsed prediction batch decays by its full submitted move count. + const committedState = __commitAgentTurn(decayedMovesCount) + + const nextState = mergeReplayResult(committedState, { + playerName: selectedAgent.playerName, + lastMoveStatus: lastReplayState.lastMoveStatus, + visitedBefore: lastReplayState?.visitedBefore, + submittedMoves: submittedMoves.map( + (move, index) => `${index}:${move}`, + ), + lastValidMoveIndex: appliedMoveCount > 0 ? appliedMoveCount - 1 : null, + decayedMovesCount, + }) + + lastActionState = nextState + __onActionState(nextState) + } catch (error) { + if (!(error instanceof DOMException) || error.name !== "AbortError") { + recordAgentNetworkError(selectedAgent) + return + } + + if (!didTimeout) { + return + } + + // Timeouts are transport failures, so only the failing agent is disabled. + recordAgentNetworkError(selectedAgent) + } finally { + abortActiveRequest() + if (shouldPollAgent()) { + scheduleNextAgentTurn() + } + } + } + + return { + __scheduleNextAgentTurn: scheduleNextAgentTurn, + __setAttached(nextAttached) { + attached = nextAttached + __elements.body.dataset.agentControl = nextAttached ? "active" : "idle" + }, + __setLastActionState(actionState) { + lastActionState = actionState + }, + __stopPolling: stopPolling, + __shouldPollAgent: shouldPollAgent, + } +} diff --git a/frontend/app/control/agent.test.ts b/frontend/app/control/agent.test.ts new file mode 100644 index 0000000..1affe9d --- /dev/null +++ b/frontend/app/control/agent.test.ts @@ -0,0 +1,670 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { createAgentMode } from "./agent" +import { CONFIG } from "../config" +import type { + AgentApiConfig, + MazeAction, + MazeActionDispatchOptions, + MazeActionState, + TraversalHistoryEntry, +} from "../types" + +const agentMovePollIntervalMs = CONFIG.timing.agentApiCoreDecayIntervalPerCellMs +const expectedAgentPrompt = [ + "Your name is Blue.", + "Use traversalHistory entries matching your playerName to review your past moves in order,", + "then use the provided context to predict the next valid moves.", + "Valid moves advance you until the first invalid move stops replay.", + "Every submitted prediction counts toward score decay until the destination is reached.", + "Locate the randomized path between the current position and destination with the highest score retention.", +].join("\n") + +function enabledAgentConfigs(): AgentApiConfig[] { + return [ + { + id: "blue-agent", + playerName: "Blue", + model: "llama3.2", + endpoint: "/api/agent/move", + enabled: true, + }, + ] +} + +function createTestAgentMode(elements: Parameters[0]) { + return createAgentMode(elements, enabledAgentConfigs) +} + +function visit(row: number, col: number): TraversalHistoryEntry { + return { playerName: "Blue", row, col } +} + +function createButton({ + action, + move, +}: { + action?: string + move?: string +}): HTMLButtonElement { + const button = document.createElement("button") + + if (action) { + button.dataset.action = action + } + + if (move) { + button.dataset.move = move + } + + return button +} + +function createActionState( + overrides: Partial = {}, +): MazeActionState { + return { + currentCell: { row: 0, col: 0 }, + destinationCell: { row: 0, col: 2 }, + traversalHistory: [visit(0, 0)], + playerName: "Blue", + level: 4, + score: 800, + model: "llama3.2", + stream: false, + format: "json", + status: "running", + allowedMoves: ["MoveUp", "MoveDown", "MoveLeft", "MoveRight"], + recommendedAvgPredictionLimit: 18, + prompt: expectedAgentPrompt, + expectedResponseFormat: { + validPredictionFormat: { + moves: ["MoveRight", "MoveDown"], + }, + }, + lastMoveStatus: null, + submittedMovesIndexBase: 0, + submittedMovesPattern: ":", + submittedMoves: [], + lastValidMoveIndex: null, + decayedMovesCount: 0, + ...overrides, + } +} + +describe("agent control mode", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it("polls the agent endpoint for traversal moves and dispatches them with feedback enabled", async () => { + const elements = { + app: document.createElement("div"), + body: document.createElement("div"), + controls: [], + measure: document.createElement("div"), + screen: document.createElement("div"), + touchButtons: [createButton({ action: "pause" })], + touchControls: document.createElement("div"), + } + elements.app.focus = vi.fn() + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ moves: ["MoveRight", "MoveDown"] }), + }) + vi.stubGlobal("fetch", fetchMock) + + const dispatch = vi + .fn() + .mockReturnValueOnce(createActionState({ + currentCell: { row: 0, col: 1 }, + traversalHistory: [visit(0, 0), visit(0, 1)], + lastMoveStatus: "applied", + submittedMoves: ["0:MoveRight"], + lastValidMoveIndex: 0, + visitedBefore: false, + })) + .mockReturnValueOnce(createActionState({ + currentCell: { row: 1, col: 1 }, + traversalHistory: [visit(0, 0), visit(0, 1), visit(1, 1)], + lastMoveStatus: "applied", + submittedMoves: ["0:MoveDown"], + lastValidMoveIndex: 0, + visitedBefore: false, + })) + + const readActionState = vi.fn().mockReturnValue(createActionState()) + const commitAgentTurn = vi.fn((decayedMovesCount: number) => + createActionState({ + currentCell: { row: 1, col: 1 }, + score: 800 - decayedMovesCount * 100, + }), + ) + + const mode = createTestAgentMode(elements) + + mode.bindActionDispatch(dispatch, readActionState, commitAgentTurn) + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + "/api/agent/move", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }), + ) + const request = fetchMock.mock.calls[0][1] as RequestInit + + if (typeof request.body !== "string") { + throw new Error("expected agent request body to be serialized json") + } + + expect(JSON.parse(request.body)).toEqual({ + currentCell: { row: 0, col: 0 }, + destinationCell: { row: 0, col: 2 }, + playerName: "Blue", + level: 4, + score: 800, + model: "llama3.2", + stream: false, + format: "json", + status: "running", + traversalHistory: [visit(0, 0)], + allowedMoves: ["MoveUp", "MoveDown", "MoveLeft", "MoveRight"], + recommendedAvgPredictionLimit: 18, + prompt: expectedAgentPrompt, + expectedResponseFormat: { + validPredictionFormat: { + moves: ["MoveRight", "MoveDown"], + }, + }, + lastMoveStatus: null, + submittedMovesIndexBase: 0, + submittedMovesPattern: ":", + submittedMoves: [], + lastValidMoveIndex: null, + decayedMovesCount: 0, + }) + expect(dispatch).toHaveBeenNthCalledWith( + 1, + { type: "MoveRight" }, + { wantFeedback: true, playerName: "Blue" }, + ) + expect(dispatch).toHaveBeenNthCalledWith( + 2, + { type: "MoveDown" }, + { wantFeedback: true, playerName: "Blue" }, + ) + expect(commitAgentTurn).toHaveBeenCalledWith( + 2, + ) + expect(commitAgentTurn).toHaveBeenCalledTimes(1) + expect(mode.readLastActionState()).toEqual( + expect.objectContaining({ + currentCell: { row: 1, col: 1 }, + lastMoveStatus: "applied", + submittedMoves: ["0:MoveRight", "1:MoveDown"], + lastValidMoveIndex: 1, + }), + ) + }) + + it("keeps a single successful prediction as applied", async () => { + const elements = { + app: document.createElement("div"), + body: document.createElement("div"), + controls: [], + measure: document.createElement("div"), + screen: document.createElement("div"), + touchButtons: [createButton({ action: "pause" })], + touchControls: document.createElement("div"), + } + elements.app.focus = vi.fn() + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ moves: ["MoveRight"] }), + }) + vi.stubGlobal("fetch", fetchMock) + + const dispatch = vi.fn().mockReturnValueOnce(createActionState({ + currentCell: { row: 0, col: 1 }, + traversalHistory: [visit(0, 0), visit(0, 1)], + lastMoveStatus: "applied", + submittedMoves: ["0:MoveRight"], + lastValidMoveIndex: 0, + visitedBefore: false, + })) + + const readActionState = vi.fn().mockReturnValue(createActionState()) + const commitAgentTurn = vi.fn((decayedMovesCount: number) => + createActionState({ + currentCell: { row: 0, col: 1 }, + score: 800 - decayedMovesCount * 100, + }), + ) + + const mode = createTestAgentMode(elements) + + mode.bindActionDispatch(dispatch, readActionState, commitAgentTurn) + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + + expect(dispatch).toHaveBeenCalledTimes(1) + expect(commitAgentTurn).toHaveBeenCalledWith(1) + expect(mode.readLastActionState()).toEqual( + expect.objectContaining({ + currentCell: { row: 0, col: 1 }, + lastMoveStatus: "applied", + submittedMoves: ["0:MoveRight"], + lastValidMoveIndex: 0, + decayedMovesCount: 1, + }), + ) + }) + + it("applies score decay to every submitted move in a valid prediction batch even when replay stops early", async () => { + const elements = { + app: document.createElement("div"), + body: document.createElement("div"), + controls: [], + measure: document.createElement("div"), + screen: document.createElement("div"), + touchButtons: [createButton({ action: "pause" })], + touchControls: document.createElement("div"), + } + elements.app.focus = vi.fn() + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + moves: ["MoveRight", "MoveDown", "MoveLeft"], + }), + }) + vi.stubGlobal("fetch", fetchMock) + + const dispatch = vi + .fn() + .mockReturnValueOnce(createActionState({ + currentCell: { row: 0, col: 1 }, + traversalHistory: [visit(0, 0), visit(0, 1)], + lastMoveStatus: "applied", + submittedMoves: ["0:MoveRight"], + lastValidMoveIndex: 0, + visitedBefore: false, + })) + .mockReturnValueOnce(createActionState({ + currentCell: { row: 0, col: 1 }, + traversalHistory: [visit(0, 0), visit(0, 1)], + lastMoveStatus: "invalid-move", + submittedMoves: ["1:MoveDown"], + lastValidMoveIndex: 0, + visitedBefore: true, + })) + + const readActionState = vi.fn().mockReturnValue(createActionState()) + const commitAgentTurn = vi.fn((decayedMovesCount: number) => + createActionState({ + currentCell: { row: 0, col: 1 }, + score: 800 - decayedMovesCount * 100, + }), + ) + + const mode = createTestAgentMode(elements) + + mode.bindActionDispatch(dispatch, readActionState, commitAgentTurn) + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + + expect(dispatch).toHaveBeenCalledTimes(2) + expect(commitAgentTurn).toHaveBeenCalledWith( + 3, + ) + expect(mode.readLastActionState()).toEqual( + expect.objectContaining({ + currentCell: { row: 0, col: 1 }, + lastMoveStatus: "invalid-move", + submittedMoves: ["0:MoveRight", "1:MoveDown", "2:MoveLeft"], + lastValidMoveIndex: 0, + decayedMovesCount: 3, + }), + ) + }) + + it("stops replaying later predictions once a move reaches the target", async () => { + const elements = { + app: document.createElement("div"), + body: document.createElement("div"), + controls: [], + measure: document.createElement("div"), + screen: document.createElement("div"), + touchButtons: [createButton({ action: "pause" })], + touchControls: document.createElement("div"), + } + elements.app.focus = vi.fn() + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + moves: ["MoveRight", "MoveDown", "MoveLeft"], + }), + }) + vi.stubGlobal("fetch", fetchMock) + + const dispatch = vi.fn().mockReturnValueOnce(createActionState({ + currentCell: { row: 0, col: 1 }, + destinationCell: { row: 0, col: 1 }, + traversalHistory: [visit(0, 0), visit(0, 1)], + lastMoveStatus: "reached-target", + submittedMoves: ["0:MoveRight"], + lastValidMoveIndex: 0, + visitedBefore: false, + status: "won", + })) + + const readActionState = vi.fn().mockReturnValue(createActionState()) + const commitAgentTurn = vi.fn((decayedMovesCount: number) => + createActionState({ + currentCell: { row: 0, col: 1 }, + destinationCell: { row: 0, col: 1 }, + score: 800 - decayedMovesCount * 100, + status: "won", + }), + ) + + const mode = createTestAgentMode(elements) + + mode.bindActionDispatch(dispatch, readActionState, commitAgentTurn) + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + + expect(dispatch).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenCalledWith( + { type: "MoveRight" }, + { wantFeedback: true, playerName: "Blue" }, + ) + expect(commitAgentTurn).toHaveBeenCalledWith(3) + expect(mode.readLastActionState()).toEqual( + expect.objectContaining({ + currentCell: { row: 0, col: 1 }, + destinationCell: { row: 0, col: 1 }, + lastMoveStatus: "reached-target", + submittedMoves: ["0:MoveRight", "1:MoveDown", "2:MoveLeft"], + lastValidMoveIndex: 0, + decayedMovesCount: 3, + }), + ) + }) + + it("keeps local session actions human-driven without requesting feedback", () => { + const elements = { + app: document.createElement("div"), + body: document.createElement("div"), + controls: [], + measure: document.createElement("div"), + screen: document.createElement("div"), + touchButtons: [ + createButton({ action: "walls" }), + createButton({ action: "proceed" }), + createButton({ action: "pause" }), + ], + touchControls: document.createElement("div"), + } + elements.app.focus = vi.fn() + + vi.stubGlobal("fetch", vi.fn()) + + const dispatch = vi.fn() + const readActionState = vi.fn().mockReturnValue( + createActionState({ + destinationCell: { row: 0, col: 1 }, + level: 1, + score: 100, + status: "paused", + }), + ) + + const mode = createTestAgentMode(elements) + + mode.bindActionDispatch(dispatch, readActionState, vi.fn(() => createActionState())) + elements.touchButtons[0].click() + elements.touchButtons[1].click() + elements.touchButtons[2].click() + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "b", + ctrlKey: true, + bubbles: true, + }), + ) + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "p", + ctrlKey: true, + bubbles: true, + }), + ) + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: " ", + bubbles: true, + }), + ) + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowRight", + bubbles: true, + }), + ) + + expect(dispatch).toHaveBeenNthCalledWith(1, { type: "cycle-walls" }, { playerName: "Self" }) + expect(dispatch).toHaveBeenNthCalledWith(2, { type: "proceed" }, { playerName: "Self" }) + expect(dispatch).toHaveBeenNthCalledWith(3, { type: "pause" }, { playerName: "Self" }) + expect(dispatch).toHaveBeenNthCalledWith(4, { type: "cycle-walls" }, { playerName: "Self" }) + expect(dispatch).toHaveBeenNthCalledWith(5, { type: "proceed" }, { playerName: "Self" }) + expect(dispatch).toHaveBeenNthCalledWith(6, { type: "pause" }, { playerName: "Self" }) + expect(dispatch).toHaveBeenCalledTimes(6) + expect(mode.readLastActionState()).toBeNull() + }) + + it("stops polling while the maze is not running and restarts after proceed", async () => { + const elements = { + app: document.createElement("div"), + body: document.createElement("div"), + controls: [], + measure: document.createElement("div"), + screen: document.createElement("div"), + touchButtons: [createButton({ action: "proceed" })], + touchControls: document.createElement("div"), + } + elements.app.focus = vi.fn() + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ moves: ["MoveRight"] }), + }) + vi.stubGlobal("fetch", fetchMock) + + let status: "paused" | "running" = "paused" + const dispatch = vi.fn((action: MazeAction, options?: MazeActionDispatchOptions) => { + if (action.type === "proceed") { + status = "running" + return null + } + + if (options?.wantFeedback) { + return { + ...createActionState({ + level: 1, + score: 100, + lastMoveStatus: "applied", + submittedMoves: ["0:MoveRight"], + lastValidMoveIndex: 0, + visitedBefore: false, + }), + } + } + + return null + }) + const readActionState = vi.fn(() => + createActionState({ + level: 1, + score: 100, + status, + }), + ) + const commitAgentTurn = vi.fn(() => createActionState({ level: 1, score: 100, status })) + + const mode = createTestAgentMode(elements) + + mode.bindActionDispatch(dispatch, readActionState, commitAgentTurn) + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + expect(fetchMock).not.toHaveBeenCalled() + + elements.touchButtons[0].click() + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenNthCalledWith(1, { type: "proceed" }, { playerName: "Self" }) + expect(dispatch).toHaveBeenNthCalledWith( + 2, + { type: "MoveRight" }, + { wantFeedback: true, playerName: "Blue" }, + ) + }) + + it("clears stale action state from the next agent request context", async () => { + const elements = { + app: document.createElement("div"), + body: document.createElement("div"), + controls: [], + measure: document.createElement("div"), + screen: document.createElement("div"), + touchButtons: [], + touchControls: document.createElement("div"), + } + elements.app.focus = vi.fn() + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ moves: ["MoveRight"] }), + }) + vi.stubGlobal("fetch", fetchMock) + + const dispatch = vi.fn().mockReturnValue(createActionState({ + currentCell: { row: 0, col: 1 }, + lastMoveStatus: "applied", + submittedMoves: ["0:MoveRight"], + lastValidMoveIndex: 0, + })) + const readActionState = vi.fn(() => createActionState({ level: 1 })) + const commitAgentTurn = vi.fn(() => createActionState({ level: 1 })) + const mode = createTestAgentMode(elements) + + mode.bindActionDispatch(dispatch, readActionState, commitAgentTurn) + mode.recordActionState(createActionState({ + currentCell: { row: 9, col: 9 }, + level: 99, + lastMoveStatus: "reached-target", + submittedMoves: ["0:MoveRight"], + })) + mode.clearActionState() + + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + + const request = fetchMock.mock.calls[0][1] as RequestInit + if (typeof request.body !== "string") { + throw new Error("expected agent request body to be serialized json") + } + + expect(JSON.parse(request.body)).toEqual(expect.objectContaining({ + currentCell: { row: 0, col: 0 }, + level: 1, + lastMoveStatus: null, + submittedMoves: [], + })) + }) + + it("records a network-disabled agent without score decay", async () => { + const elements = { + app: document.createElement("div"), + body: document.createElement("div"), + controls: [], + measure: document.createElement("div"), + screen: document.createElement("div"), + touchButtons: [createButton({ action: "pause" })], + touchControls: document.createElement("div"), + } + elements.app.focus = vi.fn() + + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("network failed"))) + + const disableAgentAfterNetworkError = vi.fn() + const mode = createAgentMode( + elements, + enabledAgentConfigs, + disableAgentAfterNetworkError, + ) + + mode.bindActionDispatch( + vi.fn(), + vi.fn(() => createActionState()), + vi.fn(() => createActionState()), + ) + await vi.advanceTimersByTimeAsync(agentMovePollIntervalMs) + + expect(disableAgentAfterNetworkError).toHaveBeenCalledWith( + enabledAgentConfigs()[0], + ) + expect(mode.readLastActionState()).toEqual( + expect.objectContaining({ + lastMoveStatus: "network-error", + decayedMovesCount: 0, + }), + ) + }) + + it("rebinds local controls without keeping stale listeners alive", () => { + const elements = { + app: document.createElement("div"), + body: document.createElement("div"), + controls: [], + measure: document.createElement("div"), + screen: document.createElement("div"), + touchButtons: [createButton({ action: "pause" })], + touchControls: document.createElement("div"), + } + elements.app.focus = vi.fn() + + vi.stubGlobal("fetch", vi.fn()) + + const firstDispatch = vi.fn() + const secondDispatch = vi.fn() + const readActionState = vi.fn().mockReturnValue( + createActionState({ + currentCell: null, + destinationCell: null, + level: 1, + score: 0, + status: "boot", + traversalHistory: [], + }), + ) + + const mode = createTestAgentMode(elements) + + mode.bindActionDispatch(firstDispatch, readActionState, vi.fn(() => createActionState())) + mode.bindActionDispatch(secondDispatch, readActionState, vi.fn(() => createActionState())) + elements.touchButtons[0].click() + + expect(firstDispatch).not.toHaveBeenCalled() + expect(secondDispatch).toHaveBeenCalledWith({ type: "pause" }, { playerName: "Self" }) + }) +}) diff --git a/frontend/app/control/agent.ts b/frontend/app/control/agent.ts new file mode 100644 index 0000000..1401f7d --- /dev/null +++ b/frontend/app/control/agent.ts @@ -0,0 +1,179 @@ +import type { + Elements, + AgentApiConfig, + MazeAction, + MazeActionControl, + MazeActionDispatch, + MazeActionState, +} from "../types" +import { + handleAgentTurnLoop, +} from "./agent-api" +import type { AgentMovePoller } from "./agent-api" +import { + releaseAllActionBindings, + sessionActionFromButton, + sessionActionFromKeyboardEvent, +} from "./session-actions" +import { + disableAgentApiConfigForNetworkError, + loadPersistedAgentApiConfigs, +} from "../storage" +import { CONFIG } from "../config" + +const { runtime } = CONFIG + +type AgentButtonBinding = { + __button: HTMLButtonElement + __onClick: () => void +} + +// createAgentMode builds the agent-api MazeActionControl while transport wiring is still pending. +export function createAgentMode( + elements: Elements, + readAgentConfigs: () => AgentApiConfig[] = loadPersistedAgentApiConfigs, + disableAgentAfterNetworkError: (agent: AgentApiConfig) => void = (agent) => { + disableAgentApiConfigForNetworkError(agent) + }, +): MazeActionControl { + let attached = false + let agentMovePoller: AgentMovePoller | null = null + let lastActionState: MazeActionState | null = null + let keydownHandler: ((event: KeyboardEvent) => void) | null = null + const buttonBindings: AgentButtonBinding[] = [] + const focusCurrentApp = (): void => { + elements.app.focus() + } + + // releaseBindings removes any listeners registered by the last active dispatch binding. + const releaseBindings = (): void => { + releaseAllActionBindings({ + __attached: attached, + __buttonBindings: buttonBindings, + __keydownHandler: keydownHandler, + __onBeforeRelease: () => { + agentMovePoller?.__setAttached(false) + agentMovePoller?.__stopPolling() + }, + __removeAppFocus: () => { + elements.app.removeEventListener("click", focusCurrentApp) + }, + __setAttached: (nextAttached) => { + attached = nextAttached + }, + __setKeydownHandler: (nextKeydownHandler) => { + keydownHandler = nextKeydownHandler + }, + }) + } + + return { + // This MazeActionControl exposes the agent-api mode name, binds local session actions, and stores feedback for agents. + // name lets the runtime identify which MazeActionControl implementation is active. + name: runtime.controlModes.agentApi, + // bindActionDispatch starts the HTTP-driven move loop while keeping session controls local. + bindActionDispatch( + dispatch: MazeActionDispatch, + readActionState, + commitAgentTurn, + ) { + // Start from a clean slate so rebinding never depends on whatever was attached before. + releaseBindings() + + const recordLastActionState = (actionState: MazeActionState): void => { + lastActionState = actionState + } + + // Agent-owned moves always ask for feedback so the next API request has fresh context. + const dispatchAgentAction = ( + action: MazeAction, + nextDispatch: MazeActionDispatch, + playerName: string, + ): MazeActionState => { + const actionState = nextDispatch(action, { wantFeedback: true, playerName }) + if (!actionState) { + throw new Error("agent move dispatch must return feedback") + } + + recordLastActionState(actionState) + return actionState + } + + agentMovePoller = handleAgentTurnLoop({ + __commitAgentTurn: commitAgentTurn, + __disableAgentAfterNetworkError: disableAgentAfterNetworkError, + __dispatch: dispatch, + __dispatchAgentAction: dispatchAgentAction, + __elements: elements, + __onActionState: recordLastActionState, + __readAgentConfigs: readAgentConfigs, + __readActionState: readActionState, + }) + + const syncCurrentPoller = (): void => { + if (!agentMovePoller) { + return + } + + agentMovePoller.__stopPolling() + if (agentMovePoller.__shouldPollAgent()) { + agentMovePoller.__scheduleNextAgentTurn() + } + } + + // Human-owned session controls stay on the no-feedback path in agent-api mode. + const bindSessionButtons = (buttons: HTMLButtonElement[]): void => { + buttons.forEach((button) => { + const onClick = (): void => { + const command = sessionActionFromButton(button.dataset) + if (!command) { + return + } + + focusCurrentApp() + dispatch(command, { playerName: runtime.interactivePlayerName }) + syncCurrentPoller() + } + + buttonBindings.push({ __button: button, __onClick: onClick }) + button.addEventListener("click", onClick) + }) + } + + bindSessionButtons(elements.controls) + bindSessionButtons(elements.touchButtons) + + keydownHandler = (event: KeyboardEvent): void => { + const command = sessionActionFromKeyboardEvent(event) + if (!command) { + return + } + + event.preventDefault() + dispatch(command, { playerName: runtime.interactivePlayerName }) + syncCurrentPoller() + } + + window.addEventListener("keydown", keydownHandler, { passive: false }) + elements.app.addEventListener("click", focusCurrentApp) + attached = true + agentMovePoller.__setAttached(true) + agentMovePoller.__setLastActionState(lastActionState) + syncCurrentPoller() + }, + // readLastActionState exposes the latest stored response state for agent-side consumers. + readLastActionState() { + return lastActionState + }, + // recordActionState keeps the last response state available for the agent-api control flow. + recordActionState(actionState: MazeActionState) { + lastActionState = actionState + agentMovePoller?.__setLastActionState(actionState) + }, + // clearActionState drops stale agent-facing context after full-session resets. + clearActionState() { + lastActionState = null + agentMovePoller?.__setLastActionState(null) + }, + } +} diff --git a/frontend/app/control/interactive.test.ts b/frontend/app/control/interactive.test.ts new file mode 100644 index 0000000..d4efbbd --- /dev/null +++ b/frontend/app/control/interactive.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from "vitest" + +import { CONFIG } from "../config" +import { createInteractiveMode } from "./interactive" +import type { MazeActionState } from "../types" + +// createButton reproduces the data attributes used by keyboard and touch controls. +function createButton({ + action, + move, +}: { + action?: string + move?: string +}): HTMLButtonElement { + const button = document.createElement("button") + + if (action) { + button.dataset.action = action + } + + if (move) { + button.dataset.move = move + } + + return button +} + +// createActionState supplies the shared flattened agent payload shape expected by the control contract. +function createActionState( + overrides: Partial = {}, +): MazeActionState { + return { + currentCell: null, + destinationCell: null, + traversalHistory: [], + playerName: "Blue", + level: 1, + score: 0, + model: "", + stream: false, + format: "json", + status: "boot", + allowedMoves: ["MoveUp", "MoveDown", "MoveLeft", "MoveRight"], + recommendedAvgPredictionLimit: 0, + prompt: "", + expectedResponseFormat: { + validPredictionFormat: { + moves: ["MoveRight", "MoveDown"], + }, + }, + lastMoveStatus: null, + submittedMovesIndexBase: 0, + submittedMovesPattern: ":", + submittedMoves: [], + lastValidMoveIndex: null, + decayedMovesCount: 0, + ...overrides, + } +} + +// These tests guard the interactive-mode translation layer and contract shape. +describe("interactive control mode", () => { + it("implements the shared control mode contract", () => { + const elements = { + app: document.createElement("div"), + body: document.createElement("div"), + controls: [createButton({ action: "restart" })], + measure: document.createElement("div"), + screen: document.createElement("div"), + touchButtons: [createButton({ move: "MoveRight" })], + touchControls: document.createElement("div"), + } + elements.app.focus = vi.fn() + const dispatch = vi.fn() + + const mode = createInteractiveMode(elements) + + expect(mode.name).toBe(CONFIG.runtime.controlModes.interactive) + expect(mode.readLastActionState()).toBeNull() + + mode.bindActionDispatch(dispatch, vi.fn(() => createActionState()), vi.fn(() => createActionState())) + elements.controls[0].click() + elements.touchButtons[0].click() + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: " ", + bubbles: true, + }), + ) + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowUp", + bubbles: true, + }), + ) + + expect(dispatch).toHaveBeenNthCalledWith(1, { type: "restart" }, { playerName: "Self" }) + expect(dispatch).toHaveBeenNthCalledWith(2, { + type: "MoveRight", + }, { playerName: "Self" }) + expect(dispatch).toHaveBeenNthCalledWith(3, { type: "pause" }, { playerName: "Self" }) + expect(dispatch).toHaveBeenNthCalledWith(4, { + type: "MoveUp", + }, { playerName: "Self" }) + + mode.recordActionState(createActionState({ + lastMoveStatus: "applied", + })) + expect(mode.readLastActionState()).toBeNull() + }) + + it("ignores unsupported button and keyboard actions", () => { + const elements = { + app: document.createElement("div"), + body: document.createElement("div"), + controls: [createButton({ action: "unknown" })], + measure: document.createElement("div"), + screen: document.createElement("div"), + touchButtons: [], + touchControls: document.createElement("div"), + } + elements.app.focus = vi.fn() + const dispatch = vi.fn() + + const mode = createInteractiveMode(elements) + + mode.bindActionDispatch(dispatch, vi.fn(() => createActionState()), vi.fn(() => createActionState())) + elements.controls[0].click() + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + }), + ) + + expect(dispatch).not.toHaveBeenCalled() + }) + + it("rebinds controls without keeping stale listeners alive", () => { + const elements = { + app: document.createElement("div"), + body: document.createElement("div"), + controls: [createButton({ action: "restart" })], + measure: document.createElement("div"), + screen: document.createElement("div"), + touchButtons: [], + touchControls: document.createElement("div"), + } + elements.app.focus = vi.fn() + const firstDispatch = vi.fn() + const secondDispatch = vi.fn() + + const mode = createInteractiveMode(elements) + + const readActionState = vi.fn(() => createActionState()) + + mode.bindActionDispatch(firstDispatch, readActionState, vi.fn(() => createActionState())) + mode.bindActionDispatch(secondDispatch, readActionState, vi.fn(() => createActionState())) + elements.controls[0].click() + + expect(firstDispatch).not.toHaveBeenCalled() + expect(secondDispatch).toHaveBeenCalledWith({ type: "restart" }, { playerName: "Self" }) + }) +}) diff --git a/frontend/app/control/interactive.ts b/frontend/app/control/interactive.ts new file mode 100644 index 0000000..8616021 --- /dev/null +++ b/frontend/app/control/interactive.ts @@ -0,0 +1,143 @@ +import type { + Elements, + MazeActionControl, + MazeActionDispatch, + MazeActionState, + MoveAction, +} from "../types" +import { + releaseAllActionBindings, + sessionActionFromButton, + sessionActionFromKeyboardEvent, +} from "./session-actions" +import { CONFIG } from "../config" + +const { runtime } = CONFIG + +// KEY_TO_MOVE_ACTION maps browser arrow-key events into semantic movement commands. +const KEY_TO_MOVE_ACTION: Partial> = { + ArrowLeft: "MoveLeft", + ArrowRight: "MoveRight", + ArrowUp: "MoveUp", + ArrowDown: "MoveDown", +} + +// createInteractiveMode builds the interactive MazeActionControl used by the main game page. +export function createInteractiveMode( + elements: Elements, +): MazeActionControl { + let attached = false + let keydownHandler: ((event: KeyboardEvent) => void) | null = null + const buttonBindings: Array<{ + __button: HTMLButtonElement + __onClick: () => void + }> = [] + + // focusApp keeps keyboard input anchored to the terminal root after button taps. + const focusApp = (): void => { + elements.app.focus() + } + + // releaseBindings removes any listeners registered by the last active dispatch binding. + const releaseBindings = (): void => { + releaseAllActionBindings({ + __attached: attached, + __buttonBindings: buttonBindings, + __keydownHandler: keydownHandler, + __removeAppFocus: () => { + elements.app.removeEventListener("click", focusApp) + }, + __setAttached: (nextAttached) => { + attached = nextAttached + }, + __setKeydownHandler: (nextKeydownHandler) => { + keydownHandler = nextKeydownHandler + }, + }) + } + + // handleButtonClick resolves one button press into a semantic runtime command. + const handleButtonClick = ( + button: HTMLButtonElement, + dispatch: MazeActionDispatch, + ): void => { + const action = button.dataset.move + ? { type: button.dataset.move as MoveAction } + : sessionActionFromButton(button.dataset) + if (!action) { + return + } + + focusApp() + // Interactive controls do not request feedback; the game view already reflects the outcome. + dispatch(action, { playerName: runtime.interactivePlayerName }) + } + + // handleKeydown routes keyboard gestures through the same command vocabulary. + const handleKeydown = ( + event: KeyboardEvent, + dispatch: MazeActionDispatch, + ): void => { + const moveAction = KEY_TO_MOVE_ACTION[event.key] + const action = moveAction + ? { type: moveAction } + : sessionActionFromKeyboardEvent(event) + if (!action) { + return + } + + event.preventDefault() + // Interactive controls do not request feedback; the game view already reflects the outcome. + dispatch(action, { playerName: runtime.interactivePlayerName }) + } + + // bindControlButtons attaches one shared button-handler path to top-menu and touch controls. + const bindControlButtons = ( + buttons: HTMLButtonElement[], + dispatch: MazeActionDispatch, + ): void => { + buttons.forEach((button) => { + const onClick = (): void => { + handleButtonClick(button, dispatch) + } + + buttonBindings.push({ __button: button, __onClick: onClick }) + button.addEventListener("click", onClick) + }) + } + + return { + // This MazeActionControl exposes the interactive mode name, binds browser inputs, and ignores stored feedback. + // name lets the runtime identify which MazeActionControl implementation is active. + name: runtime.controlModes.interactive, + // bindActionDispatch connects browser keyboard and button events to the shared action dispatcher. + bindActionDispatch(dispatch, readActionState, commitAgentTurn) { + void readActionState + void commitAgentTurn + // Start from a clean slate so rebinding never depends on whatever was attached before. + releaseBindings() + + bindControlButtons(elements.controls, dispatch) + bindControlButtons(elements.touchButtons, dispatch) + + keydownHandler = (event: KeyboardEvent): void => { + handleKeydown(event, dispatch) + } + + window.addEventListener("keydown", keydownHandler, { passive: false }) + elements.app.addEventListener("click", focusApp) + attached = true + }, + // readLastActionState stays empty here because interactive users already get visual feedback. + readLastActionState() { + return null + }, + // recordActionState is a no-op because the interactive mode does not retain command states. + recordActionState(actionState: MazeActionState) { + // Interactive controls already provide immediate visual feedback in the game view. + void actionState + }, + // clearActionState is a no-op because interactive mode never stores action states. + clearActionState() {}, + } +} diff --git a/frontend/app/control/session-actions.test.ts b/frontend/app/control/session-actions.test.ts new file mode 100644 index 0000000..f25312c --- /dev/null +++ b/frontend/app/control/session-actions.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest" + +import { + sessionActionFromButton, + sessionActionFromKeyboardEvent, +} from "./session-actions" + +// createButton reproduces the action-only touch-button dataset consumed by the shared helpers. +function createButton(action: string): HTMLButtonElement { + const button = document.createElement("button") + button.dataset.action = action + return button +} + +// These tests lock down the shared session actions used by both browser modes. +describe("shared session actions", () => { + it("translates keyboard shortcuts into pause, proceed, and wall actions", () => { + expect( + sessionActionFromKeyboardEvent({ + key: " ", + ctrlKey: false, + metaKey: false, + }), + ).toEqual({ type: "pause" }) + expect( + sessionActionFromKeyboardEvent({ + key: "Enter", + ctrlKey: false, + metaKey: false, + }), + ).toEqual({ type: "proceed" }) + expect( + sessionActionFromKeyboardEvent({ + key: "p", + ctrlKey: true, + metaKey: false, + }), + ).toEqual({ type: "proceed" }) + expect( + sessionActionFromKeyboardEvent({ + key: "b", + ctrlKey: true, + metaKey: false, + }), + ).toEqual({ type: "cycle-walls" }) + expect( + sessionActionFromKeyboardEvent({ + key: "ArrowRight", + ctrlKey: false, + metaKey: false, + }), + ).toBeNull() + }) + + it("translates shared touch-action buttons into session actions", () => { + expect(sessionActionFromButton(createButton("pause").dataset)).toEqual({ + type: "pause", + }) + expect(sessionActionFromButton(createButton("proceed").dataset)).toEqual({ + type: "proceed", + }) + expect(sessionActionFromButton(createButton("walls").dataset)).toEqual({ + type: "cycle-walls", + }) + expect(sessionActionFromButton(createButton("restart").dataset)).toEqual({ + type: "restart", + }) + }) +}) diff --git a/frontend/app/control/session-actions.ts b/frontend/app/control/session-actions.ts new file mode 100644 index 0000000..fce6cef --- /dev/null +++ b/frontend/app/control/session-actions.ts @@ -0,0 +1,93 @@ +import type { MazeAction, SessionAction } from "../types" + +// SessionMazeAction keeps the shared browser-only session actions grouped together. +export type SessionMazeAction = Extract + +type ButtonBinding = { + __button: HTMLButtonElement + __onClick: () => void +} + +type ReleaseActionBindingsOptions = { + __attached: boolean + __buttonBindings: ButtonBinding[] + __keydownHandler: ((event: KeyboardEvent) => void) | null + __onAfterRelease?: () => void + __onBeforeRelease?: () => void + __removeAppFocus: () => void + __setAttached: (attached: boolean) => void + __setKeydownHandler: (handler: ((event: KeyboardEvent) => void) | null) => void +} + +// releaseAllActionBindings clears every listener owned by the active control mode. +export function releaseAllActionBindings({ + __attached, + __buttonBindings, + __keydownHandler, + __onAfterRelease, + __onBeforeRelease, + __removeAppFocus, + __setAttached, + __setKeydownHandler, +}: ReleaseActionBindingsOptions): void { + if (!__attached) { + return + } + + __onBeforeRelease?.() + __buttonBindings.forEach(({ __button, __onClick }) => { + __button.removeEventListener("click", __onClick) + }) + __buttonBindings.length = 0 + if (__keydownHandler) { + window.removeEventListener("keydown", __keydownHandler) + __setKeydownHandler(null) + } + __removeAppFocus() + __setAttached(false) + __onAfterRelease?.() +} + +// sessionActionFromKeyboardEvent translates shared keyboard shortcuts into session actions. +export function sessionActionFromKeyboardEvent( + event: Pick, +): SessionMazeAction | null { + const lowerKey = event.key.toLowerCase() + const controlCombo = event.ctrlKey || event.metaKey + + if (controlCombo && lowerKey === "b") { + return { type: "cycle-walls" } + } + + if (controlCombo && lowerKey === "p") { + return { type: "proceed" } + } + + if (event.key === "Enter") { + return { type: "proceed" } + } + + if (event.key === " ") { + return { type: "pause" } + } + + return null +} + +// sessionActionFromButton translates shared touch-action buttons into session actions. +export function sessionActionFromButton( + dataset: DOMStringMap, +): SessionMazeAction | null { + switch (dataset.action) { + case "pause": + return { type: "pause" } + case "proceed": + return { type: "proceed" } + case "walls": + return { type: "cycle-walls" } + case "restart": + return { type: "restart" } + default: + return null + } +} diff --git a/frontend/app/dom.test.ts b/frontend/app/dom.test.ts index c266f98..2d82907 100644 --- a/frontend/app/dom.test.ts +++ b/frontend/app/dom.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +// setupTerminalDom recreates the minimal browser terminal shell used by DOM tests. function setupTerminalDom(): void { document.body.innerHTML = `
@@ -10,6 +11,7 @@ function setupTerminalDom(): void { ` } +// These tests lock down DOM discovery and viewport-based terminal measurements. describe("dom", () => { beforeEach(() => { vi.resetModules() @@ -35,7 +37,11 @@ describe("dom", () => { })), ) - const { elements, getTerminalSize } = await import("./dom") + const { getGameElements, getTerminalSize } = await import("./dom") + const elements = getGameElements() + if (!elements) { + throw new Error("expected terminal elements") + } elements.body.getBoundingClientRect = vi.fn(() => ({ width: 960, @@ -64,7 +70,7 @@ describe("dom", () => { fontSize: "16px", } as CSSStyleDeclaration) - expect(getTerminalSize()).toEqual({ length: 22, width: 11 }) + expect(getTerminalSize(elements)).toEqual({ length: 22, width: 11 }) }) it("ignores the floating touch controls when measuring the terminal", async () => { @@ -82,7 +88,11 @@ describe("dom", () => { })), ) - const { elements, getTerminalSize } = await import("./dom") + const { getGameElements, getTerminalSize } = await import("./dom") + const elements = getGameElements() + if (!elements) { + throw new Error("expected terminal elements") + } const touchControlsRect = vi.fn(() => ({ width: 2_000, @@ -124,7 +134,15 @@ describe("dom", () => { fontSize: "16px", } as CSSStyleDeclaration) - expect(getTerminalSize()).toEqual({ length: 22, width: 11 }) + expect(getTerminalSize(elements)).toEqual({ length: 22, width: 11 }) expect(touchControlsRect).not.toHaveBeenCalled() }) + + it("returns null when the page does not include a terminal game host", async () => { + document.body.innerHTML = `
` + + const { getGameElements } = await import("./dom") + + expect(getGameElements()).toBeNull() + }) }) diff --git a/frontend/app/dom.ts b/frontend/app/dom.ts index ff8dced..7660c18 100644 --- a/frontend/app/dom.ts +++ b/frontend/app/dom.ts @@ -1,62 +1,79 @@ -import { - CONFIG, - MIN_TERMINAL_COLUMNS, - MIN_TERMINAL_ROWS, - TERMINAL_SAMPLE_WIDTH, -} from "./config" +import { CONFIG } from "./config" import type { BaseDimensions, Elements } from "./types" +const { runtime, viewport } = CONFIG + +// mustElement fetches a required terminal node and fails fast when it is missing. function mustElement(id: string): T { const element = document.getElementById(id) if (!(element instanceof HTMLElement)) { - throw new Error(CONFIG.missingElementErrorTemplate.replace("{id}", id)) + throw new Error(runtime.missingElementErrorTemplate.replace("{id}", id)) } return element as T } -export const elements: Elements = { - app: mustElement("terminal-app"), - body: mustElement("terminal-body"), - screen: mustElement("terminal-screen"), - measure: mustElement("terminal-measure"), - controls: Array.from( - document.querySelectorAll( - "[data-action]:not([data-touch-control])", +// hasTerminalElements checks whether the current page actually hosts the terminal UI. +function hasTerminalElements(): boolean { + return ( + document.getElementById("terminal-app") instanceof HTMLElement && + document.getElementById("terminal-body") instanceof HTMLElement && + document.getElementById("terminal-screen") instanceof HTMLElement && + document.getElementById("terminal-measure") instanceof HTMLElement && + document.getElementById("touch-controls") instanceof HTMLElement + ) +} + +// getGameElements gathers the DOM handles used by the runtime and renderer. +export function getGameElements(): Elements | null { + if (!hasTerminalElements()) { + return null + } + + return { + app: mustElement("terminal-app"), + body: mustElement("terminal-body"), + screen: mustElement("terminal-screen"), + measure: mustElement("terminal-measure"), + controls: Array.from( + document.querySelectorAll( + "[data-action]:not([data-touch-control])", + ), ), - ), - touchControls: mustElement("touch-controls"), - touchButtons: Array.from( - document.querySelectorAll("[data-touch-control]"), - ), + touchControls: mustElement("touch-controls"), + touchButtons: Array.from( + document.querySelectorAll("[data-touch-control]"), + ), + } } -export function getTerminalSize(): BaseDimensions { +// getTerminalSize converts DOM measurements into logical maze dimensions. +export function getTerminalSize(elements: Elements): BaseDimensions { const rect = elements.body.getBoundingClientRect() const sampleRect = elements.measure.getBoundingClientRect() const screenStyle = window.getComputedStyle(elements.screen) - const charWidth = sampleRect.width / TERMINAL_SAMPLE_WIDTH || 9 + const charWidth = sampleRect.width / viewport.terminalSampleWidth || 9 const measuredRowHeight = sampleRect.height const computedLineHeight = Number.parseFloat(screenStyle.lineHeight) const computedFontSize = Number.parseFloat(screenStyle.fontSize) const terminalRowHeight = measuredRowHeight || computedLineHeight || computedFontSize || 16 const terminalColumns = Math.max( - MIN_TERMINAL_COLUMNS, + viewport.minTerminalColumns, Math.floor(rect.width / charWidth), ) const terminalRows = Math.max( - MIN_TERMINAL_ROWS, + viewport.minTerminalRows, Math.floor(rect.height / terminalRowHeight), ) return { length: Math.floor( - (terminalColumns - CONFIG.terminalHeightInset) / - CONFIG.terminalHeightScale, + (terminalColumns - viewport.terminalHeightInset) / + viewport.terminalHeightScale, ), width: Math.floor( - (terminalRows - CONFIG.terminalWidthInset) / CONFIG.terminalWidthScale, + (terminalRows - viewport.terminalWidthInset) / viewport.terminalWidthScale, ), } } diff --git a/frontend/app/game.test.ts b/frontend/app/game.test.ts index ed9235c..98af407 100644 --- a/frontend/app/game.test.ts +++ b/frontend/app/game.test.ts @@ -1,13 +1,39 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { CONFIG } from "./config" +import { isAgentApiMode } from "./status" import type { + AgentApiConfig, Elements, + GameRuntime, + MazeActionControl, + MazeControlModeName, PersistedPreferences, PersistedRound, RoundState, State, + TraversalHistoryEntry, } from "./types" +function visit(row: number, col: number): TraversalHistoryEntry { + return { playerName: "Blue", row, col } +} + +function selfVisit(row: number, col: number): TraversalHistoryEntry { + return { playerName: "Self", row, col } +} + +function enabledAgentConfig(): AgentApiConfig { + return { + id: "blue-agent", + playerName: "Blue", + model: "llama3.2", + endpoint: "/api/agent/move", + enabled: true, + } +} + +// createButton reproduces the control datasets used by the browser runtime. function createButton({ action, move, @@ -34,6 +60,7 @@ function createButton({ return button } +// createElements builds the minimal DOM and control shell required by runtime tests. function createElements(): Elements { const app = document.createElement("div") app.focus = vi.fn() @@ -64,6 +91,7 @@ function createElements(): Elements { } } +// createRound returns the smallest square round used by most harness scenarios. function createRound(): RoundState { return { maze: [ @@ -71,11 +99,12 @@ function createRound(): RoundState { ["|", " ", "|"], ["|", "---", "|"], ], - startPosition: [1, 1], - finalPosition: [1, 1], + startPosition: { x: 1, y: 1 }, + finalPosition: { x: 1, y: 1 }, } } +// createHorizontalRound exposes one movable corridor for command-dispatch tests. function createHorizontalRound(): RoundState { return { maze: [ @@ -83,23 +112,121 @@ function createHorizontalRound(): RoundState { ["|", " ", " ", " ", "|"], ["|", "---", "-", "---", "|"], ], - startPosition: [1, 1], - finalPosition: [1, 3], + startPosition: { x: 1, y: 1 }, + finalPosition: { x: 3, y: 1 }, + } +} + +// createTraversalMock keeps mocked traversal behavior aligned with the runtime helper exports. +function createTraversalMock({ + isSpaceFound = () => true, + nextWallWeight = (weight: number) => (weight === 3 ? 1 : weight + 1), + reweightMaze = (maze: string[][]) => maze, +}: { + isSpaceFound?: (cell: string) => boolean + nextWallWeight?: (weight: number) => number + reweightMaze?: (maze: string[][]) => string[][] +} = {}) { + const cellCoordinateFromGridPoint = ({ x, y }: { x: number; y: number }) => ({ + row: Math.floor((y - 1) / CONFIG.maze.cellSpan), + col: Math.floor((x - 1) / CONFIG.maze.cellSpan), + }) + const gridPointFromCellCoordinate = ({ row, col }: { row: number; col: number }) => ({ + x: col * CONFIG.maze.cellSpan + 1, + y: row * CONFIG.maze.cellSpan + 1, + }) + const mazeCellKey = ({ row, col }: { row: number; col: number }) => `${row}:${col}` + const traversalHistoryIncludes = ( + traversalHistory: TraversalHistoryEntry[], + cell: { row: number; col: number }, + ) => + traversalHistory.some( + (visitedCell) => visitedCell.row === cell.row && visitedCell.col === cell.col, + ) + + return { + cellCoordinateFromGridPoint: vi.fn(cellCoordinateFromGridPoint), + currentTotalCells: vi.fn((mazeDimensions: { length: number; width: number } | null) => { + if (!mazeDimensions || mazeDimensions.length <= 0 || mazeDimensions.width <= 0) { + return 0 + } + + return mazeDimensions.length * mazeDimensions.width + }), + gridPointFromCellCoordinate: vi.fn(gridPointFromCellCoordinate), + isMoveAction: vi.fn((action: { type: string }) => + ["MoveLeft", "MoveRight", "MoveUp", "MoveDown"].includes(action.type), + ), + isSpaceFound: vi.fn(isSpaceFound), + isWallWeight: vi.fn((value: number) => value >= 1 && value <= 3), + mazeCellKey: vi.fn(mazeCellKey), + nextWallWeight: vi.fn(nextWallWeight), + resolvePlayerMove: vi.fn((state: State, action: string) => { + if ( + state.status !== "running" || + !state.maze || + !state.mazeDimensions || + !state.playerPosition + ) { + return { canMove: false } + } + + const deltas: Record = { + MoveLeft: [0, -1], + MoveRight: [0, 1], + MoveUp: [-1, 0], + MoveDown: [1, 0], + } + const [rowDelta, columnDelta] = deltas[action] + const nextY = state.playerPosition.y + rowDelta * CONFIG.maze.moveStep + const nextX = state.playerPosition.x + columnDelta * CONFIG.maze.moveStep + const probeY = state.playerPosition.y + rowDelta + const probeX = state.playerPosition.x + columnDelta + + if (nextY <= 0 || nextY > state.mazeDimensions.width * CONFIG.maze.cellSpan) { + return { canMove: false } + } + + if (nextX <= 0 || nextX > state.mazeDimensions.length * CONFIG.maze.cellSpan) { + return { canMove: false } + } + + if (!isSpaceFound(state.maze[probeY][probeX])) { + return { canMove: false } + } + + const nextCell = cellCoordinateFromGridPoint({ x: nextX, y: nextY }) + + return { + canMove: true, + nextCell, + nextGridPoint: { x: nextX, y: nextY }, + visitedBefore: traversalHistoryIncludes(state.traversalHistory, nextCell), + } + }), + reweightMaze: vi.fn(reweightMaze), + traversalHistoryEntry: vi.fn((cell: { row: number; col: number }, playerName: string) => ({ + ...cell, + playerName, + })), + traversalHistoryIncludes: vi.fn(traversalHistoryIncludes), } } +// createPersistedWonRound simulates a stored win that can proceed into the next level. function createPersistedWonRound(): PersistedRound { return { - version: 1, level: 3, - dims: { length: 1, width: 1 }, + mazeDimensions: { length: 1, width: 1 }, maze: [ ["|", "---", "|"], ["|", " ", "|"], ["|", "---", "|"], ], - playerPosition: [1, 1], - finalPosition: [1, 1], + playerPosition: { x: 1, y: 1 }, + startCell: { row: 0, col: 0 }, + traversalHistory: [visit(0, 0)], + finalPosition: { x: 1, y: 1 }, wallWeight: 1, status: "won", score: 500, @@ -109,6 +236,7 @@ function createPersistedWonRound(): PersistedRound { } } +// latestRenderedState pulls the most recent render payload out of the mock renderer. function latestRenderedState( render: ReturnType void>>, ): State { @@ -128,6 +256,7 @@ type DimensionsResult = { } | null type GameHarness = { + clearPersistedAgentApiConfigs: ReturnType clearPersistedSnapshot: ReturnType clearPersistedRound: ReturnType elements: Elements @@ -135,13 +264,17 @@ type GameHarness = { getMazeDimensions: ReturnType intervalCallback: (() => void) | null loadPersistedSnapshot: ReturnType + mode: MazeActionControl render: ReturnType void>> reweightMaze: ReturnType - savePersistedPreferences: ReturnType - savePersistedRoundState: ReturnType + runtime: GameRuntime + saveGameProgress: ReturnType + saveActiveRoundSnapshot: ReturnType } +// bootstrapHarness wires a mocked runtime so high-level browser game flows stay testable. async function bootstrapHarness({ + agentConfigs = [], dimensionsResults = [{ level: 1, length: 1, width: 1 }], isSpaceFound = () => true, persistedSnapshots = [ @@ -149,8 +282,10 @@ async function bootstrapHarness({ ], reweightedMaze, round = createRound(), + mode = CONFIG.runtime.controlModes.interactive, terminalSizes = [{ length: 20, width: 20 }], }: { + agentConfigs?: AgentApiConfig[] dimensionsResults?: DimensionsResult[] isSpaceFound?: (cell: string) => boolean persistedSnapshots?: Array<{ @@ -159,12 +294,14 @@ async function bootstrapHarness({ }> reweightedMaze?: string[][] round?: RoundState + mode?: MazeControlModeName terminalSizes?: Array<{ length: number; width: number }> } = {}): Promise { const elements = createElements() const render = vi.fn<(elements: Elements, state: State) => void>() - const savePersistedPreferences = vi.fn() - const savePersistedRoundState = vi.fn() + const saveGameProgress = vi.fn() + const saveActiveRoundSnapshot = vi.fn() + const clearPersistedAgentApiConfigs = vi.fn() const clearPersistedSnapshot = vi.fn() const clearPersistedRound = vi.fn() const generateMaze = vi.fn(() => round) @@ -229,18 +366,24 @@ async function bootstrapHarness({ vi.doMock("./maze", () => ({ generateMaze, getMazeDimensions, - isSpaceFound: vi.fn(isSpaceFound), - isWallWeight: vi.fn((value: number) => value >= 1 && value <= 3), - nextWallWeight: vi.fn((weight: number) => (weight === 3 ? 1 : weight + 1)), - reweightMaze, + getNavigationProfile: vi.fn(() => ({ + __softCorridorLimit: 8, + __hardCorridorLimit: 10, + __preferTurnPercent: 90, + })), })) + vi.doMock("./traversal", () => createTraversalMock({ isSpaceFound, reweightMaze })) vi.doMock("./render", () => ({ render })) vi.doMock("./storage", () => ({ + clearPersistedAgentApiConfigs, clearPersistedSnapshot, clearPersistedRound, + clearStaleStorageVersions: vi.fn(), + disableAgentApiConfigForNetworkError: vi.fn(), + loadPersistedAgentApiConfigs: vi.fn(() => agentConfigs), loadPersistedSnapshot, - savePersistedPreferences, - savePersistedRoundState, + saveGameProgress, + saveActiveRoundSnapshot, })) vi.spyOn(window, "setInterval").mockImplementation( (handler: TimerHandler) => { @@ -255,10 +398,15 @@ async function bootstrapHarness({ }, ) + const { createAgentMode } = await import("./control/agent") + const { createInteractiveMode } = await import("./control/interactive") const { bootstrapGame } = await import("./game") - bootstrapGame() + const controlMode = + isAgentApiMode(mode) ? createAgentMode(elements) : createInteractiveMode(elements) + const runtime = bootstrapGame(controlMode, elements) return { + clearPersistedAgentApiConfigs, clearPersistedSnapshot, clearPersistedRound, elements, @@ -266,10 +414,12 @@ async function bootstrapHarness({ getMazeDimensions, intervalCallback, loadPersistedSnapshot, + mode: controlMode, render, reweightMaze, - savePersistedPreferences, - savePersistedRoundState, + runtime, + saveGameProgress, + saveActiveRoundSnapshot, } } @@ -282,6 +432,7 @@ describe("bootstrapGame", () => { afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() + vi.useRealTimers() Reflect.deleteProperty(window, "visualViewport") }) @@ -306,28 +457,33 @@ describe("bootstrapGame", () => { vi.doMock("./maze", () => ({ generateMaze, getMazeDimensions, - isSpaceFound: vi.fn(() => true), - isWallWeight: vi.fn((value: number) => value >= 1 && value <= 3), - nextWallWeight: vi.fn((weight: number) => - weight === 3 ? 1 : weight + 1, - ), - reweightMaze: vi.fn((maze: string[][]) => maze), + getNavigationProfile: vi.fn(() => ({ + __softCorridorLimit: 8, + __hardCorridorLimit: 10, + __preferTurnPercent: 90, + })), })) + vi.doMock("./traversal", () => createTraversalMock()) vi.doMock("./render", () => ({ render })) vi.doMock("./storage", () => ({ clearPersistedSnapshot: vi.fn(), clearPersistedRound: vi.fn(), + clearStaleStorageVersions: vi.fn(), + disableAgentApiConfigForNetworkError: vi.fn(), + loadPersistedAgentApiConfigs: vi.fn(() => []), loadPersistedSnapshot, - savePersistedPreferences: vi.fn(), - savePersistedRoundState: vi.fn(), + saveGameProgress: vi.fn(), + saveActiveRoundSnapshot: vi.fn(), })) vi.spyOn(window, "setInterval").mockImplementation(() => 1) + const { createInteractiveMode } = await import("./control/interactive") const { bootstrapGame } = await import("./game") - bootstrapGame() + bootstrapGame(createInteractiveMode(elements), elements) expect(loadPersistedSnapshot).toHaveBeenCalledWith( + CONFIG.runtime.controlModes.interactive, 1, 1, expect.any(Function), @@ -343,6 +499,7 @@ describe("bootstrapGame", () => { expect(state.status).toBe("running") expect(state.level).toBe(2) expect(state.wallWeight).toBe(1) + expect(state.traversalHistory).toEqual([selfVisit(0, 0)]) }) it("subscribes to visual viewport resize events when available", async () => { @@ -368,29 +525,33 @@ describe("bootstrapGame", () => { length: 1, width: 1, })), - isSpaceFound: vi.fn(() => true), - isWallWeight: vi.fn((value: number) => value >= 1 && value <= 3), - nextWallWeight: vi.fn((weight: number) => - weight === 3 ? 1 : weight + 1, - ), - reweightMaze: vi.fn((maze: string[][]) => maze), + getNavigationProfile: vi.fn(() => ({ + __softCorridorLimit: 8, + __hardCorridorLimit: 10, + __preferTurnPercent: 90, + })), })) + vi.doMock("./traversal", () => createTraversalMock()) vi.doMock("./render", () => ({ render })) vi.doMock("./storage", () => ({ clearPersistedSnapshot: vi.fn(), clearPersistedRound: vi.fn(), + clearStaleStorageVersions: vi.fn(), + disableAgentApiConfigForNetworkError: vi.fn(), + loadPersistedAgentApiConfigs: vi.fn(() => []), loadPersistedSnapshot: vi.fn(() => ({ preferences: { level: 1, wallWeight: 1 }, round: null, })), - savePersistedPreferences: vi.fn(), - savePersistedRoundState: vi.fn(), + saveGameProgress: vi.fn(), + saveActiveRoundSnapshot: vi.fn(), })) vi.spyOn(window, "setInterval").mockImplementation(() => 1) + const { createInteractiveMode } = await import("./control/interactive") const { bootstrapGame } = await import("./game") - bootstrapGame() + bootstrapGame(createInteractiveMode(elements), elements) expect(addViewportListener).toHaveBeenCalledWith( "resize", @@ -417,26 +578,30 @@ describe("bootstrapGame", () => { vi.doMock("./maze", () => ({ generateMaze, getMazeDimensions, - isSpaceFound: vi.fn(() => true), - isWallWeight: vi.fn((value: number) => value >= 1 && value <= 3), - nextWallWeight: vi.fn((weight: number) => - weight === 3 ? 1 : weight + 1, - ), - reweightMaze: vi.fn((maze: string[][]) => maze), + getNavigationProfile: vi.fn(() => ({ + __softCorridorLimit: 8, + __hardCorridorLimit: 10, + __preferTurnPercent: 90, + })), })) + vi.doMock("./traversal", () => createTraversalMock()) vi.doMock("./render", () => ({ render })) vi.doMock("./storage", () => ({ clearPersistedSnapshot: vi.fn(), clearPersistedRound: vi.fn(), + clearStaleStorageVersions: vi.fn(), + disableAgentApiConfigForNetworkError: vi.fn(), + loadPersistedAgentApiConfigs: vi.fn(() => []), loadPersistedSnapshot, - savePersistedPreferences: vi.fn(), - savePersistedRoundState: vi.fn(), + saveGameProgress: vi.fn(), + saveActiveRoundSnapshot: vi.fn(), })) vi.spyOn(window, "setInterval").mockImplementation(() => 1) + const { createInteractiveMode } = await import("./control/interactive") const { bootstrapGame } = await import("./game") - bootstrapGame() + bootstrapGame(createInteractiveMode(elements), elements) window.dispatchEvent( new KeyboardEvent("keydown", { key: "p", @@ -469,27 +634,35 @@ describe("bootstrapGame", () => { vi.doMock("./maze", () => ({ generateMaze: vi.fn(() => createRound()), getMazeDimensions: vi.fn(() => ({ level: 1, length: 1, width: 1 })), - isSpaceFound: vi.fn(() => true), - isWallWeight: vi.fn((value: number) => value >= 1 && value <= 3), - nextWallWeight: vi.fn(() => 2), - reweightMaze, + getNavigationProfile: vi.fn(() => ({ + __softCorridorLimit: 8, + __hardCorridorLimit: 10, + __preferTurnPercent: 90, + })), })) + vi.doMock("./traversal", () => + createTraversalMock({ nextWallWeight: () => 2, reweightMaze }), + ) vi.doMock("./render", () => ({ render })) vi.doMock("./storage", () => ({ clearPersistedSnapshot: vi.fn(), clearPersistedRound: vi.fn(), + clearStaleStorageVersions: vi.fn(), + disableAgentApiConfigForNetworkError: vi.fn(), + loadPersistedAgentApiConfigs: vi.fn(() => []), loadPersistedSnapshot: vi.fn(() => ({ preferences: { level: 1, wallWeight: 1 }, round: null, })), - savePersistedPreferences: vi.fn(), - savePersistedRoundState: vi.fn(), + saveGameProgress: vi.fn(), + saveActiveRoundSnapshot: vi.fn(), })) vi.spyOn(window, "setInterval").mockImplementation(() => 1) + const { createInteractiveMode } = await import("./control/interactive") const { bootstrapGame } = await import("./game") - bootstrapGame() + bootstrapGame(createInteractiveMode(elements), elements) window.dispatchEvent( new KeyboardEvent("keydown", { key: "b", @@ -539,7 +712,47 @@ describe("bootstrapGame", () => { expect(state.winSummary).toBe("") }) - it("pauses and resumes a running round through keyboard controls", async () => { + it("restarts agent-api game progress without deleting configured agents", async () => { + const harness = await bootstrapHarness({ + agentConfigs: [enabledAgentConfig()], + dimensionsResults: [{ level: 1, length: 2, width: 1 }], + mode: CONFIG.runtime.controlModes.agentApi, + round: createHorizontalRound(), + }) + + const actionState = harness.runtime.dispatch( + { type: "MoveRight" }, + { wantFeedback: true, playerName: "Blue" }, + ) + expect(harness.mode.readLastActionState()).toEqual(actionState) + + harness.elements.controls[0].click() + + expect(harness.clearPersistedSnapshot).toHaveBeenCalledTimes(1) + expect(harness.clearPersistedAgentApiConfigs).not.toHaveBeenCalled() + expect(harness.mode.readLastActionState()).toBeNull() + + const state = latestRenderedState(harness.render) + expect(state.controlMode).toBe(CONFIG.runtime.controlModes.agentApi) + expect(state.level).toBe(1) + expect(state.status).toBe("running") + expect(state.scoreDecayUnits).toBe(0) + expect(state.agentRequestCount).toBe(0) + expect(state.traversalHistory).toEqual([selfVisit(0, 0)]) + }) + + it("moves agent-api games into await-agent immediately when no agents are enabled", async () => { + const harness = await bootstrapHarness({ + mode: CONFIG.runtime.controlModes.agentApi, + }) + + const state = latestRenderedState(harness.render) + expect(state.controlMode).toBe(CONFIG.runtime.controlModes.agentApi) + expect(state.status).toBe("await-agent") + expect(state.canResume).toBe(false) + }) + + it("pauses and resumes a running round through interactive controls", async () => { const harness = await bootstrapHarness() window.dispatchEvent( @@ -549,8 +762,8 @@ describe("bootstrapGame", () => { let state = latestRenderedState(harness.render) expect(state.status).toBe("paused") expect(state.canResume).toBe(true) - expect(harness.savePersistedPreferences).toHaveBeenCalled() - expect(harness.savePersistedRoundState).toHaveBeenCalled() + expect(harness.saveGameProgress).toHaveBeenCalled() + expect(harness.saveActiveRoundSnapshot).toHaveBeenCalled() window.dispatchEvent( new KeyboardEvent("keydown", { @@ -579,13 +792,17 @@ describe("bootstrapGame", () => { ) const state = latestRenderedState(harness.render) - expect(state.playerPosition).toEqual([1, 3]) + expect(state.playerPosition).toEqual({ x: 3, y: 1 }) + expect(state.traversalHistory).toEqual([ + selfVisit(0, 0), + selfVisit(0, 1), + ]) expect(state.status).toBe("won") expect(state.lastRoundScore).toBe(200) expect(state.lastAttemptRetention).toBe(1_000_000) expect(state.bestWinRetention).toBe(1_000_000) expect(state.winSummary).toBe("New scores retention record") - expect(harness.savePersistedRoundState).toHaveBeenCalled() + expect(harness.saveActiveRoundSnapshot).toHaveBeenCalled() }) it("builds a browser win summary from persisted timing history", async () => { @@ -631,8 +848,35 @@ describe("bootstrapGame", () => { ) }) + it("does not duplicate traversal history when the player backtracks", async () => { + const backtrackRound: RoundState = { + maze: [ + ["|", "---", "|", "---", "|", "---", "|"], + ["|", " ", " ", " ", " ", " ", "|"], + ["|", "---", "|", "---", "|", "---", "|"], + ], + startPosition: { x: 1, y: 1 }, + finalPosition: { x: 5, y: 1 }, + } + + const harness = await bootstrapHarness({ + dimensionsResults: [{ level: 1, length: 3, width: 1 }], + round: backtrackRound, + }) + + harness.runtime.dispatch({ type: "MoveRight" }, { playerName: "Self" }) + harness.runtime.dispatch({ type: "MoveLeft" }, { playerName: "Self" }) + + const state = latestRenderedState(harness.render) + expect(state.playerPosition).toEqual({ x: 1, y: 1 }) + expect(state.traversalHistory).toEqual([ + selfVisit(0, 0), + selfVisit(0, 1), + ]) + }) + - it("marks the round as lost when the tick callback reaches zero remaining time", async () => { + it("marks the round as lost when the refresh callback depletes the score", async () => { const harness = await bootstrapHarness({ dimensionsResults: [{ level: 1, length: 2, width: 1 }], round: createHorizontalRound(), @@ -648,17 +892,17 @@ describe("bootstrapGame", () => { remainingValue: number } - clock.elapsedValue = 1000 + clock.elapsedValue = 2000 clock.remainingValue = 0 harness.intervalCallback() const state = latestRenderedState(harness.render) expect(state.status).toBe("lost") - expect(state.lastRoundScore).toBe(100) + expect(state.lastRoundScore).toBe(0) expect(state.lastAttemptRetention).toBe(0) expect(state.winSummary).toBe("") - expect(harness.savePersistedRoundState).toHaveBeenCalled() + expect(harness.saveActiveRoundSnapshot).toHaveBeenCalled() }) it("re-renders a running round when only the blink phase changes", async () => { @@ -717,14 +961,60 @@ describe("bootstrapGame", () => { expect(state.score).toBe(175) }) + it("redraws the current level when an alternate maze shape still fits after resize", async () => { + const harness = await bootstrapHarness({ + dimensionsResults: [ + { level: 1, length: 4, width: 1 }, + { level: 1, length: 1, width: 4 }, + ], + round: createHorizontalRound(), + terminalSizes: [ + { length: 20, width: 20 }, + { length: 1, width: 20 }, + { length: 1, width: 20 }, + ], + }) + + window.dispatchEvent(new Event("resize")) + + const state = latestRenderedState(harness.render) + expect(state.status).toBe("running") + expect(state.level).toBe(1) + expect(state.mazeDimensions).toEqual({ length: 1, width: 4 }) + expect(state.traversalHistory).toEqual([selfVisit(0, 0)]) + expect(harness.generateMaze).toHaveBeenCalledTimes(2) + expect(harness.generateMaze).toHaveBeenLastCalledWith( + { level: 1, length: 1, width: 4 }, + 1, + ) + }) + + it("keeps the too-small flow when both maze axes exceed the resized viewport", async () => { + const harness = await bootstrapHarness({ + dimensionsResults: [{ level: 1, length: 4, width: 4 }], + terminalSizes: [ + { length: 20, width: 20 }, + { length: 1, width: 1 }, + ], + }) + + window.dispatchEvent(new Event("resize")) + + const state = latestRenderedState(harness.render) + expect(state.status).toBe("too-small") + expect(harness.getMazeDimensions).toHaveBeenCalledTimes(1) + expect(harness.generateMaze).toHaveBeenCalledTimes(1) + }) + it("restores a persisted round in paused mode once the viewport fits again", async () => { const persistedRound: PersistedRound = { - version: 1, level: 1, - dims: { length: 2, width: 1 }, + mazeDimensions: { length: 2, width: 1 }, maze: createHorizontalRound().maze, - playerPosition: [1, 1], - finalPosition: [1, 3], + playerPosition: { x: 1, y: 1 }, + startCell: { row: 0, col: 0 }, + traversalHistory: [visit(0, 0)], + finalPosition: { x: 3, y: 1 }, wallWeight: 1, status: "running", score: 200, @@ -749,6 +1039,7 @@ describe("bootstrapGame", () => { { length: 20, width: 20 }, { length: 1, width: 1 }, { length: 1, width: 1 }, + { length: 1, width: 1 }, { length: 20, width: 20 }, ], }) @@ -763,9 +1054,44 @@ describe("bootstrapGame", () => { state = latestRenderedState(harness.render) expect(state.status).toBe("paused") expect(state.canResume).toBe(true) + expect(state.traversalHistory).toEqual([visit(0, 0)]) expect(harness.loadPersistedSnapshot).toHaveBeenCalledTimes(3) }) + it("rejects malformed persisted traversal history and falls back to a fresh round", async () => { + const invalidPersistedRound: PersistedRound = { + level: 2, + mazeDimensions: { length: 2, width: 1 }, + maze: createHorizontalRound().maze, + playerPosition: { x: 3, y: 1 }, + startCell: { row: 0, col: 0 }, + traversalHistory: [visit(0, 0), visit(0, 0)], + finalPosition: { x: 3, y: 1 }, + wallWeight: 1, + status: "running", + score: 200, + lastRoundScore: 0, + remainingMs: 1500, + winSummary: "", + } + + const harness = await bootstrapHarness({ + persistedSnapshots: [ + { + preferences: { level: 2, wallWeight: 1 }, + round: invalidPersistedRound, + }, + ], + dimensionsResults: [{ level: 2, length: 1, width: 1 }], + }) + + const state = latestRenderedState(harness.render) + expect(harness.clearPersistedRound).toHaveBeenCalledTimes(1) + expect(state.status).toBe("running") + expect(state.level).toBe(2) + expect(state.traversalHistory).toEqual([selfVisit(0, 0)]) + }) + it("does not auto-restart a too-small game when the viewport fits again without a persisted round", async () => { const harness = await bootstrapHarness({ dimensionsResults: [ @@ -803,7 +1129,7 @@ describe("bootstrapGame", () => { expect(state.status).toBe("won") window.dispatchEvent(new Event("pagehide")) - expect(harness.savePersistedRoundState).toHaveBeenCalled() + expect(harness.saveActiveRoundSnapshot).toHaveBeenCalled() harness.elements.app.click() const focusSpy = Reflect.get(harness.elements.app, "focus") as ReturnType< @@ -811,4 +1137,86 @@ describe("bootstrapGame", () => { > expect(focusSpy).toHaveBeenCalled() }) + + it("keeps traversal input out of local keyboard handling in agent-api mode", async () => { + const harness = await bootstrapHarness({ + agentConfigs: [enabledAgentConfig()], + dimensionsResults: [ + { level: 1, length: 2, width: 1 }, + { level: 2, length: 2, width: 1 }, + ], + mode: CONFIG.runtime.controlModes.agentApi, + round: createHorizontalRound(), + }) + + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowRight", + bubbles: true, + }), + ) + + let state = latestRenderedState(harness.render) + expect(state.status).toBe("running") + expect(state.playerPosition).toEqual({ x: 1, y: 1 }) + expect(state.controlMode).toBe(CONFIG.runtime.controlModes.agentApi) + expect(harness.mode.readLastActionState()).toBeNull() + + const actionState = harness.runtime.dispatch( + { type: "MoveRight" }, + { wantFeedback: true, playerName: "Blue" }, + ) + + state = latestRenderedState(harness.render) + expect(state.status).toBe("won") + expect(state.playerPosition).toEqual({ x: 3, y: 1 }) + expect(actionState).toEqual(expect.objectContaining({ + currentCell: { row: 0, col: 1 }, + destinationCell: { row: 0, col: 1 }, + traversalHistory: [selfVisit(0, 0), visit(0, 1)], + lastMoveStatus: "reached-target", + visitedBefore: false, + submittedMoves: ["0:MoveRight"], + lastValidMoveIndex: 0, + })) + expect(harness.mode.readLastActionState()).toEqual(actionState) + + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "p", + ctrlKey: true, + bubbles: true, + }), + ) + + state = latestRenderedState(harness.render) + expect(state.status).toBe("running") + expect(state.level).toBe(2) + expect(state.playerPosition).toEqual({ x: 1, y: 1 }) + expect(harness.mode.readLastActionState()).toBeNull() + }) + + it("keeps pause, proceed, and wall cycling human-driven in agent-api mode", async () => { + const harness = await bootstrapHarness({ + agentConfigs: [enabledAgentConfig()], + mode: CONFIG.runtime.controlModes.agentApi, + }) + + window.dispatchEvent( + new KeyboardEvent("keydown", { key: " ", bubbles: true }), + ) + let state = latestRenderedState(harness.render) + expect(state.status).toBe("paused") + expect(harness.mode.readLastActionState()).toBeNull() + + harness.elements.touchButtons[0].click() + state = latestRenderedState(harness.render) + expect(state.wallWeight).toBe(2) + expect(harness.mode.readLastActionState()).toBeNull() + + harness.elements.touchButtons[2].click() + state = latestRenderedState(harness.render) + expect(state.status).toBe("running") + expect(harness.mode.readLastActionState()).toBeNull() + }) }) diff --git a/frontend/app/game.ts b/frontend/app/game.ts index d29d7a4..d658288 100644 --- a/frontend/app/game.ts +++ b/frontend/app/game.ts @@ -1,76 +1,161 @@ import { GameClock } from "./clock" -import { CONFIG, ROUND_STORAGE_VERSION, WALL_WEIGHTS } from "./config" -import { elements, getTerminalSize } from "./dom" +import { + CONFIG, + WALL_WEIGHTS, +} from "./config" +import { buildMazeActionState, executeActionWithFeedback } from "./agent-context" +import { getTerminalSize } from "./dom" import { generateMaze, getMazeDimensions, - isSpaceFound, - isWallWeight, - nextWallWeight, - reweightMaze, } from "./maze" import { render } from "./render" import { isFinishedStatus, + isAgentApiMode, + isAwaitAgentStatus, isLostStatus, + isInteractiveMode, isPausedStatus, isRunningStatus, isTooSmallStatus, isWonStatus, + viewportFitStatus, } from "./status" import { clearPersistedSnapshot, clearPersistedRound, + clearStaleStorageVersions, loadPersistedSnapshot, - savePersistedPreferences, - savePersistedRoundState, + saveGameProgress, + saveActiveRoundSnapshot, } from "./storage" -import type { MoveAction, PersistedRound, Position, State } from "./types" +import { + cellCoordinateFromGridPoint, + currentTotalCells, + gridPointFromCellCoordinate, + isSpaceFound, + isWallWeight, + mazeCellKey, + nextWallWeight, + resolvePlayerMove, + reweightMaze, + traversalHistoryEntry, + traversalHistoryIncludes, +} from "./traversal" +import type { + AgentRequestBestComparison, + AgentRequestPreviousComparison, + CellCoordinate, + Elements, + GameRuntime, + LevelDimensions, + MazeAction, + MazeActionControl, + MazeActionDispatchOptions, + MazeActionState, + MazeControlModeName, + MoveAction, + PersistedRound, + PersistedSnapshot, + RenderGridPoint, + State, + TraversalHistoryEntry, + WinSummaryBestComparison, + WinSummaryPreviousComparison, +} from "./types" + +const { maze, messages, runtime, scoring, timing } = CONFIG + +type PersistenceScope = "round" | "state" const state: State = { + controlMode: runtime.controlModes.interactive, level: 1, - dims: null, maze: null, + mazeDimensions: null, playerPosition: null, + traversalHistory: [], finalPosition: null, status: "boot", score: 0, lastRoundScore: 0, lastAttemptRetention: null, bestWinRetention: null, + lastWinRequestCount: null, + bestWinRequestCount: null, winSummary: "", canResume: false, wallWeight: WALL_WEIGHTS[0], + scoreDecayUnits: 0, + agentRequestCount: 0, clock: null, } let scheduledRoundPersist: number | null = null let lastBlinkVisible: boolean | null = null +// activeControlMode keeps the currently mounted MazeActionControl so feedback and rebinding stay in sync. +let activeControlMode: MazeActionControl | null = null +let runtimeElements: Elements | null = null + +// activeCoreDecayIntervalPerCellMs resolves the current mode's per-cell timing budget. +function activeCoreDecayIntervalPerCellMs(): number { + return isAgentApiMode(state.controlMode) + ? timing.agentApiCoreDecayIntervalPerCellMs + : timing.interactiveCoreDecayIntervalPerCellMs +} -const MOVE_DELTAS: Record = { - MoveLeft: [0, -1], - MoveRight: [0, 1], - MoveUp: [-1, 0], - MoveDown: [1, 0], +// defaultLoadPersistedSnapshot keeps startup and viewport-restore storage defaults in one place. +function defaultLoadPersistedSnapshot( + mode: MazeControlModeName, +): PersistedSnapshot { + return loadPersistedSnapshot(mode, 1, WALL_WEIGHTS[0], isWallWeight) } -const KEY_TO_MOVE_ACTION: Partial> = { - ArrowLeft: "MoveLeft", - ArrowRight: "MoveRight", - ArrowUp: "MoveUp", - ArrowDown: "MoveDown", +// calculateMaxScore returns the full score budget for one maze before any decay applies. +function calculateMaxScore(totalCells: number): number { + return totalCells * scoring.budgetMultiplier } -function calculateScore(totalCells: number, elapsedMs: number): number { - const maxScore = totalCells * CONFIG.scoreMultiplier - const elapsedPenalty = Math.floor((elapsedMs * CONFIG.scoreMultiplier) / 1000) +// calculateElapsedScore converts elapsed time into the remaining score for an interactive round. +function calculateElapsedScore( + totalCells: number, + elapsedMs: number, + decayIntervalPerCellMs: number, +): number { + const maxScore = calculateMaxScore(totalCells) + const elapsedPenalty = Math.floor( + (elapsedMs * timing.scoreDecayRate) / decayIntervalPerCellMs, + ) return Math.max(0, maxScore - elapsedPenalty) } -type WinSummaryPreviousComparison = "none" | "faster" | "slower" | "matched" -type WinSummaryBestComparison = "new-record" | "matched-best" | "behind-best" +// calculateScoreAfterDecay converts explicit score-decay units into the remaining score for agent-api rounds. +function calculateScoreAfterDecay(totalCells: number, scoreDecayUnits: number): number { + const maxScore = calculateMaxScore(totalCells) + return Math.max(0, maxScore - scoreDecayUnits * timing.scoreDecayRate) +} + +// calculateRoundScore resolves authoritative score updates for gameplay state changes. +function calculateRoundScore(totalCells: number): number { + if (isInteractiveMode(state.controlMode)) { + // Interactive scoring must come from elapsed clock time; without a clock, preserve score. + if (!state.clock) { + return state.score + } + return calculateElapsedScore( + totalCells, + state.clock.elapsed(), + timing.interactiveCoreDecayIntervalPerCellMs, + ) + } + + return calculateScoreAfterDecay(totalCells, state.scoreDecayUnits) +} + +// formatWinSummaryDuration renders elapsed deltas with compact time units. function formatWinSummaryDuration(durationMs: number): string { if (durationMs < 60_000) { return `${(durationMs / 1000).toFixed(2)}s` @@ -83,8 +168,9 @@ function formatWinSummaryDuration(durationMs: number): string { return `${(durationMs / 3_600_000).toFixed(2)}h` } +// calculateScoreRetention normalizes a score into the retained percentage scale. function calculateScoreRetention(totalCells: number, score: number): number { - const maxScore = totalCells * CONFIG.scoreMultiplier + const maxScore = totalCells * scoring.budgetMultiplier if (maxScore <= 0) { return 0 } @@ -92,24 +178,26 @@ function calculateScoreRetention(totalCells: number, score: number): number { return Math.max( 0, Math.min( - CONFIG.retentionScale, + scoring.retentionScale, Math.floor( - (score * CONFIG.retentionScale + Math.floor(maxScore / 2)) / maxScore, + (score * scoring.retentionScale + Math.floor(maxScore / 2)) / maxScore, ), ), ) } +// formatWinSummaryRetentionDelta projects retention differences back into time. function formatWinSummaryRetentionDelta( deltaRetention: number, levelDurationMs: number, ): string { const deltaMs = Math.round( - (deltaRetention * levelDurationMs) / CONFIG.retentionScale, + (deltaRetention * levelDurationMs) / scoring.retentionScale, ) return formatWinSummaryDuration(deltaMs) } +// compareWinSummaryPrevious compares the current win against the last attempt. function compareWinSummaryPrevious( currentRetention: number, lastAttemptRetention: number | null, @@ -142,6 +230,7 @@ function compareWinSummaryPrevious( return { comparison: "matched", delta: "" } } +// compareWinSummaryBest compares the current win against the best retained score. function compareWinSummaryBest( currentRetention: number, bestWinRetention: number | null, @@ -164,6 +253,7 @@ function compareWinSummaryBest( return { comparison: "matched-best", delta: "" } } +// replaceWinSummaryDelta fills the selected summary template with its deltas. function replaceWinSummaryDelta( template: string, delta: string, @@ -174,56 +264,58 @@ function replaceWinSummaryDelta( .replace("{bestDelta}", bestDelta) } +// selectWinSummaryTemplate picks the right summary copy for the comparison result. function selectWinSummaryTemplate( previousComparison: WinSummaryPreviousComparison, bestComparison: WinSummaryBestComparison, ): string { if (previousComparison === "none") { if (bestComparison === "new-record") { - return CONFIG.winNoPrevNewRecord + return messages.winSummary.noPrevious.newRecord } if (bestComparison === "matched-best") { - return CONFIG.winNoPrevMatchedBest + return messages.winSummary.noPrevious.matchedBest } - return CONFIG.winNoPrevBehindBest + return messages.winSummary.noPrevious.behindBest } if (previousComparison === "faster") { if (bestComparison === "new-record") { - return CONFIG.winFasterPrevNewRecord + return messages.winSummary.fasterPrevious.newRecord } if (bestComparison === "matched-best") { - return CONFIG.winFasterPrevMatchedBest + return messages.winSummary.fasterPrevious.matchedBest } - return CONFIG.winFasterPrevBehindBest + return messages.winSummary.fasterPrevious.behindBest } if (previousComparison === "slower") { if (bestComparison === "new-record") { - return CONFIG.winSlowerPrevNewRecord + return messages.winSummary.slowerPrevious.newRecord } if (bestComparison === "matched-best") { - return CONFIG.winSlowerPrevMatchedBest + return messages.winSummary.slowerPrevious.matchedBest } - return CONFIG.winSlowerPrevBehindBest + return messages.winSummary.slowerPrevious.behindBest } if (bestComparison === "new-record") { - return CONFIG.winMatchedPrevNewRecord + return messages.winSummary.matchedPrevious.newRecord } if (bestComparison === "matched-best") { - return CONFIG.winMatchedPrevBest + return messages.winSummary.matchedPrevious.matchedBest } - return CONFIG.winMatchedPrevBehindBest + return messages.winSummary.matchedPrevious.behindBest } +// buildWinSummary assembles the final retention summary shown after a win. function buildWinSummary( currentRetention: number, lastAttemptRetention: number | null, @@ -248,58 +340,224 @@ function buildWinSummary( return replaceWinSummaryDelta(template, previous.delta, best.delta) } -function positionsEqual(left: Position, right: Position): boolean { - return left[0] === right[0] && left[1] === right[1] +// compareAgentRequestsPrevious compares the current solved round against the previous solved request count. +function compareAgentRequestsPrevious( + currentRequests: number, + lastWinRequestCount: number | null, +): { comparison: AgentRequestPreviousComparison; delta: string } { + if (lastWinRequestCount === null) { + return { comparison: "none", delta: "" } + } + + if (currentRequests < lastWinRequestCount) { + return { + comparison: "fewer", + delta: String(lastWinRequestCount - currentRequests), + } + } + + if (currentRequests > lastWinRequestCount) { + return { + comparison: "more", + delta: String(currentRequests - lastWinRequestCount), + } + } + + return { comparison: "matched", delta: "" } +} + +// compareAgentRequestsBest compares the current solved round against the best solved request count. +function compareAgentRequestsBest( + currentRequests: number, + bestWinRequestCount: number | null, +): { comparison: AgentRequestBestComparison; delta: string } { + if (bestWinRequestCount === null || currentRequests < bestWinRequestCount) { + return { comparison: "new-record", delta: "" } + } + + if (currentRequests > bestWinRequestCount) { + return { + comparison: "behind-best", + delta: String(currentRequests - bestWinRequestCount), + } + } + + return { comparison: "matched-best", delta: "" } } +// selectAgentWinSummaryTemplate chooses the request-count summary shown after an agent-api win. +function selectAgentWinSummaryTemplate( + previousComparison: AgentRequestPreviousComparison, + bestComparison: AgentRequestBestComparison, +): string { + if (previousComparison === "none") { + if (bestComparison === "new-record") { + return messages.agentWinSummary.noPrevious.newRecord + } + + if (bestComparison === "matched-best") { + return messages.agentWinSummary.noPrevious.matchedBest + } + + return messages.agentWinSummary.noPrevious.behindBest + } + + if (previousComparison === "fewer") { + if (bestComparison === "new-record") { + return messages.agentWinSummary.fewerPrevious.newRecord + } + + if (bestComparison === "matched-best") { + return messages.agentWinSummary.fewerPrevious.matchedBest + } + + return messages.agentWinSummary.fewerPrevious.behindBest + } + + if (previousComparison === "more") { + if (bestComparison === "new-record") { + return messages.agentWinSummary.morePrevious.newRecord + } + + if (bestComparison === "matched-best") { + return messages.agentWinSummary.morePrevious.matchedBest + } + + return messages.agentWinSummary.morePrevious.behindBest + } + + if (bestComparison === "new-record") { + return messages.agentWinSummary.matchedPrevious.newRecord + } + + if (bestComparison === "matched-best") { + return messages.agentWinSummary.matchedPrevious.matchedBest + } + + return messages.agentWinSummary.matchedPrevious.behindBest +} + +// buildAgentWinSummary assembles the request-count summary shown after an agent-api win. +function buildAgentWinSummary( + currentRequests: number, + lastWinRequestCount: number | null, + bestWinRequestCount: number | null, +): string { + const previous = compareAgentRequestsPrevious( + currentRequests, + lastWinRequestCount, + ) + const best = compareAgentRequestsBest(currentRequests, bestWinRequestCount) + const template = selectAgentWinSummaryTemplate( + previous.comparison, + best.comparison, + ) + + return replaceWinSummaryDelta(template, previous.delta, best.delta) +} + +// positionsEqual compares two rendered maze-grid points without allocating helper objects. +function positionsEqual(left: RenderGridPoint, right: RenderGridPoint): boolean { + return left.x === right.x && left.y === right.y +} + +// readActionState exposes the latest flattened agent-api payload so external callers can plan moves. +function readActionState(): MazeActionState { + return buildMazeActionState(state, runtime.interactivePlayerName) +} + +// isCellCoordinate validates one persisted logical cell coordinate. +function isCellCoordinate(value: unknown): value is CellCoordinate { + if ( + typeof value !== "object" || + value === null || + !("row" in value) || + !("col" in value) + ) { + return false + } + + const row = value.row + const col = value.col + + return ( + typeof row === "number" && + typeof col === "number" && + Number.isInteger(row) && + Number.isInteger(col) && + row >= 0 && + col >= 0 + ) +} + +// isTraversalHistoryEntry validates one persisted named visit record. +function isTraversalHistoryEntry(value: unknown): value is TraversalHistoryEntry { + return ( + isCellCoordinate(value) && + "playerName" in value && + typeof value.playerName === "string" && + value.playerName.length > 0 + ) +} + +// restoreClock reconstructs a live clock from persisted remaining time. function restoreClock(totalCells: number, remainingMs: number): GameClock { - const totalDurationMs = totalCells * 1000 + // Round timing is derived from the shared per-cell cadence so score decay, persisted remaining + // time, and any agent polling policy all reconstruct the same allowance after a reload. + const totalDurationMs = totalCells * activeCoreDecayIntervalPerCellMs() const clampedRemainingMs = Math.max(0, Math.min(totalDurationMs, remainingMs)) const clock = new GameClock(totalDurationMs) clock.startedAt = performance.now() - (totalDurationMs - clampedRemainingMs) return clock } -function isTraversablePosition(data: string[][], position: Position): boolean { - const [row, column] = position - if (row < 0 || row >= data.length) { +// isTraversableGridPoint validates that a stored rendered point still lands on open path. +function isTraversableGridPoint(data: string[][], position: RenderGridPoint): boolean { + const { x, y } = position + if (y < 0 || y >= data.length) { return false } - if (column < 0 || column >= data[row].length) { + if (x < 0 || x >= data[y].length) { return false } - return isSpaceFound(data[row][column]) + return isSpaceFound(data[y][x]) } +// applyTooSmallState clears the active round when the viewport can no longer fit it. function applyTooSmallState(level: number): void { state.status = "too-small" state.level = level - state.dims = null + state.mazeDimensions = null state.maze = null state.playerPosition = null + state.traversalHistory = [] state.finalPosition = null state.score = 0 state.lastRoundScore = 0 + state.scoreDecayUnits = 0 + state.agentRequestCount = 0 state.winSummary = "" state.canResume = false state.clock = null } +// isValidPersistedRound verifies that a restored round is internally consistent. function isValidPersistedRound(snapshot: PersistedRound): boolean { + // Reject impossible round metadata before trusting nested maze data. if ( - snapshot.version !== ROUND_STORAGE_VERSION || snapshot.level < 1 || !isWallWeight(snapshot.wallWeight) || - snapshot.dims.length <= 0 || - snapshot.dims.width <= 0 + snapshot.mazeDimensions.length <= 0 || + snapshot.mazeDimensions.width <= 0 ) { return false } - const expectedRows = CONFIG.cellSpan * snapshot.dims.width + 1 - const expectedColumns = snapshot.dims.length * 2 + 1 + // The stored maze grid must match the dimensions used to generate it. + const expectedRows = maze.cellSpan * snapshot.mazeDimensions.width + 1 + const expectedColumns = snapshot.mazeDimensions.length * 2 + 1 if (snapshot.maze.length !== expectedRows) { return false } @@ -312,37 +570,72 @@ function isValidPersistedRound(snapshot: PersistedRound): boolean { return false } - if (!isTraversablePosition(snapshot.maze, snapshot.playerPosition)) { + // Player and destination positions must both point at open maze cells. + if (!isTraversableGridPoint(snapshot.maze, snapshot.playerPosition)) { return false } - if (!isTraversablePosition(snapshot.maze, snapshot.finalPosition)) { + if (!isTraversableGridPoint(snapshot.maze, snapshot.finalPosition)) { return false } - return true -} + // Traversal history must start with the saved round start cell. + if ( + !isCellCoordinate(snapshot.startCell) || + !Array.isArray(snapshot.traversalHistory) || + snapshot.traversalHistory.length === 0 + ) { + return false + } -function persistedRoundFitsViewport(snapshot: PersistedRound): boolean { - const terminalSize = getTerminalSize() - return ( - snapshot.dims.length <= terminalSize.length && - snapshot.dims.width <= terminalSize.width - ) -} + // The saved start cell must still be traversable in the stored maze. + if ( + !isTraversableGridPoint( + snapshot.maze, + gridPointFromCellCoordinate(snapshot.startCell), + ) + ) { + return false + } -function currentRoundFitsViewport(): boolean { - if (!state.dims) { - return true + const firstVisitedCell = snapshot.traversalHistory[0] + if ( + !isTraversalHistoryEntry(firstVisitedCell) || + mazeCellKey(firstVisitedCell) !== mazeCellKey(snapshot.startCell) + ) { + return false } - const terminalSize = getTerminalSize() - return ( - state.dims.length <= terminalSize.length && - state.dims.width <= terminalSize.width - ) + const visitedCellKeys = new Set() + for (const visitedCell of snapshot.traversalHistory) { + // History is append-only and unique, so duplicates indicate corrupted state. + if (!isTraversalHistoryEntry(visitedCell)) { + return false + } + + const visitedCellKey = mazeCellKey(visitedCell) + if (visitedCellKeys.has(visitedCellKey)) { + return false + } + + visitedCellKeys.add(visitedCellKey) + if (!isTraversableGridPoint(snapshot.maze, gridPointFromCellCoordinate(visitedCell))) { + return false + } + } + + return true } +// persistedRoundFitsViewport checks whether a saved round still fits the viewport. +function persistedRoundFitsViewport(snapshot: PersistedRound): boolean { + return viewportFitStatus( + snapshot.mazeDimensions, + runtimeElements ? getTerminalSize(runtimeElements) : null, + ) === "fits" +} + +// cancelScheduledRoundPersist stops any deferred round persistence job. function cancelScheduledRoundPersist(): void { if (scheduledRoundPersist === null) { return @@ -352,20 +645,16 @@ function cancelScheduledRoundPersist(): void { scheduledRoundPersist = null } -function persistPreferences(): void { - savePersistedPreferences(state) -} - -function persistRoundNow(): void { +// persistNow flushes the current round and optionally includes long-lived progress preferences. +function persistNow(scope: PersistenceScope): void { cancelScheduledRoundPersist() - savePersistedRoundState(state) -} - -function persistStateNow(): void { - persistPreferences() - persistRoundNow() + if (scope === "state") { + saveGameProgress(state.controlMode, state) + } + saveActiveRoundSnapshot(state.controlMode, state) } +// currentBlinkVisible exposes the current destination blink state for rendering. function currentBlinkVisible(): boolean | null { if (!isRunningStatus(state.status) || !state.clock) { return null @@ -374,48 +663,139 @@ function currentBlinkVisible(): boolean | null { return state.clock.blink() } +// renderState pushes the current game state into the terminal-like renderer. function renderState(): void { + if (!runtimeElements) { + return + } + lastBlinkVisible = currentBlinkVisible() - render(elements, state) + render(runtimeElements, state) +} + +// applyWinSummary updates retention and mode-specific win summaries after the score has been resolved. +function applyWinSummary(totalCells: number): void { + // Retention normalizes scores so wins on different maze sizes remain comparable. + const currentRetention = calculateScoreRetention(totalCells, state.score) + + if (isInteractiveMode(state.controlMode)) { + // Interactive summaries translate retention deltas back into time-like progress messages. + const levelDurationMs = totalCells * activeCoreDecayIntervalPerCellMs() + state.winSummary = buildWinSummary( + currentRetention, + state.lastAttemptRetention, + state.bestWinRetention, + levelDurationMs, + ) + state.lastAttemptRetention = currentRetention + state.bestWinRetention = + state.bestWinRetention === null || currentRetention > state.bestWinRetention + ? currentRetention + : state.bestWinRetention + return + } + + // Agent-api summaries compare request efficiency because agents may submit batched moves. + state.winSummary = buildAgentWinSummary( + state.agentRequestCount, + state.lastWinRequestCount, + state.bestWinRequestCount, + ) + state.lastAttemptRetention = currentRetention + state.bestWinRetention = + state.bestWinRetention === null || currentRetention > state.bestWinRetention + ? currentRetention + : state.bestWinRetention + // Request counts are tracked separately from retention so agent runs can report API efficiency. + state.lastWinRequestCount = state.agentRequestCount + state.bestWinRequestCount = + state.bestWinRequestCount === null || + state.agentRequestCount < state.bestWinRequestCount + ? state.agentRequestCount + : state.bestWinRequestCount +} + +// commitAgentTurn is the only place agent-api spends score decay after one resolved request. +function commitAgentTurn(decayedMovesCount: number): MazeActionState { + const totalCells = currentTotalCells(state.mazeDimensions) + if (!isAgentApiMode(state.controlMode) || totalCells === 0) { + return readActionState() + } + + state.agentRequestCount += 1 + + state.scoreDecayUnits += decayedMovesCount + state.score = calculateRoundScore(totalCells) + + if (isWonStatus(state.status)) { + applyWinSummary(totalCells) + persistNow("state") + renderState() + return readActionState() + } + + if (isRunningStatus(state.status) && state.score <= 0) { + handleLoss() + return readActionState() + } + + persistNow("round") + renderState() + return readActionState() } +// scheduleRoundPersistence batches non-terminal round updates behind the refresh cadence. function scheduleRoundPersistence(): void { cancelScheduledRoundPersist() scheduledRoundPersist = window.setTimeout(() => { scheduledRoundPersist = null - savePersistedRoundState(state) - }, CONFIG.refreshInterval) + saveActiveRoundSnapshot(state.controlMode, state) + }, timing.refreshInterval) } +// restorePersistedRound rebuilds a saved round or falls back when it is invalid. function restorePersistedRound(snapshot: PersistedRound | null): boolean { - if (!snapshot) { + if (!runtimeElements || !snapshot) { return false } if (!isValidPersistedRound(snapshot)) { - clearPersistedRound() + clearPersistedRound(state.controlMode) return false } if (!persistedRoundFitsViewport(snapshot)) { applyTooSmallState(snapshot.level) state.wallWeight = snapshot.wallWeight - persistStateNow() + persistNow("state") renderState() return true } state.wallWeight = snapshot.wallWeight state.level = snapshot.level - state.dims = { length: snapshot.dims.length, width: snapshot.dims.width } + state.mazeDimensions = { + length: snapshot.mazeDimensions.length, + width: snapshot.mazeDimensions.width, + } state.maze = snapshot.maze.map((row) => [...row]) - state.playerPosition = [ - snapshot.playerPosition[0], - snapshot.playerPosition[1], - ] - state.finalPosition = [snapshot.finalPosition[0], snapshot.finalPosition[1]] + state.playerPosition = { + x: snapshot.playerPosition.x, + y: snapshot.playerPosition.y, + } + state.traversalHistory = snapshot.traversalHistory.map(({ playerName, row, col }) => ({ + playerName, + row, + col, + })) + state.finalPosition = { + x: snapshot.finalPosition.x, + y: snapshot.finalPosition.y, + } state.score = snapshot.score state.lastRoundScore = snapshot.lastRoundScore + state.scoreDecayUnits = snapshot.scoreDecayUnits ?? 0 + state.agentRequestCount = snapshot.agentRequestCount ?? 0 state.winSummary = snapshot.winSummary ?? "" state.canResume = false @@ -426,9 +806,17 @@ function restorePersistedRound(snapshot: PersistedRound | null): boolean { return true } - const totalCells = snapshot.dims.length * snapshot.dims.width + const totalCells = currentTotalCells(snapshot.mazeDimensions) state.clock = restoreClock(totalCells, snapshot.remainingMs) state.clock.pause() + if (isAwaitAgentStatus(snapshot.status)) { + state.status = "await-agent" + state.winSummary = "" + state.canResume = false + renderState() + return true + } + state.status = "paused" state.winSummary = "" state.canResume = true @@ -436,57 +824,113 @@ function restorePersistedRound(snapshot: PersistedRound | null): boolean { return true } +// startRoundWithDimensions initializes a round after viewport-safe dimensions have been selected. +function startRoundWithDimensions(dimensions: LevelDimensions, persist = true): void { + const round = generateMaze(dimensions, state.wallWeight) + + activeControlMode?.clearActionState() + state.level = dimensions.level + state.mazeDimensions = { length: dimensions.length, width: dimensions.width } + state.maze = round.maze + state.playerPosition = { + x: round.startPosition.x, + y: round.startPosition.y, + } + state.traversalHistory = [ + traversalHistoryEntry( + cellCoordinateFromGridPoint(round.startPosition), + runtime.interactivePlayerName, + ), + ] + state.finalPosition = { + x: round.finalPosition.x, + y: round.finalPosition.y, + } + state.status = "running" + state.canResume = false + state.lastRoundScore = 0 + state.scoreDecayUnits = 0 + state.agentRequestCount = 0 + state.winSummary = "" + + const totalCells = currentTotalCells(dimensions) + // The round clock uses the shared per-cell cadence to set both the starting score window and the + // maximum amount of playable time for this maze size. + state.clock = new GameClock(totalCells * activeCoreDecayIntervalPerCellMs()) + state.score = calculateMaxScore(totalCells) + if (persist) { + persistNow("state") + } + renderState() +} + +// startRound generates and initializes a fresh round for the requested level. function startRound(level: number, persist = true): void { - const terminalSize = getTerminalSize() + if (!runtimeElements) { + return + } + + const terminalSize = getTerminalSize(runtimeElements) const dimensions = getMazeDimensions(level, terminalSize) if (!dimensions) { applyTooSmallState(level) if (persist) { - persistStateNow() + persistNow("state") } renderState() return } - const round = generateMaze(dimensions, state.wallWeight) + startRoundWithDimensions(dimensions, persist) +} - state.level = dimensions.level - state.dims = { length: dimensions.length, width: dimensions.width } - state.maze = round.maze - state.playerPosition = [round.startPosition[0], round.startPosition[1]] - state.finalPosition = [round.finalPosition[0], round.finalPosition[1]] - state.status = "running" - state.canResume = false - state.lastRoundScore = 0 - state.winSummary = "" +// redrawRoundForViewport reshapes the current level when its existing dimensions no longer fit. +function redrawRoundForViewport(level: number): boolean { + if (!runtimeElements) { + return false + } - const totalCells = dimensions.length * dimensions.width - state.clock = new GameClock(totalCells * 1000) - state.score = calculateScore(totalCells, 0) - if (persist) { - persistStateNow() + const dimensions = getMazeDimensions(level, getTerminalSize(runtimeElements)) + if (!dimensions) { + return false } - renderState() + + startRoundWithDimensions(dimensions) + return true } +// restartGame clears persisted progress and restarts from level one. function restartGame(): void { cancelScheduledRoundPersist() - clearPersistedSnapshot() + clearPersistedSnapshot(state.controlMode) + activeControlMode?.clearActionState() state.wallWeight = WALL_WEIGHTS[0] state.lastAttemptRetention = null state.bestWinRetention = null + state.lastWinRequestCount = null + state.bestWinRequestCount = null state.lastRoundScore = 0 state.winSummary = "" startRound(1, false) } +// resumeOrProceed resumes a pause or advances from a finished round. function resumeOrProceed(): void { + if (isAwaitAgentStatus(state.status) && isAgentApiMode(state.controlMode)) { + state.clock?.resume() + state.status = "running" + state.canResume = false + persistNow("state") + renderState() + return + } + if (isPausedStatus(state.status) && state.canResume && state.clock) { state.clock.resume() state.status = "running" state.canResume = false - persistRoundNow() + persistNow("round") renderState() return } @@ -502,6 +946,20 @@ function resumeOrProceed(): void { } } +// awaitAgent pauses agent-api play before any HTTP agent has been explicitly enabled. +function awaitAgent(): void { + if (!isAgentApiMode(state.controlMode) || !isRunningStatus(state.status)) { + return + } + + state.clock?.pause() + state.status = "await-agent" + state.canResume = false + persistNow("state") + renderState() +} + +// pauseGame freezes the current round while preserving it for resume. function pauseGame(): void { if (!isRunningStatus(state.status) || !state.clock) { return @@ -510,10 +968,11 @@ function pauseGame(): void { state.clock.pause() state.status = "paused" state.canResume = true - persistStateNow() + persistNow("state") renderState() } +// cycleWallWeight swaps the live maze walls to the next supported weight. function cycleWallWeight(): void { const nextWeight = nextWallWeight(state.wallWeight) @@ -522,36 +981,15 @@ function cycleWallWeight(): void { } state.wallWeight = nextWeight - persistStateNow() + persistNow("state") renderState() } +// handleWinCheck updates retention metrics and finalizes the round after a win. function handleWinCheck(): boolean { - if (state.clock && state.dims) { - const totalCells = state.dims.length * state.dims.width - const elapsedMs = state.clock.elapsed() - const levelDurationMs = totalCells * 1000 - state.score = calculateScore(totalCells, elapsedMs) - - if ( - state.playerPosition && - state.finalPosition && - positionsEqual(state.playerPosition, state.finalPosition) - ) { - const currentRetention = calculateScoreRetention(totalCells, state.score) - state.winSummary = buildWinSummary( - currentRetention, - state.lastAttemptRetention, - state.bestWinRetention, - levelDurationMs, - ) - state.lastAttemptRetention = currentRetention - state.bestWinRetention = - state.bestWinRetention === null || - currentRetention > state.bestWinRetention - ? currentRetention - : state.bestWinRetention - } + const totalCells = currentTotalCells(state.mazeDimensions) + if (state.clock && totalCells > 0) { + state.score = calculateRoundScore(totalCells) } if ( @@ -562,181 +1000,171 @@ function handleWinCheck(): boolean { return false } + if (totalCells > 0) { + applyWinSummary(totalCells) + } state.status = "won" state.canResume = false state.lastRoundScore = state.score return true } -function movePlayer(rowDelta: number, columnDelta: number): void { - if ( - !isRunningStatus(state.status) || - !state.maze || - !state.dims || - !state.playerPosition - ) { +// movePlayer applies one validated move step and persists the updated round state. +function movePlayer(action: MoveAction, playerName: string): void { + const moveEvaluation = resolvePlayerMove(state, action) + if (!moveEvaluation.canMove) { return } - const row = state.playerPosition[0] - const column = state.playerPosition[1] - const nextRow = row + rowDelta * CONFIG.moveStep - const nextColumn = column + columnDelta * CONFIG.moveStep - const probeRow = row + rowDelta - const probeColumn = column + columnDelta - - if (nextRow <= 0 || nextRow > state.dims.width * CONFIG.cellSpan) { - return - } - - if (nextColumn <= 0 || nextColumn > state.dims.length * CONFIG.cellSpan) { - return + state.playerPosition = moveEvaluation.nextGridPoint + if (!traversalHistoryIncludes(state.traversalHistory, moveEvaluation.nextCell)) { + state.traversalHistory.push( + traversalHistoryEntry(moveEvaluation.nextCell, playerName), + ) } - if (!isSpaceFound(state.maze[probeRow][probeColumn])) { + const won = handleWinCheck() + if (isAgentApiMode(state.controlMode)) { + // Agent-api applies position, history, and win state immediately, but defers score + // decay, persistence, and rendering until commitAgentTurn processes the full + // prediction batch. Committing per move would over-render and double-spend score + // decay for one API response. return } - state.playerPosition[0] = nextRow - state.playerPosition[1] = nextColumn - - if (handleWinCheck()) { - persistStateNow() + if (won) { + persistNow("state") } else { scheduleRoundPersistence() } renderState() } +// handleLoss finalizes the round when the timer has fully expired. function handleLoss(): void { - if (!isRunningStatus(state.status) || !state.dims) { + const totalCells = currentTotalCells(state.mazeDimensions) + if (!isRunningStatus(state.status) || totalCells === 0) { return } - if (state.clock) { - const totalCells = state.dims.length * state.dims.width - state.score = calculateScore(totalCells, state.clock.elapsed()) - } + state.score = calculateRoundScore(totalCells) state.status = "lost" state.canResume = false state.lastRoundScore = state.score state.lastAttemptRetention = 0 state.winSummary = "" - persistStateNow() + persistNow("state") renderState() } -function tick(): void { - if (!isRunningStatus(state.status) || !state.clock || !state.dims) { +// refreshRunningRound handles presentation-time updates only: score refresh, loss detection, and +// blink refresh while still running. Agent-api refreshes reuse committed score so they never spend extra decay. +function refreshRunningRound(): void { + const totalCells = currentTotalCells(state.mazeDimensions) + if (!isRunningStatus(state.status) || !state.clock || totalCells === 0) { return } - const totalCells = state.dims.length * state.dims.width - const nextScore = calculateScore(totalCells, state.clock.elapsed()) - const remainingMs = state.clock.remaining() - const nextBlinkVisible = state.clock.blink() - const scoreChanged = nextScore !== state.score - const blinkChanged = nextBlinkVisible !== lastBlinkVisible - state.score = nextScore + const previousScore = state.score + if (isInteractiveMode(state.controlMode)) { + state.score = calculateRoundScore(totalCells) + } + // Agent-api score is left untouched here; commitAgentTurn owns request-based score decay. - if (remainingMs <= 0) { + // Score depletion is the shared loss signal for both interactive and agent-api modes. + if (state.score <= 0) { handleLoss() return } + const nextBlinkVisible = state.clock.blink() + const scoreChanged = state.score !== previousScore + const blinkChanged = nextBlinkVisible !== lastBlinkVisible if (scoreChanged || blinkChanged) { renderState() } } -function handleMove(action: MoveAction): void { - const [rowDelta, columnDelta] = MOVE_DELTAS[action] - movePlayer(rowDelta, columnDelta) -} - -function handleKeydown(event: KeyboardEvent): void { - const key = event.key - const lowerKey = key.toLowerCase() - const controlCombo = event.ctrlKey || event.metaKey - const moveAction = KEY_TO_MOVE_ACTION[key] - - if ( - moveAction || - key === " " || - key === "Enter" || - (controlCombo && lowerKey === "b") || - (controlCombo && lowerKey === "p") - ) { - event.preventDefault() - } - - if (controlCombo && lowerKey === "b") { - cycleWallWeight() - return - } - - if (controlCombo && lowerKey === "p") { - resumeOrProceed() - return +// executeCommand runs one semantic control command without building feedback. +// playerName is required at this boundary even when a session command ignores it, because any +// traversal command must be attributed to the control mode or agent that actually requested it. +function executeCommand(action: MazeAction, playerName: string): void { + switch (action.type) { + case "MoveLeft": + case "MoveRight": + case "MoveUp": + case "MoveDown": + movePlayer(action.type, playerName) + return + case "pause": + pauseGame() + return + case "proceed": + resumeOrProceed() + return + case "cycle-walls": + cycleWallWeight() + return + case "restart": + restartGame() + return + case "await-agent": + awaitAgent() + return } +} - if ( - key === "Enter" && - (isPausedStatus(state.status) || - isWonStatus(state.status) || - isLostStatus(state.status)) - ) { - resumeOrProceed() - return +// dispatchControl routes one action and optionally returns command state. +function dispatchControl( + action: MazeAction, + options: MazeActionDispatchOptions, +): ReturnType | null { + // Control modes translate their own input sources into semantic maze commands, + // and the shared runtime resolves those commands here. + if (!options.wantFeedback) { + executeCommand(action, options.playerName) + return null } - if (key === " ") { - pauseGame() - return - } + // Feedback requests run through the agent-context path so moves can return structured state. + const actionState = executeActionWithFeedback(action, { + executeCommand: (nextAction) => { + executeCommand(nextAction, options.playerName) + }, + state, + handleMove: movePlayer, + playerName: options.playerName, + }) - if (moveAction) { - handleMove(moveAction) + if (actionState) { + // The active control mode owns how it stores or forwards the latest agent-facing state. + activeControlMode?.recordActionState(actionState) } + return actionState } -function handleAction(action: string): void { - elements.app.focus() - - if (action === "restart") { - restartGame() - return - } - - if (action === "pause") { - pauseGame() - return - } - - if (action === "walls") { - cycleWallWeight() +// handleResize revalidates the active or persisted round against the viewport. +function handleResize(): void { + if (!runtimeElements) { return } - if (action === "proceed") { - resumeOrProceed() - return - } -} + const fitStatus = viewportFitStatus( + state.mazeDimensions, + getTerminalSize(runtimeElements), + ) + if (!isTooSmallStatus(state.status) && isTooSmallStatus(fitStatus)) { + if (fitStatus !== "too-small-all" && redrawRoundForViewport(state.level)) { + return + } -function handleResize(): void { - if (!isTooSmallStatus(state.status) && !currentRoundFitsViewport()) { applyTooSmallState(state.level) - persistStateNow() + persistNow("state") } if (isTooSmallStatus(state.status)) { - if ( - restorePersistedRound( - loadPersistedSnapshot(1, WALL_WEIGHTS[0], isWallWeight).round, - ) - ) { + if (restorePersistedRound(defaultLoadPersistedSnapshot(state.controlMode).round)) { return } } @@ -744,52 +1172,42 @@ function handleResize(): void { renderState() } -export function bootstrapGame(): void { - elements.controls.forEach((button) => { - button.addEventListener("click", () => { - handleAction(button.dataset.action || "") - }) - }) - - elements.touchButtons.forEach((button) => { - button.addEventListener("click", () => { - const move = button.dataset.move - if (move) { - handleMove(move as MoveAction) - return - } +// bootstrapGame wires the runtime, restores persistence, and starts the first render. +export function bootstrapGame( + // controlMode is the page-selected MazeActionControl that supplies the active input behavior. + controlMode: MazeActionControl, + elements: Elements, +): GameRuntime { + runtimeElements = elements + activeControlMode = controlMode - handleAction(button.dataset.action || "") - }) - }) + state.controlMode = controlMode.name + clearStaleStorageVersions() - window.addEventListener("keydown", handleKeydown, { passive: false }) window.addEventListener("resize", handleResize) window.visualViewport?.addEventListener("resize", handleResize) - window.addEventListener("pagehide", () => { - persistStateNow() - }) - window.setInterval(tick, CONFIG.refreshInterval) + window.addEventListener("pagehide", () => { persistNow("state") }) + window.setInterval(refreshRunningRound, timing.refreshInterval) - elements.app.addEventListener("click", () => { - elements.app.focus() - }) - - const persistedSnapshot = loadPersistedSnapshot( - 1, - WALL_WEIGHTS[0], - isWallWeight, - ) + const persistedSnapshot = defaultLoadPersistedSnapshot(controlMode.name) state.wallWeight = persistedSnapshot.preferences.wallWeight state.level = persistedSnapshot.preferences.level - state.lastAttemptRetention = - persistedSnapshot.preferences.lastAttemptRetention ?? null - state.bestWinRetention = - persistedSnapshot.preferences.bestWinRetention ?? null + state.lastAttemptRetention = persistedSnapshot.preferences.lastAttemptRetention ?? null + state.bestWinRetention = persistedSnapshot.preferences.bestWinRetention ?? null + state.lastWinRequestCount = persistedSnapshot.preferences.lastWinRequestCount ?? null + state.bestWinRequestCount = persistedSnapshot.preferences.bestWinRequestCount ?? null if (!restorePersistedRound(persistedSnapshot.round)) { startRound(state.level) } - elements.app.focus() + controlMode.bindActionDispatch(dispatchControl, readActionState, commitAgentTurn) + if (isInteractiveMode(controlMode.name)) { + runtimeElements.app.focus() + } + + return { + mode: controlMode.name, + dispatch: dispatchControl, + } } diff --git a/frontend/app/maze.test.ts b/frontend/app/maze.test.ts index ceebc66..12828ad 100644 --- a/frontend/app/maze.test.ts +++ b/frontend/app/maze.test.ts @@ -4,40 +4,27 @@ import { generateMaze, getNavigationProfile, getMazeDimensions, - isSpaceFound, - isWallWeight, - nextWallWeight, - reweightMaze, } from "./maze" +// These tests keep maze sizing, navigation tuning, and deterministic carving stable. describe("maze", () => { afterEach(() => { vi.restoreAllMocks() }) - it("accepts supported wall weights and cycles them in order", () => { - expect(isWallWeight(1)).toBe(true) - expect(isWallWeight(2)).toBe(true) - expect(isWallWeight(3)).toBe(true) - expect(isWallWeight(4)).toBe(false) - - expect(nextWallWeight(1)).toBe(2) - expect(nextWallWeight(2)).toBe(3) - expect(nextWallWeight(3)).toBe(1) - }) - - it("treats maze paths as space-prefixed segments", () => { - expect(isSpaceFound(" ")).toBe(true) - expect(isSpaceFound(" wall")).toBe(true) - expect(isSpaceFound("|")).toBe(false) - expect(isSpaceFound("")).toBe(false) - }) - it("returns the preferred maze dimensions for a fitting viewport", () => { expect(getMazeDimensions(1, { length: 20, width: 20 })).toEqual({ level: 1, + length: 7, + width: 10, + }) + }) + + it("prefers balanced maze dimensions before viewport aspect ratio", () => { + expect(getMazeDimensions(2, { length: 30, width: 10 })).toEqual({ + level: 2, length: 10, - width: 7, + width: 8, }) }) @@ -86,25 +73,11 @@ describe("maze", () => { expect(round).toMatchSnapshot() expect(round.startPosition).not.toEqual(round.finalPosition) - expect(round.maze[round.startPosition[0]][round.startPosition[1]]).toBe( + expect(round.maze[round.startPosition.y][round.startPosition.x]).toBe( " ", ) - expect(round.maze[round.finalPosition[0]][round.finalPosition[1]]).toBe( + expect(round.maze[round.finalPosition.y][round.finalPosition.x]).toBe( " ", ) }) - - it("reweights maze walls without changing open paths", () => { - const regularMaze = [ - ["|", "---", "|"], - ["|", " ", "|"], - ["|", "-", "|"], - ] - - expect(reweightMaze(regularMaze, 1)).toEqual([ - ["╏", "╍╍╍", "╏"], - ["╏", " ", "╏"], - ["╏", "╍", "╏"], - ]) - }) }) diff --git a/frontend/app/maze.ts b/frontend/app/maze.ts index 92ec585..4f4cc87 100644 --- a/frontend/app/maze.ts +++ b/frontend/app/maze.ts @@ -1,4 +1,5 @@ -import { CONFIG, WALL_WEIGHTS } from "./config" +import { CONFIG } from "./config" +import { isSpaceFound } from "./traversal" import type { BaseDimensions, CellAddress, @@ -7,59 +8,44 @@ import type { LevelDimensions, NavigationProfile, PathStep, - Position, + RenderGridPoint, RoundState, WallWeight, } from "./types" +const { generation, maze: mazeConfig, scoring } = CONFIG + +// getRandomNo returns a bounded pseudo-random index for maze generation. function getRandomNo(limit: number): number { if (limit <= 0) { return 0 } - return Math.floor(Math.random() * limit) } +// absInt normalizes signed values when comparing candidate dimensions. function absInt(value: number): number { return value < 0 ? -value : value } -function minInt(left: number, right: number): number { - return left < right ? left : right -} - +// getWallCharacters resolves the glyph set for the requested wall weight. function getWallCharacters(weight: WallWeight): [string, string, string] { - return CONFIG.walls[weight] -} - -export function isWallWeight(value: number): value is WallWeight { - return WALL_WEIGHTS.includes(value as WallWeight) -} - -export function nextWallWeight(weight: WallWeight): WallWeight { - const index = WALL_WEIGHTS.indexOf(weight) - if (index === -1) { - return WALL_WEIGHTS[0] - } - - return WALL_WEIGHTS[(index + 1) % WALL_WEIGHTS.length] -} - -export function isSpaceFound(item: string): boolean { - return item.length > 0 && item.charCodeAt(0) === 32 + return mazeConfig.walls[weight] } +// generateMazeArea turns a level number into the target maze area. function generateMazeArea(level: number): number { - return level * CONFIG.diff + CONFIG.seed + return level * generation.diff + generation.seed } +// appendFittingDimensions records factor pairs that still fit the viewport. function appendFittingDimensions( candidates: BaseDimensions[], length: number, width: number, terminalSize: BaseDimensions, ): BaseDimensions[] { - if (length < CONFIG.minMazeDimension || width < CONFIG.minMazeDimension) { + if (length < mazeConfig.minDimension || width < mazeConfig.minDimension) { return candidates } @@ -78,6 +64,7 @@ function appendFittingDimensions( return candidates } +// fittingDimensionsForArea enumerates all viewport-safe factor pairs for an area. function fittingDimensionsForArea( area: number, terminalSize: BaseDimensions, @@ -86,7 +73,7 @@ function fittingDimensionsForArea( for ( let divisor = Math.floor(Math.sqrt(area)); - divisor >= CONFIG.minMazeDimension; + divisor >= mazeConfig.minDimension; divisor -= 1 ) { if (area % divisor !== 0) { @@ -104,46 +91,41 @@ function fittingDimensionsForArea( return candidates } +// aspectMismatchScore measures how far a candidate is from the viewport aspect ratio. function aspectMismatchScore( candidate: BaseDimensions, terminalSize: BaseDimensions, ): number { return absInt( - candidate.length * terminalSize.width - - candidate.width * terminalSize.length, + candidate.length * terminalSize.width - candidate.width * terminalSize.length, ) } +// isPreferredMazeDimensions ranks one candidate against the current best fit. function isPreferredMazeDimensions( candidate: BaseDimensions, currentBest: BaseDimensions, terminalSize: BaseDimensions, ): boolean { - const candidatePenalty = aspectMismatchScore(candidate, terminalSize) - const bestPenalty = aspectMismatchScore(currentBest, terminalSize) - if (candidatePenalty !== bestPenalty) { - return candidatePenalty < bestPenalty - } - + // Prefer the less skewed (close to square) shape so the maze stays balanced and playable. const candidateSkew = absInt(candidate.length - candidate.width) const bestSkew = absInt(currentBest.length - currentBest.width) if (candidateSkew !== bestSkew) { return candidateSkew < bestSkew } - const candidateMinEdge = minInt(candidate.length, candidate.width) - const bestMinEdge = minInt(currentBest.length, currentBest.width) - if (candidateMinEdge !== bestMinEdge) { - return candidateMinEdge > bestMinEdge - } - - if (candidate.length !== currentBest.length) { - return candidate.length > currentBest.length + // When skew ties, choose the shape that better matches the viewport. + const candidatePenalty = aspectMismatchScore(candidate, terminalSize) + const bestPenalty = aspectMismatchScore(currentBest, terminalSize) + if (candidatePenalty !== bestPenalty) { + return candidatePenalty < bestPenalty } - return candidate.width > currentBest.width + // Remaining ties are equivalent enough that the first deterministic candidate should stay selected. + return false } +// chooseBestMazeDimensions picks the most balanced candidate for the viewport. function chooseBestMazeDimensions( candidates: BaseDimensions[], terminalSize: BaseDimensions, @@ -159,6 +141,7 @@ function chooseBestMazeDimensions( return best } +// getMazeDimensions finds the best playable dimensions for a level and viewport. export function getMazeDimensions( level: number, terminalSize: BaseDimensions, @@ -177,13 +160,14 @@ export function getMazeDimensions( return { ...selected, level } } +// createPlayingField builds the initial fully-walled maze grid. function createPlayingField( dimensions: BaseDimensions, weight: WallWeight, ): string[][] { const chars = getWallCharacters(weight) - const rows = CONFIG.cellSpan * dimensions.width + 1 - const path = " ".repeat(CONFIG.cellPathWidth) + const rows = mazeConfig.cellSpan * dimensions.width + 1 + const path = " ".repeat(mazeConfig.cellPathWidth) const data: string[][] = [] for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) { @@ -209,6 +193,7 @@ function createPlayingField( return data } +// getCellAddress maps a cell number to its wall and center coordinates. function getCellAddress( dimensions: BaseDimensions, cellNo: number, @@ -218,22 +203,23 @@ function getCellAddress( } const row = - (Math.floor((cellNo - 1) / dimensions.length) + 1) * CONFIG.cellSpan - const column = (((cellNo - 1) % dimensions.length) + 1) * CONFIG.cellSpan + (Math.floor((cellNo - 1) / dimensions.length) + 1) * mazeConfig.cellSpan + const column = (((cellNo - 1) % dimensions.length) + 1) * mazeConfig.cellSpan return { - __bottomCenter: [row, column - 1], - __bottomLeft: [row, column - CONFIG.cellSpan], - __bottomRight: [row, column], - __middleCenter: [row - 1, column - 1], - __middleLeft: [row - 1, column - CONFIG.cellSpan], - __middleRight: [row - 1, column], - __topCenter: [row - CONFIG.cellSpan, column - 1], - __topLeft: [row - CONFIG.cellSpan, column - CONFIG.cellSpan], - __topRight: [row - CONFIG.cellSpan, column], + __bottomCenter: { x: column - 1, y: row }, + __bottomLeft: { x: column - mazeConfig.cellSpan, y: row }, + __bottomRight: { x: column, y: row }, + __middleCenter: { x: column - 1, y: row - 1 }, + __middleLeft: { x: column - mazeConfig.cellSpan, y: row - 1 }, + __middleRight: { x: column, y: row - 1 }, + __topCenter: { x: column - 1, y: row - mazeConfig.cellSpan }, + __topLeft: { x: column - mazeConfig.cellSpan, y: row - mazeConfig.cellSpan }, + __topRight: { x: column, y: row - mazeConfig.cellSpan }, } } +// getCellNeighbors returns the adjacent cell numbers around one cell. function getCellNeighbors( dimensions: BaseDimensions, cellNo: number, @@ -269,6 +255,7 @@ function getCellNeighbors( return neighbors } +// countNeighbors counts how many edges a cell exposes to the grid. function countNeighbors(neighbors: CellNeighbors): number { let count = 0 @@ -291,6 +278,7 @@ function countNeighbors(neighbors: CellNeighbors): number { return count } +// getPresentNeighbors filters neighboring cells down to the unvisited options. function getPresentNeighbors( dimensions: BaseDimensions, cellNo: number, @@ -329,39 +317,41 @@ export function getNavigationProfile( return { __softCorridorLimit: interpolateNavigationValue( - CONFIG.navigationFriendlyProfile.__softCorridorLimit, - CONFIG.navigationHardestProfile.__softCorridorLimit, + generation.navigation.friendlyProfile.__softCorridorLimit, + generation.navigation.hardestProfile.__softCorridorLimit, difficultyFactor, ), __hardCorridorLimit: interpolateNavigationValue( - CONFIG.navigationFriendlyProfile.__hardCorridorLimit, - CONFIG.navigationHardestProfile.__hardCorridorLimit, + generation.navigation.friendlyProfile.__hardCorridorLimit, + generation.navigation.hardestProfile.__hardCorridorLimit, difficultyFactor, ), __preferTurnPercent: interpolateNavigationValue( - CONFIG.navigationFriendlyProfile.__preferTurnPercent, - CONFIG.navigationHardestProfile.__preferTurnPercent, + generation.navigation.friendlyProfile.__preferTurnPercent, + generation.navigation.hardestProfile.__preferTurnPercent, difficultyFactor, ), } } +// navigationDifficultyFactor normalizes maze area into a 0..1 difficulty value. function navigationDifficultyFactor(area: number): number { - if (area <= CONFIG.navigationFriendlyMaxArea) { + if (area <= generation.navigation.friendlyMaxArea) { return 0 } - if (area >= CONFIG.navigationHardestArea) { + if (area >= generation.navigation.hardestArea) { return 1 } const normalizedArea = - (area - CONFIG.navigationFriendlyMaxArea) / - (CONFIG.navigationHardestArea - CONFIG.navigationFriendlyMaxArea) + (area - generation.navigation.friendlyMaxArea) / + (generation.navigation.hardestArea - generation.navigation.friendlyMaxArea) return Math.sqrt(normalizedArea) } +// interpolateNavigationValue blends between the friendly and hardest profile values. function interpolateNavigationValue( friendly: number, hardest: number, @@ -370,6 +360,7 @@ function interpolateNavigationValue( return Math.round(friendly + (hardest - friendly) * difficultyFactor) } +// directionBetween converts two adjacent cells into a movement direction. function directionBetween( dimensions: BaseDimensions, currentCell: number, @@ -391,6 +382,7 @@ function directionBetween( } } +// backtrackToBranch rewinds the carved path until an unvisited branch is found. function backtrackToBranch( dimensions: BaseDimensions, path: PathStep[], @@ -410,6 +402,7 @@ function backtrackToBranch( throw new Error("failed to backtrack to a maze branch") } +// chooseNextCell applies the navigation profile to the next branch decision. function chooseNextCell( dimensions: BaseDimensions, neighbors: number[], @@ -453,7 +446,7 @@ function chooseNextCell( } if (currentState.__moveDirection !== "none" && turnChoices.length > 0) { - const turnPreferenceRoll = getRandomNo(CONFIG.percentScale) + const turnPreferenceRoll = getRandomNo(scoring.percentScale) if (currentState.__corridorLength >= profile.__hardCorridorLimit) { choices = turnChoices @@ -468,6 +461,7 @@ function chooseNextCell( return choices[getRandomNo(choices.length)] } +// getStartPosition prefers an edge cell so the opening feels less uniform. function getStartPosition(dimensions: BaseDimensions): number { const totalCells = dimensions.length * dimensions.width @@ -479,6 +473,7 @@ function getStartPosition(dimensions: BaseDimensions): number { } } +// createPath removes the wall segment between two connected cells. function createPath( dimensions: BaseDimensions, maze: string[][], @@ -494,23 +489,24 @@ function createPath( switch (nextCellNo) { case neighbors.__bottom: - maze[address.__bottomCenter[0]][address.__bottomCenter[1]] = " " + maze[address.__bottomCenter.y][address.__bottomCenter.x] = " " break case neighbors.__left: - maze[address.__middleLeft[0]][address.__middleLeft[1]] = " " + maze[address.__middleLeft.y][address.__middleLeft.x] = " " break case neighbors.__right: - maze[address.__middleRight[0]][address.__middleRight[1]] = " " + maze[address.__middleRight.y][address.__middleRight.x] = " " break case neighbors.__top: - maze[address.__topCenter[0]][address.__topCenter[1]] = " " + maze[address.__topCenter.y][address.__topCenter.x] = " " break } } +// replaceChar swaps a junction glyph only when the vertical path stays open. function replaceChar( dimensions: BaseDimensions, - point: Position, + point: RenderGridPoint, replacement: string, maze: string[][], ): void { @@ -519,18 +515,18 @@ function replaceChar( let hasTop = false let hasBottom = false - if (point[0] - 1 > 0) { - topItem = maze[point[0] - 1][point[1]] + if (point.y - 1 > 0) { + topItem = maze[point.y - 1][point.x] hasTop = true } - if (point[0] + 1 <= dimensions.width * CONFIG.cellSpan) { - bottomItem = maze[point[0] + 1][point[1]] + if (point.y + 1 <= dimensions.width * mazeConfig.cellSpan) { + bottomItem = maze[point.y + 1][point.x] hasBottom = true } - const row = point[0] - const column = point[1] + const row = point.y + const column = point.x if (!hasTop && hasBottom && isSpaceFound(bottomItem)) { maze[row][column] = replacement @@ -546,6 +542,7 @@ function replaceChar( } } +// optimizeMaze softens eligible vertical joints after the maze is carved. function optimizeMaze( dimensions: BaseDimensions, weight: WallWeight, @@ -564,6 +561,7 @@ function optimizeMaze( } } +// generateMaze carves the maze, then returns the grid plus start and target positions. export function generateMaze( dimensions: BaseDimensions, weight: WallWeight, @@ -624,28 +622,13 @@ export function generateMaze( return { maze, - startPosition: [ - startAddress.__middleCenter[0], - startAddress.__middleCenter[1], - ], - finalPosition: [ - finalAddress.__middleCenter[0], - finalAddress.__middleCenter[1], - ], + startPosition: { + x: startAddress.__middleCenter.x, + y: startAddress.__middleCenter.y, + }, + finalPosition: { + x: finalAddress.__middleCenter.x, + y: finalAddress.__middleCenter.y, + }, } } - -export function reweightMaze( - data: string[][], - currentWeight: WallWeight, -): string[][] { - const fromChars = getWallCharacters(currentWeight) - const toChars = getWallCharacters(nextWallWeight(currentWeight)) - const replacements = new Map([ - [fromChars[0], toChars[0]], - [fromChars[1], toChars[1]], - [fromChars[2], toChars[2]], - ]) - - return data.map((row) => row.map((cell) => replacements.get(cell) ?? cell)) -} diff --git a/frontend/app/render.test.ts b/frontend/app/render.test.ts index ecbb8ce..1b64384 100644 --- a/frontend/app/render.test.ts +++ b/frontend/app/render.test.ts @@ -3,12 +3,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { GameClock } from "./clock" import { CONFIG } from "./config" import { render } from "./render" -import type { Elements, State } from "./types" +import type { Elements, State, TraversalHistoryEntry } from "./types" +const { messages } = CONFIG + +function visit(row: number, col: number): TraversalHistoryEntry { + return { playerName: "Blue", row, col } +} + +// normalizeScreenText keeps DOM assertions readable by collapsing non-breaking spaces. function normalizeScreenText(value: string | null): string { return (value ?? "").replaceAll("\u00a0", " ") } +// createButton reproduces the control-button dataset contract expected by the renderer. function createButton({ action, move, @@ -30,6 +38,7 @@ function createButton({ return button } +// createElements assembles the DOM shell consumed by the renderer during tests. function createElements(): Elements { const screen = document.createElement("div") const touchControls = document.createElement("div") @@ -54,10 +63,12 @@ function createElements(): Elements { } } +// createState builds a representative runtime state for render scenarios. function createState(overrides: Partial = {}): State { return { + controlMode: CONFIG.runtime.controlModes.interactive, level: 1, - dims: { length: 2, width: 2 }, + mazeDimensions: { length: 2, width: 2 }, maze: [ ["|", "---", "|", "---", "|"], ["|", " ", " ", " ", "|"], @@ -65,21 +76,27 @@ function createState(overrides: Partial = {}): State { ["|", " ", "|", " ", "|"], ["|", "---", "|", "---", "|"], ], - playerPosition: [1, 1], - finalPosition: [1, 2], + playerPosition: { x: 1, y: 1 }, + traversalHistory: [visit(0, 0)], + finalPosition: { x: 2, y: 1 }, status: "running", score: 900, lastRoundScore: 0, lastAttemptRetention: null, bestWinRetention: null, + lastWinRequestCount: null, + bestWinRequestCount: null, winSummary: "", canResume: false, wallWeight: 1, + scoreDecayUnits: 0, + agentRequestCount: 0, clock: null, ...overrides, } } +// These tests keep browser terminal output and touch-control visibility consistent. describe("render", () => { beforeEach(() => { vi.stubGlobal( @@ -156,7 +173,7 @@ describe("render", () => { render( elements, createState({ - dims: null, + mazeDimensions: null, maze: null, playerPosition: null, finalPosition: null, @@ -166,9 +183,9 @@ describe("render", () => { const text = normalizeScreenText(elements.screen.textContent) - expect(text).toContain(CONFIG.navigationCompact) + expect(text).toContain(messages.navigation.compact) expect(text).toContain("Level 1 needs more screen room!") - expect(text).toContain(CONFIG.tooSmallActionMessage) + expect(text).toContain(messages.tooSmallActionMessage) }) it("renders the maze, markers, and running status line", () => { @@ -178,7 +195,7 @@ describe("render", () => { const text = normalizeScreenText(elements.screen.textContent) - expect(text).toContain(CONFIG.navigation) + expect(text).toContain(messages.navigation.default) expect(text).toContain("Level: 1") expect(text).toContain("Scores: 900") expect(elements.screen.innerHTML).toContain('class="maze-cell player"') @@ -197,6 +214,27 @@ describe("render", () => { ]) }) + it("shows only local action touch controls when the agent-api mode is active", () => { + const elements = createElements() + + render( + elements, + createState({ + controlMode: CONFIG.runtime.controlModes.agentApi, + }), + ) + + const visibleLabels = elements.touchButtons + .filter((button) => !button.hidden) + .map((button) => button.dataset.action ?? button.dataset.move) + + expect(visibleLabels).toEqual(["pause"]) + expect(elements.touchControls.hidden).toBe(false) + expect( + elements.touchControls.classList.contains("touch-controls--single-action"), + ).toBe(true) + }) + it("skips drawing the destination while a running round blink phase is off", () => { const elements = createElements() const clock = new GameClock(10_000) @@ -238,8 +276,8 @@ describe("render", () => { const text = normalizeScreenText(elements.screen.textContent) - expect(text).toContain(CONFIG.pauseMessage) - expect(text).toContain(CONFIG.proceedMessage) + expect(text).toContain(messages.pauseMessage) + expect(text).toContain(messages.proceedMessage) const visibleLabels = elements.touchButtons .filter((button) => !button.hidden) @@ -312,8 +350,8 @@ describe("render", () => { const text = normalizeScreenText(elements.screen.textContent) - expect(text).toContain(CONFIG.touchProceedMessage) - expect(text).not.toContain(CONFIG.proceedMessage) + expect(text).toContain(messages.touchProceedMessage) + expect(text).not.toContain(messages.proceedMessage) }) it("shows walls plus proceed touch controls after a win", () => { @@ -322,7 +360,7 @@ describe("render", () => { render( elements, createState({ - dims: { length: 3, width: 3 }, + mazeDimensions: { length: 3, width: 3 }, level: 3, status: "won", lastRoundScore: 900, @@ -332,8 +370,8 @@ describe("render", () => { const text = normalizeScreenText(elements.screen.textContent) - expect(text).toContain(CONFIG.successMessage) - expect(text).toContain(CONFIG.proceedMessage) + expect(text).toContain(messages.successMessage) + expect(text).toContain(messages.proceedMessage) expect(text).toContain("Final Level 3 Scores: 900 (100% retention)") expect(text).toContain("1.20s faster than previous (new record)") @@ -353,7 +391,7 @@ describe("render", () => { render( elements, createState({ - dims: { length: 3, width: 3 }, + mazeDimensions: { length: 3, width: 3 }, level: 1, status: "won", lastRoundScore: 900, @@ -381,8 +419,8 @@ describe("render", () => { const text = normalizeScreenText(elements.screen.textContent) - expect(text).toContain(CONFIG.failedMessage) - expect(text).toContain(CONFIG.proceedMessage) + expect(text).toContain(messages.failedMessage) + expect(text).toContain(messages.proceedMessage) expect(text).not.toContain("Final Level 3 Scores:") }) @@ -392,7 +430,7 @@ describe("render", () => { render( elements, createState({ - dims: null, + mazeDimensions: null, maze: null, playerPosition: null, finalPosition: null, @@ -403,7 +441,7 @@ describe("render", () => { const text = normalizeScreenText(elements.screen.textContent) expect(text).toContain("Level 1 needs more screen room!") - expect(text).toContain(CONFIG.tooSmallActionMessage) + expect(text).toContain(messages.tooSmallActionMessage) const visibleLabels = elements.touchButtons .filter((button) => !button.hidden) @@ -438,7 +476,7 @@ describe("render", () => { render( elements, createState({ - dims: null, + mazeDimensions: null, maze: null, playerPosition: null, finalPosition: null, @@ -448,9 +486,9 @@ describe("render", () => { const text = normalizeScreenText(elements.screen.textContent) - expect(text).toContain(CONFIG.navigationCompact) + expect(text).toContain(messages.navigation.compact) expect(text).toContain("Level 1 needs more screen room!") - expect(text).toContain(CONFIG.tooSmallActionMessage) + expect(text).toContain(messages.tooSmallActionMessage) expect(elements.touchControls.hidden).toBe(true) }) @@ -496,8 +534,8 @@ describe("render", () => { const text = normalizeScreenText(elements.screen.textContent) - expect(text).toContain(CONFIG.navigationCompact) - expect(text).not.toContain(CONFIG.navigation) + expect(text).toContain(messages.navigation.compact) + expect(text).not.toContain(messages.navigation.default) }) it("keeps the full keyboard navigation on medium-width screens", () => { @@ -518,7 +556,7 @@ describe("render", () => { const text = normalizeScreenText(elements.screen.textContent) - expect(text).toContain(CONFIG.navigation) - expect(text).not.toContain(CONFIG.navigationCompact) + expect(text).toContain(messages.navigation.default) + expect(text).not.toContain(messages.navigation.compact) }) }) diff --git a/frontend/app/render.ts b/frontend/app/render.ts index 7ef6ad4..3e6751e 100644 --- a/frontend/app/render.ts +++ b/frontend/app/render.ts @@ -2,6 +2,9 @@ import { CONFIG } from "./config" import { canProceedStatus, canShowWallsStatus, + isAgentApiMode, + isAwaitAgentStatus, + isInteractiveMode, isLostStatus, isPausedStatus, isRunningStatus, @@ -10,6 +13,9 @@ import { } from "./status" import type { Elements, ScreenLine, State } from "./types" +const { maze, messages, scoring, viewport } = CONFIG + +// escapeHtml protects text rows before they are written as HTML. function escapeHtml(value: string): string { return value .replaceAll("&", "&") @@ -17,10 +23,12 @@ function escapeHtml(value: string): string { .replaceAll(">", ">") } +// leftPad offsets maze rows so the browser view mirrors the terminal layout. function leftPad(value: string, padding: number): string { return `${" ".repeat(Math.max(0, padding))}${value}` } +// padLine keeps overlay-cleared maze rows aligned to a fixed width. function padLine(value: string, width: number): string { if (value.length >= width) { return value @@ -29,6 +37,7 @@ function padLine(value: string, width: number): string { return `${value}${" ".repeat(width - value.length)}` } +// replaceAt swaps a single visible marker into an already-built maze row. function replaceAt(line: string, index: number, char: string): string { if (index < 0 || index >= line.length) { return line @@ -37,16 +46,18 @@ function replaceAt(line: string, index: number, char: string): string { return `${line.slice(0, index)}${char}${line.slice(index + 1)}` } +// statusText selects the running-status footer copy for the current display size. function statusText(elements: Elements, state: State): string { const template = isCompactDisplay(elements) - ? CONFIG.touchStatusTemplate - : CONFIG.statusTemplate + ? messages.touchStatusTemplate + : messages.statusTemplate return template .replace("{level}", String(state.level)) .replace("{score}", String(state.score)) } +// viewportMetrics gathers every meaningful browser measurement used for compact mode detection. function viewportMetrics(elements: Elements): { heightCandidates: number[] widthCandidates: number[] @@ -75,37 +86,41 @@ function viewportMetrics(elements: Elements): { } } +// isCompactDisplay collapses copy when any viewport dimension crosses the compact threshold. function isCompactDisplay(elements: Elements): boolean { const { availableWidth, availableHeight } = { availableWidth: window.matchMedia( - `(max-width: ${CONFIG.compactViewportWidth}px)`, + `(max-width: ${viewport.compactWidth}px)`, ).matches, availableHeight: window.matchMedia( - `(max-height: ${CONFIG.compactViewportHeight}px)`, + `(max-height: ${viewport.compactHeight}px)`, ).matches, } const { widthCandidates, heightCandidates } = viewportMetrics(elements) const compactWidth = widthCandidates.some( - (width) => width <= CONFIG.compactViewportWidth, + (width) => width <= viewport.compactWidth, ) const compactHeight = heightCandidates.some( - (height) => height <= CONFIG.compactViewportHeight, + (height) => height <= viewport.compactHeight, ) return availableWidth || availableHeight || compactWidth || compactHeight } +// navigationText picks the verbose or compact navigation hint for the viewport. function navigationText(elements: Elements): string { const compact = isCompactDisplay(elements) - return compact ? CONFIG.navigationCompact : CONFIG.navigation + return compact ? messages.navigation.compact : messages.navigation.default } +// proceedText picks the interactive or touch proceed hint for the viewport. function proceedText(elements: Elements): string { return isCompactDisplay(elements) - ? CONFIG.touchProceedMessage - : CONFIG.proceedMessage + ? messages.touchProceedMessage + : messages.proceedMessage } +// centeredTextRow creates one centered text line for the rendered screen model. function centeredTextRow(text: string, className = "screen-text"): ScreenLine { return { kind: "text", @@ -114,6 +129,7 @@ function centeredTextRow(text: string, className = "screen-text"): ScreenLine { } } +// emptyTextRow creates a spacer line while preserving the renderer's line model. function emptyTextRow(): ScreenLine { return { kind: "text", @@ -122,32 +138,37 @@ function emptyTextRow(): ScreenLine { } } +// rowsWithSpacer inserts blank lines before each supplied row for terminal-style spacing. function rowsWithSpacer(...rows: ScreenLine[]): ScreenLine[] { return rows.flatMap((row) => [emptyTextRow(), row]) } +// tooSmallRows builds the viewport warning shown when the maze no longer fits. function tooSmallRows(state: State): ScreenLine[] { return [ centeredTextRow( - CONFIG.tooSmallMessage.replace("{level}", String(state.level)), + messages.tooSmallMessage.replace("{level}", String(state.level)), "status", ), - centeredTextRow(CONFIG.tooSmallActionMessage), + centeredTextRow(messages.tooSmallActionMessage), ] } +// successText picks the win message sized for the current viewport. function successText(elements: Elements): string { return isCompactDisplay(elements) - ? CONFIG.successCompactMessage - : CONFIG.successMessage + ? messages.successCompactMessage + : messages.successMessage } +// failedText picks the loss message sized for the current viewport. function failedText(elements: Elements): string { return isCompactDisplay(elements) - ? CONFIG.failedCompactMessage - : CONFIG.failedMessage + ? messages.failedCompactMessage + : messages.failedMessage } +// shouldDrawDestination decides whether the blinking destination is visible this frame. function shouldDrawDestination(state: State): boolean { if (!isRunningStatus(state.status) || !state.clock) { return true @@ -156,6 +177,7 @@ function shouldDrawDestination(state: State): boolean { return state.clock.blink() } +// buildMazeLines merges the maze grid with the current player and target markers. function buildMazeLines(state: State): string[] { if (!state.maze) { return [] @@ -164,33 +186,34 @@ function buildMazeLines(state: State): string[] { const lines = state.maze.map((row) => row.join("")) if (state.finalPosition && shouldDrawDestination(state)) { - lines[state.finalPosition[0]] = replaceAt( - lines[state.finalPosition[0]], - state.finalPosition[1] * CONFIG.cellSpan, - CONFIG.destinationMarker, + lines[state.finalPosition.y] = replaceAt( + lines[state.finalPosition.y], + state.finalPosition.x * maze.cellSpan, + maze.destinationMarker, ) } if (state.playerPosition) { - lines[state.playerPosition[0]] = replaceAt( - lines[state.playerPosition[0]], - state.playerPosition[1] * CONFIG.cellSpan, - CONFIG.playerMarker, + lines[state.playerPosition.y] = replaceAt( + lines[state.playerPosition.y], + state.playerPosition.x * maze.cellSpan, + maze.playerMarker, ) } - return lines.map((line) => leftPad(line, CONFIG.mazeLeftPadding)) + return lines.map((line) => leftPad(line, maze.leftPadding)) } +// renderMarkedLine wraps one maze row in span markup for colorized rendering. function renderMarkedLine(rawLine: string): string { let html = "" for (const char of rawLine) { const value = char === " " ? " " : escapeHtml(char) - if (char === CONFIG.playerMarker) { + if (char === maze.playerMarker) { html += `${value}` - } else if (char === CONFIG.destinationMarker) { + } else if (char === maze.destinationMarker) { html += `${value}` } else { html += `${value}` @@ -200,19 +223,23 @@ function renderMarkedLine(rawLine: string): string { return `${html}` } +// renderTextLine converts a text row into HTML while preserving spacing. function renderTextLine(value: string, className = "screen-text"): string { const html = value === "" ? " " : escapeHtml(value).replaceAll(" ", " ") return `${html}` } +// scorePercent converts the last-round score into a compact retention percentage. function scorePercent(state: State): number { - if (!state.dims) { + if (!state.mazeDimensions) { return 0 } const maxScore = - state.dims.length * state.dims.width * CONFIG.scoreMultiplier + state.mazeDimensions.length * + state.mazeDimensions.width * + scoring.budgetMultiplier if (maxScore <= 0) { return 0 } @@ -220,22 +247,32 @@ function scorePercent(state: State): number { return Math.max( 0, Math.min( - CONFIG.percentScale, - Math.round((state.lastRoundScore * CONFIG.percentScale) / maxScore), + scoring.percentScale, + Math.round((state.lastRoundScore * scoring.percentScale) / maxScore), ), ) } +// overlayRows builds the centered pause, win, loss, or too-small overlay lines. function overlayRows(elements: Elements, state: State): ScreenLine[] { + if (isAwaitAgentStatus(state.status) && isAgentApiMode(state.controlMode)) { + return [ + centeredTextRow(messages.agentAwaitMessage, "status"), + centeredTextRow( + `${messages.agentAwaitActionMessage} ${proceedText(elements)}`, + ), + ] + } + if (isPausedStatus(state.status)) { return [ - centeredTextRow(CONFIG.pauseMessage, "status"), + centeredTextRow(messages.pauseMessage, "status"), centeredTextRow(proceedText(elements)), ] } if (isWonStatus(state.status)) { - const scoresMsg = CONFIG.highScoreTemplate + const scoresMsg = messages.highScoreTemplate .replace("{level}", String(state.level)) .replace("{score}", String(state.lastRoundScore)) .replace("{percent}", String(scorePercent(state))) @@ -266,6 +303,7 @@ function overlayRows(elements: Elements, state: State): ScreenLine[] { return [] } +// applyOverlayToMaze clears the maze center area and drops the overlay into it. function applyOverlayToMaze( elements: Elements, state: State, @@ -305,6 +343,7 @@ function applyOverlayToMaze( return screenMaze } +// buildScreenLines assembles the final screen model for the current state. function buildScreenLines(elements: Elements, state: State): ScreenLine[] { const mazeLines = buildMazeLines(state) const mazeWidth = mazeLines.reduce( @@ -331,11 +370,13 @@ function buildScreenLines(elements: Elements, state: State): ScreenLine[] { return lines } +// updateTouchControls shows only the touch controls that make sense for the current state. function updateTouchControls(elements: Elements, state: State): void { const canProceed = state.canResume ? canProceedStatus(state.status) - : isWonStatus(state.status) || isLostStatus(state.status) - const showMoveControls = isRunningStatus(state.status) + : isAwaitAgentStatus(state.status) || isWonStatus(state.status) || isLostStatus(state.status) + const showMoveControls = + isInteractiveMode(state.controlMode) && isRunningStatus(state.status) const showPause = isRunningStatus(state.status) const showWalls = canShowWallsStatus(state.status) let visibleButtons = 0 @@ -374,6 +415,7 @@ function updateTouchControls(elements: Elements, state: State): void { ) } +// render turns the current state into HTML and syncs the touch-control visibility. export function render(elements: Elements, state: State): void { const screenLines = buildScreenLines(elements, state) elements.screen.innerHTML = screenLines diff --git a/frontend/app/status.ts b/frontend/app/status.ts index bad0b95..f65bf1d 100644 --- a/frontend/app/status.ts +++ b/frontend/app/status.ts @@ -1,39 +1,118 @@ -import type { GameStatus, PersistedGameStatus } from "./types" +import { CONFIG } from "./config" +import type { + BaseDimensions, + GameStatus, + MazeControlModeName, + PersistedGameStatus, +} from "./types" +const { controlModes } = CONFIG.runtime + +// ViewportFitStatus classifies whether the maze fits or which viewport axis blocks it. +export type ViewportFitStatus = + | "fits" + | "too-small-length" + | "too-small-width" + | "too-small-all" + +export type TooSmallStatus = "too-small" | Exclude + +// isAgentApiMode identifies the browser mode where maze traversal comes from configured agents. +export function isAgentApiMode( + modeName: MazeControlModeName, +): modeName is "agent-api" { + return modeName === controlModes.agentApi +} + +// isInteractiveMode identifies the human-controlled browser mode. +export function isInteractiveMode( + modeName: MazeControlModeName, +): modeName is "interactive" { + return modeName === controlModes.interactive +} + +// viewportFitStatus classifies which axis blocks the current maze from fitting. +export function viewportFitStatus( + dimensions: BaseDimensions | null, + terminalSize: BaseDimensions | null, +): ViewportFitStatus { + if (!dimensions) { + return "fits" + } + + if (!terminalSize) { + return "too-small-all" + } + + const lengthTooSmall = dimensions.length > terminalSize.length + const widthTooSmall = dimensions.width > terminalSize.width + + if (lengthTooSmall && widthTooSmall) { + return "too-small-all" + } + + if (lengthTooSmall) { + return "too-small-length" + } + + if (widthTooSmall) { + return "too-small-width" + } + + return "fits" +} + +// isAwaitAgentStatus identifies agent-api play before any enabled agent exists. +export function isAwaitAgentStatus( + status: GameStatus, +): status is "await-agent" { + return status === "await-agent" +} + +// isRunningStatus narrows a status value to the active gameplay state. export function isRunningStatus( - status: GameStatus | PersistedGameStatus, + status: GameStatus, ): status is "running" { return status === "running" } +// isPausedStatus narrows a status value to the resumable pause state. export function isPausedStatus( - status: GameStatus | PersistedGameStatus, + status: GameStatus, ): status is "paused" { return status === "paused" } -export function isWonStatus( - status: GameStatus | PersistedGameStatus, -): status is "won" { +// isWonStatus narrows a status value to the successful end-of-round state. +export function isWonStatus(status: GameStatus): status is "won" { return status === "won" } -export function isLostStatus( - status: GameStatus | PersistedGameStatus, -): status is "lost" { +// isLostStatus narrows a status value to the failed end-of-round state. +export function isLostStatus(status: GameStatus): status is "lost" { return status === "lost" } -export function isTooSmallStatus(status: GameStatus): status is "too-small" { - return status === "too-small" +// isTooSmallStatus identifies both rendered and internal viewport-too-small states. +export function isTooSmallStatus( + status: GameStatus | ViewportFitStatus, +): status is TooSmallStatus { + return ( + status === "too-small" || + status === "too-small-length" || + status === "too-small-width" || + status === "too-small-all" + ) } +// isFinishedStatus groups the terminal win/loss states together. export function isFinishedStatus( - status: GameStatus | PersistedGameStatus, + status: GameStatus, ): status is "won" | "lost" { return isWonStatus(status) || isLostStatus(status) } +// canPersistRoundStatus limits persistence to round states that can be restored later. export function canPersistRoundStatus( status: GameStatus, ): status is PersistedGameStatus { @@ -41,14 +120,21 @@ export function canPersistRoundStatus( isRunningStatus(status) || isPausedStatus(status) || isWonStatus(status) || - isLostStatus(status) + isLostStatus(status) || + isAwaitAgentStatus(status) ) } +// canProceedStatus marks states that accept the proceed action. export function canProceedStatus(status: GameStatus): boolean { - return isPausedStatus(status) || isFinishedStatus(status) + return ( + isAwaitAgentStatus(status) || + isPausedStatus(status) || + isFinishedStatus(status) + ) } +// canShowWallsStatus marks states where wall reweighting remains safe to expose. export function canShowWallsStatus(status: GameStatus): boolean { return isPausedStatus(status) || isFinishedStatus(status) } diff --git a/frontend/app/storage.test.ts b/frontend/app/storage.test.ts index 63b51c3..4641d1d 100644 --- a/frontend/app/storage.test.ts +++ b/frontend/app/storage.test.ts @@ -1,26 +1,56 @@ -import { beforeEach, describe, expect, it } from "vitest" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { CONFIG, STORE_ENCODING_PREFIX } from "./config" import { - BEST_WIN_RETENTION_STORAGE_KEY, - LEVEL_STORAGE_KEY, - LAST_ATTEMPT_RETENTION_STORAGE_KEY, - ROUND_STORAGE_KEY, - STORE_ENCODING_PREFIX, - WALL_WEIGHT_STORAGE_KEY, -} from "./config" -import { - clearPersistedSnapshot, - clearPersistedRound, - loadPersistedSnapshot, - savePersistedPreferences, - savePersistedRoundState, + clearPersistedAgentApiConfigs, + clearPersistedAgentApiSnapshot, + clearPersistedInteractiveRound, + clearPersistedInteractiveSnapshot, + clearStaleStorageVersions, + disableAgentApiConfigForNetworkError, + loadPersistedAgentApiConfigs, + loadPersistedAgentApiSnapshot, + loadPersistedInteractiveSnapshot, + saveAgentApiGameProgress, + savePersistedAgentApiConfigs, + saveActiveInteractiveRoundSnapshot, + saveInteractiveGameProgress, } from "./storage" -import type { State } from "./types" +import type { State, TraversalHistoryEntry } from "./types" + +const MODE = CONFIG.runtime.controlModes.interactive +const AGENT_MODE = CONFIG.runtime.controlModes.agentApi +const { agentConfigs, gameSetup, winMetrics } = CONFIG.runtime.storage.suffixes + +function visit(row: number, col: number): TraversalHistoryEntry { + return { playerName: "Blue", row, col } +} + +// storageKey mirrors the production per-mode browser storage naming. +function storageKey(suffix: string): string { + return `tapoo.v${CONFIG.runtime.storage.version}.${MODE}.${suffix}` +} +// agentStorageKey mirrors the separate agent-api storage namespace. +function agentStorageKey(suffix: string): string { + return `tapoo.v${CONFIG.runtime.storage.version}.${AGENT_MODE}.${suffix}` +} + +// versionedStorageKey builds explicit namespaces for storage-version cleanup tests. +function versionedStorageKey( + version: number, + mode: string, + suffix: string, +): string { + return `tapoo.v${version}.${mode}.${suffix}` +} + +// isWallWeight mirrors the production wall-weight guard for persistence tests. function isWallWeight(value: number): value is 1 | 2 | 3 { return value === 1 || value === 2 || value === 3 } +// createMemoryStorage provides a minimal Storage implementation for browser persistence tests. function createMemoryStorage(): Storage { const values = new Map() @@ -46,32 +76,41 @@ function createMemoryStorage(): Storage { } } +// createState builds a restorable runtime state for storage-oriented scenarios. function createState(overrides: Partial = {}): State { return { + controlMode: CONFIG.runtime.controlModes.interactive, level: 4, - dims: { length: 5, width: 5 }, + mazeDimensions: { length: 5, width: 5 }, maze: [ ["|", "---", "|"], ["|", " ", "|"], ["|", "---", "|"], ], - playerPosition: [1, 1], - finalPosition: [1, 1 + 0], + playerPosition: { x: 1, y: 1 }, + traversalHistory: [visit(0, 0)], + finalPosition: { x: 1, y: 1 }, status: "running", score: 1200, lastRoundScore: 700, lastAttemptRetention: 700000, bestWinRetention: 820000, + lastWinRequestCount: null, + bestWinRequestCount: null, winSummary: "", canResume: false, wallWeight: 2, + scoreDecayUnits: 0, + agentRequestCount: 0, clock: null, ...overrides, } } +// These tests keep browser persistence resilient to corrupt and partial storage payloads. describe("storage", () => { beforeEach(() => { + vi.restoreAllMocks() Object.defineProperty(window, "localStorage", { configurable: true, value: createMemoryStorage(), @@ -86,94 +125,254 @@ describe("storage", () => { }) it("saves and reloads obfuscated frontend preferences", () => { - savePersistedPreferences({ + saveInteractiveGameProgress({ level: 8, wallWeight: 3, lastAttemptRetention: 710000, bestWinRetention: 840000, + lastWinRequestCount: null, + bestWinRequestCount: null, }) - const storedLevel = window.localStorage.getItem(LEVEL_STORAGE_KEY) - const storedWeight = window.localStorage.getItem(WALL_WEIGHT_STORAGE_KEY) - const storedLastAttemptRetention = window.localStorage.getItem( - LAST_ATTEMPT_RETENTION_STORAGE_KEY, - ) - const storedBestWinRetention = window.localStorage.getItem( - BEST_WIN_RETENTION_STORAGE_KEY, - ) + const storedGameSetup = window.localStorage.getItem(storageKey(gameSetup)) + const storedWinMetrics = window.localStorage.getItem(storageKey(winMetrics)) - expect(storedLevel).toContain(STORE_ENCODING_PREFIX) - expect(storedWeight).toContain(STORE_ENCODING_PREFIX) - expect(storedLastAttemptRetention).toContain(STORE_ENCODING_PREFIX) - expect(storedBestWinRetention).toContain(STORE_ENCODING_PREFIX) - expect(storedLevel).not.toBe("8") - expect(storedWeight).not.toBe("3") + expect(storedGameSetup).toContain(STORE_ENCODING_PREFIX) + expect(storedWinMetrics).toContain(STORE_ENCODING_PREFIX) + expect(storedGameSetup).not.toContain("8") + expect(storedGameSetup).not.toContain("3") - const snapshot = loadPersistedSnapshot(1, 1, isWallWeight) + const snapshot = loadPersistedInteractiveSnapshot(1, 1, isWallWeight) expect(snapshot.preferences).toEqual({ level: 8, wallWeight: 3, lastAttemptRetention: 710000, bestWinRetention: 840000, + lastWinRequestCount: null, + bestWinRequestCount: null, + }) + }) + + it("saves and reloads configured agent api details separately from game progress", () => { + savePersistedAgentApiConfigs([ + { + id: "agent-a", + playerName: "Agent A", + model: "llama3.2", + endpoint: "/api/agents/a/move", + enabled: true, + }, + { + id: "agent-b", + playerName: "Agent B", + model: "gemma4", + endpoint: "/api/agents/b/move", + enabled: false, + disabledReason: "network-error", + lastErrorAt: 1_725_000_000_000, + }, + ]) + + const storedConfigs = window.localStorage.getItem( + agentStorageKey(agentConfigs), + ) + + expect(storedConfigs).toContain(STORE_ENCODING_PREFIX) + expect(storedConfigs).not.toContain("/api/agents/a/move") + expect(loadPersistedAgentApiConfigs()).toEqual([ + { + id: "agent-a", + playerName: "Agent A", + model: "llama3.2", + endpoint: "/api/agents/a/move", + enabled: true, + }, + { + id: "agent-b", + playerName: "Agent B", + model: "gemma4", + endpoint: "/api/agents/b/move", + enabled: false, + disabledReason: "network-error", + lastErrorAt: 1_725_000_000_000, + }, + ]) + + clearPersistedAgentApiSnapshot() + expect(loadPersistedAgentApiConfigs()).toHaveLength(2) + + clearPersistedAgentApiConfigs() + expect(loadPersistedAgentApiConfigs()).toEqual([]) + }) + + it("saves and reloads agent api progress in the agent storage namespace", () => { + saveAgentApiGameProgress({ + level: 6, + wallWeight: 2, + lastAttemptRetention: 640000, + bestWinRetention: 760000, + lastWinRequestCount: 8, + bestWinRequestCount: 5, + }) + + expect(window.localStorage.getItem(agentStorageKey(gameSetup))).toContain( + STORE_ENCODING_PREFIX, + ) + expect(window.localStorage.getItem(storageKey(gameSetup))).toBeNull() + + const snapshot = loadPersistedAgentApiSnapshot(1, 1, isWallWeight) + + expect(snapshot.preferences).toEqual({ + level: 6, + wallWeight: 2, + lastAttemptRetention: 640000, + bestWinRetention: 760000, + lastWinRequestCount: 8, + bestWinRequestCount: 5, }) }) + it("disables one network-failed agent without touching the others", () => { + vi.spyOn(Date, "now").mockReturnValue(1_725_000_000_001) + savePersistedAgentApiConfigs([ + { + id: "agent-a", + playerName: "Agent A", + model: "llama3.2", + endpoint: "/api/agents/a/move", + enabled: true, + }, + { + id: "agent-b", + playerName: "Agent B", + model: "gemma4", + endpoint: "/api/agents/b/move", + enabled: true, + }, + ]) + + const nextConfigs = disableAgentApiConfigForNetworkError({ + id: "agent-b", + playerName: "Agent B", + model: "gemma4", + endpoint: "/api/agents/b/move", + enabled: true, + }) + + expect(nextConfigs).toEqual([ + { + id: "agent-a", + playerName: "Agent A", + model: "llama3.2", + endpoint: "/api/agents/a/move", + enabled: true, + }, + { + id: "agent-b", + playerName: "Agent B", + model: "gemma4", + endpoint: "/api/agents/b/move", + enabled: false, + disabledReason: "network-error", + lastErrorAt: 1_725_000_000_001, + }, + ]) + expect(loadPersistedAgentApiConfigs()).toEqual(nextConfigs) + }) + it("saves and reloads the active round state", () => { const state = createState({ - playerPosition: [1, 1], - finalPosition: [1, 1], + playerPosition: { x: 1, y: 1 }, + finalPosition: { x: 1, y: 1 }, }) - savePersistedRoundState(state) + saveActiveInteractiveRoundSnapshot(state) - const snapshot = loadPersistedSnapshot(1, 1, isWallWeight) + const snapshot = loadPersistedInteractiveSnapshot(1, 1, isWallWeight) expect(snapshot.round).toEqual({ - version: 1, level: 4, - dims: { length: 5, width: 5 }, + mazeDimensions: { length: 5, width: 5 }, maze: state.maze, - playerPosition: [1, 1], - finalPosition: [1, 1], + startCell: { row: 0, col: 0 }, + traversalHistory: [visit(0, 0)], + playerPosition: { x: 1, y: 1 }, + finalPosition: { x: 1, y: 1 }, wallWeight: 2, status: "running", score: 1200, lastRoundScore: 700, winSummary: "", remainingMs: 25_000, + scoreDecayUnits: 0, + agentRequestCount: 0, }) }) it("falls back to defaults and clears unreadable stored state", () => { - window.localStorage.setItem(LEVEL_STORAGE_KEY, "not-base64") - window.localStorage.setItem(WALL_WEIGHT_STORAGE_KEY, "not-base64") - window.sessionStorage.setItem(ROUND_STORAGE_KEY, "not-base64") + window.localStorage.setItem(storageKey(gameSetup), "not-base64") + window.localStorage.setItem(storageKey(winMetrics), "not-base64") + window.sessionStorage.setItem(storageKey("round"), "not-base64") - const snapshot = loadPersistedSnapshot(2, 1, isWallWeight) + const snapshot = loadPersistedInteractiveSnapshot(2, 1, isWallWeight) expect(snapshot.preferences).toEqual({ level: 2, wallWeight: 1, lastAttemptRetention: null, bestWinRetention: null, + lastWinRequestCount: null, + bestWinRequestCount: null, }) expect(snapshot.round).toBeNull() - expect(window.sessionStorage.getItem(ROUND_STORAGE_KEY)).toBeNull() + expect(window.sessionStorage.getItem(storageKey("round"))).toBeNull() + }) + + it("clears stale browser storage versions without touching the current version", () => { + const currentVersion = CONFIG.runtime.storage.version + const staleVersion = currentVersion + 1 + const staleGameSetupKey = versionedStorageKey(staleVersion, MODE, gameSetup) + const staleRoundKey = versionedStorageKey(staleVersion, MODE, "round") + + window.localStorage.setItem(staleGameSetupKey, "old") + window.sessionStorage.setItem(staleRoundKey, "old") + saveInteractiveGameProgress({ + level: 8, + wallWeight: 3, + lastAttemptRetention: 710000, + bestWinRetention: 840000, + lastWinRequestCount: 6, + bestWinRequestCount: 4, + }) + saveActiveInteractiveRoundSnapshot( + createState({ + playerPosition: { x: 1, y: 1 }, + finalPosition: { x: 1, y: 1 }, + }), + ) + + clearStaleStorageVersions() + + expect(window.localStorage.getItem(staleGameSetupKey)).toBeNull() + expect(window.sessionStorage.getItem(staleRoundKey)).toBeNull() + expect(window.localStorage.getItem(storageKey(gameSetup))).not.toBeNull() + expect(window.localStorage.getItem(storageKey(winMetrics))).not.toBeNull() + expect(window.sessionStorage.getItem(storageKey("round"))).not.toBeNull() }) it("removes the stored round when the current state cannot be persisted", () => { const persistedState = createState({ - playerPosition: [1, 1], - finalPosition: [1, 1], + playerPosition: { x: 1, y: 1 }, + finalPosition: { x: 1, y: 1 }, }) - savePersistedRoundState(persistedState) - expect(window.sessionStorage.getItem(ROUND_STORAGE_KEY)).not.toBeNull() + saveActiveInteractiveRoundSnapshot(persistedState) + expect(window.sessionStorage.getItem(storageKey("round"))).not.toBeNull() - savePersistedRoundState( + saveActiveInteractiveRoundSnapshot( createState({ - dims: null, + mazeDimensions: null, maze: null, playerPosition: null, finalPosition: null, @@ -181,42 +380,42 @@ describe("storage", () => { }), ) - expect(window.sessionStorage.getItem(ROUND_STORAGE_KEY)).toBeNull() + expect(window.sessionStorage.getItem(storageKey("round"))).toBeNull() }) it("clears the persisted round on demand", () => { - savePersistedRoundState( + saveActiveInteractiveRoundSnapshot( createState({ - playerPosition: [1, 1], - finalPosition: [1, 1], + playerPosition: { x: 1, y: 1 }, + finalPosition: { x: 1, y: 1 }, }), ) - clearPersistedRound() + clearPersistedInteractiveRound() - expect(window.sessionStorage.getItem(ROUND_STORAGE_KEY)).toBeNull() + expect(window.sessionStorage.getItem(storageKey("round"))).toBeNull() }) it("clears persisted preferences and the active round on demand", () => { - savePersistedPreferences({ + saveInteractiveGameProgress({ level: 8, wallWeight: 3, lastAttemptRetention: 710000, bestWinRetention: 840000, + lastWinRequestCount: null, + bestWinRequestCount: null, }) - savePersistedRoundState( + saveActiveInteractiveRoundSnapshot( createState({ - playerPosition: [1, 1], - finalPosition: [1, 1], + playerPosition: { x: 1, y: 1 }, + finalPosition: { x: 1, y: 1 }, }), ) - clearPersistedSnapshot() + clearPersistedInteractiveSnapshot() - expect(window.localStorage.getItem(LEVEL_STORAGE_KEY)).toBeNull() - expect(window.localStorage.getItem(WALL_WEIGHT_STORAGE_KEY)).toBeNull() - expect(window.localStorage.getItem(LAST_ATTEMPT_RETENTION_STORAGE_KEY)).toBeNull() - expect(window.localStorage.getItem(BEST_WIN_RETENTION_STORAGE_KEY)).toBeNull() - expect(window.sessionStorage.getItem(ROUND_STORAGE_KEY)).toBeNull() + expect(window.localStorage.getItem(storageKey(gameSetup))).toBeNull() + expect(window.localStorage.getItem(storageKey(winMetrics))).toBeNull() + expect(window.sessionStorage.getItem(storageKey("round"))).toBeNull() }) }) diff --git a/frontend/app/storage.ts b/frontend/app/storage.ts index c069fcd..955f446 100644 --- a/frontend/app/storage.ts +++ b/frontend/app/storage.ts @@ -1,15 +1,13 @@ import { - BEST_WIN_RETENTION_STORAGE_KEY, - LEVEL_STORAGE_KEY, - LAST_ATTEMPT_RETENTION_STORAGE_KEY, - ROUND_STORAGE_KEY, - ROUND_STORAGE_VERSION, + CONFIG, STORE_BLEND_KEY, STORE_ENCODING_PREFIX, - WALL_WEIGHT_STORAGE_KEY, } from "./config" -import { canPersistRoundStatus } from "./status" +import { canPersistRoundStatus, isAgentApiMode } from "./status" +import { currentTotalCells } from "./traversal" import type { + AgentApiConfig, + MazeControlModeName, PersistedPreferences, PersistedRound, PersistedSnapshot, @@ -17,6 +15,70 @@ import type { WallWeight, } from "./types" +const { runtime, timing } = CONFIG +const storageConfig = runtime.storage +const { agentApi: agentApiModeName, interactive: interactiveModeName } = + runtime.controlModes + +type PersistableProgressState = Pick< + State, + | "level" + | "wallWeight" + | "lastAttemptRetention" + | "bestWinRetention" + | "lastWinRequestCount" + | "bestWinRequestCount" +> + +type PersistedGameSetup = Pick +type PersistedWinMetrics = Required< + Pick< + PersistedPreferences, + | "lastAttemptRetention" + | "bestWinRetention" + | "lastWinRequestCount" + | "bestWinRequestCount" + > +> + +// Shared storage helpers. + +// storageKey namespaces browser persistence by mode and schema version so stale payloads are ignored. +function storageKey( + modeName: MazeControlModeName, + suffix: string, +): string { + return `tapoo.v${storageConfig.version}.${modeName}.${suffix}` +} + +// removeStaleStorageEntries clears old versioned Tapoo keys without touching current-version data. +function removeStaleStorageEntries(storage: Storage): void { + const currentPrefix = `tapoo.v${storageConfig.version}.` + + for (let index = storage.length - 1; index >= 0; index -= 1) { + const key = storage.key(index) + if (key?.startsWith("tapoo.v") && !key.startsWith(currentPrefix)) { + storage.removeItem(key) + } + } +} + +// clearStaleStorageVersions runs once during startup to discard obsolete browser storage versions. +export function clearStaleStorageVersions(): void { + try { + removeStaleStorageEntries(window.localStorage) + } catch { + // Ignore storage failures so startup can continue without browser persistence. + } + + try { + removeStaleStorageEntries(window.sessionStorage) + } catch { + // Ignore storage failures so startup can continue without browser persistence. + } +} + +// toBase64 converts raw bytes into a storage-safe browser string. function toBase64(payloadBytes: Uint8Array): string { let binaryPayload = "" @@ -27,6 +89,7 @@ function toBase64(payloadBytes: Uint8Array): string { return window.btoa(binaryPayload) } +// fromBase64 decodes stored browser payloads back into raw bytes. function fromBase64(encodedPayload: string): Uint8Array | null { try { const binaryPayload = window.atob(encodedPayload) @@ -38,6 +101,7 @@ function fromBase64(encodedPayload: string): Uint8Array | null { } } +// xorStoredPayload applies the lightweight reversible obfuscation used for browser storage. function xorStoredPayload(payloadBytes: Uint8Array): Uint8Array { const passphraseBytes = new TextEncoder().encode(STORE_BLEND_KEY) const encodedBytes = new Uint8Array(payloadBytes.length) @@ -50,18 +114,18 @@ function xorStoredPayload(payloadBytes: Uint8Array): Uint8Array { return encodedBytes } +// encodeStoredPayload serializes and obfuscates values before persistence. function encodeStoredPayload(value: unknown): string { const jsonPayload = JSON.stringify(value) const payloadBytes = new TextEncoder().encode(jsonPayload) return `${STORE_ENCODING_PREFIX}${toBase64(xorStoredPayload(payloadBytes))}` } +// decodeStoredPayload reverses the browser storage encoding back into JSON. function decodeStoredPayload(encodedPayload: string): T | null { const payloadBytes = encodedPayload.startsWith(STORE_ENCODING_PREFIX) ? (() => { - const encodedCipherText = encodedPayload.slice( - STORE_ENCODING_PREFIX.length, - ) + const encodedCipherText = encodedPayload.slice(STORE_ENCODING_PREFIX.length) const cipherText = fromBase64(encodedCipherText) if (!cipherText) { return null @@ -83,104 +147,233 @@ function decodeStoredPayload(encodedPayload: string): T | null { } } -function buildRoundSnapshot(state: State): PersistedRound | null { +// Agent API configuration persistence. + +// isAgentApiConfig validates one persisted HTTP agent configuration. +function isAgentApiConfig(value: unknown): value is AgentApiConfig { if ( - !state.dims || - !state.maze || - !state.playerPosition || - !state.finalPosition || - !canPersistRoundStatus(state.status) + typeof value !== "object" || + value === null || + !("id" in value) || + !("playerName" in value) || + !("model" in value) || + !("endpoint" in value) || + !("enabled" in value) ) { - return null + return false } - const totalCells = state.dims.length * state.dims.width - const remainingMs = state.clock ? state.clock.remaining() : totalCells * 1000 + const disabledReason = "disabledReason" in value ? value.disabledReason : undefined + const lastErrorAt = "lastErrorAt" in value ? value.lastErrorAt : undefined - return { - version: ROUND_STORAGE_VERSION, - level: state.level, - dims: { length: state.dims.length, width: state.dims.width }, - maze: state.maze.map((row) => [...row]), - playerPosition: [state.playerPosition[0], state.playerPosition[1]], - finalPosition: [state.finalPosition[0], state.finalPosition[1]], - wallWeight: state.wallWeight, - status: state.status, - score: state.score, - lastRoundScore: state.lastRoundScore, - remainingMs, - winSummary: state.winSummary, + return ( + typeof value.id === "string" && + value.id.length > 0 && + typeof value.playerName === "string" && + value.playerName.length > 0 && + typeof value.model === "string" && + value.model.length > 0 && + typeof value.endpoint === "string" && + value.endpoint.length > 0 && + typeof value.enabled === "boolean" && + (disabledReason === undefined || disabledReason === "network-error") && + (lastErrorAt === undefined || + (typeof lastErrorAt === "number" && Number.isFinite(lastErrorAt))) + ) +} + +// normalizeAgentApiConfigs keeps only valid configs and de-duplicates by stable id. +function normalizeAgentApiConfigs(configs: unknown): AgentApiConfig[] { + if (!Array.isArray(configs)) { + return [] } + + const seenIds = new Set() + const normalizedConfigs: AgentApiConfig[] = [] + + for (const config of configs) { + if (!isAgentApiConfig(config) || seenIds.has(config.id)) { + continue + } + + seenIds.add(config.id) + normalizedConfigs.push({ ...config }) + } + + return normalizedConfigs } -function savePreferences(preferences: PersistedPreferences): void { +// loadPersistedAgentApiConfigs restores the configurable HTTP agents for agent-api mode. +export function loadPersistedAgentApiConfigs(): AgentApiConfig[] { try { - window.localStorage.setItem( - WALL_WEIGHT_STORAGE_KEY, - encodeStoredPayload(preferences.wallWeight), + const storedConfigs = window.localStorage.getItem( + storageKey(agentApiModeName, storageConfig.suffixes.agentConfigs), ) + if (!storedConfigs) { + return [] + } + + return normalizeAgentApiConfigs( + decodeStoredPayload(storedConfigs), + ) + } catch { + return [] + } +} + +// savePersistedAgentApiConfigs stores configured HTTP agents separately from game progress. +export function savePersistedAgentApiConfigs(configs: AgentApiConfig[]): void { + try { window.localStorage.setItem( - LEVEL_STORAGE_KEY, - encodeStoredPayload(preferences.level), + storageKey(agentApiModeName, storageConfig.suffixes.agentConfigs), + encodeStoredPayload(normalizeAgentApiConfigs(configs)), ) + } catch { + // Ignore storage failures so agent configuration remains best-effort only. + } +} + +// disableAgentApiConfigForNetworkError marks one transport-failing agent ineligible for later turns. +export function disableAgentApiConfigForNetworkError( + failedAgent: AgentApiConfig, +): AgentApiConfig[] { + const nextConfigs = loadPersistedAgentApiConfigs().map((agent) => { + if (agent.id !== failedAgent.id) { + return agent + } + + const disabledAgent: AgentApiConfig = { + ...agent, + enabled: false, + disabledReason: "network-error", + lastErrorAt: Date.now(), + } + + return disabledAgent + }) + + savePersistedAgentApiConfigs(nextConfigs) + return nextConfigs +} + +// clearPersistedAgentApiConfigs removes agent setup without touching game progress. +export function clearPersistedAgentApiConfigs(): void { + try { + window.localStorage.removeItem( + storageKey(agentApiModeName, storageConfig.suffixes.agentConfigs), + ) + } catch { + // Ignore storage failures so clearing remains best-effort only. + } +} + +// Game progress preference persistence. + +// validLevelPreference keeps invalid or stale setup data from escaping storage. +function validLevelPreference(value: unknown, defaultLevel: number): number { + return typeof value === "number" && Number.isInteger(value) && value >= 1 + ? value + : defaultLevel +} + +// validWallWeightPreference keeps wall weights inside the currently supported set. +function validWallWeightPreference( + value: unknown, + defaultWeight: WallWeight, + isWallWeight: (value: number) => value is WallWeight, +): WallWeight { + return typeof value === "number" && isWallWeight(value) ? value : defaultWeight +} + +// validRetentionPreference restores normalized retention values stored in millionths. +function validRetentionPreference(value: unknown): number | null { + return typeof value === "number" && + Number.isFinite(value) && + value >= 0 && + value <= 1_000_000 + ? value + : null +} + +// validRequestCountPreference restores positive agent request counters. +function validRequestCountPreference(value: unknown): number | null { + return typeof value === "number" && Number.isInteger(value) && value >= 1 + ? value + : null +} + +// savePreferences persists the long-lived browser preferences in local storage. +function savePreferences( + modeName: MazeControlModeName, + preferences: PersistedPreferences, +): void { + const gameSetup: PersistedGameSetup = { + level: preferences.level, + wallWeight: preferences.wallWeight, + } + const winMetrics: PersistedWinMetrics = { + lastAttemptRetention: preferences.lastAttemptRetention ?? null, + bestWinRetention: preferences.bestWinRetention ?? null, + lastWinRequestCount: preferences.lastWinRequestCount ?? null, + bestWinRequestCount: preferences.bestWinRequestCount ?? null, + } + + try { window.localStorage.setItem( - LAST_ATTEMPT_RETENTION_STORAGE_KEY, - encodeStoredPayload(preferences.lastAttemptRetention ?? null), + storageKey(modeName, storageConfig.suffixes.gameSetup), + encodeStoredPayload(gameSetup), ) window.localStorage.setItem( - BEST_WIN_RETENTION_STORAGE_KEY, - encodeStoredPayload(preferences.bestWinRetention ?? null), + storageKey(modeName, storageConfig.suffixes.winMetrics), + encodeStoredPayload(winMetrics), ) } catch { // Ignore storage failures so durable browser preferences remain best-effort only. } } +// loadPreferences restores browser preferences while validating their value ranges. function loadPreferences( + modeName: MazeControlModeName, defaultLevel: number, defaultWeight: WallWeight, isWallWeight: (value: number) => value is WallWeight, ): PersistedPreferences { try { - const storedLevel = window.localStorage.getItem(LEVEL_STORAGE_KEY) - const storedWeight = window.localStorage.getItem(WALL_WEIGHT_STORAGE_KEY) - const storedLastAttemptRetention = window.localStorage.getItem( - LAST_ATTEMPT_RETENTION_STORAGE_KEY, + const storedGameSetup = window.localStorage.getItem( + storageKey(modeName, storageConfig.suffixes.gameSetup), ) - const storedBestWinRetention = window.localStorage.getItem( - BEST_WIN_RETENTION_STORAGE_KEY, + const storedWinMetrics = window.localStorage.getItem( + storageKey(modeName, storageConfig.suffixes.winMetrics), ) - const parsedLevel = - storedLevel === null ? null : decodeStoredPayload(storedLevel) - const parsedWeight = - storedWeight === null ? null : decodeStoredPayload(storedWeight) - const parsedLastAttemptRetention = - storedLastAttemptRetention === null + const parsedGameSetup = + storedGameSetup === null ? null - : decodeStoredPayload(storedLastAttemptRetention) - const parsedBestWinRetention = - storedBestWinRetention === null + : decodeStoredPayload>(storedGameSetup) + const parsedWinMetrics = + storedWinMetrics === null ? null - : decodeStoredPayload(storedBestWinRetention) + : decodeStoredPayload>(storedWinMetrics) return { - level: - Number.isInteger(parsedLevel) && parsedLevel >= 1 - ? parsedLevel - : defaultLevel, - wallWeight: isWallWeight(parsedWeight) ? parsedWeight : defaultWeight, - lastAttemptRetention: - Number.isFinite(parsedLastAttemptRetention) && - parsedLastAttemptRetention >= 0 && - parsedLastAttemptRetention <= 1_000_000 - ? parsedLastAttemptRetention - : null, - bestWinRetention: - Number.isFinite(parsedBestWinRetention) && - parsedBestWinRetention >= 0 && - parsedBestWinRetention <= 1_000_000 - ? parsedBestWinRetention - : null, + level: validLevelPreference(parsedGameSetup?.level, defaultLevel), + wallWeight: validWallWeightPreference( + parsedGameSetup?.wallWeight, + defaultWeight, + isWallWeight, + ), + lastAttemptRetention: validRetentionPreference( + parsedWinMetrics?.lastAttemptRetention, + ), + bestWinRetention: validRetentionPreference( + parsedWinMetrics?.bestWinRetention, + ), + lastWinRequestCount: validRequestCountPreference( + parsedWinMetrics?.lastWinRequestCount, + ), + bestWinRequestCount: validRequestCountPreference( + parsedWinMetrics?.bestWinRequestCount, + ), } } catch { return { @@ -188,28 +381,123 @@ function loadPreferences( wallWeight: defaultWeight, lastAttemptRetention: null, bestWinRetention: null, + lastWinRequestCount: null, + bestWinRequestCount: null, } } } -function saveRound(round: PersistedRound | null): void { +// saveGameProgress writes long-lived localStorage progress from the live game state. +export function saveGameProgress( + modeName: MazeControlModeName, + state: PersistableProgressState, +): void { + savePreferences(modeName, { + level: state.level, + wallWeight: state.wallWeight, + lastAttemptRetention: state.lastAttemptRetention, + bestWinRetention: state.bestWinRetention, + lastWinRequestCount: state.lastWinRequestCount, + bestWinRequestCount: state.bestWinRequestCount, + }) +} + +// saveInteractiveGameProgress writes long-lived localStorage progress for interactive mode. +export function saveInteractiveGameProgress( + state: PersistableProgressState, +): void { + saveGameProgress(interactiveModeName, state) +} + +// saveAgentApiGameProgress writes long-lived localStorage progress for agent-api mode. +export function saveAgentApiGameProgress(state: PersistableProgressState): void { + saveGameProgress(agentApiModeName, state) +} + +// Active round persistence. + +// buildRoundSnapshot extracts the restorable round state from the live runtime. +function buildRoundSnapshot(state: State): PersistedRound | null { + if ( + !state.mazeDimensions || + !state.maze || + !state.playerPosition || + state.traversalHistory.length === 0 || + !state.finalPosition || + !canPersistRoundStatus(state.status) + ) { + return null + } + + const totalCells = currentTotalCells(state.mazeDimensions) + const decayIntervalPerCellMs = isAgentApiMode(state.controlMode) + ? timing.agentApiCoreDecayIntervalPerCellMs + : timing.interactiveCoreDecayIntervalPerCellMs + const remainingMs = state.clock + ? state.clock.remaining() + : totalCells * decayIntervalPerCellMs + + return { + level: state.level, + mazeDimensions: { + length: state.mazeDimensions.length, + width: state.mazeDimensions.width, + }, + maze: state.maze.map((row) => [...row]), + startCell: { + row: state.traversalHistory[0].row, + col: state.traversalHistory[0].col, + }, + traversalHistory: state.traversalHistory.map(({ playerName, row, col }) => ({ + playerName, + row, + col, + })), + playerPosition: { + x: state.playerPosition.x, + y: state.playerPosition.y, + }, + finalPosition: { + x: state.finalPosition.x, + y: state.finalPosition.y, + }, + wallWeight: state.wallWeight, + status: state.status, + score: state.score, + lastRoundScore: state.lastRoundScore, + remainingMs, + winSummary: state.winSummary, + scoreDecayUnits: state.scoreDecayUnits, + agentRequestCount: state.agentRequestCount, + } +} + +// saveRound persists the short-lived active round in session storage. +function saveRound( + modeName: MazeControlModeName, + round: PersistedRound | null, +): void { try { if (!round) { - window.sessionStorage.removeItem(ROUND_STORAGE_KEY) + window.sessionStorage.removeItem(storageKey(modeName, "round")) return } - window.sessionStorage.setItem(ROUND_STORAGE_KEY, encodeStoredPayload(round)) + window.sessionStorage.setItem( + storageKey(modeName, "round"), + encodeStoredPayload(round), + ) } catch { // Ignore storage failures so the active game can continue even without session persistence. } } -function loadRound(): PersistedRound | null { +// loadRound restores the current round snapshot and clears corrupt or stale payloads. +function loadRound(modeName: MazeControlModeName): PersistedRound | null { let rawSnapshot: string | null try { - rawSnapshot = window.sessionStorage.getItem(ROUND_STORAGE_KEY) + rawSnapshot = window.sessionStorage.getItem(storageKey(modeName, "round")) } catch { return null } @@ -220,54 +508,118 @@ function loadRound(): PersistedRound | null { const snapshot = decodeStoredPayload(rawSnapshot) if (!snapshot) { - clearPersistedRound() + clearPersistedRound(modeName) + return null } return snapshot } +// saveActiveRoundSnapshot writes the short-lived sessionStorage round snapshot. +export function saveActiveRoundSnapshot( + modeName: MazeControlModeName, + state: State, +): void { + saveRound(modeName, buildRoundSnapshot(state)) +} + +// saveActiveInteractiveRoundSnapshot writes the short-lived interactive round snapshot. +export function saveActiveInteractiveRoundSnapshot(state: State): void { + saveActiveRoundSnapshot(interactiveModeName, state) +} + +// saveActiveAgentApiRoundSnapshot writes the short-lived agent-api round snapshot. +export function saveActiveAgentApiRoundSnapshot(state: State): void { + saveActiveRoundSnapshot(agentApiModeName, state) +} + +// clearPersistedRound drops only the short-lived active round snapshot. +export function clearPersistedRound(modeName: MazeControlModeName): void { + saveRound(modeName, null) +} + +// clearPersistedInteractiveRound drops only the interactive active round snapshot. +export function clearPersistedInteractiveRound(): void { + clearPersistedRound(interactiveModeName) +} + +// clearPersistedAgentApiRound drops only the agent-api active round snapshot. +export function clearPersistedAgentApiRound(): void { + clearPersistedRound(agentApiModeName) +} + +// Combined progress and round persistence. + +// loadPersistedSnapshot restores both browser preferences and the active round. export function loadPersistedSnapshot( + modeName: MazeControlModeName, defaultLevel: number, defaultWeight: WallWeight, isWallWeight: (value: number) => value is WallWeight, ): PersistedSnapshot { + const round = loadRound(modeName) + return { - preferences: loadPreferences(defaultLevel, defaultWeight, isWallWeight), - round: loadRound(), + preferences: loadPreferences( + modeName, + defaultLevel, + defaultWeight, + isWallWeight, + ), + round, } } -export function savePersistedPreferences( - state: Pick< - State, - "level" | "wallWeight" | "lastAttemptRetention" | "bestWinRetention" - >, -): void { - savePreferences({ - level: state.level, - wallWeight: state.wallWeight, - lastAttemptRetention: state.lastAttemptRetention, - bestWinRetention: state.bestWinRetention, - }) -} - -export function savePersistedRoundState(state: State): void { - saveRound(buildRoundSnapshot(state)) +// loadPersistedInteractiveSnapshot restores the interactive-mode preferences and active round. +export function loadPersistedInteractiveSnapshot( + defaultLevel: number, + defaultWeight: WallWeight, + isWallWeight: (value: number) => value is WallWeight, +): PersistedSnapshot { + return loadPersistedSnapshot( + interactiveModeName, + defaultLevel, + defaultWeight, + isWallWeight, + ) } -export function clearPersistedRound(): void { - saveRound(null) +// loadPersistedAgentApiSnapshot restores the agent-api preferences and active round. +export function loadPersistedAgentApiSnapshot( + defaultLevel: number, + defaultWeight: WallWeight, + isWallWeight: (value: number) => value is WallWeight, +): PersistedSnapshot { + return loadPersistedSnapshot( + agentApiModeName, + defaultLevel, + defaultWeight, + isWallWeight, + ) } -export function clearPersistedSnapshot(): void { +// clearPersistedSnapshot clears both long-lived preferences and the active round. +export function clearPersistedSnapshot(modeName: MazeControlModeName): void { try { - window.localStorage.removeItem(LEVEL_STORAGE_KEY) - window.localStorage.removeItem(WALL_WEIGHT_STORAGE_KEY) - window.localStorage.removeItem(LAST_ATTEMPT_RETENTION_STORAGE_KEY) - window.localStorage.removeItem(BEST_WIN_RETENTION_STORAGE_KEY) + window.localStorage.removeItem( + storageKey(modeName, storageConfig.suffixes.gameSetup), + ) + window.localStorage.removeItem( + storageKey(modeName, storageConfig.suffixes.winMetrics), + ) } catch { // Ignore storage failures so reset remains best-effort only. } - clearPersistedRound() + clearPersistedRound(modeName) +} + +// clearPersistedInteractiveSnapshot clears interactive preferences and the active round. +export function clearPersistedInteractiveSnapshot(): void { + clearPersistedSnapshot(interactiveModeName) +} + +// clearPersistedAgentApiSnapshot clears agent-api preferences and the active round. +export function clearPersistedAgentApiSnapshot(): void { + clearPersistedSnapshot(agentApiModeName) } diff --git a/frontend/app/traversal.test.ts b/frontend/app/traversal.test.ts new file mode 100644 index 0000000..dd4896f --- /dev/null +++ b/frontend/app/traversal.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest" + +import { CONFIG } from "./config" +import { + cellCoordinateFromGridPoint, + currentTotalCells, + gridPointFromCellCoordinate, + isMoveAction, + isSpaceFound, + isWallWeight, + mazeCellKey, + nextWallWeight, + resolvePlayerMove, + reweightMaze, + traversalHistoryEntry, + traversalHistoryIncludes, +} from "./traversal" +import type { MazeAction, State, TraversalHistoryEntry } from "./types" + +function visit(row: number, col: number): TraversalHistoryEntry { + return { playerName: "Self", row, col } +} + +function createState(overrides: Partial = {}): State { + return { + controlMode: CONFIG.runtime.controlModes.interactive, + level: 1, + maze: [ + ["|", "---", "-", "---", "|"], + ["|", " ", " ", " ", "|"], + ["|", "---", "-", "---", "|"], + ], + mazeDimensions: { length: 2, width: 1 }, + playerPosition: { x: 1, y: 1 }, + traversalHistory: [visit(0, 0)], + finalPosition: { x: 3, y: 1 }, + status: "running", + score: 100, + lastRoundScore: 0, + lastAttemptRetention: null, + bestWinRetention: null, + lastWinRequestCount: null, + bestWinRequestCount: null, + winSummary: "", + canResume: false, + wallWeight: 1, + scoreDecayUnits: 0, + agentRequestCount: 0, + clock: null, + ...overrides, + } +} + +// These tests cover the traversal-only helpers kept separate from maze generation. +describe("traversal", () => { + it("accepts supported wall weights and cycles them in order", () => { + expect(isWallWeight(1)).toBe(true) + expect(isWallWeight(2)).toBe(true) + expect(isWallWeight(3)).toBe(true) + expect(isWallWeight(4)).toBe(false) + + expect(nextWallWeight(1)).toBe(2) + expect(nextWallWeight(2)).toBe(3) + expect(nextWallWeight(3)).toBe(1) + }) + + it("treats maze paths as space-prefixed segments", () => { + expect(isSpaceFound(" ")).toBe(true) + expect(isSpaceFound(" wall")).toBe(true) + expect(isSpaceFound("|")).toBe(false) + expect(isSpaceFound("")).toBe(false) + }) + + it("reweights maze walls without changing open paths", () => { + const regularMaze = [ + ["|", "---", "|"], + ["|", " ", "|"], + ["|", "-", "|"], + ] + + expect(reweightMaze(regularMaze, 1)).toEqual([ + ["╏", "╍╍╍", "╏"], + ["╏", " ", "╏"], + ["╏", "╍", "╏"], + ]) + }) + + it("classifies only movement actions as maze moves", () => { + expect(isMoveAction({ type: "MoveRight" })).toBe(true) + expect(isMoveAction({ type: "pause" })).toBe(false) + expect(isMoveAction({ type: "await-agent" })).toBe(false) + expect(isMoveAction({ type: "Unknown" } as unknown as MazeAction)).toBe(false) + }) + + it("converts between render-grid points and logical cell coordinates", () => { + expect(cellCoordinateFromGridPoint({ x: 1, y: 1 })).toEqual({ row: 0, col: 0 }) + expect(cellCoordinateFromGridPoint({ x: 3, y: 1 })).toEqual({ row: 0, col: 1 }) + expect(gridPointFromCellCoordinate({ row: 2, col: 3 })).toEqual({ x: 7, y: 5 }) + }) + + it("returns the total logical cells only for usable maze dimensions", () => { + expect(currentTotalCells({ length: 4, width: 3 })).toBe(12) + expect(currentTotalCells(null)).toBe(0) + expect(currentTotalCells({ length: 0, width: 3 })).toBe(0) + expect(currentTotalCells({ length: 4, width: -1 })).toBe(0) + }) + + it("tracks traversal history entries by logical cell identity", () => { + const currentVisit = traversalHistoryEntry({ row: 0, col: 1 }, "Blue") + const history = [visit(0, 0), currentVisit] + + expect(currentVisit).toEqual({ playerName: "Blue", row: 0, col: 1 }) + expect(mazeCellKey(currentVisit)).toBe("0:1") + expect(traversalHistoryIncludes(history, { row: 0, col: 1 })).toBe(true) + expect(traversalHistoryIncludes(history, { row: 1, col: 0 })).toBe(false) + }) + + it("resolves a valid player move without mutating state", () => { + const state = createState() + + expect(resolvePlayerMove(state, "MoveRight")).toEqual({ + canMove: true, + nextCell: { row: 0, col: 1 }, + nextGridPoint: { x: 3, y: 1 }, + visitedBefore: false, + }) + expect(state.playerPosition).toEqual({ x: 1, y: 1 }) + expect(state.traversalHistory).toEqual([visit(0, 0)]) + }) + + it("marks revisits without duplicating traversal history", () => { + const state = createState({ + playerPosition: { x: 3, y: 1 }, + traversalHistory: [visit(0, 0), visit(0, 1)], + }) + + expect(resolvePlayerMove(state, "MoveLeft")).toEqual({ + canMove: true, + nextCell: { row: 0, col: 0 }, + nextGridPoint: { x: 1, y: 1 }, + visitedBefore: true, + }) + expect(state.traversalHistory).toEqual([visit(0, 0), visit(0, 1)]) + }) + + it("rejects movement when the round is not running, blocked, or out of bounds", () => { + expect(resolvePlayerMove(createState({ status: "paused" }), "MoveRight")).toEqual({ + canMove: false, + }) + + expect( + resolvePlayerMove( + createState({ + maze: [ + ["|", "---", "-", "---", "|"], + ["|", " ", "|", " ", "|"], + ["|", "---", "-", "---", "|"], + ], + }), + "MoveRight", + ), + ).toEqual({ canMove: false }) + + expect(resolvePlayerMove(createState(), "MoveLeft")).toEqual({ + canMove: false, + }) + }) +}) diff --git a/frontend/app/traversal.ts b/frontend/app/traversal.ts new file mode 100644 index 0000000..9a3d58a --- /dev/null +++ b/frontend/app/traversal.ts @@ -0,0 +1,179 @@ +import { CONFIG, WALL_WEIGHTS } from "./config" +import { isRunningStatus } from "./status" +import type { + BaseDimensions, + CellCoordinate, + MazeAction, + MoveAction, + RenderGridPoint, + State, + TraversalHistoryEntry, + WallWeight, +} from "./types" + +const { maze } = CONFIG + +export const MOVE_DELTAS: Record = { + MoveLeft: [0, -1], + MoveRight: [0, 1], + MoveUp: [-1, 0], + MoveDown: [1, 0], +} + +// currentTotalCells returns the logical maze area, or zero when dimensions are not usable. +export function currentTotalCells( + mazeDimensions: BaseDimensions | null | undefined, +): number { + if ( + !mazeDimensions || + mazeDimensions.width <= 0 || + mazeDimensions.length <= 0 + ) { + return 0 + } + + return mazeDimensions.length * mazeDimensions.width +} + +export type ResolvedPlayerMove = + | { canMove: false } + | { + canMove: true + nextCell: CellCoordinate + nextGridPoint: RenderGridPoint + visitedBefore: boolean + } + +// getWallCharacters resolves the glyph set for the requested traversal wall weight. +function getWallCharacters(weight: WallWeight): [string, string, string] { + return maze.walls[weight] +} + +// isWallWeight validates numeric wall styles restored from storage or tests. +export function isWallWeight(value: number): value is WallWeight { + return WALL_WEIGHTS.includes(value as WallWeight) +} + +// nextWallWeight advances to the next supported wall style and wraps at the end. +export function nextWallWeight(weight: WallWeight): WallWeight { + const index = WALL_WEIGHTS.indexOf(weight) + if (index === -1) { + return WALL_WEIGHTS[0] + } + + return WALL_WEIGHTS[(index + 1) % WALL_WEIGHTS.length] +} + +// isSpaceFound treats any space-prefixed segment as traversable path. +export function isSpaceFound(item: string): boolean { + return item.length > 0 && item.charCodeAt(0) === 32 +} + +// isMoveAction reports whether one semantic action is a traversable maze move. +export function isMoveAction( + action: MazeAction, +): action is Extract { + return action.type in MOVE_DELTAS +} + +// cellCoordinateFromGridPoint converts one rendered maze-grid point into a logical cell position. +export function cellCoordinateFromGridPoint(position: RenderGridPoint): CellCoordinate { + return { + row: Math.floor((position.y - 1) / maze.cellSpan), + col: Math.floor((position.x - 1) / maze.cellSpan), + } +} + +// gridPointFromCellCoordinate expands a logical cell position back into rendered maze-grid space. +export function gridPointFromCellCoordinate(cell: CellCoordinate): RenderGridPoint { + return { + x: cell.col * maze.cellSpan + 1, + y: cell.row * maze.cellSpan + 1, + } +} + +// mazeCellKey builds a stable string key for deduplicating logical maze cells. +export function mazeCellKey(cell: CellCoordinate): string { + return `${cell.row}:${cell.col}` +} + +// traversalHistoryEntry records one logical-cell visit for the player who made the move. +export function traversalHistoryEntry( + cell: CellCoordinate, + playerName: string, +): TraversalHistoryEntry { + return { + ...cell, + playerName, + } +} + +// traversalHistoryIncludes reports whether the ordered visit history already contains a cell. +export function traversalHistoryIncludes( + traversalHistory: TraversalHistoryEntry[], + cell: CellCoordinate, +): boolean { + return traversalHistory.some( + (visitedCell) => mazeCellKey(visitedCell) === mazeCellKey(cell), + ) +} + +// resolvePlayerMove applies the shared movement rules without mutating game state. +export function resolvePlayerMove( + state: State, + action: MoveAction, +): ResolvedPlayerMove { + if ( + !isRunningStatus(state.status) || + !state.maze || + !state.mazeDimensions || + !state.playerPosition + ) { + return { canMove: false } + } + + const [rowDelta, columnDelta] = MOVE_DELTAS[action] + const { x, y } = state.playerPosition + const nextY = y + rowDelta * maze.moveStep + const nextX = x + columnDelta * maze.moveStep + const probeY = y + rowDelta + const probeX = x + columnDelta + + if (nextY <= 0 || nextY > state.mazeDimensions.width * maze.cellSpan) { + return { canMove: false } + } + + if (nextX <= 0 || nextX > state.mazeDimensions.length * maze.cellSpan) { + return { canMove: false } + } + + if (!isSpaceFound(state.maze[probeY][probeX])) { + return { canMove: false } + } + + const nextGridPoint = { x: nextX, y: nextY } + const nextCell = cellCoordinateFromGridPoint(nextGridPoint) + + return { + canMove: true, + nextCell, + nextGridPoint, + visitedBefore: traversalHistoryIncludes(state.traversalHistory, nextCell), + } +} + +// reweightMaze swaps wall glyphs without disturbing already-open path segments. +export function reweightMaze( + data: string[][], + currentWeight: WallWeight, +): string[][] { + const fromChars = getWallCharacters(currentWeight) + const toChars = getWallCharacters(nextWallWeight(currentWeight)) + const replacements = new Map([ + [fromChars[0], toChars[0]], + [fromChars[1], toChars[1]], + [fromChars[2], toChars[2]], + ]) + + return data.map((row) => row.map((cell) => replacements.get(cell) ?? cell)) +} diff --git a/frontend/app/types.ts b/frontend/app/types.ts index 1b6b1d9..41e7c2c 100644 --- a/frontend/app/types.ts +++ b/frontend/app/types.ts @@ -1,33 +1,102 @@ import type { GameClock } from "./clock" -export type GameStatus = - "boot" | "running" | "paused" | "won" | "lost" | "too-small" +// PersistedGameStatus lists only round states that are safe to restore from browser storage. +export type PersistedGameStatus = + | "running" + | "paused" + | "won" + | "lost" + | "await-agent" -export type Position = [number, number] +// Shared runtime types live here so rendering, control, storage, and generation stay aligned. +export type GameStatus = PersistedGameStatus | "boot" | "too-small" -export type WallWeight = 1 | 2 | 3 +// CellCoordinate represents one logical cell position using zero-based row and column indexes. +export type CellCoordinate = { + row: number + col: number +} + +// TraversalHistoryEntry records one chronological logical-cell visit for the named player. +export type TraversalHistoryEntry = CellCoordinate & { + playerName: string +} + +// RenderGridPoint represents one drawn maze-grid point using positive x/y coordinates. +export type RenderGridPoint = { + x: number + y: number +} +// BaseDimensions captures a maze size without tying it to a specific level. export type BaseDimensions = { length: number width: number } +// LevelDimensions couples a generated maze size back to its source level. export type LevelDimensions = BaseDimensions & { level: number } +// WallWeight selects one of the supported visual wall styles. +export type WallWeight = 1 | 2 | 3 + +// MoveAction is the semantic movement vocabulary shared by all control modes. +export type MoveAction = "MoveUp" | "MoveDown" | "MoveLeft" | "MoveRight" + +// SessionAction groups non-movement actions that affect the active game session. +export type SessionAction = + | "pause" + | "proceed" + | "restart" + | "cycle-walls" + | "await-agent" + +// Direction extends MoveAction with the neutral "none" state used during generation. +export type Direction = "none" | MoveAction + +export type MazeControlModeName = "interactive" | "agent-api" + +// MazeAction describes one abstract game action issued to the runtime. +export type MazeAction = + | { type: MoveAction } + | { type: SessionAction } + +// MoveStatus keeps single-move and batch-replay outcomes in one shared vocabulary. +export type MoveStatus = + | "applied" + | "invalid-move" + | "network-error" + | "reached-target" + | "malformed-response" + +// WinSummaryPreviousComparison describes how the current win compares to the last completed attempt. +export type WinSummaryPreviousComparison = "none" | "faster" | "slower" | "matched" + +// WinSummaryBestComparison describes how the current win compares to the best stored win. +export type WinSummaryBestComparison = "new-record" | "matched-best" | "behind-best" + +// AgentRequestPreviousComparison compares the current agent-api win request count to the last win. +export type AgentRequestPreviousComparison = "none" | "fewer" | "more" | "matched" + +// AgentRequestBestComparison compares the current agent-api win request count to the best win. +export type AgentRequestBestComparison = "new-record" | "matched-best" | "behind-best" + +// CellAddress records the render-grid coordinates around a logical maze cell. export type CellAddress = { - __bottomCenter: Position - __bottomLeft: Position - __bottomRight: Position - __middleCenter: Position - __middleLeft: Position - __middleRight: Position - __topCenter: Position - __topLeft: Position - __topRight: Position + __bottomCenter: RenderGridPoint + __bottomLeft: RenderGridPoint + __bottomRight: RenderGridPoint + __middleCenter: RenderGridPoint + __middleLeft: RenderGridPoint + __middleRight: RenderGridPoint + __topCenter: RenderGridPoint + __topLeft: RenderGridPoint + __topRight: RenderGridPoint } +// CellNeighbors stores the neighboring cell numbers around one logical cell. export type CellNeighbors = { __bottom: number __left: number @@ -35,63 +104,172 @@ export type CellNeighbors = { __top: number } -export type MoveAction = "MoveUp" | "MoveDown" | "MoveLeft" | "MoveRight" - -export type Direction = "none" | MoveAction - +// NavigationProfile shapes corridor and turning behavior during maze generation. export type NavigationProfile = { __softCorridorLimit: number __hardCorridorLimit: number __preferTurnPercent: number } +// PathStep tracks one generation step and its corridor history. export type PathStep = { __cellNo: number __moveDirection: Direction __corridorLength: number } -export type PersistedGameStatus = "running" | "paused" | "won" | "lost" +// RoundState is the maze-generation result consumed by the game runtime. +export type RoundState = { + maze: string[][] + startPosition: RenderGridPoint + finalPosition: RenderGridPoint +} +// PersistedRound captures the active or finished round state restored across reloads. export type PersistedRound = { - version: 1 level: number - dims: BaseDimensions + mazeDimensions: BaseDimensions maze: string[][] - playerPosition: Position - finalPosition: Position + startCell: CellCoordinate + traversalHistory: TraversalHistoryEntry[] + playerPosition: RenderGridPoint + finalPosition: RenderGridPoint wallWeight: WallWeight status: PersistedGameStatus score: number lastRoundScore: number remainingMs: number winSummary?: string + scoreDecayUnits?: number + agentRequestCount?: number } +// PersistedPreferences stores the long-lived browser preferences between rounds. export type PersistedPreferences = { level: number wallWeight: WallWeight lastAttemptRetention?: number | null bestWinRetention?: number | null + lastWinRequestCount?: number | null + bestWinRequestCount?: number | null } +// PersistedSnapshot bundles long-lived preferences with the short-lived round snapshot. export type PersistedSnapshot = { preferences: PersistedPreferences round: PersistedRound | null } -export type RoundState = { - maze: string[][] - startPosition: Position - finalPosition: Position +// AgentExpectedResponseFormat documents the one supported prediction payload shape. +export type AgentExpectedResponseFormat = { + validPredictionFormat: { + moves: MoveAction[] + } +} + +// AgentApiConfig stores one HTTP-controlled agent that can join the shared agent-api maze. +export type AgentApiConfig = { + id: string + playerName: string + model: string + endpoint: string + enabled: boolean + disabledReason?: "network-error" + lastErrorAt?: number +} + +// MazeActionState is the flattened agent-api payload that combines live maze context with replay results. +export type MazeActionState = { + level: number + status: GameStatus + score: number + model: string + stream: false + format: "json" + playerName: string + currentCell: CellCoordinate | null + destinationCell: CellCoordinate | null + traversalHistory: TraversalHistoryEntry[] + + prompt: string + allowedMoves: MoveAction[] + recommendedAvgPredictionLimit: number + expectedResponseFormat: AgentExpectedResponseFormat + + submittedMovesPattern: string + submittedMovesIndexBase: 0 + submittedMoves: string[] + lastMoveStatus: MoveStatus | null + lastValidMoveIndex: number | null + visitedBefore?: boolean + decayedMovesCount: number } +// MazeActionDispatchOptions lets each dispatched command opt into feedback when it needs it. +export type MazeActionDispatchOptions = { + wantFeedback?: boolean + playerName: string +} + +export type MazeActionDispatch = ( + action: MazeAction, + options: MazeActionDispatchOptions, +) => MazeActionState | null + +// MazeActionControl defines the production contract that each browser action-control mode implements. +export interface MazeActionControl { + name: MazeControlModeName + bindActionDispatch: ( + dispatch: MazeActionDispatch, + readActionState: () => MazeActionState, + commitAgentTurn: (decayedMovesCount: number) => MazeActionState, + ) => void + readLastActionState: () => MazeActionState | null + recordActionState: (actionState: MazeActionState) => void + clearActionState: () => void +} + +// State is the browser runtime's single source of truth for one session. +export type State = { + controlMode: MazeControlModeName + level: number + status: GameStatus + canResume: boolean + + maze: string[][] | null + mazeDimensions: BaseDimensions | null + playerPosition: RenderGridPoint | null + finalPosition: RenderGridPoint | null + traversalHistory: TraversalHistoryEntry[] + wallWeight: WallWeight + + score: number + lastRoundScore: number + lastAttemptRetention: number | null + bestWinRetention: number | null + lastWinRequestCount: number | null + bestWinRequestCount: number | null + winSummary: string + scoreDecayUnits: number + agentRequestCount: number + + clock: GameClock | null +} + +// GameRuntime exposes the active mode plus a direct dispatch hook for tests and integrations. +export type GameRuntime = { + mode: MazeControlModeName + dispatch: MazeActionDispatch +} + +// ScreenLine is the renderer's normalized line model before HTML generation. export type ScreenLine = { kind: "text" | "maze" text: string className: string } +// Elements collects the DOM handles that power the browser terminal. export type Elements = { app: HTMLElement body: HTMLElement @@ -102,118 +280,159 @@ export type Elements = { touchButtons: HTMLButtonElement[] } -export type State = { - level: number - dims: BaseDimensions | null - maze: string[][] | null - playerPosition: Position | null - finalPosition: Position | null - status: GameStatus - score: number - lastRoundScore: number - lastAttemptRetention: number | null - bestWinRetention: number | null - winSummary: string - canResume: boolean - wallWeight: WallWeight - clock: GameClock | null -} - +// AppConfig gathers translatable copy and shared runtime constants. export type AppConfig = { - // Shared page chrome. - appName: string - appSubtitle: string - appControlsAriaLabel: string - moreActionsAriaLabel: string - footerAriaLabel: string - pageVersionTemplate: string - contactLabel: string - contactAriaLabel: string - - // Game page chrome. - gameDocumentTitle: string - gameDescription: string - gamePageLabel: string - aiAgentsLabel: string - aiAgentsPageAriaLabel: string - resetProgressLabel: string - resetProgressAriaLabel: string - terminalAriaLabel: string - touchControlsAriaLabel: string - - // AI Agents page chrome. - agentsDocumentTitle: string - agentsDescription: string - agentsPageLabel: string - agentsPageAriaLabel: string - backToGameLabel: string - backToGameAriaLabel: string - - // Gameplay text. - navigation: string - navigationCompact: string - touchNavigation: string - touchNavigationCompact: string - pauseMessage: string - successMessage: string - successCompactMessage: string - failedMessage: string - failedCompactMessage: string - proceedMessage: string - touchProceedMessage: string - tooSmallMessage: string - tooSmallActionMessage: string - statusTemplate: string - touchStatusTemplate: string - highScoreTemplate: string - winNoPrevNewRecord: string - winNoPrevMatchedBest: string - winNoPrevBehindBest: string - winFasterPrevNewRecord: string - winFasterPrevMatchedBest: string - winFasterPrevBehindBest: string - winSlowerPrevNewRecord: string - winSlowerPrevMatchedBest: string - winSlowerPrevBehindBest: string - winMatchedPrevNewRecord: string - winMatchedPrevBest: string - winMatchedPrevBehindBest: string - - // Touch-control labels. - wallsTouchLabel: string - pauseTouchLabel: string - proceedTouchLabel: string - touchMoveUpAriaLabel: string - touchMoveLeftAriaLabel: string - touchMoveRightAriaLabel: string - touchMoveDownAriaLabel: string - - // Maze rendering. - playerMarker: string - destinationMarker: string - walls: Record - - // Runtime and layout settings. - cellSpan: number - cellPathWidth: number - moveStep: number - scoreMultiplier: number - percentScale: number - retentionScale: number - refreshInterval: number - navigationFriendlyMaxArea: number - navigationHardestArea: number - navigationFriendlyProfile: NavigationProfile - navigationHardestProfile: NavigationProfile - mazeLeftPadding: number - seed: number - diff: number - minMazeDimension: number - missingElementErrorTemplate: string - compactViewportWidth: number - compactViewportHeight: number - terminalHeightInset: number - terminalHeightScale: number - terminalWidthInset: number - terminalWidthScale: number + chrome: { + appName: string + appSubtitle: string + pageVersionTemplate: string + contactLabel: string + } + pages: { + game: { + documentTitle: string + description: string + pageLabel: string + aiAgentsLabel: string + resetProgressLabel: string + } + agents: { + documentTitle: string + description: string + pageLabel: string + backToGameLabel: string + resetProgressLabel: string + } + } + messages: { + navigation: { + default: string + compact: string + } + pauseMessage: string + successMessage: string + successCompactMessage: string + failedMessage: string + failedCompactMessage: string + proceedMessage: string + touchProceedMessage: string + agentAwaitMessage: string + agentAwaitActionMessage: string + tooSmallMessage: string + tooSmallActionMessage: string + statusTemplate: string + touchStatusTemplate: string + highScoreTemplate: string + winSummary: { + noPrevious: { + newRecord: string + matchedBest: string + behindBest: string + } + fasterPrevious: { + newRecord: string + matchedBest: string + behindBest: string + } + slowerPrevious: { + newRecord: string + matchedBest: string + behindBest: string + } + matchedPrevious: { + newRecord: string + matchedBest: string + behindBest: string + } + } + agentWinSummary: { + noPrevious: { + newRecord: string + matchedBest: string + behindBest: string + } + fewerPrevious: { + newRecord: string + matchedBest: string + behindBest: string + } + morePrevious: { + newRecord: string + matchedBest: string + behindBest: string + } + matchedPrevious: { + newRecord: string + matchedBest: string + behindBest: string + } + } + } + controls: { + touch: { + wallsLabel: string + pauseLabel: string + proceedLabel: string + } + } + maze: { + playerMarker: string + destinationMarker: string + walls: Record + cellSpan: number + cellPathWidth: number + moveStep: number + leftPadding: number + minDimension: number + } + generation: { + seed: number + diff: number + navigation: { + friendlyMaxArea: number + hardestArea: number + friendlyProfile: NavigationProfile + hardestProfile: NavigationProfile + } + } + scoring: { + budgetMultiplier: number + percentScale: number + retentionScale: number + } + timing: { + refreshInterval: number + scoreDecayRate: number + interactiveCoreDecayIntervalPerCellMs: number + agentApiCoreDecayIntervalPerCellMs: number + agentApiResponseTimeoutMs: number + } + viewport: { + compactWidth: number + compactHeight: number + terminalSampleWidth: number + minTerminalRows: number + minTerminalColumns: number + terminalHeightInset: number + terminalHeightScale: number + terminalWidthInset: number + terminalWidthScale: number + } + runtime: { + controlModes: { + interactive: MazeControlModeName + agentApi: MazeControlModeName + } + storage: { + version: number + suffixes: { + agentConfigs: string + gameSetup: string + winMetrics: string + } + } + missingElementErrorTemplate: string + agentApiMistakePenaltyMoves: number + interactivePlayerName: string + } } diff --git a/frontend/app/version.test.ts b/frontend/app/version.test.ts deleted file mode 100644 index 9274536..0000000 --- a/frontend/app/version.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest" - -import { - APP_VERSION, - VERSION_MAJOR, - VERSION_MINOR, - VERSION_PATCH, -} from "./version" - -describe("APP_VERSION", () => { - it("uses semantic versioning", () => { - expect(APP_VERSION).toMatch(/^\d+\.\d+\.\d+$/) - }) - - it("matches the version parts", () => { - expect(APP_VERSION).toBe( - `${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}`, - ) - }) -}) diff --git a/frontend/app/version.ts b/frontend/app/version.ts deleted file mode 100644 index 3968d8b..0000000 --- a/frontend/app/version.ts +++ /dev/null @@ -1,11 +0,0 @@ -// VERSION_MAJOR is the semantic major version for the browser SPA runtime. -export const VERSION_MAJOR = 1 - -// VERSION_MINOR is the semantic minor version for the browser SPA runtime. -export const VERSION_MINOR = 0 - -// VERSION_PATCH is the semantic patch version for the browser SPA runtime. -export const VERSION_PATCH = 2 - -// APP_VERSION is the semantic version rendered in browser metadata and the page footer. -export const APP_VERSION = `${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}` diff --git a/frontend/page-chrome.ts b/frontend/page-chrome.ts new file mode 100644 index 0000000..acd377f --- /dev/null +++ b/frontend/page-chrome.ts @@ -0,0 +1,183 @@ +import { CONFIG, PAGE_COPYRIGHT_TEXT } from "./app/config" + +const { viewport } = CONFIG +const compactChromeClass = "page-chrome--compact" +const wideChromeClass = "page-chrome--wide" + +// configValue resolves a dotted CONFIG path and rejects missing segments early. +function configValue(path: string): unknown { + return path + .split(".") + .reduce((currentValue, pathSegment) => { + if ( + typeof currentValue !== "object" || + currentValue === null || + !(pathSegment in currentValue) + ) { + throw new Error(`missing config entry: ${path}`) + } + + return currentValue[pathSegment as keyof typeof currentValue] + }, CONFIG) +} + +// configText resolves a visible text entry from CONFIG and rejects non-string keys early. +function configText(key: string): string { + const value = configValue(key) + if (typeof value !== "string") { + throw new Error(`missing translatable config entry: ${key}`) + } + + return value +} + +// applyConfigAttribute copies CONFIG-backed text into matching DOM attributes. +function applyConfigAttribute( + selector: string, + attributeName: "content" | "textContent", +): void { + const nodes = document.querySelectorAll(selector) + + for (const node of nodes) { + const configKey = node.dataset.configKey + if (!configKey) { + continue + } + + const value = configText(configKey) + if (attributeName === "textContent") { + node.textContent = value + continue + } + + node.setAttribute(attributeName, value) + } +} + +// applyDocumentTitle keeps the live document title aligned with page metadata. +function applyDocumentTitle(): void { + const titleElement = document.querySelector("title[data-config-key]") + if (!(titleElement instanceof HTMLTitleElement)) { + return + } + + const configKey = titleElement.dataset.configKey + if (!configKey) { + return + } + + const value = configText(configKey) + titleElement.textContent = value + document.title = value +} + +// applyPageVersion keeps shared page chrome in sync with the configured copyright text. +function applyPageVersion(): void { + const versionCopies = document.querySelectorAll("[data-page-version]") + for (const element of versionCopies) { + element.textContent = PAGE_COPYRIGHT_TEXT + } +} + +// applyPageText hydrates shared static copy such as labels and meta descriptions. +function applyPageText(): void { + applyDocumentTitle() + applyConfigAttribute("[data-config-text]", "textContent") + applyConfigAttribute("meta[data-config-key]", "content") +} + +// initTopMenus keeps shared top-bar menus expanded on wide screens and collapsible on compact ones. +function initTopMenus(): void { + const menus = Array.from(document.querySelectorAll("details.top-menu")) + const compactViewport = window.matchMedia( + `(max-width: ${viewport.compactWidth}px)`, + ) + + // isCompactMode centralizes the breakpoint used by the menu behavior. + function isCompactMode(): boolean { + return compactViewport.matches + } + + // syncChromeMode exposes the shared compact/wide page state to CSS for every page. + function syncChromeMode(): boolean { + const compact = isCompactMode() + document.documentElement.classList.toggle(compactChromeClass, compact) + document.documentElement.classList.toggle(wideChromeClass, !compact) + + return compact + } + + if (menus.length === 0) { + syncChromeMode() + compactViewport.addEventListener("change", syncChromeMode) + return + } + + // closeMenu hides one details menu without duplicating attribute writes. + function closeMenu(menu: HTMLDetailsElement): void { + menu.open = false + } + + // syncMenuMode expands menus on wide screens and collapses them on compact ones. + function syncMenuMode(): void { + const compact = syncChromeMode() + for (const menu of menus) { + menu.open = !compact + } + } + + // closeOtherMenus preserves a single open menu in compact mode. + function closeOtherMenus(activeMenu: HTMLDetailsElement): void { + for (const menu of menus) { + if (menu !== activeMenu) { + closeMenu(menu) + } + } + } + + for (const menu of menus) { + menu.addEventListener("toggle", () => { + if (isCompactMode() && menu.open) { + closeOtherMenus(menu) + } + }) + } + + document.addEventListener("click", (event: MouseEvent) => { + if (!isCompactMode()) { + return + } + + const target = event.target + if (!(target instanceof Node)) { + return + } + + for (const menu of menus) { + if (menu.open && !menu.contains(target)) { + closeMenu(menu) + } + } + }) + + document.addEventListener("keydown", (event: KeyboardEvent) => { + if (!isCompactMode()) { + return + } + + if (event.key !== "Escape") { + return + } + + for (const menu of menus) { + closeMenu(menu) + } + }) + + compactViewport.addEventListener("change", syncMenuMode) + syncMenuMode() +} + +applyPageText() +applyPageVersion() +initTopMenus() diff --git a/frontend/page-meta.ts b/frontend/page-meta.ts deleted file mode 100644 index e6de5d5..0000000 --- a/frontend/page-meta.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { CONFIG } from "./app/config" -import { APP_VERSION } from "./app/version" - -declare const __TAPOO_BUILD_YEAR__: number - -function configText(key: string): string { - const value = CONFIG[key as keyof typeof CONFIG] - if (typeof value !== "string") { - throw new Error(`missing translatable config entry: ${key}`) - } - - return value -} - -function applyConfigAttribute( - selector: string, - attributeName: "aria-label" | "content" | "textContent", -): void { - const nodes = document.querySelectorAll(selector) - - for (const node of nodes) { - const configKey = node.dataset.configKey - if (!configKey) { - continue - } - - const value = configText(configKey) - if (attributeName === "textContent") { - node.textContent = value - continue - } - - node.setAttribute(attributeName, value) - } -} - -function applyDocumentTitle(): void { - const titleElement = document.querySelector("title[data-config-key]") - if (!(titleElement instanceof HTMLTitleElement)) { - return - } - - const configKey = titleElement.dataset.configKey - if (!configKey) { - return - } - - const value = configText(configKey) - titleElement.textContent = value - document.title = value -} - -// applyPageVersion keeps shared page chrome in sync with the semantic version injected at build time. -function applyPageVersion(): void { - const versionCopies = document.querySelectorAll("[data-page-version]") - for (const element of versionCopies) { - element.textContent = CONFIG.pageVersionTemplate - .replace("{version}", APP_VERSION) - .replace("{year}", String(__TAPOO_BUILD_YEAR__)) - } -} - -function applyPageText(): void { - applyDocumentTitle() - applyConfigAttribute("[data-config-text]", "textContent") - applyConfigAttribute("[data-config-aria-label]", "aria-label") - applyConfigAttribute("meta[data-config-key]", "content") -} - -applyPageText() -applyPageVersion() diff --git a/frontend/styles/tapoo.css b/frontend/styles/tapoo.css index e62ecc7..d040a34 100644 --- a/frontend/styles/tapoo.css +++ b/frontend/styles/tapoo.css @@ -288,57 +288,53 @@ body { padding: var(--top-button-padding-y); } -@media (min-width: 901px) { - .top-menu { - display: flex; - align-items: center; - } +.page-chrome--wide .top-menu { + display: flex; + align-items: center; +} - .top-menu__summary { - display: none; - } +.page-chrome--wide .top-menu__summary { + display: none; +} - .top-menu__panel { - position: static; - min-width: 0; - max-width: none; - padding: 0; - border: 0; - border-radius: 0; - background: transparent; - box-shadow: none; - display: flex; - align-items: center; - gap: 8px; - } +.page-chrome--wide .top-menu__panel { + position: static; + min-width: 0; + max-width: none; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + display: flex; + align-items: center; + gap: 8px; +} - .top-menu:not([open]) > .top-menu__panel { - display: flex; - } +.page-chrome--wide .top-menu:not([open]) > .top-menu__panel { + display: flex; +} - .top-menu__item { - width: auto; - flex: 0 0 auto; - white-space: nowrap; - } +.page-chrome--wide .top-menu__item { + width: auto; + flex: 0 0 auto; + white-space: nowrap; } -@media (max-width: 900px) { - .top-menu { - display: block; - } +.page-chrome--compact .top-menu { + display: block; +} - .top-menu__summary { - display: inline-flex; - } +.page-chrome--compact .top-menu__summary { + display: inline-flex; +} - .top-menu__panel { - min-width: 236px; - } +.page-chrome--compact .top-menu__panel { + min-width: 236px; +} - .top-menu[open] > .top-menu__panel { - display: grid; - } +.page-chrome--compact .top-menu[open] > .top-menu__panel { + display: grid; } .placeholder-page { diff --git a/frontend/tapoo.test.ts b/frontend/tapoo.test.ts index 8c68264..6a8fabd 100644 --- a/frontend/tapoo.test.ts +++ b/frontend/tapoo.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest" +// These tests verify that the entrypoint boots only on pages that expose a game host. describe("tapoo entrypoint", () => { beforeEach(() => { vi.resetModules() @@ -7,11 +8,67 @@ describe("tapoo entrypoint", () => { it("boots the game on import", async () => { const bootstrapGame = vi.fn() + const getGameElements = vi.fn(() => ({ app: {} })) + const interactiveMode = { + bindActionDispatch: vi.fn(), + clearActionState: vi.fn(), + name: "interactive", + readLastActionState: vi.fn(), + recordActionState: vi.fn(), + } + document.body.dataset.tapooControlMode = "interactive" + + vi.doMock("./app/dom", () => ({ getGameElements })) + vi.doMock("./app/control/interactive", () => ({ + createInteractiveMode: vi.fn(() => interactiveMode), + })) + vi.doMock("./app/control/agent", () => ({ + createAgentMode: vi.fn(), + })) vi.doMock("./app/game", () => ({ bootstrapGame })) await import("./tapoo") expect(bootstrapGame).toHaveBeenCalledTimes(1) + expect(bootstrapGame).toHaveBeenCalledWith(interactiveMode, { app: {} }) + }) + + it("uses the agent-api page mode when configured in html", async () => { + const bootstrapGame = vi.fn() + const elements = { app: {} } + const agentMode = { + bindActionDispatch: vi.fn(), + clearActionState: vi.fn(), + name: "agent-api", + readLastActionState: vi.fn(), + recordActionState: vi.fn(), + } + + document.body.dataset.tapooControlMode = "agent-api" + + vi.doMock("./app/dom", () => ({ getGameElements: vi.fn(() => elements) })) + vi.doMock("./app/control/interactive", () => ({ + createInteractiveMode: vi.fn(), + })) + vi.doMock("./app/control/agent", () => ({ + createAgentMode: vi.fn(() => agentMode), + })) + vi.doMock("./app/game", () => ({ bootstrapGame })) + + await import("./tapoo") + + expect(bootstrapGame).toHaveBeenCalledWith(agentMode, elements) + }) + + it("does not boot the game bundle on pages without a terminal host", async () => { + const bootstrapGame = vi.fn() + + vi.doMock("./app/dom", () => ({ getGameElements: vi.fn(() => null) })) + vi.doMock("./app/game", () => ({ bootstrapGame })) + + await import("./tapoo") + + expect(bootstrapGame).not.toHaveBeenCalled() }) }) diff --git a/frontend/tapoo.ts b/frontend/tapoo.ts index 8d109cc..c6c26f5 100644 --- a/frontend/tapoo.ts +++ b/frontend/tapoo.ts @@ -1,2 +1,21 @@ +import { createAgentMode } from "./app/control/agent" +import { createInteractiveMode } from "./app/control/interactive" +import { CONFIG } from "./app/config" +import { getGameElements } from "./app/dom" import { bootstrapGame } from "./app/game" -bootstrapGame() +import type { Elements, MazeActionControl } from "./app/types" + +// pageControlMode chooses the MazeActionControl implementation declared by the page shell. +function pageControlMode(elements: Elements): MazeActionControl { + const configuredMode = document.body.dataset.tapooControlMode + return configuredMode === CONFIG.runtime.controlModes.agentApi + ? createAgentMode(elements) + : createInteractiveMode(elements) +} + +const elements = getGameElements() + +if (elements) { + const mode = pageControlMode(elements) + bootstrapGame(mode, elements) +} diff --git a/frontend/top-menu.ts b/frontend/top-menu.ts deleted file mode 100644 index c37e1d5..0000000 --- a/frontend/top-menu.ts +++ /dev/null @@ -1,74 +0,0 @@ -function initTopMenus(): void { - const menus = Array.from(document.querySelectorAll("details.top-menu")) - const compactViewport = window.matchMedia("(max-width: 900px)") - - if (menus.length === 0) { - return - } - - function isCompactMode(): boolean { - return compactViewport.matches - } - - function closeMenu(menu: HTMLDetailsElement): void { - menu.open = false - } - - function syncMenuMode(): void { - for (const menu of menus) { - menu.open = !isCompactMode() - } - } - - function closeOtherMenus(activeMenu: HTMLDetailsElement): void { - for (const menu of menus) { - if (menu !== activeMenu) { - closeMenu(menu) - } - } - } - - for (const menu of menus) { - menu.addEventListener("toggle", () => { - if (isCompactMode() && menu.open) { - closeOtherMenus(menu) - } - }) - } - - document.addEventListener("click", (event: MouseEvent) => { - if (!isCompactMode()) { - return - } - - const target = event.target - if (!(target instanceof Node)) { - return - } - - for (const menu of menus) { - if (menu.open && !menu.contains(target)) { - closeMenu(menu) - } - } - }) - - document.addEventListener("keydown", (event: KeyboardEvent) => { - if (!isCompactMode()) { - return - } - - if (event.key !== "Escape") { - return - } - - for (const menu of menus) { - closeMenu(menu) - } - }) - - compactViewport.addEventListener("change", syncMenuMode) - syncMenuMode() -} - -initTopMenus() diff --git a/maze/levels.go b/maze/levels.go index 512b201..ca15e55 100644 --- a/maze/levels.go +++ b/maze/levels.go @@ -51,34 +51,23 @@ func aspectMismatchScore(candidate, terminalSize Dimensions) int { // isPreferredMazeDimensions compares two fitting candidates and reports whether candidate should win. // Preference order is: -// 1. Closest aspect ratio match to the terminal. -// 2. Lowest internal skew so more balanced mazes win ties. -// 3. Largest smaller edge so narrow mazes lose when quality is otherwise equal. -// 4. Deterministic final ordering by length, then width. +// 1. Lowest internal skew so more balanced mazes win first. +// 2. Closest aspect ratio match to the terminal. +// Any remaining tie keeps the first deterministic candidate. func isPreferredMazeDimensions(candidate, currentBest, terminalSize Dimensions) bool { - candidatePenalty := aspectMismatchScore(candidate, terminalSize) - bestPenalty := aspectMismatchScore(currentBest, terminalSize) - if candidatePenalty != bestPenalty { - return candidatePenalty < bestPenalty - } - candidateSkew := absInt(candidate.Length - candidate.Width) bestSkew := absInt(currentBest.Length - currentBest.Width) if candidateSkew != bestSkew { return candidateSkew < bestSkew } - candidateMinEdge := min(candidate.Length, candidate.Width) - bestMinEdge := min(currentBest.Length, currentBest.Width) - if candidateMinEdge != bestMinEdge { - return candidateMinEdge > bestMinEdge - } - - if candidate.Length != currentBest.Length { - return candidate.Length > currentBest.Length + candidatePenalty := aspectMismatchScore(candidate, terminalSize) + bestPenalty := aspectMismatchScore(currentBest, terminalSize) + if candidatePenalty != bestPenalty { + return candidatePenalty < bestPenalty } - return candidate.Width > currentBest.Width + return false } // chooseBestMazeDimensions selects the single candidate that best matches the current terminal. diff --git a/maze/levels_test.go b/maze/levels_test.go index 776644f..a2cae59 100644 --- a/maze/levels_test.go +++ b/maze/levels_test.go @@ -186,6 +186,12 @@ func TestGetMazeDimensionsFits(t *testing.T) { size: maze.Dimensions{Length: 16, Width: 10}, want: maze.Dimensions{Length: 10, Width: 8}, }, + { + name: "prefers balanced dimensions before viewport aspect ratio", + level: 2, + size: maze.Dimensions{Length: 30, Width: 10}, + want: maze.Dimensions{Length: 10, Width: 8}, + }, { name: "prefers balanced fit when aspect score ties", level: 2, diff --git a/public/agents.html b/public/agents.html index 209dea8..15c0de7 100644 --- a/public/agents.html +++ b/public/agents.html @@ -6,25 +6,25 @@ name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> - + - +
-
+
- - - + + +
- + -
-
-
- +
+
+
+
+

+              MMMMMMMMMM
+              
+            
+
-
+
- - + + diff --git a/public/index.html b/public/index.html index 7c4844f..61585f4 100644 --- a/public/index.html +++ b/public/index.html @@ -6,25 +6,25 @@ name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> - + - +
-
+
- - - + + +
- + -
-
+

               MMMMMMMMMM
-              
       
-
+
- - + diff --git a/scripts/build-frontend.sh b/scripts/build-frontend.sh index 3cf722a..e0ee163 100755 --- a/scripts/build-frontend.sh +++ b/scripts/build-frontend.sh @@ -15,23 +15,14 @@ fi --outfile=./public/css/tapoo.min.css ./node_modules/.bin/esbuild \ - ./frontend/page-meta.ts \ + ./frontend/page-chrome.ts \ --bundle \ --minify \ --mangle-props=^__ \ --platform=browser \ --target=es2022 \ --define:__TAPOO_BUILD_YEAR__=${BUILD_YEAR} \ - --outfile=./public/js/page-meta.min.js - -./node_modules/.bin/esbuild \ - ./frontend/top-menu.ts \ - --bundle \ - --minify \ - --mangle-props=^__ \ - --platform=browser \ - --target=es2022 \ - --outfile=./public/js/top-menu.min.js + --outfile=./public/js/page-chrome.min.js ./node_modules/.bin/esbuild \ ./frontend/tapoo.ts \