feat(three-monks): Add tool effect statistics tracking

This commit is contained in:
hyper 2026-07-01 16:12:40 +08:00
parent 572b473089
commit 6fa1f511bd
3 changed files with 171 additions and 22 deletions

View File

@ -1,5 +1,6 @@
import type { Entity, World } from "../../src/index"; import type { Entity, World } from "../../src/index";
import { query } from "../../src/query"; import { query } from "../../src/query";
import type { ToolEffectRecorder } from "./stats";
import { import {
ActionCard, ActionCard,
CARRYING_TOOLS, CARRYING_TOOLS,
@ -82,20 +83,20 @@ export function collectProposals(world: World): RoundProposals {
return proposals; return proposals;
} }
export function resolveRound(world: World): void { export function resolveRound(world: World, stats?: ToolEffectRecorder): void {
const state = world.getSingleton(GameState); const state = world.getSingleton(GameState);
if (state.phase === "gameOver") return; if (state.phase === "gameOver") return;
state.phase = "resolving"; state.phase = "resolving";
const proposals = collectProposals(world); const proposals = collectProposals(world);
if (proposals.chant) resolveChant(world); if (proposals.chant) resolveChant(world, stats);
if (proposals.fetchWater) resolveFetchWater(world); if (proposals.fetchWater) resolveFetchWater(world, stats);
if (proposals.exchange) resolveExchange(world); if (proposals.exchange) resolveExchange(world, stats);
if (proposals.drink) resolveDrink(world); if (proposals.drink) resolveDrink(world, stats);
if (world.getSingleton(GameState).phase === "gameOver") return; if (world.getSingleton(GameState).phase === "gameOver") return;
resolveEndOfRoundTools(world); resolveEndOfRoundTools(world, stats);
checkWinner(world); checkWinner(world);
} }
@ -118,7 +119,10 @@ export function prepareNextRound(world: World): void {
state.message = `Round ${world.getSingleton(Table).round}: choose an action card.`; 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 table = world.getSingleton(Table);
const players = getPlayersInSeatOrder(world); const players = getPlayersInSeatOrder(world);
const participants = players.filter((player) => { const participants = players.filter((player) => {
@ -141,6 +145,12 @@ export function resolveFetchWater(world: World): void {
if (playerData.water >= placed) { if (playerData.water >= placed) {
playerData.water -= placed; playerData.water -= placed;
tool.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) { if (tool.kind === "ladle" && table.centralWater > 0) {
table.centralWater--; table.centralWater--;
tool.water++; 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( const participants = getPlayersInSeatOrder(world).filter(
(player) => getSelectedAction(world, player) !== "chant", (player) => getSelectedAction(world, player) !== "chant",
); );
@ -167,8 +184,13 @@ export function resolveExchange(world: World): void {
for (const toolEntity of tools) { for (const toolEntity of tools) {
const tool = world.get(toolEntity, Tool); const tool = world.get(toolEntity, Tool);
if (tool.kind === "shoulderPole" && tool.water > 0) { if (tool.kind === "shoulderPole" && tool.water > 0) {
table.centralWater += tool.water; const dumped = tool.water;
table.centralWater += dumped;
tool.water = 0; 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)) { for (const player of getPlayersFromMarker(world)) {
if (!canParticipateInDrink(world, player)) continue; if (!canParticipateInDrink(world, player)) continue;
@ -199,10 +221,17 @@ export function resolveDrink(world: World): void {
tool.water -= maxFromTool; tool.water -= maxFromTool;
playerData.water += maxFromTool; playerData.water += maxFromTool;
drankFromTool = maxFromTool; drankFromTool = maxFromTool;
stats?.record(tool.kind, "喝水", {
playerWaterDelta: maxFromTool,
ownToolWaterDelta: -maxFromTool,
});
} }
if (tool.kind === "waterJar") { if (tool.kind === "waterJar") {
playerData.water++; playerData.water++;
stats?.record(tool.kind, "喝水", {
playerWaterDelta: 1,
});
} }
if ( if (
@ -212,6 +241,10 @@ export function resolveDrink(world: World): void {
) { ) {
table.centralWater--; table.centralWater--;
tool.water++; tool.water++;
stats?.record(tool.kind, "喝水", {
ownToolWaterDelta: 1,
centralWaterDelta: -1,
});
} }
if (playerData.water >= 10) { 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( const chanters = getPlayersFromMarker(world).filter(
(player) => getSelectedAction(world, player) === "chant", (player) => getSelectedAction(world, player) === "chant",
); );
@ -235,17 +268,32 @@ export function resolveChant(world: World): void {
if (tool.kind === "bottle") { if (tool.kind === "bottle") {
tool.water++; tool.water++;
stats?.record(tool.kind, "念经", {
ownToolWaterDelta: 1,
});
} else if (tool.kind === "mouse") { } 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))) { for (const toolEntity of world.query(query(Tool))) {
const tool = world.get(toolEntity, Tool); const tool = world.get(toolEntity, Tool);
if (tool.kind === "woodenFish") { if (tool.kind === "woodenFish") {
world.getSingleton(WoodenFishMarker).holder = tool.owner; world.getSingleton(WoodenFishMarker).holder = tool.owner;
stats?.record(tool.kind, "轮末", {}, "获得木鱼标记");
return; return;
} }
} }
@ -271,7 +319,7 @@ function moveOneNeighborWaterTo(
world: World, world: World,
player: Entity, player: Entity,
destinationTool: Entity, destinationTool: Entity,
): void { ): boolean {
const neighbors = [ const neighbors = [
getLeftPlayer(world, player), getLeftPlayer(world, player),
getRightPlayer(world, player), getRightPlayer(world, player),
@ -282,9 +330,10 @@ function moveOneNeighborWaterTo(
if (neighborTool.water > 0) { if (neighborTool.water > 0) {
neighborTool.water--; neighborTool.water--;
world.get(destinationTool, Tool).water++; world.get(destinationTool, Tool).water++;
return; return true;
} }
} }
return false;
} }
function setWinner(world: World, player: Entity): void { function setWinner(world: World, player: Entity): void {

View File

@ -8,6 +8,7 @@ import {
getPlayersInSeatOrder, getPlayersInSeatOrder,
type ActionKind, type ActionKind,
} from "./components"; } from "./components";
import { ToolEffectStats, formatToolEffectSummaries } from "./stats";
import { import {
beginSelectionPhase, beginSelectionPhase,
checkWinner, checkWinner,
@ -52,7 +53,7 @@ interface GameStats {
waterDiff: number; waterDiff: number;
} }
function runSingleGame(seed: number): GameStats { function runSingleGame(seed: number, stats?: ToolEffectStats): GameStats {
const random = mulberry32(seed); const random = mulberry32(seed);
const world = new World(); const world = new World();
const playerNames = ["慧空", "明心", "了尘", "净远"]; const playerNames = ["慧空", "明心", "了尘", "净远"];
@ -76,13 +77,13 @@ function runSingleGame(seed: number): GameStats {
const proposals = collectProposals(world); const proposals = collectProposals(world);
if (proposals.chant) resolveChant(world); if (proposals.chant) resolveChant(world, stats);
if (proposals.fetchWater) resolveFetchWater(world); if (proposals.fetchWater) resolveFetchWater(world, stats);
if (proposals.exchange) resolveExchange(world); if (proposals.exchange) resolveExchange(world, stats);
if (proposals.drink) resolveDrink(world); if (proposals.drink) resolveDrink(world, stats);
if (world.getSingleton(GameState).phase === "gameOver") break; if (world.getSingleton(GameState).phase === "gameOver") break;
resolveEndOfRoundTools(world); resolveEndOfRoundTools(world, stats);
checkWinner(world); checkWinner(world);
if (world.getSingleton(GameState).phase === "gameOver") break; if (world.getSingleton(GameState).phase === "gameOver") break;
@ -104,12 +105,13 @@ function runSingleGame(seed: number): GameStats {
export function runSimulation(gameCount = 100, startSeed = 20260701): void { export function runSimulation(gameCount = 100, startSeed = 20260701): void {
let totalRounds = 0; let totalRounds = 0;
let totalWaterDiff = 0; let totalWaterDiff = 0;
const toolStats = new ToolEffectStats();
console.log(`开始模拟 ${gameCount} 局游戏...`); console.log(`开始模拟 ${gameCount} 局游戏...`);
for (let i = 0; i < gameCount; i++) { for (let i = 0; i < gameCount; i++) {
const seed = startSeed + i; const seed = startSeed + i;
const stats = runSingleGame(seed); const stats = runSingleGame(seed, toolStats);
totalRounds += stats.rounds; totalRounds += stats.rounds;
totalWaterDiff += stats.waterDiff; totalWaterDiff += stats.waterDiff;
} }
@ -122,6 +124,10 @@ export function runSimulation(gameCount = 100, startSeed = 20260701): void {
console.log(`结束时平均轮数: ${avgRounds.toFixed(2)}`); console.log(`结束时平均轮数: ${avgRounds.toFixed(2)}`);
console.log(`结束时首尾玩家平均水差距: ${avgWaterDiff.toFixed(2)} 口水`); console.log(`结束时首尾玩家平均水差距: ${avgWaterDiff.toFixed(2)} 口水`);
console.log("=========================================="); console.log("==========================================");
console.log("\n道具效果统计总变化量:");
for (const line of formatToolEffectSummaries(toolStats)) {
console.log(line);
}
} }
if (process.argv[1]?.endsWith("simulate.ts")) { if (process.argv[1]?.endsWith("simulate.ts")) {

View File

@ -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<ToolEffectDelta> {
tool: ToolKind;
phase: string;
description?: string;
}
export interface ToolEffectSummary extends Required<ToolEffectDelta> {
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<ToolKind, ToolEffectSummary>();
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(" | "),
),
];
}