在 ~/.upulse/engine/ 中重写狼人杀 workflow(werewolf.ts + werewolf.t

This commit is contained in:
小橘 2026-04-18 10:05:47 +00:00
parent 4974e90c4f
commit 41b831a2a0
2 changed files with 330 additions and 223 deletions

View File

@ -1,7 +1,7 @@
/** /**
* Werewolf workflow tests mock + + * Werewolf workflow v3 mock LLM
* *
* 🍊 (NEKO Team) * (NEKO Team)
*/ */
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'bun:test';
@ -22,74 +22,147 @@ import {
createWerewolfWorkflow, createWerewolfWorkflow,
filterChainForPlayer, filterChainForPlayer,
parseGameState, parseGameState,
werewolfDefaultMocks,
} from './werewolf.js'; } from './werewolf.js';
describe('werewolf WorkflowType', () => { describe('游戏初始化', () => {
let store: PulseStore; it('9 人局身份配置:3 狼、预言家、女巫、猎人、3 村民', () => {
let tmpDir: string; const players = createPlayers();
expect(players).toHaveLength(9);
function setup() { const roles = players.map((p) => p.identity.name);
tmpDir = mkdtempSync(join(tmpdir(), 'werewolf-engine-')); expect(roles.filter((r) => r === '狼人')).toHaveLength(3);
store = createStore({ expect(roles.filter((r) => r === '村民')).toHaveLength(3);
eventsDbPath: join(tmpDir, 'test.db'), expect(roles).toContain('预言家');
objectsDir: join(tmpDir, 'objects'), expect(roles).toContain('女巫');
}); expect(roles).toContain('猎人');
}
function cleanup() { const teams = { wolf: players.filter((p) => p.identity.team === 'wolf').length, good: 0 };
try { teams.good = players.length - teams.wolf;
store?.close(); expect(teams.wolf).toBe(3);
} catch { expect(teams.good).toBe(6);
/* ignore */
}
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
}
async function trigger(topicId: string) { expect(players[0]!.id).toBe('p1');
const hash = await store.putObject('werewolf game start'); expect(players[8]!.id).toBe('p9');
await store.appendEvent({ });
occurredAt: Date.now(), });
kind: 'werewolf.__start__',
key: topicId,
hash,
meta: JSON.stringify({}),
});
}
it('mock 模式能跑完整局', async () => { describe('信息可见性过滤', () => {
setup(); it('狼人可见狼夜且互通;预言家仅见验人;女巫仅见用药;平民仅见公开阶段', () => {
try { const players = createPlayers();
const wf = createWerewolfWorkflow(); const wolf1 = players[0]!;
const rule = createWorkflowRule(wf, store); const wolf2 = players[1]!;
await trigger('g1'); const seer = players[3]!;
const witch = players[4]!;
const villager = players[6]!;
const order: string[] = []; const chain: WorkflowMessage[] = [
for (let i = 0; i < 200; i++) { {
const r = await rule.tick(); role: 'wolf-night',
if (r.executed.length === 0) break; content: '狼人杀人',
order.push(...r.executed.map((x) => x.role)); meta: { phase: 'wolf-night', targetId: 'p7', visibleTo: ['p1', 'p2', 'p3'] },
} timestamp: 1,
},
{
role: 'seer-check',
content: '预言家验人',
meta: {
phase: 'seer-check',
targetId: 'p1',
isWolf: true,
visibleTo: ['p4'],
},
timestamp: 2,
},
{
role: 'witch-action',
content: '女巫行动',
meta: {
phase: 'witch-action',
saved: false,
poisonTarget: null,
visibleTo: ['p5'],
witchPotion: true,
witchPoison: true,
},
timestamp: 3,
},
{
role: 'day-speech',
content: '白天发言',
meta: { phase: 'day-speech', speeches: [] },
timestamp: 4,
},
{
role: 'vote',
content: '投票',
meta: { phase: 'vote', votes: {}, eliminatedId: 'p8' },
timestamp: 5,
},
];
expect(order[0]).toBe('wolf-night'); const w1 = filterChainForPlayer(chain, wolf1.id, wolf1.identity);
expect(order[order.length - 1]).toBe('__end__'); expect(w1.some((m) => (m.meta as { phase?: string })?.phase === 'wolf-night')).toBe(true);
expect(order[order.length - 2]).toBe('game-end'); expect(w1.some((m) => (m.meta as { phase?: string })?.phase === 'seer-check')).toBe(false);
expect(order.length).toBeGreaterThanOrEqual(7); expect(w1.some((m) => (m.meta as { phase?: string })?.phase === 'witch-action')).toBe(false);
const events = await store.getAfter(0); const w2 = filterChainForPlayer(chain, wolf2.id, wolf2.identity);
expect(events.some((e) => e.kind === 'werewolf.__end__')).toBe(true); expect(w2.some((m) => (m.meta as { phase?: string })?.phase === 'wolf-night')).toBe(true);
} finally {
cleanup(); const seerView = filterChainForPlayer(chain, seer.id, seer.identity);
} expect(seerView.some((m) => (m.meta as { phase?: string })?.phase === 'seer-check')).toBe(true);
expect(seerView.some((m) => (m.meta as { phase?: string })?.phase === 'wolf-night')).toBe(false);
const witchView = filterChainForPlayer(chain, witch.id, witch.identity);
expect(witchView.some((m) => (m.meta as { phase?: string })?.phase === 'witch-action')).toBe(
true,
);
const villagerView = filterChainForPlayer(chain, villager.id, villager.identity);
expect(
villagerView.some((m) => (m.meta as { phase?: string })?.phase === 'wolf-night'),
).toBe(false);
expect(
villagerView.some((m) => (m.meta as { phase?: string })?.phase === 'seer-check'),
).toBe(false);
expect(
villagerView.some((m) => (m.meta as { phase?: string })?.phase === 'witch-action'),
).toBe(false);
expect(
villagerView.some((m) => (m.meta as { phase?: string })?.phase === 'day-speech'),
).toBe(true);
expect(villagerView.some((m) => (m.meta as { phase?: string })?.phase === 'vote')).toBe(true);
}); });
it('阶段正确轮转', () => { it('无 visibleTo 的狼夜 meta 仍对全体狼人可见(兼容)', () => {
const players = createPlayers();
const wolf = players[0]!;
const chain: WorkflowMessage[] = [
{
role: 'wolf-night',
content: 'x',
meta: { phase: 'wolf-night', targetId: 'p7' },
timestamp: 1,
},
];
expect(
filterChainForPlayer(chain, wolf.id, wolf.identity).length,
).toBeGreaterThan(0);
});
});
describe('阶段流转', () => {
it('moderator:黑夜 → 白天 → 投票;终局或猎人后回到狼夜或结束', () => {
const wf = createWerewolfWorkflow(); const wf = createWerewolfWorkflow();
expect(wf.moderator({ role: START, meta: null }, 'x')).toBe('wolf-night'); expect(wf.moderator({ role: START, meta: null }, 'x')).toBe('wolf-night');
expect( expect(
wf.moderator( wf.moderator(
{ role: 'wolf-night', meta: { phase: 'wolf-night', targetId: 'p7' } }, {
role: 'wolf-night',
meta: { phase: 'wolf-night', targetId: 'p7', visibleTo: ['p1', 'p2', 'p3'] },
},
'x', 'x',
), ),
).toBe('seer-check'); ).toBe('seer-check');
@ -151,10 +224,31 @@ describe('werewolf WorkflowType', () => {
).toBe('wolf-night'); ).toBe('wolf-night');
}); });
it('狼人全死 → 好人胜', () => { it('猎人被投票出局后进入 hunter-shot', () => {
const wf = createWerewolfWorkflow();
expect(
wf.moderator(
{
role: 'vote',
meta: {
phase: 'vote',
votes: {},
eliminatedId: 'p6',
gameOver: false,
hunterTriggered: true,
},
},
'x',
),
).toBe('hunter-shot');
});
});
describe('终局判定', () => {
it('狼人全死 → 好人胜局可结束 workflow', () => {
const players = createPlayers(); const players = createPlayers();
const wolves = players.filter((p) => p.identity.team === 'wolf'); const wolves = players.filter((p) => p.identity.team === 'wolf');
expect(wolves.length).toBe(3); expect(wolves).toHaveLength(3);
const aliveWithoutWolves = players.filter((p) => p.identity.team === 'good'); const aliveWithoutWolves = players.filter((p) => p.identity.team === 'good');
expect(checkGameOver(aliveWithoutWolves)).toBe(true); expect(checkGameOver(aliveWithoutWolves)).toBe(true);
@ -186,28 +280,19 @@ describe('werewolf WorkflowType', () => {
).toBe('__end__'); ).toBe('__end__');
expect( expect(
wf.moderator( wf.moderator({ role: '__end__', meta: { phase: '__end__' } }, 'x'),
{ role: '__end__', meta: { phase: '__end__' } },
'x',
),
).toBe(END); ).toBe(END);
}); });
it('好人数 <= 狼人数 → 狼人胜', () => { it('好人存活数 ≤ 狼人数 → 狼人胜', () => {
const players = createPlayers(); const players = createPlayers();
const alive = [players[0], players[1], players[2], players[6]]; const alive = [players[0], players[1], players[2], players[6]];
expect(checkGameOver(alive)).toBe(true); expect(checkGameOver(alive)).toBe(true);
const alive2 = [ const alive2 = [players[0], players[1], players[2], players[6], players[7]];
players[0],
players[1],
players[2],
players[6],
players[7],
];
expect(checkGameOver(alive2)).toBe(true); expect(checkGameOver(alive2)).toBe(true);
const alive3 = [ const balanced = [
players[0], players[0],
players[1], players[1],
players[2], players[2],
@ -216,125 +301,17 @@ describe('werewolf WorkflowType', () => {
players[5], players[5],
players[6], players[6],
]; ];
expect(checkGameOver(alive3)).toBe(false); expect(checkGameOver(balanced)).toBe(false);
}); });
});
it('信息可见性正确', () => { describe('链上状态重建', () => {
const players = createPlayers(); it('女巫用完解药后 witchPotion 从 chain 解析一致', () => {
const wolf = players[0];
const seer = players[3];
const villager = players[6];
const chain: WorkflowMessage[] = [
{
role: 'wolf-night',
content: '狼人杀人',
meta: { phase: 'wolf-night', targetId: 'p7' },
timestamp: 1,
},
{
role: 'seer-check',
content: '预言家验人',
meta: {
phase: 'seer-check',
targetId: 'p1',
isWolf: true,
visibleTo: ['p4'],
},
timestamp: 2,
},
{
role: 'witch-action',
content: '女巫行动',
meta: {
phase: 'witch-action',
saved: false,
poisonTarget: null,
visibleTo: ['p5'],
},
timestamp: 3,
},
{
role: 'day-speech',
content: '白天发言',
meta: { phase: 'day-speech', speeches: [] },
timestamp: 4,
},
{
role: 'vote',
content: '投票',
meta: { phase: 'vote', votes: {}, eliminatedId: 'p8' },
timestamp: 5,
},
];
const wolfView = filterChainForPlayer(chain, wolf.id, wolf.identity);
expect(wolfView.some((m) => (m.meta as { phase?: string })?.phase === 'wolf-night')).toBe(
true,
);
expect(wolfView.some((m) => (m.meta as { phase?: string })?.phase === 'seer-check')).toBe(
false,
);
expect(wolfView.some((m) => (m.meta as { phase?: string })?.phase === 'witch-action')).toBe(
false,
);
expect(wolfView.some((m) => (m.meta as { phase?: string })?.phase === 'day-speech')).toBe(
true,
);
expect(wolfView.some((m) => (m.meta as { phase?: string })?.phase === 'vote')).toBe(true);
const seerView = filterChainForPlayer(chain, seer.id, seer.identity);
expect(seerView.some((m) => (m.meta as { phase?: string })?.phase === 'seer-check')).toBe(
true,
);
expect(seerView.some((m) => (m.meta as { phase?: string })?.phase === 'wolf-night')).toBe(
false,
);
expect(seerView.some((m) => (m.meta as { phase?: string })?.phase === 'witch-action')).toBe(
false,
);
const villagerView = filterChainForPlayer(chain, villager.id, villager.identity);
expect(
villagerView.some((m) => (m.meta as { phase?: string })?.phase === 'wolf-night'),
).toBe(false);
expect(
villagerView.some((m) => (m.meta as { phase?: string })?.phase === 'seer-check'),
).toBe(false);
expect(
villagerView.some((m) => (m.meta as { phase?: string })?.phase === 'witch-action'),
).toBe(false);
expect(
villagerView.some((m) => (m.meta as { phase?: string })?.phase === 'day-speech'),
).toBe(true);
expect(villagerView.some((m) => (m.meta as { phase?: string })?.phase === 'vote')).toBe(true);
});
it('猎人被投票出局触发开枪', () => {
const wf = createWerewolfWorkflow();
expect(
wf.moderator(
{
role: 'vote',
meta: {
phase: 'vote',
votes: {},
eliminatedId: 'p6',
gameOver: false,
hunterTriggered: true,
},
},
'x',
),
).toBe('hunter-shot');
});
it('女巫用完解药后状态持久化到 chain', () => {
const chain: WorkflowMessage[] = [ const chain: WorkflowMessage[] = [
{ {
role: 'wolf-night', role: 'wolf-night',
content: '狼人杀 p7', content: '狼人杀 p7',
meta: { phase: 'wolf-night', targetId: 'p7' }, meta: { phase: 'wolf-night', targetId: 'p7', visibleTo: ['p1', 'p2', 'p3'] },
timestamp: 1, timestamp: 1,
}, },
{ {
@ -359,7 +336,7 @@ describe('werewolf WorkflowType', () => {
chain.push({ chain.push({
role: 'wolf-night', role: 'wolf-night',
content: '狼人杀 p8', content: '狼人杀 p8',
meta: { phase: 'wolf-night', targetId: 'p8' }, meta: { phase: 'wolf-night', targetId: 'p8', visibleTo: ['p1', 'p2', 'p3'] },
timestamp: 3, timestamp: 3,
}); });
@ -368,3 +345,118 @@ describe('werewolf WorkflowType', () => {
expect(state2.lastKill).toBe('p8'); expect(state2.lastKill).toBe('p8');
}); });
}); });
describe('mock LLM:注入 Role 跑完整 Pulse 局', () => {
let store: PulseStore;
let tmpDir: string;
function setup() {
tmpDir = mkdtempSync(join(tmpdir(), 'werewolf-engine-'));
store = createStore({
eventsDbPath: join(tmpDir, 'test.db'),
objectsDir: join(tmpDir, 'objects'),
});
}
function cleanup() {
try {
store?.close();
} catch {
/* ignore */
}
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
}
async function trigger(topicId: string) {
const hash = await store.putObject('werewolf game start');
await store.appendEvent({
occurredAt: Date.now(),
kind: 'werewolf.__start__',
key: topicId,
hash,
meta: JSON.stringify({}),
});
}
it('默认 mock 能 tick 到 game-end → __end__', async () => {
setup();
try {
const wf = createWerewolfWorkflow();
const rule = createWorkflowRule(wf, store);
await trigger('g1');
const order: string[] = [];
for (let i = 0; i < 200; i++) {
const r = await rule.tick();
if (r.executed.length === 0) break;
order.push(...r.executed.map((x) => x.role));
}
expect(order[0]).toBe('wolf-night');
expect(order[order.length - 1]).toBe('__end__');
expect(order[order.length - 2]).toBe('game-end');
expect(order.length).toBeGreaterThanOrEqual(7);
const events = await store.getAfter(0);
expect(events.some((e) => e.kind === 'werewolf.__end__')).toBe(true);
} finally {
cleanup();
}
});
it('包装 default mocks 模拟「LLM」调用仍跑通全流程', async () => {
setup();
try {
const llmSteps: string[] = [];
const wf = createWerewolfWorkflow({
wolfNightFn: async (chain, topicId, storeArg) => {
llmSteps.push('wolf-night');
return werewolfDefaultMocks.wolfNightFn!(chain, topicId, storeArg);
},
seerCheckFn: async (chain, topicId, storeArg) => {
llmSteps.push('seer-check');
return werewolfDefaultMocks.seerCheckFn!(chain, topicId, storeArg);
},
witchActionFn: async (chain, topicId, storeArg) => {
llmSteps.push('witch-action');
return werewolfDefaultMocks.witchActionFn!(chain, topicId, storeArg);
},
daySpeechFn: async (chain, topicId, storeArg) => {
llmSteps.push('day-speech');
return werewolfDefaultMocks.daySpeechFn!(chain, topicId, storeArg);
},
voteFn: async (chain, topicId, storeArg) => {
llmSteps.push('vote');
return werewolfDefaultMocks.voteFn!(chain, topicId, storeArg);
},
hunterShotFn: async (chain, topicId, storeArg) => {
llmSteps.push('hunter-shot');
return werewolfDefaultMocks.hunterShotFn!(chain, topicId, storeArg);
},
gameEndFn: async (chain, topicId, storeArg) => {
llmSteps.push('game-end');
return werewolfDefaultMocks.gameEndFn!(chain, topicId, storeArg);
},
workflowEndFn: async (chain, topicId, storeArg) => {
llmSteps.push('__end__');
return werewolfDefaultMocks.workflowEndFn!(chain, topicId, storeArg);
},
});
const rule = createWorkflowRule(wf, store);
await trigger('g2');
for (let i = 0; i < 200; i++) {
const r = await rule.tick();
if (r.executed.length === 0) break;
}
expect(llmSteps.length).toBeGreaterThan(0);
expect(llmSteps[0]).toBe('wolf-night');
expect(llmSteps.includes('game-end')).toBe(true);
expect(llmSteps[llmSteps.length - 1]).toBe('__end__');
} finally {
cleanup();
}
});
});

View File

@ -1,12 +1,12 @@
/** /**
* Werewolf () workflow 9-player game with information asymmetry. * Werewolf () workflow v3 9 +
* *
* Pure roles + START/END automaton. Trigger: werewolf.__start__ * 3 1 1 1 3
* Role
* *
* Role chain parseGameState * Trigger: `werewolf.__start__`
* mock createWerewolfWorkflow(opts) Role
* *
* 🍊 (NEKO Team) * (NEKO Team)
*/ */
import { import {
@ -64,6 +64,7 @@ const PERSONALITIES = [
'温和', '温和',
] as const; ] as const;
/** 固定 9 人:p1–p3 狼,p4 预言家,p5 女巫,p6 猎人,p7–p9 村民 */
const IDENTITIES: Identity[] = [ const IDENTITIES: Identity[] = [
{ name: '狼人', team: 'wolf' }, { name: '狼人', team: 'wolf' },
{ name: '狼人', team: 'wolf' }, { name: '狼人', team: 'wolf' },
@ -201,7 +202,7 @@ export function parseGameState(chain: WorkflowMessage[]): GameState {
}; };
} }
// ── Information visibility filter ────────────────────────────── // ── Information visibility ───────────────────────────────────────
export function filterChainForPlayer( export function filterChainForPlayer(
chain: WorkflowMessage[], chain: WorkflowMessage[],
@ -227,7 +228,10 @@ export function filterChainForPlayer(
return true; return true;
} }
if (ph === 'wolf-night') return identity.team === 'wolf'; if (ph === 'wolf-night') {
if (visibleTo && visibleTo.length > 0) return visibleTo.includes(playerId);
return identity.team === 'wolf';
}
if (ph === 'seer-check' || ph === 'witch-action') { if (ph === 'seer-check' || ph === 'witch-action') {
return visibleTo ? visibleTo.includes(playerId) : false; return visibleTo ? visibleTo.includes(playerId) : false;
@ -259,6 +263,8 @@ ${player.identity.abilities ? `特殊能力:${player.identity.abilities}` : ''
export type WolfNightMeta = { export type WolfNightMeta = {
phase: 'wolf-night'; phase: 'wolf-night';
targetId: string; targetId: string;
/** 所有存活狼人 id — 仅狼队可见本场刀法讨论与目标 */
visibleTo: string[];
}; };
export type SeerCheckMeta = { export type SeerCheckMeta = {
@ -303,7 +309,6 @@ export type GameEndMeta = {
summary: string; summary: string;
}; };
/** Terminal marker — adapter writes `{name}.__end__` for meta-tester e2e. */
export type WerewolfEndMeta = { export type WerewolfEndMeta = {
phase: '__end__'; phase: '__end__';
}; };
@ -321,10 +326,12 @@ export type WerewolfRoles = {
// ── Win condition ────────────────────────────────────────────── // ── Win condition ──────────────────────────────────────────────
/** 狼人全灭,或好人人数 ≤ 狼人人数(屠边前好人已输) */
export function checkGameOver(alive: Player[]): boolean { export function checkGameOver(alive: Player[]): boolean {
const wolves = alive.filter((p) => p.identity.team === 'wolf'); const wolves = alive.filter((p) => p.identity.team === 'wolf');
if (wolves.length === 0) return true; if (wolves.length === 0) return true;
if (wolves.length >= alive.length - wolves.length) return true; const good = alive.length - wolves.length;
if (good <= wolves.length) return true;
return false; return false;
} }
@ -367,21 +374,30 @@ function werewolfModerator(
} }
} }
// ── Default mock roles (deterministic, no LLM) ───────────────── // ── Default mock roles (deterministic) ─────────────────────────
const mockWolfNight: Role<WolfNightMeta> = async (chain) => { function aliveWolfIds(state: GameState): string[] {
return state.alive
.filter((p) => p.identity.team === 'wolf')
.sort(byPlayerId)
.map((p) => p.id);
}
export const mockWolfNight: Role<WolfNightMeta> = async (chain) => {
const state = parseGameState(chain); const state = parseGameState(chain);
const goods = state.alive const goods = state.alive.filter((p) => p.identity.team === 'good').sort(byPlayerId);
.filter((p) => p.identity.team === 'good')
.sort(byPlayerId);
const target = goods[0]!; const target = goods[0]!;
return { return {
content: `[狼人夜晚] 狼人决定击杀 ${target.name} (${target.id})`, content: `[狼人夜晚] 狼人决定击杀 ${target.name} (${target.id})`,
meta: { phase: 'wolf-night', targetId: target.id }, meta: {
phase: 'wolf-night',
targetId: target.id,
visibleTo: aliveWolfIds(state),
},
}; };
}; };
const mockSeerCheck: Role<SeerCheckMeta> = async (chain) => { export const mockSeerCheck: Role<SeerCheckMeta> = async (chain) => {
const state = parseGameState(chain); const state = parseGameState(chain);
const seer = state.alive.find((p) => p.identity.name === '预言家'); const seer = state.alive.find((p) => p.identity.name === '预言家');
if (!seer) { if (!seer) {
@ -395,9 +411,7 @@ const mockSeerCheck: Role<SeerCheckMeta> = async (chain) => {
}, },
}; };
} }
const others = state.alive const others = state.alive.filter((p) => p.id !== seer.id).sort(byPlayerId);
.filter((p) => p.id !== seer.id)
.sort(byPlayerId);
const target = others[0]!; const target = others[0]!;
return { return {
content: `[预言家查验] ${target.name}${target.identity.team === 'wolf' ? '狼人' : '好人'}`, content: `[预言家查验] ${target.name}${target.identity.team === 'wolf' ? '狼人' : '好人'}`,
@ -410,14 +424,11 @@ const mockSeerCheck: Role<SeerCheckMeta> = async (chain) => {
}; };
}; };
const mockWitchAction: Role<WitchActionMeta> = async (chain) => { export const mockWitchAction: Role<WitchActionMeta> = async (chain) => {
const state = parseGameState(chain); const state = parseGameState(chain);
const witch = state.alive.find((p) => p.identity.name === '女巫'); const witch = state.alive.find((p) => p.identity.name === '女巫');
const saved = false; const saved = false;
/** Mock:每夜毒最小编号存活狼人,加速终局(便于 e2e / meta-tester 在有限 tick 内结束)。 */ const wolves = state.alive.filter((p) => p.identity.team === 'wolf').sort(byPlayerId);
const wolves = state.alive
.filter((p) => p.identity.team === 'wolf')
.sort(byPlayerId);
const poisonTarget = const poisonTarget =
witch && state.witchPoison && wolves.length > 0 ? wolves[0]!.id : null; witch && state.witchPoison && wolves.length > 0 ? wolves[0]!.id : null;
const witchId = witch?.id ?? ''; const witchId = witch?.id ?? '';
@ -444,7 +455,7 @@ const mockWitchAction: Role<WitchActionMeta> = async (chain) => {
}; };
}; };
const mockDaySpeech: Role<DaySpeechMeta> = async (chain) => { export const mockDaySpeech: Role<DaySpeechMeta> = async (chain) => {
const state = parseGameState(chain); const state = parseGameState(chain);
const speeches = state.alive.map((p) => { const speeches = state.alive.map((p) => {
const visible = filterChainForPlayer(chain, p.id, p.identity); const visible = filterChainForPlayer(chain, p.id, p.identity);
@ -459,15 +470,11 @@ const mockDaySpeech: Role<DaySpeechMeta> = async (chain) => {
}; };
}; };
const mockVote: Role<VoteMeta> = async (chain) => { export const mockVote: Role<VoteMeta> = async (chain) => {
const state = parseGameState(chain); const state = parseGameState(chain);
const alive = state.alive; const alive = state.alive;
const wolves = alive const wolves = alive.filter((p) => p.identity.team === 'wolf').sort(byPlayerId);
.filter((p) => p.identity.team === 'wolf') const eliminated = wolves.length > 0 ? wolves[0] : undefined;
.sort(byPlayerId);
/** 狼已全灭时不再放逐(好人胜局已定) */
const eliminated =
wolves.length > 0 ? wolves[0] : undefined;
const eliminatedId = eliminated?.id ?? null; const eliminatedId = eliminated?.id ?? null;
const votes: Record<string, string> = {}; const votes: Record<string, string> = {};
@ -475,9 +482,7 @@ const mockVote: Role<VoteMeta> = async (chain) => {
for (const p of alive) votes[p.id] = eliminatedId; for (const p of alive) votes[p.id] = eliminatedId;
} }
const aliveAfter = eliminatedId const aliveAfter = eliminatedId ? alive.filter((p) => p.id !== eliminatedId) : alive;
? alive.filter((p) => p.id !== eliminatedId)
: alive;
const gameOver = checkGameOver(aliveAfter); const gameOver = checkGameOver(aliveAfter);
const hunterTriggered = eliminated?.identity.name === '猎人'; const hunterTriggered = eliminated?.identity.name === '猎人';
@ -493,11 +498,9 @@ const mockVote: Role<VoteMeta> = async (chain) => {
}; };
}; };
const mockHunterShot: Role<HunterShotMeta> = async (chain) => { export const mockHunterShot: Role<HunterShotMeta> = async (chain) => {
const state = parseGameState(chain); const state = parseGameState(chain);
const wolves = state.alive const wolves = state.alive.filter((p) => p.identity.team === 'wolf').sort(byPlayerId);
.filter((p) => p.identity.team === 'wolf')
.sort(byPlayerId);
const target = wolves[0] ?? state.alive.sort(byPlayerId)[0]!; const target = wolves[0] ?? state.alive.sort(byPlayerId)[0]!;
const aliveAfter = state.alive.filter((p) => p.id !== target.id); const aliveAfter = state.alive.filter((p) => p.id !== target.id);
const gameOver = checkGameOver(aliveAfter); const gameOver = checkGameOver(aliveAfter);
@ -512,7 +515,7 @@ const mockHunterShot: Role<HunterShotMeta> = async (chain) => {
}; };
}; };
const mockGameEnd: Role<GameEndMeta> = async (chain) => { export const mockGameEnd: Role<GameEndMeta> = async (chain) => {
const state = parseGameState(chain); const state = parseGameState(chain);
const wolves = state.alive.filter((p) => p.identity.team === 'wolf'); const wolves = state.alive.filter((p) => p.identity.team === 'wolf');
const winner: Team = wolves.length === 0 ? 'good' : 'wolf'; const winner: Team = wolves.length === 0 ? 'good' : 'wolf';
@ -526,7 +529,7 @@ const mockGameEnd: Role<GameEndMeta> = async (chain) => {
}; };
}; };
const mockWorkflowEnd: Role<WerewolfEndMeta> = async () => ({ export const mockWorkflowEnd: Role<WerewolfEndMeta> = async () => ({
content: '[workflow finished]', content: '[workflow finished]',
meta: { phase: '__end__' as const }, meta: { phase: '__end__' as const },
}); });
@ -544,6 +547,18 @@ export type CreateWerewolfWorkflowOpts = {
workflowEndFn?: Role<WerewolfEndMeta>; workflowEndFn?: Role<WerewolfEndMeta>;
}; };
/** 默认 mock 实现,可供测试包装为「LLM」调用 */
export const werewolfDefaultMocks: CreateWerewolfWorkflowOpts = {
wolfNightFn: mockWolfNight,
seerCheckFn: mockSeerCheck,
witchActionFn: mockWitchAction,
daySpeechFn: mockDaySpeech,
voteFn: mockVote,
hunterShotFn: mockHunterShot,
gameEndFn: mockGameEnd,
workflowEndFn: mockWorkflowEnd,
};
export function createWerewolfWorkflow( export function createWerewolfWorkflow(
opts?: CreateWerewolfWorkflowOpts, opts?: CreateWerewolfWorkflowOpts,
): WorkflowType<WerewolfRoles> { ): WorkflowType<WerewolfRoles> {