Browse Source

feat(chase): move logic under stages/games/chase, solvability

- Relocate generator/model/graph/rng/types into logic/
- Add solvability helpers and tests
- Fix vitest mock path for logic/generator after move
- Fix page_chase stage test import (page_chase.ts)

Made-with: Cursor
master
npmrun 2 weeks ago
parent
commit
a0cef22eda
  1. 4
      docs/superpowers/specs/2026-04-26-hex-chase-game-design.md
  2. 74
      src/stages/games/chase/logic/generator.ts
  3. 49
      src/stages/games/chase/logic/hexGraph.ts
  4. 18
      src/stages/games/chase/logic/index.ts
  5. 2
      src/stages/games/chase/logic/model.ts
  6. 0
      src/stages/games/chase/logic/rng.ts
  7. 103
      src/stages/games/chase/logic/solvability.ts
  8. 0
      src/stages/games/chase/logic/types.ts
  9. 4
      src/stages/games/chase/page_chase.ts
  10. 96
      tests/chase/chaseGenerator.test.ts
  11. 6
      tests/chase/chaseModel.test.ts
  12. 6
      tests/chase/hexGraph.test.ts
  13. 2
      tests/chase/rng.test.ts
  14. 53
      tests/chase/solvability.test.ts
  15. 2
      tests/stages/page_chase.test.ts

4
docs/superpowers/specs/2026-04-26-hex-chase-game-design.md

@ -32,7 +32,7 @@
## 3. 总体架构 ## 3. 总体架构
新增主玩法场景 `page_chase.ts`,从 `init` 进入。场景内部采用“模型与渲染分离”: 新增主玩法场景 `src/stages/games/chase/page_chase.ts`,从 `init` 进入。场景内部采用“模型与渲染分离”:
- `GameModel`(纯逻辑层) - `GameModel`(纯逻辑层)
- 管理地图、seed、回合、角色位置、胜负状态; - 管理地图、seed、回合、角色位置、胜负状态;
@ -213,6 +213,6 @@
建议按以下顺序进入实现计划: 建议按以下顺序进入实现计划:
1. 先完成 `GameModel` 与地图生成/校验; 1. 先完成 `GameModel` 与地图生成/校验;
2. 接入 `page_chase` 基础渲染与点击移动; 2. 接入 `games/chase` 场景基础渲染与点击移动;
3. 接入难度策略与胜负/结算流程; 3. 接入难度策略与胜负/结算流程;
4. 最后完善 HUD、seed 输入、按钮交互与测试。 4. 最后完善 HUD、seed 输入、按钮交互与测试。

