diff --git a/docs/superpowers/specs/2026-04-26-hex-chase-game-design.md b/docs/superpowers/specs/2026-04-26-hex-chase-game-design.md index 773e4e0..df9829e 100644 --- a/docs/superpowers/specs/2026-04-26-hex-chase-game-design.md +++ b/docs/superpowers/specs/2026-04-26-hex-chase-game-design.md @@ -32,7 +32,7 @@ ## 3. 总体架构 -新增主玩法场景 `page_chase.ts`,从 `init` 进入。场景内部采用“模型与渲染分离”: +新增主玩法场景 `src/stages/games/chase/page_chase.ts`,从 `init` 进入。场景内部采用“模型与渲染分离”: - `GameModel`(纯逻辑层) - 管理地图、seed、回合、角色位置、胜负状态; @@ -213,6 +213,6 @@ 建议按以下顺序进入实现计划: 1. 先完成 `GameModel` 与地图生成/校验; -2. 接入 `page_chase` 基础渲染与点击移动; +2. 接入 `games/chase` 场景基础渲染与点击移动; 3. 接入难度策略与胜负/结算流程; 4. 最后完善 HUD、seed 输入、按钮交互与测试。 diff --git a/src/game/chase/generator.ts b/src/stages/games/chase/logic/generator.ts similarity index 77% rename from src/game/chase/generator.ts rename to src/stages/games/chase/logic/generator.ts index 6a3f470..00e474a 100644 --- a/src/game/chase/generator.ts +++ b/src/stages/games/chase/logic/generator.ts @@ -1,8 +1,14 @@ -import { axialNeighbors, isConnected, shortestPathLength } from "./hexGraph"; +import { + axialNeighbors, + isConnected, + shortestPath, + shortestPathLength, +} from "./hexGraph"; import { createRng, normalizeSeed } from "./rng"; +import { thiefHasWinningStrategy } from "./solvability"; import type { Difficulty, GameGraph, GraphNode, NodeId } from "./types"; -const MAX_ATTEMPTS = 40; +const MAX_ATTEMPTS = 80; type CoordKey = `${number},${number}`; @@ -120,6 +126,38 @@ function getFarthestNode(graph: GameGraph, from: NodeId): { id: NodeId; distance return farthest; } +/** + * 在「偷 → 出口」的一条最短路径上选取官兵起点(严格在偷与出口之间,不含两端)。 + * easy:偏靠近出口一侧;hard:偏靠近小偷一侧;normal:路径内均匀随机。 + */ +function pickGuardBetweenThiefAndExit( + graph: GameGraph, + rng: () => number, + difficulty: Difficulty, + thiefStartNodeId: NodeId, + exitNodeId: NodeId, +): NodeId | null { + const path = shortestPath(graph, thiefStartNodeId, exitNodeId); + if (path.length < 3) { + return null; + } + + const inner = path.slice(1, -1); + const n = inner.length; + + if (difficulty === "easy") { + const from = Math.floor(n / 2); + return inner[from + randomInt(rng, 0, n - 1 - from)]; + } + + if (difficulty === "hard") { + const to = Math.ceil(n / 2) - 1; + return inner[randomInt(rng, 0, Math.max(0, to))]; + } + + return inner[randomInt(rng, 0, n - 1)]; +} + function generateGraphAttempt(seed: number, difficulty: Difficulty): ChaseRound { const rng = createRng(seed); const [minNodes, maxNodes] = difficultyNodeRange(difficulty); @@ -177,14 +215,9 @@ function generateGraphAttempt(seed: number, difficulty: Difficulty): ChaseRound const farthest = getFarthestNode(graph, thiefStartNodeId); const exitNodeId = farthest.id; const pathLength = shortestPathLength(graph, thiefStartNodeId, exitNodeId); - const farthestFromExit = getFarthestNode(graph, exitNodeId); - let guardStartNodeId = farthestFromExit.id; - if (guardStartNodeId === thiefStartNodeId) { - const fallbackCandidates = existingNodeIds.filter( - (nodeId) => nodeId !== thiefStartNodeId, - ); - guardStartNodeId = fallbackCandidates[0] ?? thiefStartNodeId; - } + const guardStartNodeId = + pickGuardBetweenThiefAndExit(graph, rng, difficulty, thiefStartNodeId, exitNodeId) ?? + thiefStartNodeId; return { snapshot: { @@ -217,11 +250,30 @@ export function generateChaseRound( for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) { const attemptSeed = normalizeSeed(baseSeed + attempt * 0x9e3779b9); const round = generateAttempt(attemptSeed, input.difficulty); + const solvable = + thiefHasWinningStrategy( + round.snapshot.graph, + round.snapshot.thiefStartNodeId, + round.snapshot.guardStartNodeId, + round.snapshot.exitNodeId, + ); + + const pathThiefExit = shortestPath( + round.snapshot.graph, + round.snapshot.thiefStartNodeId, + round.snapshot.exitNodeId, + ); + const guardOnShortestPathBetween = + pathThiefExit.length >= 3 && + pathThiefExit.slice(1, -1).includes(round.snapshot.guardStartNodeId); + const isPlayable = isConnected(round.snapshot.graph) && round.meta.hasEscapePath && round.snapshot.guardStartNodeId !== round.snapshot.thiefStartNodeId && - round.meta.pathLength >= minPathLength; + round.meta.pathLength >= minPathLength && + guardOnShortestPathBetween && + solvable; if (isPlayable) { return { diff --git a/src/game/chase/hexGraph.ts b/src/stages/games/chase/logic/hexGraph.ts similarity index 56% rename from src/game/chase/hexGraph.ts rename to src/stages/games/chase/logic/hexGraph.ts index e4011ca..ca65bd2 100644 --- a/src/game/chase/hexGraph.ts +++ b/src/stages/games/chase/logic/hexGraph.ts @@ -47,6 +47,55 @@ export function shortestPathLength( return Infinity; } +/** + * 一条最短路径(BFS;邻接 tie-break 按 nodeId 排序,保证对同一图可复现)。 + */ +export function shortestPath(graph: GameGraph, from: NodeId, to: NodeId): NodeId[] { + if (!graph.nodes[from] || !graph.nodes[to]) { + return []; + } + if (from === to) { + return [from]; + } + + const parent = new Map(); + const queue: NodeId[] = [from]; + let head = 0; + const visited = new Set([from]); + + while (head < queue.length) { + const current = queue[head]; + head += 1; + const node = graph.nodes[current]; + const neighbors = [...node.neighbors].sort((a, b) => a.localeCompare(b)); + + for (const neighbor of neighbors) { + if (neighbor === to) { + const path: NodeId[] = [to]; + let walk = current; + path.push(walk); + while (walk !== from) { + const p = parent.get(walk); + if (p === undefined) { + return []; + } + path.push(p); + walk = p; + } + path.reverse(); + return path; + } + if (!visited.has(neighbor) && graph.nodes[neighbor]) { + visited.add(neighbor); + parent.set(neighbor, current); + queue.push(neighbor); + } + } + } + + return []; +} + export function isConnected(graph: GameGraph): boolean { const nodeIds = Object.keys(graph.nodes); if (nodeIds.length <= 1) { diff --git a/src/stages/games/chase/logic/index.ts b/src/stages/games/chase/logic/index.ts new file mode 100644 index 0000000..522cd5a --- /dev/null +++ b/src/stages/games/chase/logic/index.ts @@ -0,0 +1,18 @@ +export { default as ChaseScene } from "../scene"; +export { ChaseGameModel } from "./model"; +export { createRng, normalizeSeed } from "./rng"; +export { axialNeighbors, isConnected, shortestPath, shortestPathLength } from "./hexGraph"; +export { generateChaseRound } from "./generator"; +export { optimalGuardStep, thiefHasWinningStrategy } from "./solvability"; +export type { + Difficulty, + GameGraph, + GameStatus, + GraphNode, + NodeId, +} from "./types"; +export type { + ChaseRound, + GenerateChaseRoundInput, + GenerateChaseRoundOptions, +} from "./generator"; diff --git a/src/game/chase/model.ts b/src/stages/games/chase/logic/model.ts similarity index 98% rename from src/game/chase/model.ts rename to src/stages/games/chase/logic/model.ts index 63efb36..482a500 100644 --- a/src/game/chase/model.ts +++ b/src/stages/games/chase/logic/model.ts @@ -4,7 +4,7 @@ import { type ChaseRound, type GenerateChaseRoundInput, } from "./generator"; -import { createRng, normalizeSeed, type Rng } from "./rng"; +import { createRng, normalizeSeed } from "./rng"; import type { Difficulty, NodeId } from "./types"; type GameStatus = "playing" | "win" | "lose"; diff --git a/src/game/chase/rng.ts b/src/stages/games/chase/logic/rng.ts similarity index 100% rename from src/game/chase/rng.ts rename to src/stages/games/chase/logic/rng.ts diff --git a/src/stages/games/chase/logic/solvability.ts b/src/stages/games/chase/logic/solvability.ts new file mode 100644 index 0000000..555cbe5 --- /dev/null +++ b/src/stages/games/chase/logic/solvability.ts @@ -0,0 +1,103 @@ +import { shortestPathLength } from "./hexGraph"; +import type { GameGraph, NodeId } from "./types"; + +/** + * 官兵一步:始终走离小偷最短的邻格(与 ChaseGameModel hard 档一致,tie-break 按 nodeId)。 + */ +export function optimalGuardStep(graph: GameGraph, guard: NodeId, thief: NodeId): NodeId { + const guardNode = graph.nodes[guard]; + if (!guardNode || guardNode.neighbors.length === 0) { + return guard; + } + + const movesWithDistance = guardNode.neighbors.map((neighborId) => ({ + nodeId: neighborId, + distance: shortestPathLength(graph, neighborId, thief), + })); + + const finiteMoves = movesWithDistance.filter((item) => Number.isFinite(item.distance)); + if (finiteMoves.length === 0) { + return guard; + } + + finiteMoves.sort((a, b) => a.distance - b.distance || a.nodeId.localeCompare(b.nodeId)); + return finiteMoves[0].nodeId; +} + +function stateKey(thief: NodeId, guard: NodeId): string { + return `${thief}|${guard}`; +} + +/** + * 是否存在小偷的必胜策略(对最优追击官兵)。 + * 回合:小偷先走一步;若未到出口,官兵走一步;重复。 + * + * 用交替可达性的最小不动点处理状态环,避免递归栈溢出。 + */ +export function thiefHasWinningStrategy( + graph: GameGraph, + thiefStart: NodeId, + guardStart: NodeId, + exitNodeId: NodeId, +): boolean { + if (thiefStart === guardStart) { + return false; + } + + const nodeIds = Object.keys(graph.nodes); + const pairs: Array<[NodeId, NodeId]> = []; + for (const t of nodeIds) { + for (const g of nodeIds) { + if (t !== g) { + pairs.push([t, g]); + } + } + } + + const winning = new Set(); + + for (const [t, g] of pairs) { + const tNode = graph.nodes[t]; + if (!tNode) { + continue; + } + const canStepToExit = tNode.neighbors.some((m) => m !== g && m === exitNodeId); + if (canStepToExit) { + winning.add(stateKey(t, g)); + } + } + + let changed = true; + while (changed) { + changed = false; + for (const [t, g] of pairs) { + const key = stateKey(t, g); + if (winning.has(key)) { + continue; + } + const tNode = graph.nodes[t]; + if (!tNode) { + continue; + } + for (const m of tNode.neighbors) { + if (m === g) { + continue; + } + if (m === exitNodeId) { + continue; + } + const g2 = optimalGuardStep(graph, g, m); + if (g2 === m) { + continue; + } + if (winning.has(stateKey(m, g2))) { + winning.add(key); + changed = true; + break; + } + } + } + } + + return winning.has(stateKey(thiefStart, guardStart)); +} diff --git a/src/game/chase/types.ts b/src/stages/games/chase/logic/types.ts similarity index 100% rename from src/game/chase/types.ts rename to src/stages/games/chase/logic/types.ts diff --git a/src/stages/page_chase.ts b/src/stages/games/chase/page_chase.ts similarity index 99% rename from src/stages/page_chase.ts rename to src/stages/games/chase/page_chase.ts index 63ed952..91bd63b 100644 --- a/src/stages/page_chase.ts +++ b/src/stages/games/chase/page_chase.ts @@ -2,8 +2,8 @@ import { BaseScene } from "@/scene/BaseScene"; import { sceneManager } from "@/scene/SceneManager"; import { SceneType } from "@/enums/SceneType"; import { appRuntime } from "@/kernel/AppRuntime"; -import { ChaseGameModel } from "@/game/chase/model"; -import type { Difficulty, NodeId } from "@/game/chase/types"; +import { ChaseGameModel } from "./logic/model"; +import type { Difficulty, NodeId } from "./logic/types"; import { Container, Graphics, Text, TextStyle, type Ticker } from "pixi.js"; const NODE_RADIUS = 24; diff --git a/tests/chase/chaseGenerator.test.ts b/tests/chase/chaseGenerator.test.ts index 8c0003b..dbb56c2 100644 --- a/tests/chase/chaseGenerator.test.ts +++ b/tests/chase/chaseGenerator.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; -import { shortestPathLength } from "../../src/game/chase/hexGraph"; -import { generateChaseRound } from "../../src/game/chase/generator"; -import type { Difficulty } from "../../src/game/chase/types"; +import { shortestPath, shortestPathLength } from "../../src/stages/games/chase/logic/hexGraph"; +import { generateChaseRound } from "../../src/stages/games/chase/logic/generator"; +import { thiefHasWinningStrategy } from "../../src/stages/games/chase/logic/solvability"; +import type { Difficulty, GameGraph } from "../../src/stages/games/chase/logic/types"; describe("chase round generator", () => { it("is deterministic for same seed and difficulty", () => { @@ -31,23 +32,23 @@ describe("chase round generator", () => { }).toMatchInlineSnapshot(` { "difficulty": "easy", - "edgeCount": 19, - "exitNodeId": "0,-1", + "edgeCount": 25, + "exitNodeId": "-2,0", "firstFiveNodeIds": [ "0,0", - "1,-1", - "2,-2", - "2,-1", - "1,-2", + "0,1", + "0,-1", + "-1,1", + "-1,2", ], - "guardNodeId": "0,0", - "guardStartNodeId": "0,0", + "guardNodeId": "-2,1", + "guardStartNodeId": "-2,1", "hasEscapePath": true, - "nodeCount": 20, - "seed": 424242, + "nodeCount": 24, + "seed": 2654860011, "status": "playing", - "thiefNodeId": "4,-5", - "thiefStartNodeId": "4,-5", + "thiefNodeId": "2,-1", + "thiefStartNodeId": "2,-1", } `); }); @@ -91,6 +92,25 @@ describe("chase round generator", () => { } }); + it("places guard strictly between thief and exit on the canonical shortest path", () => { + for (let seed = 1; seed <= 40; seed += 1) { + for (const difficulty of ["easy", "normal", "hard"] as const) { + const round = generateChaseRound({ seed, difficulty }); + const path = shortestPath( + round.snapshot.graph, + round.snapshot.thiefStartNodeId, + round.snapshot.exitNodeId, + ); + expect(path.length).toBeGreaterThanOrEqual(3); + const inner = path.slice(1, -1); + expect(inner).toContain(round.snapshot.guardStartNodeId); + expect(round.meta.pathLength).toBeGreaterThanOrEqual( + difficulty === "easy" ? 3 : difficulty === "hard" ? 6 : 4, + ); + } + } + }); + it("throws after retry limit when all attempts are unplayable", () => { let attemptCount = 0; const unplayableStrategy = (seed: number, difficulty: Difficulty) => { @@ -121,6 +141,50 @@ describe("chase round generator", () => { { generateAttempt: unplayableStrategy }, ), ).toThrow("Unable to generate playable chase round within retry limit"); - expect(attemptCount).toBe(40); + expect(attemptCount).toBe(80); + }); + + it("rejects rounds that are unwinnable against optimal guard", () => { + const trapGraph: GameGraph = { + nodes: { + L: { id: "L", q: 0, r: 0, neighbors: ["N"] }, + N: { id: "N", q: 1, r: 0, neighbors: ["L", "E"] }, + E: { id: "E", q: 2, r: 0, neighbors: ["N"] }, + }, + edgeList: [ + ["L", "N"], + ["N", "E"], + ], + }; + + expect(thiefHasWinningStrategy(trapGraph, "L", "N", "E")).toBe(false); + + let attemptCount = 0; + const corridorTrap = (seed: number, difficulty: Difficulty) => { + attemptCount += 1; + return { + snapshot: { + seed, + difficulty, + graph: trapGraph, + thiefStartNodeId: "L", + guardStartNodeId: "N", + thiefNodeId: "L", + guardNodeId: "N", + exitNodeId: "E", + status: "playing" as const, + }, + meta: { + hasEscapePath: true, + pathLength: 2, + attemptsUsed: 1, + }, + }; + }; + + expect(() => + generateChaseRound({ seed: 1, difficulty: "normal" }, { generateAttempt: corridorTrap }), + ).toThrow("Unable to generate playable chase round within retry limit"); + expect(attemptCount).toBe(80); }); }); diff --git a/tests/chase/chaseModel.test.ts b/tests/chase/chaseModel.test.ts index 0d94e48..e7f817a 100644 --- a/tests/chase/chaseModel.test.ts +++ b/tests/chase/chaseModel.test.ts @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { ChaseGameModel } from "../../src/game/chase/model"; -import type { Difficulty, GameGraph } from "../../src/game/chase/types"; +import { ChaseGameModel } from "../../src/stages/games/chase/logic/model"; +import type { Difficulty, GameGraph } from "../../src/stages/games/chase/logic/types"; const mockedGenerateChaseRound = vi.fn(); -vi.mock("../../src/game/chase/generator", () => ({ +vi.mock("../../src/stages/games/chase/logic/generator", () => ({ generateChaseRound: (...args: unknown[]) => mockedGenerateChaseRound(...args), })); diff --git a/tests/chase/hexGraph.test.ts b/tests/chase/hexGraph.test.ts index 2ee9f4c..92329b0 100644 --- a/tests/chase/hexGraph.test.ts +++ b/tests/chase/hexGraph.test.ts @@ -2,9 +2,10 @@ import { describe, expect, it } from "vitest"; import { axialNeighbors, isConnected, + shortestPath, shortestPathLength, -} from "../../src/game/chase/hexGraph"; -import type { GameGraph } from "../../src/game/chase/types"; +} from "../../src/stages/games/chase/logic/hexGraph"; +import type { GameGraph } from "../../src/stages/games/chase/logic/types"; function buildGraph(nodes: GameGraph["nodes"]): GameGraph { return { @@ -36,6 +37,7 @@ describe("hex graph utilities", () => { expect(shortestPathLength(graph, "A", "D")).toBe(3); expect(shortestPathLength(graph, "A", "A")).toBe(0); + expect(shortestPath(graph, "A", "D")).toEqual(["A", "B", "C", "D"]); }); it("returns Infinity when target is unreachable", () => { diff --git a/tests/chase/rng.test.ts b/tests/chase/rng.test.ts index 4fc48ea..8af0a75 100644 --- a/tests/chase/rng.test.ts +++ b/tests/chase/rng.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createRng, normalizeSeed } from "../../src/game/chase/rng"; +import { createRng, normalizeSeed } from "../../src/stages/games/chase/logic/rng"; describe("chase rng", () => { it("generates the same sequence for the same seed", () => { diff --git a/tests/chase/solvability.test.ts b/tests/chase/solvability.test.ts new file mode 100644 index 0000000..c30902c --- /dev/null +++ b/tests/chase/solvability.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { optimalGuardStep, thiefHasWinningStrategy } from "../../src/stages/games/chase/logic/solvability"; +import type { GameGraph } from "../../src/stages/games/chase/logic/types"; + +function lineGraph(ids: string[]): GameGraph { + const nodes: GameGraph["nodes"] = {}; + const edgeList: Array<[string, string]> = []; + for (let i = 0; i < ids.length; i += 1) { + const id = ids[i]; + const prev = ids[i - 1]; + const next = ids[i + 1]; + const neighbors: string[] = []; + if (prev) { + neighbors.push(prev); + } + if (next) { + neighbors.push(next); + } + nodes[id] = { id, q: i, r: 0, neighbors }; + } + for (let i = 0; i < ids.length - 1; i += 1) { + edgeList.push([ids[i], ids[i + 1]]); + } + return { nodes, edgeList }; +} + +describe("solvability (optimal guard)", () => { + it("returns false when guard blocks the only exit from a leaf", () => { + const graph = lineGraph(["L", "N", "E"]); + expect(thiefHasWinningStrategy(graph, "L", "N", "E")).toBe(false); + }); + + it("returns true when thief reaches exit in one step before guard reacts", () => { + const graph: GameGraph = { + nodes: { + T: { id: "T", q: 0, r: 0, neighbors: ["E", "G"] }, + E: { id: "E", q: 1, r: 0, neighbors: ["T"] }, + G: { id: "G", q: 0, r: 1, neighbors: ["T"] }, + }, + edgeList: [ + ["T", "E"], + ["T", "G"], + ], + }; + expect(thiefHasWinningStrategy(graph, "T", "G", "E")).toBe(true); + }); + + it("optimalGuardStep matches shortest-path tie-break toward thief", () => { + const graph = lineGraph(["A", "B", "C"]); + expect(optimalGuardStep(graph, "A", "C")).toBe("B"); + expect(optimalGuardStep(graph, "C", "A")).toBe("B"); + }); +}); diff --git a/tests/stages/page_chase.test.ts b/tests/stages/page_chase.test.ts index 32de639..d6c3868 100644 --- a/tests/stages/page_chase.test.ts +++ b/tests/stages/page_chase.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import ChaseScene from "../../src/stages/page_chase"; +import ChaseScene from "../../src/stages/games/chase/page_chase"; import InitScene from "../../src/stages/page_init"; import { sceneManager } from "../../src/scene/SceneManager"; import { Text } from "pixi.js";