diff --git a/examples/three-monks/rules.ts b/examples/three-monks/rules.ts index 781d2d4..9489fdb 100644 --- a/examples/three-monks/rules.ts +++ b/examples/three-monks/rules.ts @@ -1,5 +1,6 @@ import type { Entity, World } from "../../src/index"; import { query } from "../../src/query"; +import type { ToolEffectRecorder } from "./stats"; import { ActionCard, CARRYING_TOOLS, @@ -82,20 +83,20 @@ export function collectProposals(world: World): RoundProposals { return proposals; } -export function resolveRound(world: World): void { +export function resolveRound(world: World, stats?: ToolEffectRecorder): void { const state = world.getSingleton(GameState); if (state.phase === "gameOver") return; state.phase = "resolving"; const proposals = collectProposals(world); - if (proposals.chant) resolveChant(world); - if (proposals.fetchWater) resolveFetchWater(world); - if (proposals.exchange) resolveExchange(world); - if (proposals.drink) resolveDrink(world); + if (proposals.chant) resolveChant(world, stats); + if (proposals.fetchWater) resolveFetchWater(world, stats); + if (proposals.exchange) resolveExchange(world, stats); + if (proposals.drink) resolveDrink(world, stats); if (world.getSingleton(GameState).phase === "gameOver") return; - resolveEndOfRoundTools(world); + resolveEndOfRoundTools(world, stats); checkWinner(world); } @@ -118,7 +119,10 @@ export function prepareNextRound(world: World): void { state.message = `Round ${world.getSingleton(Table).round}: choose an action card.`; } -export function resolveFetchWater(world: World): void { +export function resolveFetchWater( + world: World, + stats?: ToolEffectRecorder, +): void { const table = world.getSingleton(Table); const players = getPlayersInSeatOrder(world); const participants = players.filter((player) => { @@ -141,6 +145,12 @@ export function resolveFetchWater(world: World): void { if (playerData.water >= placed) { playerData.water -= placed; tool.water += placed; + stats?.record(tool.kind, "挑水", { + playerWaterDelta: -placed, + ownToolWaterDelta: placed, + }); + } else { + stats?.record(tool.kind, "挑水", {}, "水不足,未放水"); } } @@ -151,11 +161,18 @@ export function resolveFetchWater(world: World): void { if (tool.kind === "ladle" && table.centralWater > 0) { table.centralWater--; tool.water++; + stats?.record(tool.kind, "挑水", { + ownToolWaterDelta: 1, + centralWaterDelta: -1, + }); } } } -export function resolveExchange(world: World): void { +export function resolveExchange( + world: World, + stats?: ToolEffectRecorder, +): void { const participants = getPlayersInSeatOrder(world).filter( (player) => getSelectedAction(world, player) !== "chant", ); @@ -167,8 +184,13 @@ export function resolveExchange(world: World): void { for (const toolEntity of tools) { const tool = world.get(toolEntity, Tool); if (tool.kind === "shoulderPole" && tool.water > 0) { - table.centralWater += tool.water; + const dumped = tool.water; + table.centralWater += dumped; tool.water = 0; + stats?.record(tool.kind, "交换", { + ownToolWaterDelta: -dumped, + centralWaterDelta: dumped, + }); } } @@ -179,7 +201,7 @@ export function resolveExchange(world: World): void { } } -export function resolveDrink(world: World): void { +export function resolveDrink(world: World, stats?: ToolEffectRecorder): void { for (const player of getPlayersFromMarker(world)) { if (!canParticipateInDrink(world, player)) continue; @@ -199,10 +221,17 @@ export function resolveDrink(world: World): void { tool.water -= maxFromTool; playerData.water += maxFromTool; drankFromTool = maxFromTool; + stats?.record(tool.kind, "喝水", { + playerWaterDelta: maxFromTool, + ownToolWaterDelta: -maxFromTool, + }); } if (tool.kind === "waterJar") { playerData.water++; + stats?.record(tool.kind, "喝水", { + playerWaterDelta: 1, + }); } if ( @@ -212,6 +241,10 @@ export function resolveDrink(world: World): void { ) { table.centralWater--; tool.water++; + stats?.record(tool.kind, "喝水", { + ownToolWaterDelta: 1, + centralWaterDelta: -1, + }); } if (playerData.water >= 10) { @@ -221,7 +254,7 @@ export function resolveDrink(world: World): void { } } -export function resolveChant(world: World): void { +export function resolveChant(world: World, stats?: ToolEffectRecorder): void { const chanters = getPlayersFromMarker(world).filter( (player) => getSelectedAction(world, player) === "chant", ); @@ -235,17 +268,32 @@ export function resolveChant(world: World): void { if (tool.kind === "bottle") { tool.water++; + stats?.record(tool.kind, "念经", { + ownToolWaterDelta: 1, + }); } else if (tool.kind === "mouse") { - moveOneNeighborWaterTo(world, player, toolEntity); + const moved = moveOneNeighborWaterTo(world, player, toolEntity); + if (moved) { + stats?.record(tool.kind, "念经", { + ownToolWaterDelta: 1, + otherToolWaterDelta: -1, + }); + } else { + stats?.record(tool.kind, "念经", {}, "相邻道具无水可偷"); + } } } } -export function resolveEndOfRoundTools(world: World): void { +export function resolveEndOfRoundTools( + world: World, + stats?: ToolEffectRecorder, +): void { for (const toolEntity of world.query(query(Tool))) { const tool = world.get(toolEntity, Tool); if (tool.kind === "woodenFish") { world.getSingleton(WoodenFishMarker).holder = tool.owner; + stats?.record(tool.kind, "轮末", {}, "获得木鱼标记"); return; } } @@ -271,7 +319,7 @@ function moveOneNeighborWaterTo( world: World, player: Entity, destinationTool: Entity, -): void { +): boolean { const neighbors = [ getLeftPlayer(world, player), getRightPlayer(world, player), @@ -282,9 +330,10 @@ function moveOneNeighborWaterTo( if (neighborTool.water > 0) { neighborTool.water--; world.get(destinationTool, Tool).water++; - return; + return true; } } + return false; } function setWinner(world: World, player: Entity): void { diff --git a/examples/three-monks/simulate.ts b/examples/three-monks/simulate.ts index 555a420..e21543b 100644 --- a/examples/three-monks/simulate.ts +++ b/examples/three-monks/simulate.ts @@ -8,6 +8,7 @@ import { getPlayersInSeatOrder, type ActionKind, } from "./components"; +import { ToolEffectStats, formatToolEffectSummaries } from "./stats"; import { beginSelectionPhase, checkWinner, @@ -52,7 +53,7 @@ interface GameStats { waterDiff: number; } -function runSingleGame(seed: number): GameStats { +function runSingleGame(seed: number, stats?: ToolEffectStats): GameStats { const random = mulberry32(seed); const world = new World(); const playerNames = ["慧空", "明心", "了尘", "净远"]; @@ -76,13 +77,13 @@ function runSingleGame(seed: number): GameStats { const proposals = collectProposals(world); - if (proposals.chant) resolveChant(world); - if (proposals.fetchWater) resolveFetchWater(world); - if (proposals.exchange) resolveExchange(world); - if (proposals.drink) resolveDrink(world); + if (proposals.chant) resolveChant(world, stats); + if (proposals.fetchWater) resolveFetchWater(world, stats); + if (proposals.exchange) resolveExchange(world, stats); + if (proposals.drink) resolveDrink(world, stats); if (world.getSingleton(GameState).phase === "gameOver") break; - resolveEndOfRoundTools(world); + resolveEndOfRoundTools(world, stats); checkWinner(world); if (world.getSingleton(GameState).phase === "gameOver") break; @@ -104,12 +105,13 @@ function runSingleGame(seed: number): GameStats { export function runSimulation(gameCount = 100, startSeed = 20260701): void { let totalRounds = 0; let totalWaterDiff = 0; + const toolStats = new ToolEffectStats(); console.log(`开始模拟 ${gameCount} 局游戏...`); for (let i = 0; i < gameCount; i++) { const seed = startSeed + i; - const stats = runSingleGame(seed); + const stats = runSingleGame(seed, toolStats); totalRounds += stats.rounds; totalWaterDiff += stats.waterDiff; } @@ -122,6 +124,10 @@ export function runSimulation(gameCount = 100, startSeed = 20260701): void { console.log(`结束时平均轮数: ${avgRounds.toFixed(2)} 轮`); console.log(`结束时首尾玩家平均水差距: ${avgWaterDiff.toFixed(2)} 口水`); console.log("=========================================="); + console.log("\n道具效果统计(总变化量):"); + for (const line of formatToolEffectSummaries(toolStats)) { + console.log(line); + } } if (process.argv[1]?.endsWith("simulate.ts")) { diff --git a/examples/three-monks/stats.ts b/examples/three-monks/stats.ts new file mode 100644 index 0000000..f7a45f6 --- /dev/null +++ b/examples/three-monks/stats.ts @@ -0,0 +1,94 @@ +import type { ToolKind } from "./components"; +import { TOOL_LABELS } from "./logging"; + +export interface ToolEffectDelta { + playerWaterDelta?: number; + ownToolWaterDelta?: number; + otherToolWaterDelta?: number; + centralWaterDelta?: number; +} + +export interface ToolEffectRecord extends Required { + tool: ToolKind; + phase: string; + description?: string; +} + +export interface ToolEffectSummary extends Required { + tool: ToolKind; + activations: number; +} + +export interface ToolEffectRecorder { + record(tool: ToolKind, phase: string, delta?: ToolEffectDelta, description?: string): void; +} + +export class ToolEffectStats implements ToolEffectRecorder { + private readonly summaries = new Map(); + private readonly records: ToolEffectRecord[] = []; + + record( + tool: ToolKind, + phase: string, + delta: ToolEffectDelta = {}, + description?: string, + ): void { + const record: ToolEffectRecord = { + tool, + phase, + description, + playerWaterDelta: delta.playerWaterDelta ?? 0, + ownToolWaterDelta: delta.ownToolWaterDelta ?? 0, + otherToolWaterDelta: delta.otherToolWaterDelta ?? 0, + centralWaterDelta: delta.centralWaterDelta ?? 0, + }; + + this.records.push(record); + + const summary = this.summaries.get(tool) ?? { + tool, + activations: 0, + playerWaterDelta: 0, + ownToolWaterDelta: 0, + otherToolWaterDelta: 0, + centralWaterDelta: 0, + }; + + summary.activations++; + summary.playerWaterDelta += record.playerWaterDelta; + summary.ownToolWaterDelta += record.ownToolWaterDelta; + summary.otherToolWaterDelta += record.otherToolWaterDelta; + summary.centralWaterDelta += record.centralWaterDelta; + this.summaries.set(tool, summary); + } + + getRecords(): readonly ToolEffectRecord[] { + return this.records; + } + + getSummaries(): ToolEffectSummary[] { + return [...this.summaries.values()].sort((a, b) => + TOOL_LABELS[a.tool].localeCompare(TOOL_LABELS[b.tool], "zh-Hans-CN"), + ); + } +} + +export function formatToolEffectSummaries(stats: ToolEffectStats): string[] { + const summaries = stats.getSummaries(); + if (summaries.length === 0) return ["无道具效果记录"]; + + return [ + "道具 | 触发次数 | 玩家水变化 | 自身道具水变化 | 其他道具水变化 | 中央水变化", + "--- | ---: | ---: | ---: | ---: | ---:", + ...summaries.map((summary) => + [ + TOOL_LABELS[summary.tool], + summary.activations, + summary.playerWaterDelta, + summary.ownToolWaterDelta, + summary.otherToolWaterDelta, + summary.centralWaterDelta, + ].join(" | "), + ), + ]; +}