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 { 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 {

View File

@ -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")) {

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(" | "),
),
];
}