diff --git a/src/workflows/werewolf.test.ts b/src/workflows/werewolf.test.ts index b0f37a9..1e114bd 100644 --- a/src/workflows/werewolf.test.ts +++ b/src/workflows/werewolf.test.ts @@ -1,7 +1,7 @@ /** - * Werewolf workflow tests — mock 跑通全流程 + 可见性 + 胜负与阶段。 + * Werewolf workflow v3 测试:初始化、可见性、阶段、终局、注入 mock LLM。 * - * 小橘 🍊 (NEKO Team) + * 小橘 (NEKO Team) */ import { describe, expect, it } from 'bun:test'; @@ -22,74 +22,147 @@ import { createWerewolfWorkflow, filterChainForPlayer, parseGameState, + werewolfDefaultMocks, } from './werewolf.js'; -describe('werewolf WorkflowType', () => { - let store: PulseStore; - let tmpDir: string; +describe('游戏初始化', () => { + it('9 人局身份配置:3 狼、预言家、女巫、猎人、3 村民', () => { + const players = createPlayers(); + expect(players).toHaveLength(9); - function setup() { - tmpDir = mkdtempSync(join(tmpdir(), 'werewolf-engine-')); - store = createStore({ - eventsDbPath: join(tmpDir, 'test.db'), - objectsDir: join(tmpDir, 'objects'), - }); - } + const roles = players.map((p) => p.identity.name); + expect(roles.filter((r) => r === '狼人')).toHaveLength(3); + expect(roles.filter((r) => r === '村民')).toHaveLength(3); + expect(roles).toContain('预言家'); + expect(roles).toContain('女巫'); + expect(roles).toContain('猎人'); - function cleanup() { - try { - store?.close(); - } catch { - /* ignore */ - } - if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); - } + const teams = { wolf: players.filter((p) => p.identity.team === 'wolf').length, good: 0 }; + teams.good = players.length - teams.wolf; + expect(teams.wolf).toBe(3); + expect(teams.good).toBe(6); - 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({}), - }); - } + expect(players[0]!.id).toBe('p1'); + expect(players[8]!.id).toBe('p9'); + }); +}); - it('mock 模式能跑完整局', async () => { - setup(); - try { - const wf = createWerewolfWorkflow(); - const rule = createWorkflowRule(wf, store); - await trigger('g1'); +describe('信息可见性过滤', () => { + it('狼人可见狼夜且互通;预言家仅见验人;女巫仅见用药;平民仅见公开阶段', () => { + const players = createPlayers(); + const wolf1 = players[0]!; + const wolf2 = players[1]!; + const seer = players[3]!; + const witch = players[4]!; + const villager = players[6]!; - 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)); - } + const chain: WorkflowMessage[] = [ + { + role: 'wolf-night', + content: '狼人杀人', + 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'); - expect(order[order.length - 1]).toBe('__end__'); - expect(order[order.length - 2]).toBe('game-end'); - expect(order.length).toBeGreaterThanOrEqual(7); + const w1 = filterChainForPlayer(chain, wolf1.id, wolf1.identity); + expect(w1.some((m) => (m.meta as { phase?: string })?.phase === 'wolf-night')).toBe(true); + expect(w1.some((m) => (m.meta as { phase?: string })?.phase === 'seer-check')).toBe(false); + expect(w1.some((m) => (m.meta as { phase?: string })?.phase === 'witch-action')).toBe(false); - const events = await store.getAfter(0); - expect(events.some((e) => e.kind === 'werewolf.__end__')).toBe(true); - } finally { - cleanup(); - } + const w2 = filterChainForPlayer(chain, wolf2.id, wolf2.identity); + expect(w2.some((m) => (m.meta as { phase?: string })?.phase === 'wolf-night')).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); + + 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(); expect(wf.moderator({ role: START, meta: null }, 'x')).toBe('wolf-night'); expect( 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', ), ).toBe('seer-check'); @@ -151,10 +224,31 @@ describe('werewolf WorkflowType', () => { ).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 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'); expect(checkGameOver(aliveWithoutWolves)).toBe(true); @@ -186,28 +280,19 @@ describe('werewolf WorkflowType', () => { ).toBe('__end__'); expect( - wf.moderator( - { role: '__end__', meta: { phase: '__end__' } }, - 'x', - ), + wf.moderator({ role: '__end__', meta: { phase: '__end__' } }, 'x'), ).toBe(END); }); - it('好人数 <= 狼人数 → 狼人胜', () => { + it('好人存活数 ≤ 狼人数 → 狼人胜', () => { const players = createPlayers(); const alive = [players[0], players[1], players[2], players[6]]; expect(checkGameOver(alive)).toBe(true); - const alive2 = [ - players[0], - players[1], - players[2], - players[6], - players[7], - ]; + const alive2 = [players[0], players[1], players[2], players[6], players[7]]; expect(checkGameOver(alive2)).toBe(true); - const alive3 = [ + const balanced = [ players[0], players[1], players[2], @@ -216,125 +301,17 @@ describe('werewolf WorkflowType', () => { players[5], players[6], ]; - expect(checkGameOver(alive3)).toBe(false); + expect(checkGameOver(balanced)).toBe(false); }); +}); - it('信息可见性正确', () => { - const players = createPlayers(); - 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', () => { +describe('链上状态重建', () => { + it('女巫用完解药后 witchPotion 从 chain 解析一致', () => { const chain: WorkflowMessage[] = [ { role: 'wolf-night', content: '狼人杀 p7', - meta: { phase: 'wolf-night', targetId: 'p7' }, + meta: { phase: 'wolf-night', targetId: 'p7', visibleTo: ['p1', 'p2', 'p3'] }, timestamp: 1, }, { @@ -359,7 +336,7 @@ describe('werewolf WorkflowType', () => { chain.push({ role: 'wolf-night', content: '狼人杀 p8', - meta: { phase: 'wolf-night', targetId: 'p8' }, + meta: { phase: 'wolf-night', targetId: 'p8', visibleTo: ['p1', 'p2', 'p3'] }, timestamp: 3, }); @@ -368,3 +345,118 @@ describe('werewolf WorkflowType', () => { 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(); + } + }); +}); diff --git a/src/workflows/werewolf.ts b/src/workflows/werewolf.ts index f64ab76..07dc506 100644 --- a/src/workflows/werewolf.ts +++ b/src/workflows/werewolf.ts @@ -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)。 - * 默认全部为 mock;通过 createWerewolfWorkflow(opts) 注入真实 Role。 + * Trigger: `werewolf.__start__` * - * 小橘 🍊 (NEKO Team) + * 小橘 (NEKO Team) */ import { @@ -64,6 +64,7 @@ const PERSONALITIES = [ '温和', ] as const; +/** 固定 9 人:p1–p3 狼,p4 预言家,p5 女巫,p6 猎人,p7–p9 村民 */ const IDENTITIES: Identity[] = [ { name: '狼人', team: 'wolf' }, { name: '狼人', team: 'wolf' }, @@ -201,7 +202,7 @@ export function parseGameState(chain: WorkflowMessage[]): GameState { }; } -// ── Information visibility filter ────────────────────────────── +// ── Information visibility ─────────────────────────────────────── export function filterChainForPlayer( chain: WorkflowMessage[], @@ -227,7 +228,10 @@ export function filterChainForPlayer( 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') { return visibleTo ? visibleTo.includes(playerId) : false; @@ -259,6 +263,8 @@ ${player.identity.abilities ? `特殊能力:${player.identity.abilities}` : '' export type WolfNightMeta = { phase: 'wolf-night'; targetId: string; + /** 所有存活狼人 id — 仅狼队可见本场刀法讨论与目标 */ + visibleTo: string[]; }; export type SeerCheckMeta = { @@ -303,7 +309,6 @@ export type GameEndMeta = { summary: string; }; -/** Terminal marker — adapter writes `{name}.__end__` for meta-tester e2e. */ export type WerewolfEndMeta = { phase: '__end__'; }; @@ -321,10 +326,12 @@ export type WerewolfRoles = { // ── Win condition ────────────────────────────────────────────── +/** 狼人全灭,或好人人数 ≤ 狼人人数(屠边前好人已输) */ export function checkGameOver(alive: Player[]): boolean { const wolves = alive.filter((p) => p.identity.team === 'wolf'); 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; } @@ -367,21 +374,30 @@ function werewolfModerator( } } -// ── Default mock roles (deterministic, no LLM) ───────────────── +// ── Default mock roles (deterministic) ───────────────────────── -const mockWolfNight: Role = 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 = async (chain) => { const state = parseGameState(chain); - const goods = state.alive - .filter((p) => p.identity.team === 'good') - .sort(byPlayerId); + const goods = state.alive.filter((p) => p.identity.team === 'good').sort(byPlayerId); const target = goods[0]!; return { 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 = async (chain) => { +export const mockSeerCheck: Role = async (chain) => { const state = parseGameState(chain); const seer = state.alive.find((p) => p.identity.name === '预言家'); if (!seer) { @@ -395,9 +411,7 @@ const mockSeerCheck: Role = async (chain) => { }, }; } - const others = state.alive - .filter((p) => p.id !== seer.id) - .sort(byPlayerId); + const others = state.alive.filter((p) => p.id !== seer.id).sort(byPlayerId); const target = others[0]!; return { content: `[预言家查验] ${target.name} 是${target.identity.team === 'wolf' ? '狼人' : '好人'}`, @@ -410,14 +424,11 @@ const mockSeerCheck: Role = async (chain) => { }; }; -const mockWitchAction: Role = async (chain) => { +export const mockWitchAction: Role = async (chain) => { const state = parseGameState(chain); const witch = state.alive.find((p) => p.identity.name === '女巫'); 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 = witch && state.witchPoison && wolves.length > 0 ? wolves[0]!.id : null; const witchId = witch?.id ?? ''; @@ -444,7 +455,7 @@ const mockWitchAction: Role = async (chain) => { }; }; -const mockDaySpeech: Role = async (chain) => { +export const mockDaySpeech: Role = async (chain) => { const state = parseGameState(chain); const speeches = state.alive.map((p) => { const visible = filterChainForPlayer(chain, p.id, p.identity); @@ -459,15 +470,11 @@ const mockDaySpeech: Role = async (chain) => { }; }; -const mockVote: Role = async (chain) => { +export const mockVote: Role = async (chain) => { const state = parseGameState(chain); const alive = state.alive; - const wolves = alive - .filter((p) => p.identity.team === 'wolf') - .sort(byPlayerId); - /** 狼已全灭时不再放逐(好人胜局已定) */ - const eliminated = - wolves.length > 0 ? wolves[0] : undefined; + const wolves = alive.filter((p) => p.identity.team === 'wolf').sort(byPlayerId); + const eliminated = wolves.length > 0 ? wolves[0] : undefined; const eliminatedId = eliminated?.id ?? null; const votes: Record = {}; @@ -475,9 +482,7 @@ const mockVote: Role = async (chain) => { for (const p of alive) votes[p.id] = eliminatedId; } - const aliveAfter = eliminatedId - ? alive.filter((p) => p.id !== eliminatedId) - : alive; + const aliveAfter = eliminatedId ? alive.filter((p) => p.id !== eliminatedId) : alive; const gameOver = checkGameOver(aliveAfter); const hunterTriggered = eliminated?.identity.name === '猎人'; @@ -493,11 +498,9 @@ const mockVote: Role = async (chain) => { }; }; -const mockHunterShot: Role = async (chain) => { +export const mockHunterShot: Role = async (chain) => { const state = parseGameState(chain); - 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 target = wolves[0] ?? state.alive.sort(byPlayerId)[0]!; const aliveAfter = state.alive.filter((p) => p.id !== target.id); const gameOver = checkGameOver(aliveAfter); @@ -512,7 +515,7 @@ const mockHunterShot: Role = async (chain) => { }; }; -const mockGameEnd: Role = async (chain) => { +export const mockGameEnd: Role = async (chain) => { const state = parseGameState(chain); const wolves = state.alive.filter((p) => p.identity.team === 'wolf'); const winner: Team = wolves.length === 0 ? 'good' : 'wolf'; @@ -526,7 +529,7 @@ const mockGameEnd: Role = async (chain) => { }; }; -const mockWorkflowEnd: Role = async () => ({ +export const mockWorkflowEnd: Role = async () => ({ content: '[workflow finished]', meta: { phase: '__end__' as const }, }); @@ -544,6 +547,18 @@ export type CreateWerewolfWorkflowOpts = { workflowEndFn?: Role; }; +/** 默认 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( opts?: CreateWerewolfWorkflowOpts, ): WorkflowType {