74
src/game/chase/generator.ts → 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 { createRng, normalizeSeed } from "./rng";
import { thiefHasWinningStrategy } from "./solvability";
import type { Difficulty, GameGraph, GraphNode, NodeId } from "./types"; import type { Difficulty, GameGraph, GraphNode, NodeId } from "./types";
const MAX_ATTEMPTS = 40; const MAX_ATTEMPTS = 80;
type CoordKey = `${number},${number}`; type CoordKey = `${number},${number}`;
@ -120,6 +126,38 @@ function getFarthestNode(graph: GameGraph, from: NodeId): { id: NodeId; distance
return farthest; return farthest;
} }
/**
*
* easyhardnormal
*/
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 { function generateGraphAttempt(seed: number, difficulty: Difficulty): ChaseRound {
const rng = createRng(seed); const rng = createRng(seed);
const [minNodes, maxNodes] = difficultyNodeRange(difficulty); const [minNodes, maxNodes] = difficultyNodeRange(difficulty);
@ -177,14 +215,9 @@ function generateGraphAttempt(seed: number, difficulty: Difficulty): ChaseRound
const farthest = getFarthestNode(graph, thiefStartNodeId); const farthest = getFarthestNode(graph, thiefStartNodeId);
const exitNodeId = farthest.id; const exitNodeId = farthest.id;
const pathLength = shortestPathLength(graph, thiefStartNodeId, exitNodeId); const pathLength = shortestPathLength(graph, thiefStartNodeId, exitNodeId);
const farthestFromExit = getFarthestNode(graph, exitNodeId); const guardStartNodeId =
let guardStartNodeId = farthestFromExit.id; pickGuardBetweenThiefAndExit(graph, rng, difficulty, thiefStartNodeId, exitNodeId) ??
if (guardStartNodeId === thiefStartNodeId) { thiefStartNodeId;
const fallbackCandidates = existingNodeIds.filter(
(nodeId) => nodeId !== thiefStartNodeId,
);
guardStartNodeId = fallbackCandidates[0] ?? thiefStartNodeId;
}
return { return {
snapshot: { snapshot: {
@ -217,11 +250,30 @@ export function generateChaseRound(
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) { for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
const attemptSeed = normalizeSeed(baseSeed + attempt * 0x9e3779b9); const attemptSeed = normalizeSeed(baseSeed + attempt * 0x9e3779b9);
const round = generateAttempt(attemptSeed, input.difficulty); 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 = const isPlayable =
isConnected(round.snapshot.graph) && isConnected(round.snapshot.graph) &&
round.meta.hasEscapePath && round.meta.hasEscapePath &&
round.snapshot.guardStartNodeId !== round.snapshot.thiefStartNodeId && round.snapshot.guardStartNodeId !== round.snapshot.thiefStartNodeId &&
round.meta.pathLength >= minPathLength; round.meta.pathLength >= minPathLength &&
guardOnShortestPathBetween &&
solvable;
if (isPlayable) { if (isPlayable) {
return { return {

49
src/game/chase/hexGraph.ts → src/stages/games/chase/logic/hexGraph.ts

@ -47,6 +47,55 @@ export function shortestPathLength(
return Infinity; 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<NodeId, NodeId>();
const queue: NodeId[] = [from];
let head = 0;
const visited = new Set<NodeId>([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 { export function isConnected(graph: GameGraph): boolean {
const nodeIds = Object.keys(graph.nodes); const nodeIds = Object.keys(graph.nodes);
if (nodeIds.length <= 1) { if (nodeIds.length <= 1) {

18
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";

2
src/game/chase/model.ts → src/stages/games/chase/logic/model.ts

@ -4,7 +4,7 @@ import {
type ChaseRound, type ChaseRound,
type GenerateChaseRoundInput, type GenerateChaseRoundInput,
} from "./generator"; } from "./generator";
import { createRng, normalizeSeed, type Rng } from "./rng"; import { createRng, normalizeSeed } from "./rng";
import type { Difficulty, NodeId } from "./types"; import type { Difficulty, NodeId } from "./types";
type GameStatus = "playing" | "win" | "lose"; type GameStatus = "playing" | "win" | "lose";

0
src/game/chase/rng.ts → src/stages/games/chase/logic/rng.ts

103
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<string>();
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));
}

0
src/game/chase/types.ts → src/stages/games/chase/logic/types.ts

4
src/stages/page_chase.ts → src/stages/games/chase/page_chase.ts

@ -2,8 +2,8 @@ import { BaseScene } from "@/scene/BaseScene";
import { sceneManager } from "@/scene/SceneManager"; import { sceneManager } from "@/scene/SceneManager";
import { SceneType } from "@/enums/SceneType"; import { SceneType } from "@/enums/SceneType";
import { appRuntime } from "@/kernel/AppRuntime"; import { appRuntime } from "@/kernel/AppRuntime";
import { ChaseGameModel } from "@/game/chase/model"; import { ChaseGameModel } from "./logic/model";
import type { Difficulty, NodeId } from "@/game/chase/types"; import type { Difficulty, NodeId } from "./logic/types";
import { Container, Graphics, Text, TextStyle, type Ticker } from "pixi.js"; import { Container, Graphics, Text, TextStyle, type Ticker } from "pixi.js";
const NODE_RADIUS = 24; const NODE_RADIUS = 24;

96
tests/chase/chaseGenerator.test.ts

@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { shortestPathLength } from "../../src/game/chase/hexGraph"; import { shortestPath, shortestPathLength } from "../../src/stages/games/chase/logic/hexGraph";
import { generateChaseRound } from "../../src/game/chase/generator"; import { generateChaseRound } from "../../src/stages/games/chase/logic/generator";
import type { Difficulty } from "../../src/game/chase/types"; import { thiefHasWinningStrategy } from "../../src/stages/games/chase/logic/solvability";
import type { Difficulty, GameGraph } from "../../src/stages/games/chase/logic/types";
describe("chase round generator", () => { describe("chase round generator", () => {
it("is deterministic for same seed and difficulty", () => { it("is deterministic for same seed and difficulty", () => {
@ -31,23 +32,23 @@ describe("chase round generator", () => {
}).toMatchInlineSnapshot(` }).toMatchInlineSnapshot(`
{ {
"difficulty": "easy", "difficulty": "easy",
"edgeCount": 19, "edgeCount": 25,
"exitNodeId": "0,-1", "exitNodeId": "-2,0",
"firstFiveNodeIds": [ "firstFiveNodeIds": [
"0,0", "0,0",
"1,-1", "0,1",
"2,-2", "0,-1",
"2,-1", "-1,1",
"1,-2", "-1,2",
], ],
"guardNodeId": "0,0", "guardNodeId": "-2,1",
"guardStartNodeId": "0,0", "guardStartNodeId": "-2,1",
"hasEscapePath": true, "hasEscapePath": true,
"nodeCount": 20, "nodeCount": 24,
"seed": 424242, "seed": 2654860011,
"status": "playing", "status": "playing",
"thiefNodeId": "4,-5", "thiefNodeId": "2,-1",
"thiefStartNodeId": "4,-5", "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", () => { it("throws after retry limit when all attempts are unplayable", () => {
let attemptCount = 0; let attemptCount = 0;
const unplayableStrategy = (seed: number, difficulty: Difficulty) => { const unplayableStrategy = (seed: number, difficulty: Difficulty) => {
@ -121,6 +141,50 @@ describe("chase round generator", () => {
{ generateAttempt: unplayableStrategy }, { generateAttempt: unplayableStrategy },
), ),
).toThrow("Unable to generate playable chase round within retry limit"); ).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);
}); });
}); });

6
tests/chase/chaseModel.test.ts

@ -1,10 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { ChaseGameModel } from "../../src/game/chase/model"; import { ChaseGameModel } from "../../src/stages/games/chase/logic/model";
import type { Difficulty, GameGraph } from "../../src/game/chase/types"; import type { Difficulty, GameGraph } from "../../src/stages/games/chase/logic/types";
const mockedGenerateChaseRound = vi.fn(); const mockedGenerateChaseRound = vi.fn();
vi.mock("../../src/game/chase/generator", () => ({ vi.mock("../../src/stages/games/chase/logic/generator", () => ({
generateChaseRound: (...args: unknown[]) => mockedGenerateChaseRound(...args), generateChaseRound: (...args: unknown[]) => mockedGenerateChaseRound(...args),
})); }));

6
tests/chase/hexGraph.test.ts

@ -2,9 +2,10 @@ import { describe, expect, it } from "vitest";
import { import {
axialNeighbors, axialNeighbors,
isConnected, isConnected,
shortestPath,
shortestPathLength, shortestPathLength,
} from "../../src/game/chase/hexGraph"; } from "../../src/stages/games/chase/logic/hexGraph";
import type { GameGraph } from "../../src/game/chase/types"; import type { GameGraph } from "../../src/stages/games/chase/logic/types";
function buildGraph(nodes: GameGraph["nodes"]): GameGraph { function buildGraph(nodes: GameGraph["nodes"]): GameGraph {
return { return {
@ -36,6 +37,7 @@ describe("hex graph utilities", () => {
expect(shortestPathLength(graph, "A", "D")).toBe(3); expect(shortestPathLength(graph, "A", "D")).toBe(3);
expect(shortestPathLength(graph, "A", "A")).toBe(0); expect(shortestPathLength(graph, "A", "A")).toBe(0);
expect(shortestPath(graph, "A", "D")).toEqual(["A", "B", "C", "D"]);
}); });
it("returns Infinity when target is unreachable", () => { it("returns Infinity when target is unreachable", () => {

2
tests/chase/rng.test.ts

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; 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", () => { describe("chase rng", () => {
it("generates the same sequence for the same seed", () => { it("generates the same sequence for the same seed", () => {

53
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");
});
});

2
tests/stages/page_chase.test.ts

@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest"; 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 InitScene from "../../src/stages/page_init";
import { sceneManager } from "../../src/scene/SceneManager"; import { sceneManager } from "../../src/scene/SceneManager";
import { Text } from "pixi.js"; import { Text } from "pixi.js";

Loading…
Cancel
Save