From 4974e90c4f6494ed6f369b4997e264ff308506f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sat, 18 Apr 2026 08:14:04 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BB=8E=E9=9B=B6=E5=AE=9E=E7=8E=B0=E7=8B=BC?= =?UTF-8?q?=E4=BA=BA=E6=9D=80=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/workflows/ping-pong.test.ts | 9 +- src/workflows/ping-pong.ts | 7 + src/workflows/werewolf.test.ts | 370 +++++++++++++++++++++ src/workflows/werewolf.ts | 565 ++++++++++++++++++++++++++++++++ 4 files changed, 949 insertions(+), 2 deletions(-) create mode 100644 src/workflows/werewolf.test.ts create mode 100644 src/workflows/werewolf.ts diff --git a/src/workflows/ping-pong.test.ts b/src/workflows/ping-pong.test.ts index b35de67..3432d5e 100644 --- a/src/workflows/ping-pong.test.ts +++ b/src/workflows/ping-pong.test.ts @@ -40,9 +40,14 @@ describe('ping-pong workflow', () => { expect(r1.executed).toMatchObject([{ topicId: 't1', role: 'pong' }]); expect(r1.executed[0].content).toBe('pong: ping'); - // No more work const r2 = await rule.tick(); - expect(r2.executed).toEqual([]); + expect(r2.executed).toMatchObject([{ topicId: 't1', role: '__end__' }]); + + const r3 = await rule.tick(); + expect(r3.executed).toEqual([]); + + const events = await store.getAfter(0); + expect(events.some((e) => e.kind === 'ping-pong.__end__')).toBe(true); store.close(); }); diff --git a/src/workflows/ping-pong.ts b/src/workflows/ping-pong.ts index e1ea1c0..a813389 100644 --- a/src/workflows/ping-pong.ts +++ b/src/workflows/ping-pong.ts @@ -8,6 +8,8 @@ import { END, START, type Role, type WorkflowType } from '@uncaged/pulse'; type PingPongRoles = { pong: Role<{ echo: true }>; + /** Emits `ping-pong.__end__` for e2e meta-tester (adapter never writes END). */ + __end__: Role<{ done: true }>; }; export const pingPong: WorkflowType = { @@ -20,9 +22,14 @@ export const pingPong: WorkflowType = { meta: { echo: true as const }, }; }, + __end__: async () => ({ + content: 'ok', + meta: { done: true as const }, + }), }, moderator: (output) => { if (output.role === START) return 'pong'; + if (output.role === 'pong') return '__end__'; return END; }, }; diff --git a/src/workflows/werewolf.test.ts b/src/workflows/werewolf.test.ts new file mode 100644 index 0000000..b0f37a9 --- /dev/null +++ b/src/workflows/werewolf.test.ts @@ -0,0 +1,370 @@ +/** + * Werewolf workflow tests — mock 跑通全流程 + 可见性 + 胜负与阶段。 + * + * 小橘 🍊 (NEKO Team) + */ + +import { describe, expect, it } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + createStore, + createWorkflowRule, + END, + START, + type PulseStore, + type WorkflowMessage, +} from '@uncaged/pulse'; +import { + checkGameOver, + createPlayers, + createWerewolfWorkflow, + filterChainForPlayer, + parseGameState, +} from './werewolf.js'; + +describe('werewolf WorkflowType', () => { + 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 模式能跑完整局', 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('阶段正确轮转', () => { + 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' } }, + 'x', + ), + ).toBe('seer-check'); + + expect( + wf.moderator( + { + role: 'seer-check', + meta: { + phase: 'seer-check', + targetId: 'p1', + isWolf: true, + visibleTo: ['p4'], + }, + }, + 'x', + ), + ).toBe('witch-action'); + + expect( + wf.moderator( + { + role: 'witch-action', + meta: { + phase: 'witch-action', + saved: false, + poisonTarget: null, + visibleTo: ['p5'], + witchPotion: true, + witchPoison: true, + gameOver: false, + }, + }, + 'x', + ), + ).toBe('day-speech'); + + expect( + wf.moderator( + { role: 'day-speech', meta: { phase: 'day-speech', speeches: [] } }, + 'x', + ), + ).toBe('vote'); + + expect( + wf.moderator( + { + role: 'vote', + meta: { + phase: 'vote', + votes: {}, + eliminatedId: 'p7', + gameOver: false, + hunterTriggered: false, + }, + }, + 'x', + ), + ).toBe('wolf-night'); + }); + + it('狼人全死 → 好人胜', () => { + const players = createPlayers(); + const wolves = players.filter((p) => p.identity.team === 'wolf'); + expect(wolves.length).toBe(3); + + const aliveWithoutWolves = players.filter((p) => p.identity.team === 'good'); + expect(checkGameOver(aliveWithoutWolves)).toBe(true); + + const wf = createWerewolfWorkflow(); + expect( + wf.moderator( + { + role: 'vote', + meta: { + phase: 'vote', + votes: {}, + eliminatedId: 'p3', + gameOver: true, + }, + }, + 'x', + ), + ).toBe('game-end'); + + expect( + wf.moderator( + { + role: 'game-end', + meta: { phase: 'game-end', winner: 'good', summary: '' }, + }, + 'x', + ), + ).toBe('__end__'); + + expect( + wf.moderator( + { role: '__end__', meta: { phase: '__end__' } }, + 'x', + ), + ).toBe(END); + }); + + 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], + ]; + expect(checkGameOver(alive2)).toBe(true); + + const alive3 = [ + players[0], + players[1], + players[2], + players[3], + players[4], + players[5], + players[6], + ]; + expect(checkGameOver(alive3)).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', () => { + const chain: WorkflowMessage[] = [ + { + role: 'wolf-night', + content: '狼人杀 p7', + meta: { phase: 'wolf-night', targetId: 'p7' }, + timestamp: 1, + }, + { + role: 'witch-action', + content: '女巫救人', + meta: { + phase: 'witch-action', + saved: true, + poisonTarget: null, + visibleTo: ['p5'], + witchPotion: false, + witchPoison: true, + }, + timestamp: 2, + }, + ]; + + const state = parseGameState(chain); + expect(state.witchPotion).toBe(false); + expect(state.witchPoison).toBe(true); + + chain.push({ + role: 'wolf-night', + content: '狼人杀 p8', + meta: { phase: 'wolf-night', targetId: 'p8' }, + timestamp: 3, + }); + + const state2 = parseGameState(chain); + expect(state2.witchPotion).toBe(false); + expect(state2.lastKill).toBe('p8'); + }); +}); diff --git a/src/workflows/werewolf.ts b/src/workflows/werewolf.ts new file mode 100644 index 0000000..f64ab76 --- /dev/null +++ b/src/workflows/werewolf.ts @@ -0,0 +1,565 @@ +/** + * Werewolf (狼人杀) workflow — 9-player game with information asymmetry. + * + * Pure roles + START/END automaton. Trigger: werewolf.__start__ + * + * 阶段 Role 内部遍历玩家;状态从 chain 重建(parseGameState)。 + * 默认全部为 mock;通过 createWerewolfWorkflow(opts) 注入真实 Role。 + * + * 小橘 🍊 (NEKO Team) + */ + +import { + END, + type ModeratorInput, + type Role, + START, + type WorkflowMessage, + type WorkflowType, +} from '@uncaged/pulse'; + +// ── Types ────────────────────────────────────────────────────── + +export type Team = 'wolf' | 'good'; + +export interface Identity { + name: string; + team: Team; + abilities?: string; +} + +export interface Player { + id: string; + name: string; + identity: Identity; + personality: string; +} + +export interface DeadPlayer extends Player { + cause: 'wolf-kill' | 'vote' | 'poison' | 'hunter-shot'; + day: number; +} + +export interface GameState { + players: Player[]; + alive: Player[]; + dead: DeadPlayer[]; + day: number; + phase: string; + witchPotion: boolean; + witchPoison: boolean; + lastKill: string | null; + lastDeath: DeadPlayer | null; +} + +const PERSONALITIES = [ + '谨慎', + '激进', + '善于伪装', + '逻辑型', + '冷静', + '情绪化', + '理性', + '直觉型', + '温和', +] as const; + +const IDENTITIES: Identity[] = [ + { name: '狼人', team: 'wolf' }, + { name: '狼人', team: 'wolf' }, + { name: '狼人', team: 'wolf' }, + { name: '预言家', team: 'good', abilities: '每晚可查验一名玩家身份' }, + { name: '女巫', team: 'good', abilities: '有一瓶解药和一瓶毒药' }, + { name: '猎人', team: 'good', abilities: '死亡时可开枪带走一人' }, + { name: '村民', team: 'good' }, + { name: '村民', team: 'good' }, + { name: '村民', team: 'good' }, +]; + +export function createPlayers(): Player[] { + return IDENTITIES.map((id, i) => ({ + id: `p${i + 1}`, + name: `玩家${i + 1}`, + identity: id, + personality: PERSONALITIES[i % PERSONALITIES.length], + })); +} + +function byPlayerId(a: Player, b: Player): number { + return a.id.localeCompare(b.id); +} + +// ── Game state from chain (event sourcing) ───────────────────── + +export function parseGameState(chain: WorkflowMessage[]): GameState { + const players = createPlayers(); + const dead: DeadPlayer[] = []; + let day = 1; + let witchPotion = true; + let witchPoison = true; + let lastKill: string | null = null; + let lastDeath: DeadPlayer | null = null; + let phase = ''; + + for (const msg of chain) { + const meta = msg.meta as Record | null; + if (!meta) continue; + + if (meta.phase === 'wolf-night') { + phase = 'wolf-night'; + if (meta.targetId) lastKill = String(meta.targetId); + } + + if (meta.phase === 'witch-action') { + phase = 'witch-action'; + if (meta.saved === true) { + lastKill = null; + witchPotion = false; + } else if (lastKill) { + const p = players.find((pp) => pp.id === lastKill); + if (p && !dead.some((d) => d.id === lastKill)) { + const dp: DeadPlayer = { ...p, cause: 'wolf-kill', day }; + dead.push(dp); + lastDeath = dp; + } + lastKill = null; + } + if (meta.poisonTarget) { + witchPoison = false; + const pid = String(meta.poisonTarget); + const p = players.find((pp) => pp.id === pid); + if (p && !dead.some((d) => d.id === pid)) { + const dp: DeadPlayer = { ...p, cause: 'poison', day }; + dead.push(dp); + lastDeath = dp; + } + } + } + + if (meta.phase === 'dawn') { + if (lastKill) { + const p = players.find((pp) => pp.id === lastKill); + if (p && !dead.some((d) => d.id === lastKill)) { + const dp: DeadPlayer = { ...p, cause: 'wolf-kill', day }; + dead.push(dp); + lastDeath = dp; + } + } + lastKill = null; + } + + if (meta.phase === 'day-speech') phase = 'day-speech'; + + if (meta.phase === 'vote') { + phase = 'vote'; + if (meta.eliminatedId) { + const pid = String(meta.eliminatedId); + const p = players.find((pp) => pp.id === pid); + if (p && !dead.some((d) => d.id === pid)) { + const dp: DeadPlayer = { ...p, cause: 'vote', day }; + dead.push(dp); + lastDeath = dp; + } + } + } + + if (meta.phase === 'hunter-shot') { + phase = 'hunter-shot'; + if (meta.shotTarget) { + const pid = String(meta.shotTarget); + const p = players.find((pp) => pp.id === pid); + if (p && !dead.some((d) => d.id === pid)) { + const dp: DeadPlayer = { ...p, cause: 'hunter-shot', day }; + dead.push(dp); + lastDeath = dp; + } + } + } + + if (meta.phase === 'new-night') { + day++; + lastKill = null; + } + + if (meta.witchPotion === false) witchPotion = false; + if (meta.witchPoison === false) witchPoison = false; + } + + const deadIds = new Set(dead.map((d) => d.id)); + const alive = players.filter((p) => !deadIds.has(p.id)); + + return { + players, + alive, + dead, + day, + phase, + witchPotion, + witchPoison, + lastKill, + lastDeath, + }; +} + +// ── Information visibility filter ────────────────────────────── + +export function filterChainForPlayer( + chain: WorkflowMessage[], + playerId: string, + identity: Identity, +): WorkflowMessage[] { + return chain.filter((msg) => { + const meta = msg.meta as Record | null; + if (!meta) return true; + const ph = meta.phase as string | undefined; + const visibleTo = meta.visibleTo as string[] | undefined; + + if ( + ph === 'day-speech' || + ph === 'vote' || + ph === 'death' || + ph === 'dawn' || + ph === 'hunter-shot' || + ph === 'game-end' || + ph === '__end__' || + ph === 'new-night' + ) { + return true; + } + + if (ph === 'wolf-night') return identity.team === 'wolf'; + + if (ph === 'seer-check' || ph === 'witch-action') { + return visibleTo ? visibleTo.includes(playerId) : false; + } + + if (ph === 'system') { + return visibleTo ? visibleTo.includes(playerId) : true; + } + + return true; + }); +} + +export function buildPlayerPrompt(player: Player): string { + return `你是玩家 ${player.name},身份是【${player.identity.name}】。 +性格特征:${player.personality}。 +阵营:${player.identity.team === 'wolf' ? '狼人阵营' : '好人阵营'}。 +胜利条件:${player.identity.team === 'wolf' ? '淘汰所有神民与村民(好人)' : '找出并淘汰所有狼人'}。 +${player.identity.abilities ? `特殊能力:${player.identity.abilities}` : ''} + +重要规则: +- 不要直接暴露自己的身份(除非策略需要) +- 根据场上信息做出合理推理 +- 发言要有逻辑,但也可以有情感和策略`; +} + +// ── Role Meta types ───────────────────────────────────────────── + +export type WolfNightMeta = { + phase: 'wolf-night'; + targetId: string; +}; + +export type SeerCheckMeta = { + phase: 'seer-check'; + targetId: string; + isWolf: boolean; + visibleTo: string[]; +}; + +export type WitchActionMeta = { + phase: 'witch-action'; + saved: boolean; + poisonTarget: string | null; + visibleTo: string[]; + witchPotion: boolean; + witchPoison: boolean; + gameOver?: boolean; +}; + +export type DaySpeechMeta = { + phase: 'day-speech'; + speeches: Array<{ playerId: string; speech: string }>; +}; + +export type VoteMeta = { + phase: 'vote'; + votes: Record; + eliminatedId: string | null; + hunterTriggered?: boolean; + gameOver?: boolean; +}; + +export type HunterShotMeta = { + phase: 'hunter-shot'; + shotTarget: string; + gameOver?: boolean; +}; + +export type GameEndMeta = { + phase: 'game-end'; + winner: Team; + summary: string; +}; + +/** Terminal marker — adapter writes `{name}.__end__` for meta-tester e2e. */ +export type WerewolfEndMeta = { + phase: '__end__'; +}; + +export type WerewolfRoles = { + 'wolf-night': Role; + 'seer-check': Role; + 'witch-action': Role; + 'day-speech': Role; + vote: Role; + 'hunter-shot': Role; + 'game-end': Role; + __end__: Role; +}; + +// ── 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; + return false; +} + +type WerewolfInput = ModeratorInput; + +function werewolfModerator( + output: WerewolfInput, + _topicId: string, +): keyof WerewolfRoles | typeof END { + if (output.role === START) return 'wolf-night'; + + const meta = output.meta as Record | null; + const gameOver = meta?.gameOver === true; + + switch (output.role) { + case 'wolf-night': + return 'seer-check'; + case 'seer-check': + return 'witch-action'; + case 'witch-action': + if (gameOver) return 'game-end'; + return 'day-speech'; + case 'day-speech': + return 'vote'; + case 'vote': { + if (gameOver) return 'game-end'; + if (meta?.hunterTriggered === true) return 'hunter-shot'; + return 'wolf-night'; + } + case 'hunter-shot': { + if (gameOver) return 'game-end'; + return 'wolf-night'; + } + case 'game-end': + return '__end__'; + case '__end__': + return END; + default: + return END; + } +} + +// ── Default mock roles (deterministic, no LLM) ───────────────── + +const mockWolfNight: Role = async (chain) => { + const state = parseGameState(chain); + 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 }, + }; +}; + +const mockSeerCheck: Role = async (chain) => { + const state = parseGameState(chain); + const seer = state.alive.find((p) => p.identity.name === '预言家'); + if (!seer) { + return { + content: '[预言家已死,跳过验人]', + meta: { + phase: 'seer-check', + targetId: '', + isWolf: false, + visibleTo: [], + }, + }; + } + const others = state.alive + .filter((p) => p.id !== seer.id) + .sort(byPlayerId); + const target = others[0]!; + return { + content: `[预言家查验] ${target.name} 是${target.identity.team === 'wolf' ? '狼人' : '好人'}`, + meta: { + phase: 'seer-check', + targetId: target.id, + isWolf: target.identity.team === 'wolf', + visibleTo: [seer.id], + }, + }; +}; + +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 poisonTarget = + witch && state.witchPoison && wolves.length > 0 ? wolves[0]!.id : null; + const witchId = witch?.id ?? ''; + + const nightDead: string[] = []; + if (state.lastKill && !saved) nightDead.push(state.lastKill); + if (poisonTarget) nightDead.push(poisonTarget); + + const deadIds = new Set([...state.dead.map((d) => d.id), ...nightDead]); + const aliveAfter = state.players.filter((p) => !deadIds.has(p.id)); + const gameOver = checkGameOver(aliveAfter); + + return { + content: `[女巫行动] 未使用解药;${poisonTarget ? `毒杀 ${poisonTarget}` : '未用毒药'}`, + meta: { + phase: 'witch-action', + saved, + poisonTarget, + visibleTo: witchId ? [witchId] : [], + witchPotion: saved ? false : state.witchPotion, + witchPoison: poisonTarget ? false : state.witchPoison, + gameOver, + }, + }; +}; + +const mockDaySpeech: Role = async (chain) => { + const state = parseGameState(chain); + const speeches = state.alive.map((p) => { + const visible = filterChainForPlayer(chain, p.id, p.identity); + return { + playerId: p.id, + speech: `(可见消息 ${visible.length} 条)${buildPlayerPrompt(p).split('\n')[0]} — 我认为需要结合投票阶段再判断。`, + }; + }); + return { + content: speeches.map((s) => `【${s.playerId}】${s.speech}`).join('\n'), + meta: { phase: 'day-speech', speeches }, + }; +}; + +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 eliminatedId = eliminated?.id ?? null; + + const votes: Record = {}; + if (eliminatedId) { + for (const p of alive) votes[p.id] = eliminatedId; + } + + const aliveAfter = eliminatedId + ? alive.filter((p) => p.id !== eliminatedId) + : alive; + const gameOver = checkGameOver(aliveAfter); + const hunterTriggered = eliminated?.identity.name === '猎人'; + + return { + content: `[投票结果] ${eliminated?.name ?? '无人'} 被放逐`, + meta: { + phase: 'vote', + votes, + eliminatedId, + hunterTriggered, + gameOver, + }, + }; +}; + +const mockHunterShot: Role = async (chain) => { + const state = parseGameState(chain); + 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); + + return { + content: `[猎人开枪] 带走了 ${target.name}`, + meta: { + phase: 'hunter-shot', + shotTarget: target.id, + gameOver, + }, + }; +}; + +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'; + return { + content: `[游戏结束] ${winner === 'good' ? '好人阵营' : '狼人阵营'}获胜`, + meta: { + phase: 'game-end', + winner, + summary: `存活: ${state.alive.map((p) => p.name).join(', ')};死亡: ${state.dead.map((d) => `${d.name}(${d.cause})`).join(', ')}`, + }, + }; +}; + +const mockWorkflowEnd: Role = async () => ({ + content: '[workflow finished]', + meta: { phase: '__end__' as const }, +}); + +// ── Factory ──────────────────────────────────────────────────── + +export type CreateWerewolfWorkflowOpts = { + wolfNightFn?: Role; + seerCheckFn?: Role; + witchActionFn?: Role; + daySpeechFn?: Role; + voteFn?: Role; + hunterShotFn?: Role; + gameEndFn?: Role; + workflowEndFn?: Role; +}; + +export function createWerewolfWorkflow( + opts?: CreateWerewolfWorkflowOpts, +): WorkflowType { + return { + name: 'werewolf', + roles: { + 'wolf-night': opts?.wolfNightFn ?? mockWolfNight, + 'seer-check': opts?.seerCheckFn ?? mockSeerCheck, + 'witch-action': opts?.witchActionFn ?? mockWitchAction, + 'day-speech': opts?.daySpeechFn ?? mockDaySpeech, + vote: opts?.voteFn ?? mockVote, + 'hunter-shot': opts?.hunterShotFn ?? mockHunterShot, + 'game-end': opts?.gameEndFn ?? mockGameEnd, + __end__: opts?.workflowEndFn ?? mockWorkflowEnd, + }, + moderator: werewolfModerator, + limits: { maxRounds: 80 }, + }; +}