From e8771f3e2e3bd1593442322fc8baf3fd75e0ca4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sat, 18 Apr 2026 07:22:27 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20simplify=20meta=20workflow=20?= =?UTF-8?q?=E2=80=94=20coder=20=E2=86=92=20tester=20=E2=86=92=20promoter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/werewolf-design.md | 215 ++++++++++++ src/workflows/ping-pong.test.ts | 4 +- src/workflows/ping-pong.ts | 2 +- src/workflows/werewolf.test.ts | 354 +++++++++++++++++++ src/workflows/werewolf.ts | 578 ++++++++++++++++++++++++++++++++ 5 files changed, 1150 insertions(+), 3 deletions(-) create mode 100644 docs/werewolf-design.md create mode 100644 src/workflows/werewolf.test.ts create mode 100644 src/workflows/werewolf.ts diff --git a/docs/werewolf-design.md b/docs/werewolf-design.md new file mode 100644 index 0000000..12961f3 --- /dev/null +++ b/docs/werewolf-design.md @@ -0,0 +1,215 @@ +# 狼人杀 Workflow 设计文档 + +## 概述 + +用 Pulse workflow 实现 AI 狼人杀。每个玩家是一个 Role(背后一个 LLM), +moderator 是主持人控制阶段轮转和胜负判定。 + +## 游戏配置(9人局) + +| 身份 | 数量 | 阵营 | +|------|------|------| +| 狼人 | 3 | 狼 | +| 预言家 | 1 | 好人 | +| 女巫 | 1 | 好人 | +| 猎人 | 1 | 好人 | +| 村民 | 3 | 好人 | + +## 核心设计 + +### 信息可见性 — 关键创新点 + +每个 Role 的 `prepPrompt(chain)` 过滤 chain,只暴露该角色可见的信息: + +```typescript +function filterChainForPlayer(chain: WorkflowMessage[], playerId: string, identity: Identity): WorkflowMessage[] { + return chain.filter(msg => { + const phase = msg.meta?.phase as string; + const target = msg.meta?.visibleTo as string[] | undefined; + + // 公开阶段(白天发言、投票结果、死亡公告)所有人可见 + if (phase === 'day-speech' || phase === 'vote-result' || phase === 'death') return true; + + // 狼人夜晚讨论:只有狼人可见 + if (phase === 'wolf-night' && identity.team === 'wolf') return true; + + // 预言家验人:只有预言家自己可见 + if (phase === 'seer-check' && target?.includes(playerId)) return true; + + // 女巫信息:只有女巫自己可见 + if (phase === 'witch-action' && target?.includes(playerId)) return true; + + // 系统消息(角色分配等):只有目标可见 + if (phase === 'system' && target?.includes(playerId)) return true; + + return false; + }); +} +``` + +### Role 设计 + +不是每个玩家一个 Role。而是 **每个阶段一个 Role**,Role 内部遍历该阶段需要行动的玩家: + +```typescript +type WerewolfRoles = { + // 夜晚阶段 + 'wolf-night': Role; // 狼人讨论+投票杀谁 + 'seer-check': Role; // 预言家验人 + 'witch-action': Role; // 女巫用药 + + // 白天阶段 + 'day-speech': Role; // 所有存活玩家依次发言 + 'vote': Role; // 投票放逐 + + // 特殊 + 'hunter-shot': Role; // 猎人开枪(死亡触发) + + // 结算 + 'game-end': Role; // 生成游戏总结 +}; +``` + +### 阶段内多玩家执行 + +每个阶段 Role 内部对多个玩家分别调 LLM: + +```typescript +const daySpeechRole: Role = async (chain, topicId, store) => { + const state = parseGameState(chain); + const speeches: PlayerSpeech[] = []; + + for (const player of state.alivePlayers) { + // 过滤 chain,只给该玩家可见的信息 + const visibleChain = filterChainForPlayer(chain, player.id, player.identity); + + // 调 LLM(通过 Sigil executor) + const speech = await invokeLlm({ + system: buildPlayerPrompt(player), + messages: visibleChain, + instruction: '现在轮到你发言,分析场上局势,表达你的观点。', + }); + + speeches.push({ playerId: player.id, speech }); + } + + return { + content: speeches.map(s => `【${s.playerId}】${s.speech}`).join('\n\n'), + meta: { speeches, phase: 'day-speech', visibleTo: null }, // 公开 + }; +}; +``` + +### Moderator — 主持人 + +```typescript +function werewolfModerator( + output: ModeratorInput, + topicId: string, +): keyof WerewolfRoles | typeof END { + if (output.role === START) return 'wolf-night'; // 天黑请闭眼 + + const state = output.meta?.gameState as GameState; + + // 胜负判定 + if (state) { + const wolves = state.alive.filter(p => p.team === 'wolf'); + if (wolves.length === 0) return 'game-end'; // 好人胜 + if (wolves.length >= state.alive.length - wolves.length) return 'game-end'; // 狼人胜 + } + + // 阶段轮转 + switch (output.role) { + case 'wolf-night': return 'seer-check'; + case 'seer-check': return 'witch-action'; + case 'witch-action': return 'day-speech'; // 天亮了 + case 'day-speech': return 'vote'; + case 'vote': { + // 猎人被投票出局 → 开枪 + if (state?.lastDeath?.identity === 'hunter') return 'hunter-shot'; + return 'wolf-night'; // 下一夜 + } + case 'hunter-shot': return 'wolf-night'; + case 'game-end': return END; + default: return END; + } +} +``` + +### 游戏状态 + +不存在独立的 state 对象——游戏状态从 chain 重建(event sourcing): + +```typescript +interface GameState { + players: Player[]; // 所有玩家 + alive: Player[]; // 存活玩家 + dead: DeadPlayer[]; // 死亡玩家 + 死因 + day: number; // 第几天 + phase: string; // 当前阶段 + witchPotion: boolean; // 女巫解药是否还在 + witchPoison: boolean; // 女巫毒药是否还在 + lastDeath: DeadPlayer | null; +} + +function parseGameState(chain: WorkflowMessage[]): GameState { + // 从 chain 的 meta 中重建完整游戏状态 + // 每个阶段 Role 的 meta 都带 gameState diff +} +``` + +### 玩家 Prompt 设计 + +每个玩家的 system prompt 包含: +1. **角色身份**:你是预言家/狼人/村民... +2. **性格特征**:随机分配(谨慎/激进/善于伪装/逻辑型...) +3. **游戏规则**:简要规则提醒 +4. **目标**:你的胜利条件 + +```typescript +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}` : ''} + +重要规则: +- 不要直接暴露自己的身份(除非策略需要) +- 根据场上信息做出合理推理 +- 发言要有逻辑,但也可以有情感和策略`; +} +``` + +## Meta Workflow 集成 + +让 meta workflow 生成这个 werewolf.ts: + +### 任务描述(给 meta workflow 的 __start__ event) + +``` +目标:实现狼人杀 workflow(werewolf.ts + werewolf.test.ts) +位置:packages/pulse/src/workflows/werewolf.ts + +要求: +1. 9人局(3狼人 + 预言家 + 女巫 + 猎人 + 3村民) +2. 信息可见性:chain 过滤,每个玩家只看到该看的 +3. 阶段:wolf-night → seer-check → witch-action → day-speech → vote → (hunter-shot) → 循环 +4. 默认 mock Role(不调 LLM),可注入真 LLM Role +5. Moderator 判胜负 + 阶段轮转 +6. 测试:mock 模式跑完整局,验证阶段流转和胜负判定 + +参考:coding-tdd.ts 的结构(WorkflowType + Roles + Moderator + Factory) +验证:bun test packages/pulse/src/workflows/werewolf.test.ts +``` + +## 后续扩展 + +1. **真 LLM 对战**:注入 Sigil executor 做的 LLM Role,看 AI 之间互相推理 +2. **人类参与**:某个玩家的 Role 改为等待用户输入(device effect 模式) +3. **观战模式**:实时输出每个阶段的公开信息 +4. **复盘**:游戏结束后展示所有信息(包括夜晚行动),像"上帝视角" +5. **更多身份**:守卫、白痴、丘比特... + +--- +小橘 🍊 (NEKO Team) diff --git a/src/workflows/ping-pong.test.ts b/src/workflows/ping-pong.test.ts index a1a418c..b35de67 100644 --- a/src/workflows/ping-pong.test.ts +++ b/src/workflows/ping-pong.test.ts @@ -28,8 +28,8 @@ describe('ping-pong workflow', () => { const rule = createWorkflowRule(pingPong, store); // Trigger - const hash = store.putObject('ping'); - store.appendEvent({ + const hash = await store.putObject('ping'); + await store.appendEvent({ occurredAt: Date.now(), kind: 'ping-pong.__start__', key: 't1', diff --git a/src/workflows/ping-pong.ts b/src/workflows/ping-pong.ts index 0aeca36..e1ea1c0 100644 --- a/src/workflows/ping-pong.ts +++ b/src/workflows/ping-pong.ts @@ -4,7 +4,7 @@ * 小橘 🍊 (NEKO Team) */ -import { END, START, type Role, type WorkflowType } from '@uncaged/pulse/src/workflows/workflow-type.js'; +import { END, START, type Role, type WorkflowType } from '@uncaged/pulse'; type PingPongRoles = { pong: Role<{ echo: true }>; diff --git a/src/workflows/werewolf.test.ts b/src/workflows/werewolf.test.ts new file mode 100644 index 0000000..95fd1fb --- /dev/null +++ b/src/workflows/werewolf.test.ts @@ -0,0 +1,354 @@ +/** + * Werewolf workflow tests — engine package. + * + * 小橘 🍊 (NEKO Team) + */ + +import { afterEach, 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, type PulseStore } from '@uncaged/pulse'; +import { END, START, type Role, type WorkflowMessage } from '@uncaged/pulse'; +import { + checkGameOver, + createPlayers, + createWerewolfWorkflow, + filterChainForPlayer, + parseGameState, + type WitchActionMeta, +} from './werewolf.js'; + +describe('werewolf workflow', () => { + let tmpDir: string; + + afterEach(() => { + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + }); + + async function makeStore(): Promise { + tmpDir = mkdtempSync(join(tmpdir(), 'engine-werewolf-')); + return createStore({ + eventsDbPath: join(tmpDir, 'test.db'), + objectsDir: join(tmpDir, 'objects'), + }); + } + + async function trigger(store: PulseStore, topicId: string) { + const hash = await store.putObject('werewolf start'); + await store.appendEvent({ + occurredAt: Date.now(), + kind: 'werewolf.__start__', + key: topicId, + hash, + meta: JSON.stringify({}), + }); + } + + it('mock 模式跑完整局', async () => { + const store = await makeStore(); + try { + const wf = createWerewolfWorkflow(); + const rule = createWorkflowRule(wf, store); + await trigger(store, 'g1'); + + const order: string[] = []; + for (let i = 0; i < 300; 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('game-end'); + expect(order.length).toBeGreaterThanOrEqual(8); + } finally { + store.close(); + } + }); + + 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', + knifedPlayerId: 'p7', + saved: false, + poisonTarget: null, + visibleTo: ['p5'], + witchPotion: true, + witchPoison: true, + gameOver: false, + }, + }, + 'x', + ), + ).toBe('dawn'); + + expect( + wf.moderator( + { + role: 'dawn', + meta: { phase: 'dawn', peaceful: true, publicDeathIds: [], day: 1 }, + }, + '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'); + + expect(wf.moderator({ role: 'game-end', meta: { phase: 'game-end', winner: 'good', summary: '' } }, 'x')).toBe( + END, + ); + }); + + it('胜负判定:狼全死 → 好人胜(进入 game-end)', () => { + const wf = createWerewolfWorkflow(); + expect( + wf.moderator( + { role: 'vote', meta: { phase: 'vote', votes: {}, eliminatedId: 'p3', gameOver: true } }, + 'x', + ), + ).toBe('game-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[3], players[4], players[5], players[6]]; + expect(checkGameOver(alive2)).toBe(false); + }); + + it('filterChainForPlayer 信息隔离', () => { + 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', + knifedPlayerId: 'p7', + saved: false, + poisonTarget: null, + visibleTo: ['p5'], + witchPotion: true, + witchPoison: true, + }, + timestamp: 3, + }, + { + role: 'dawn', + content: '天亮', + meta: { phase: 'dawn', peaceful: false, publicDeathIds: ['p7'], day: 1 }, + timestamp: 4, + }, + { + role: 'day-speech', + content: '发言', + meta: { phase: 'day-speech', speeches: [] }, + timestamp: 5, + }, + { + role: 'vote', + content: '投票', + meta: { phase: 'vote', votes: {}, eliminatedId: 'p8' }, + timestamp: 6, + }, + ]; + + 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); + + 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 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 === 'witch-action')).toBe(false); + expect(villagerView.some((m) => (m.meta as { phase?: string })?.phase === 'dawn')).toBe(true); + expect(villagerView.some((m) => (m.meta as { phase?: string })?.phase === 'day-speech')).toBe(true); + }); + + it('平安夜:村民视角看不到刀口 meta(女巫可见 witch-action)', () => { + const players = createPlayers(); + const witch = players[4]; + const villager = players[6]; + + const chain: WorkflowMessage[] = [ + { + role: 'wolf-night', + content: '狼刀 p7', + meta: { phase: 'wolf-night', targetId: 'p7' }, + timestamp: 1, + }, + { + role: 'witch-action', + content: '女巫救', + meta: { + phase: 'witch-action', + knifedPlayerId: 'p7', + saved: true, + poisonTarget: null, + visibleTo: [witch.id], + witchPotion: false, + witchPoison: true, + }, + timestamp: 2, + }, + { + role: 'dawn', + content: '平安夜', + meta: { phase: 'dawn', peaceful: true, publicDeathIds: [], day: 1 }, + timestamp: 3, + }, + ]; + + const villagerView = filterChainForPlayer(chain, villager.id, villager.identity); + const hasKnifeMeta = villagerView.some((m) => { + const meta = m.meta as Record | null; + return meta != null && 'knifedPlayerId' in meta; + }); + expect(hasKnifeMeta).toBe(false); + + const witchView = filterChainForPlayer(chain, witch.id, witch.identity); + expect(witchView.some((m) => (m.meta as { phase?: string })?.phase === 'witch-action')).toBe(true); + expect( + (witchView.find((m) => (m.meta as { phase?: string })?.phase === 'witch-action')?.meta as WitchActionMeta) + ?.knifedPlayerId, + ).toBe('p7'); + }); + + 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'); + }); + + it('注入的 witch Role 可覆盖默认实现', async () => { + const store = await makeStore(); + try { + const savingWitch: Role = async (chain) => { + const state = parseGameState(chain); + const witch = state.alive.find((p) => p.identity.name === '女巫'); + const knifed = state.lastKill; + const saved = Boolean(knifed && state.witchPotion); + const poisonTarget: string | null = null; + const aliveAfter = aliveAfterNightForTest(state, saved, poisonTarget, knifed); + const gameOver = checkGameOver(aliveAfter); + return { + content: saved ? '救' : '不救', + meta: { + phase: 'witch-action', + knifedPlayerId: knifed, + saved, + poisonTarget, + visibleTo: witch ? [witch.id] : [], + witchPotion: saved ? false : state.witchPotion, + witchPoison: state.witchPoison, + gameOver, + }, + }; + }; + + const wf = createWerewolfWorkflow({ witchActionFn: savingWitch }); + const rule = createWorkflowRule(wf, store); + await trigger(store, 'inj'); + + let sawPeacefulDawn = false; + for (let i = 0; i < 40; i++) { + const r = await rule.tick(); + if (r.executed.length === 0) break; + for (const ex of r.executed) { + if (ex.role === 'dawn' && ex.meta?.peaceful === true) sawPeacefulDawn = true; + } + } + expect(sawPeacefulDawn).toBe(true); + } finally { + store.close(); + } + }); +}); + +function aliveAfterNightForTest( + state: ReturnType, + witchSaved: boolean, + poisonId: string | null, + knifedId: string | null, +) { + const deadIds = new Set(state.dead.map((d) => d.id)); + const pending = new Set(); + if (poisonId) pending.add(poisonId); + if (knifedId && !witchSaved) pending.add(knifedId); + return state.players.filter((p) => !deadIds.has(p.id) && !pending.has(p.id)); +} diff --git a/src/workflows/werewolf.ts b/src/workflows/werewolf.ts new file mode 100644 index 0000000..378775c --- /dev/null +++ b/src/workflows/werewolf.ts @@ -0,0 +1,578 @@ +/** + * Werewolf (狼人杀) workflow — 9-player game with information asymmetry. + * + * Trigger: werewolf.__start__ + * Roles are per-phase; game state is rebuilt from chain via parseGameState. + * Default roles are deterministic mocks (no LLM). Inject real roles via createWerewolfWorkflow(opts). + * + * 小橘 🍊 (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; +} + +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; +} + +// ── 9 人局 ───────────────────────────────────────────────────── + +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, + })); +} + +// ── Game state from chain ───────────────────────────────────── + +export function parseGameState(chain: WorkflowMessage[]): GameState { + const players = createPlayers(); + const dead: DeadPlayer[] = []; + let calendarDay = 1; + let nightNum = 0; + 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; + + const p = meta.phase as string | undefined; + if (p === 'wolf-night') { + phase = 'wolf-night'; + nightNum++; + if (meta.targetId) lastKill = meta.targetId as string; + } + + if (p === 'seer-check') { + phase = 'seer-check'; + } + + if (p === 'witch-action') { + phase = 'witch-action'; + const saved = meta.saved === true; + if (saved) { + lastKill = null; + witchPotion = false; + } + if (meta.poisonTarget) { + witchPoison = false; + const pid = meta.poisonTarget as string; + const pl = players.find((pp) => pp.id === pid); + if (pl && !dead.some((d) => d.id === pid)) { + const dp: DeadPlayer = { ...pl, cause: 'poison', day: nightNum }; + dead.push(dp); + lastDeath = dp; + } + } + if (lastKill) { + const pl = players.find((pp) => pp.id === lastKill); + if (pl && !dead.some((d) => d.id === lastKill)) { + const dp: DeadPlayer = { ...pl, cause: 'wolf-kill', day: nightNum }; + dead.push(dp); + lastDeath = dp; + } + } + lastKill = null; + } + + if (p === 'dawn') { + phase = 'dawn'; + calendarDay = (meta.day as number) ?? calendarDay; + } + + if (p === 'day-speech') { + phase = 'day-speech'; + } + + if (p === 'vote') { + phase = 'vote'; + if (meta.eliminatedId) { + const pid = meta.eliminatedId as string; + const pl = players.find((pp) => pp.id === pid); + if (pl && !dead.some((d) => d.id === pid)) { + const dp: DeadPlayer = { ...pl, cause: 'vote', day: calendarDay }; + dead.push(dp); + lastDeath = dp; + } + } + } + + if (p === 'hunter-shot') { + phase = 'hunter-shot'; + if (meta.shotTarget) { + const pid = meta.shotTarget as string; + const pl = players.find((pp) => pp.id === pid); + if (pl && !dead.some((d) => d.id === pid)) { + const dp: DeadPlayer = { ...pl, cause: 'hunter-shot', day: calendarDay }; + dead.push(dp); + lastDeath = dp; + } + } + } + + if (p === 'game-end') { + phase = 'game-end'; + } + + 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((pl) => !deadIds.has(pl.id)); + + return { + players, + alive, + dead, + day: calendarDay, + phase, + witchPotion, + witchPoison, + lastKill, + lastDeath, + }; +} + +// ── Information visibility ──────────────────────────────────── + +/** + * 过滤 chain:每个玩家只看到允许的信息。 + * 平安夜:狼人仍见狼队讨论与刀口;女巫见系统告知的刀口;好人阵营其余角色不见「谁被刀」 + * (狼夜消息本身仅狼可见;公开阶段不得带 knifedPlayerId)。 + */ +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 === 'hunter-shot' || + ph === 'game-end' + ) { + return true; + } + + // 天亮公告:全员可见,但 meta 不得包含 knifedPlayerId(由角色保证) + if (ph === 'dawn') 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; + }); +} + +// ── 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'; + /** 狼人刀口(仅女巫可见,用于平安夜信息隔离) */ + knifedPlayerId: string | null; + saved: boolean; + poisonTarget: string | null; + visibleTo: string[]; + witchPotion: boolean; + witchPoison: boolean; + gameOver?: boolean; +}; + +export type DawnMeta = { + phase: 'dawn'; + peaceful: boolean; + /** 公开死亡名单( id ),平安夜为空;不含「被刀但未死者」 */ + publicDeathIds: string[]; + day: number; +}; + +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; +}; + +export type WerewolfRoles = { + 'wolf-night': Role; + 'seer-check': Role; + 'witch-action': Role; + 'dawn': Role; + 'day-speech': Role; + vote: Role; + 'hunter-shot': Role; + 'game-end': Role; +}; + +// ── Helpers ─────────────────────────────────────────────────── + +function firstById(arr: T[]): T { + return [...arr].sort((a, b) => a.id.localeCompare(b.id))[0]; +} + +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; +} + +function aliveAfterNight( + state: GameState, + witchSaved: boolean, + poisonId: string | null, + knifedId: string | null, +): Player[] { + const deadIds = new Set(state.dead.map((d) => d.id)); + const pending = new Set(); + if (poisonId) pending.add(poisonId); + if (knifedId && !witchSaved) pending.add(knifedId); + return state.players.filter((p) => !deadIds.has(p.id) && !pending.has(p.id)); +} + +// ── Default mock roles (deterministic) ──────────────────────── + +const mockWolfNight: Role = async (chain) => { + const state = parseGameState(chain); + const goodAlive = state.alive.filter((p) => p.identity.team === 'good'); + if (goodAlive.length === 0) { + const fallback = firstById(state.alive); + return { + content: `[狼人夜晚] 无好人可刀,指认 ${fallback.name}`, + meta: { phase: 'wolf-night', targetId: fallback.id }, + }; + } + const target = firstById(goodAlive); + return { + content: `[狼人夜晚] 狼人决定击杀 ${target.name}`, + 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); + const target = firstById(others); + 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 knifedPlayerId = state.lastKill; + const saved = false; + const poisonTarget: string | null = null; + + const aliveAfter = aliveAfterNight(state, saved, poisonTarget, knifedPlayerId); + const gameOver = checkGameOver(aliveAfter); + + return { + content: `[女巫行动] 未使用解药与毒药`, + meta: { + phase: 'witch-action', + knifedPlayerId, + saved, + poisonTarget, + visibleTo: witch ? [witch.id] : [], + witchPotion: state.witchPotion, + witchPoison: state.witchPoison, + gameOver, + }, + }; +}; + +const mockDawn: Role = async (chain) => { + const stateAfter = parseGameState(chain); + const stateBefore = parseGameState(chain.slice(0, -1)); + const beforeIds = new Set(stateBefore.dead.map((d) => d.id)); + const newDead = stateAfter.dead.filter((d) => !beforeIds.has(d.id)); + const publicDeathIds = newDead.map((d) => d.id); + + const peaceful = publicDeathIds.length === 0; + const priorDawns = chain.slice(0, -1).filter((m) => (m.meta as Record | null)?.phase === 'dawn').length; + const dayNum = priorDawns + 1; + + const lines = peaceful + ? ['天亮了,昨晚平安夜。'] + : [ + `天亮了,昨夜死亡:${publicDeathIds + .map((id) => stateAfter.players.find((p) => p.id === id)?.name ?? id) + .join('、')}`, + ]; + + return { + content: lines.join('\n'), + meta: { + phase: 'dawn', + peaceful, + publicDeathIds, + day: dayNum, + }, + }; +}; + +const mockDaySpeech: Role = async (chain) => { + const state = parseGameState(chain); + const speeches = state.alive.map((p) => ({ + playerId: p.id, + speech: `${p.name}:请大家理性分析。`, + })); + 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 votes: Record = {}; + for (const p of state.alive) { + const others = state.alive.filter((o) => o.id !== p.id); + if (others.length > 0) votes[p.id] = firstById(others).id; + } + const tally: Record = {}; + for (const target of Object.values(votes)) { + tally[target] = (tally[target] || 0) + 1; + } + const maxVotes = Math.max(0, ...Object.values(tally)); + const topIds = Object.entries(tally) + .filter(([, v]) => v === maxVotes) + .map(([k]) => k); + const eliminatedId = topIds.length === 1 ? topIds[0] : firstById(topIds.map((id) => ({ id }))).id; + + const eliminated = state.alive.find((p) => p.id === eliminatedId); + const aliveAfter = state.alive.filter((p) => p.id !== eliminatedId); + 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 target = firstById(state.alive); + 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(', ')}`, + }, + }; +}; + +// ── Moderator ───────────────────────────────────────────────── + +type WerewolfModIn = ModeratorInput; + +function werewolfModerator( + output: WerewolfModIn, + _topicId: string, + remainingRounds?: number, +): keyof WerewolfRoles | typeof END { + if (remainingRounds !== undefined && remainingRounds <= 1) return 'game-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 'dawn'; + case 'dawn': + 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; + default: + return END; + } +} + +// ── Factory ─────────────────────────────────────────────────── + +export type CreateWerewolfWorkflowOpts = { + wolfNightFn?: Role; + seerCheckFn?: Role; + witchActionFn?: Role; + dawnFn?: Role; + daySpeechFn?: Role; + voteFn?: Role; + hunterShotFn?: Role; + gameEndFn?: 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, + dawn: opts?.dawnFn ?? mockDawn, + 'day-speech': opts?.daySpeechFn ?? mockDaySpeech, + vote: opts?.voteFn ?? mockVote, + 'hunter-shot': opts?.hunterShotFn ?? mockHunterShot, + 'game-end': opts?.gameEndFn ?? mockGameEnd, + }, + moderator: werewolfModerator, + limits: { maxRounds: 80 }, + }; +}