Browse Source
- 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: Cursormaster
21 changed files with 1665 additions and 1324 deletions
@ -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"; |
|||
@ -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,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"); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue