diff --git a/packages/pulse/src/workflows/werewolf.test.ts b/packages/pulse/src/workflows/werewolf.test.ts new file mode 100644 index 0000000..abddafa --- /dev/null +++ b/packages/pulse/src/workflows/werewolf.test.ts @@ -0,0 +1,280 @@ +/** + * Werewolf (狼人杀) WorkflowType tests. + * + * 小橘 🍊 (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, type PulseStore } from '../store.js'; +import { + createWerewolfWorkflow, + createPlayers, + parseGameState, + filterChainForPlayer, + checkGameOver, + type GameState, + type WolfNightMeta, + type SeerCheckMeta, + type WitchActionMeta, + type DaySpeechMeta, + type VoteMeta, + type HunterShotMeta, + type GameEndMeta, +} from './werewolf.js'; +import { createWorkflowRule } from './workflow-rule-adapter.js'; +import { END, START, type WorkflowMessage, type Role } from './workflow-type.js'; + +describe('werewolf WorkflowType', () => { + let store: PulseStore; + let tmpDir: string; + + function setup() { + tmpDir = mkdtempSync(join(tmpdir(), 'werewolf-')); + store = createStore({ + eventsDbPath: join(tmpDir, 'test.db'), + objectsDir: join(tmpDir, 'objects'), + }); + } + + function cleanup() { + try { + store?.close(); + } catch {} + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + } + + function trigger(topicId: string) { + const hash = store.putObject('werewolf game start'); + 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); + trigger('g1'); + + const order: string[] = []; + // Max 200 ticks to allow the game to finish + 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)); + } + + // Game must have started and ended + expect(order[0]).toBe('wolf-night'); + expect(order[order.length - 1]).toBe('game-end'); + expect(order.length).toBeGreaterThanOrEqual(6); // at least one full cycle + game-end + } finally { + cleanup(); + } + }); + + it('阶段正确轮转', () => { + const wf = createWerewolfWorkflow(); + + // START → wolf-night + expect(wf.moderator({ role: START, meta: null }, 'x')).toBe('wolf-night'); + + // wolf-night → seer-check + expect(wf.moderator( + { role: 'wolf-night', meta: { phase: 'wolf-night', targetId: 'p7' } }, + 'x', + )).toBe('seer-check'); + + // seer-check → witch-action + expect(wf.moderator( + { role: 'seer-check', meta: { phase: 'seer-check', targetId: 'p1', isWolf: true, visibleTo: ['p4'] } }, + 'x', + )).toBe('witch-action'); + + // witch-action (no game over) → day-speech + expect(wf.moderator( + { role: 'witch-action', meta: { phase: 'witch-action', saved: false, poisonTarget: null, visibleTo: ['p5'], witchPotion: true, witchPoison: true, gameOver: false } as any }, + 'x', + )).toBe('day-speech'); + + // day-speech → vote + expect(wf.moderator( + { role: 'day-speech', meta: { phase: 'day-speech', speeches: [] } }, + 'x', + )).toBe('vote'); + + // vote (no game over, no hunter) → wolf-night (next cycle) + expect(wf.moderator( + { role: 'vote', meta: { phase: 'vote', votes: {}, eliminatedId: 'p7', gameOver: false, hunterTriggered: false } as any }, + 'x', + )).toBe('wolf-night'); + }); + + it('狼人全死 → 好人胜', () => { + const players = createPlayers(); + // 3 wolves are p1, p2, p3 + const wolves = players.filter(p => p.identity.team === 'wolf'); + expect(wolves.length).toBe(3); + + // All wolves dead + const aliveWithoutWolves = players.filter(p => p.identity.team === 'good'); + expect(checkGameOver(aliveWithoutWolves)).toBe(true); + + // Verify via moderator: vote with gameOver=true → game-end + const wf = createWerewolfWorkflow(); + expect(wf.moderator( + { role: 'vote', meta: { phase: 'vote', votes: {}, eliminatedId: 'p3', gameOver: true } as any }, + 'x', + )).toBe('game-end'); + + // game-end → END + expect(wf.moderator( + { role: 'game-end', meta: { phase: 'game-end', winner: 'good', summary: '' } }, + 'x', + )).toBe(END); + }); + + it('好人数 <= 狼人数 → 狼人胜', () => { + const players = createPlayers(); + // 3 wolves + 1 good alive → wolves >= good → game over + const alive = [ + players[0], // wolf + players[1], // wolf + players[2], // wolf + players[6], // villager + ]; + expect(checkGameOver(alive)).toBe(true); + + // 3 wolves + 2 good → wolves >= good-wolves → still over + const alive2 = [ + players[0], players[1], players[2], // 3 wolves + players[6], players[7], // 2 good + ]; + // 3 >= 2 → true + expect(checkGameOver(alive2)).toBe(true); + + // 3 wolves + 4 good → not over + const alive3 = [ + players[0], players[1], players[2], // 3 wolves + players[3], players[4], players[5], players[6], // 4 good + ]; + expect(checkGameOver(alive3)).toBe(false); + }); + + it('信息可见性正确', () => { + const players = createPlayers(); + const wolf = players[0]; // p1, wolf + const seer = players[3]; // p4, 预言家 + const villager = players[6]; // p7, 村民 + + 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, + }, + ]; + + // Wolf can see wolf-night, day-speech, vote + const wolfView = filterChainForPlayer(chain, wolf.id, wolf.identity); + expect(wolfView.some(m => (m.meta as any)?.phase === 'wolf-night')).toBe(true); + expect(wolfView.some(m => (m.meta as any)?.phase === 'seer-check')).toBe(false); + expect(wolfView.some(m => (m.meta as any)?.phase === 'witch-action')).toBe(false); + expect(wolfView.some(m => (m.meta as any)?.phase === 'day-speech')).toBe(true); + expect(wolfView.some(m => (m.meta as any)?.phase === 'vote')).toBe(true); + + // Seer can see seer-check but not wolf-night or witch-action + const seerView = filterChainForPlayer(chain, seer.id, seer.identity); + expect(seerView.some(m => (m.meta as any)?.phase === 'seer-check')).toBe(true); + expect(seerView.some(m => (m.meta as any)?.phase === 'wolf-night')).toBe(false); + expect(seerView.some(m => (m.meta as any)?.phase === 'witch-action')).toBe(false); + + // Villager can only see day-speech and vote + const villagerView = filterChainForPlayer(chain, villager.id, villager.identity); + expect(villagerView.some(m => (m.meta as any)?.phase === 'wolf-night')).toBe(false); + expect(villagerView.some(m => (m.meta as any)?.phase === 'seer-check')).toBe(false); + expect(villagerView.some(m => (m.meta as any)?.phase === 'witch-action')).toBe(false); + expect(villagerView.some(m => (m.meta as any)?.phase === 'day-speech')).toBe(true); + expect(villagerView.some(m => (m.meta as any)?.phase === 'vote')).toBe(true); + }); + + it('猎人被投票出局触发开枪', () => { + const wf = createWerewolfWorkflow(); + // Vote eliminates hunter → hunterTriggered + expect(wf.moderator( + { role: 'vote', meta: { phase: 'vote', votes: {}, eliminatedId: 'p6', gameOver: false, hunterTriggered: true } as any }, + 'x', + )).toBe('hunter-shot'); + }); + + it('女巫用完药后不能再用', () => { + 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); + // After using potion, witchPotion should be false + expect(state.witchPotion).toBe(false); + expect(state.witchPoison).toBe(true); + + // Second night, add another wolf kill + chain.push({ + role: 'wolf-night', + content: '狼人杀 p8', + meta: { phase: 'wolf-night', targetId: 'p8' }, + timestamp: 3, + }); + + // Now create a mock witch role that respects potion state + // The mock witch checks state.witchPotion — it should be false, so no save + const state2 = parseGameState(chain); + expect(state2.witchPotion).toBe(false); + expect(state2.lastKill).toBe('p8'); + // If witchPotion is false, the mock witch cannot save + // (The mock uses state.witchPotion in the condition) + }); +}); diff --git a/packages/pulse/src/workflows/werewolf.ts b/packages/pulse/src/workflows/werewolf.ts new file mode 100644 index 0000000..b45b098 --- /dev/null +++ b/packages/pulse/src/workflows/werewolf.ts @@ -0,0 +1,693 @@ +/** + * Werewolf (狼人杀) workflow — 9-player game with information asymmetry. + * + * Pure roles + START/END automaton. Trigger: werewolf.__start__ + * + * Roles are per-phase (not per-player). Each phase role iterates over + * the relevant players internally. Game state is rebuilt from chain + * (event sourcing via parseGameState). + * + * Default: all mock roles (no LLM). Inject real implementations via + * CreateWerewolfWorkflowOpts. + * + * 小橘 🍊 (NEKO Team) + */ + +import { + END, + type ModeratorInput, + type Role, + START, + type WorkflowMessage, + type WorkflowType, +} from './workflow-type.js'; + +// ── 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; // 今晚被狼人杀的 id + lastDeath: DeadPlayer | null; // 最近一次死亡 +} + +// ── Player setup (9 players) ─────────────────────────────────── + +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 (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 = meta.targetId as string; + } + + if (meta.phase === 'witch-action') { + phase = 'witch-action'; + if (meta.saved === true) { + lastKill = null; + witchPotion = false; + } + if (meta.poisonTarget) { + witchPoison = false; + const pid = meta.poisonTarget as string; + 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') { + // Wolf kill resolves at dawn (after witch action) + 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 = meta.eliminatedId as string; + 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 = meta.shotTarget as string; + 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; // system messages without meta are public + const phase = meta.phase as string | undefined; + const visibleTo = meta.visibleTo as string[] | undefined; + + // Public phases + if (phase === 'day-speech' || phase === 'vote' || phase === 'death' || + phase === 'dawn' || phase === 'hunter-shot' || phase === 'game-end' || + phase === 'new-night') return true; + + // Wolf night: only wolves see + if (phase === 'wolf-night') return identity.team === 'wolf'; + + // Seer / witch: only visible to target player + if (phase === 'seer-check' || phase === 'witch-action') { + return visibleTo ? visibleTo.includes(playerId) : false; + } + + // System messages with explicit visibility + if (phase === 'system') { + return visibleTo ? visibleTo.includes(playerId) : true; + } + + return true; + }); +} + +// ── Role Meta types ──────────────────────────────────────────── + +export type WolfNightMeta = { + phase: 'wolf-night'; + targetId: string; + visibleTo?: undefined; // wolves see via team filter +}; + +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; +}; + +export type DaySpeechMeta = { + phase: 'day-speech'; + speeches: Array<{ playerId: string; speech: string }>; +}; + +export type VoteMeta = { + phase: 'vote'; + votes: Record; // voterId → targetId + eliminatedId: string | null; +}; + +export type HunterShotMeta = { + phase: 'hunter-shot'; + shotTarget: string; +}; + +export type GameEndMeta = { + phase: 'game-end'; + winner: Team; + summary: string; +}; + +// helper: dawn message injected by moderator indirectly via roles +type DawnMeta = { phase: 'dawn'; died: string[] }; +type NewNightMeta = { phase: 'new-night'; day: number }; + +// ── Roles record ─────────────────────────────────────────────── + +export type WerewolfRoles = { + 'wolf-night': Role; + 'seer-check': Role; + 'witch-action': Role; + 'day-speech': Role; + 'vote': Role; + 'hunter-shot': Role; + 'game-end': Role; +}; + +// ── Helpers ──────────────────────────────────────────────────── + +function pick(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +// ── Default mock implementations ──────────────────────────────── + +const defaultWolfNight: Role = async (chain) => { + const state = parseGameState(chain); + const goodAlive = state.alive.filter(p => p.identity.team === 'good'); + const target = pick(goodAlive); + return { + content: `[狼人夜晚] 狼人决定击杀 ${target.name}`, + meta: { phase: 'wolf-night', targetId: target.id }, + }; +}; + +const defaultSeerCheck: 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 = pick(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 defaultWitchAction: Role = async (chain) => { + const state = parseGameState(chain); + const witch = state.alive.find(p => p.identity.name === '女巫'); + if (!witch) { + // Witch dead — still emit dawn event with kill resolved + const died: string[] = []; + if (state.lastKill) died.push(state.lastKill); + return { + content: `[女巫已死,跳过] 天亮了${died.length ? ',有人死亡' : ''}`, + meta: { + phase: 'witch-action', + saved: false, + poisonTarget: null, + visibleTo: [], + witchPotion: state.witchPotion, + witchPoison: state.witchPoison, + }, + }; + } + + let saved = false; + let poisonTarget: string | null = null; + + // 50% chance to save if someone was killed and potion available + if (state.lastKill && state.witchPotion && Math.random() < 0.5) { + saved = true; + } + + return { + content: `[女巫行动] ${saved ? '使用了解药' : '未使用解药'}${poisonTarget ? `,毒杀了${poisonTarget}` : ''}`, + meta: { + phase: 'witch-action', + saved, + poisonTarget, + visibleTo: [witch.id], + witchPotion: saved ? false : state.witchPotion, + witchPoison: poisonTarget ? false : state.witchPoison, + }, + }; +}; + +const defaultDaySpeech: 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 defaultVote: 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); + votes[p.id] = pick(others).id; + } + // Tally + const tally: Record = {}; + for (const target of Object.values(votes)) { + tally[target] = (tally[target] || 0) + 1; + } + const maxVotes = Math.max(...Object.values(tally)); + const topIds = Object.entries(tally).filter(([, v]) => v === maxVotes).map(([k]) => k); + const eliminatedId = topIds.length === 1 ? topIds[0] : pick(topIds); + + const eliminated = state.alive.find(p => p.id === eliminatedId); + return { + content: `[投票结果] ${eliminated?.name ?? eliminatedId} 被放逐出局`, + meta: { phase: 'vote', votes, eliminatedId }, + }; +}; + +const defaultHunterShot: Role = async (chain) => { + const state = parseGameState(chain); + // Hunter shoots a random alive player + const target = pick(state.alive); + return { + content: `[猎人开枪] 猎人带走了 ${target.name}`, + meta: { phase: 'hunter-shot', shotTarget: target.id }, + }; +}; + +const defaultGameEnd: 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(', ')}`, + }, + }; +}; + +// ── Moderator ────────────────────────────────────────────────── + +type WerewolfInput = ModeratorInput; + +function werewolfModerator( + output: WerewolfInput, + _topicId: string, + remainingRounds?: number, +): keyof WerewolfRoles | typeof END { + const emergency = remainingRounds !== undefined && remainingRounds <= 1; + + if (output.role === START) return 'wolf-night'; + + // Build state from meta for win-condition checks + const meta = output.meta as Record | null; + + 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': { + // Check if game should end after vote + if (meta?.gameOver === true || emergency) return 'game-end'; + // Check if hunter was eliminated + if (meta?.hunterTriggered === true) return 'hunter-shot'; + return 'wolf-night'; + } + case 'hunter-shot': { + if (meta?.gameOver === true || emergency) return 'game-end'; + return 'wolf-night'; + } + case 'game-end': + return END; + default: + return END; + } +} + +// Wait — the moderator doesn't have access to game state from the chain. +// It only gets meta from the last role output. So roles need to include +// win-condition info in their meta. Let me revise the approach: +// The vote role and hunter-shot role should check win conditions and set +// meta flags. Similarly wolf-night + witch-action combined produce deaths +// that day-speech should account for. +// +// Actually, looking at the adapter code, moderator only gets last role's meta. +// So each role that might end the game needs to put gameOver + hunterTriggered in meta. + +// Let me redesign: make a "smart" moderator that rebuilds state from chain. +// But moderator only gets (output, topicId) — no chain access. +// The design doc shows moderator reading meta.gameState. +// Solution: roles embed necessary routing info in meta. + +// Revised approach: vote/hunter roles add gameOver and hunterTriggered flags. +// wolf-night already doesn't end game. witch-action doesn't either (dawn deaths +// are checked when entering day-speech... actually no, we need to check after +// night phases complete). +// +// Simpler: day-speech role checks win condition before speeches (night deaths resolved). +// If game over, it signals in meta. +// +// Actually let me look at this more carefully. The moderator transition is: +// wolf-night → seer-check → witch-action → day-speech → vote → wolf-night (loop) +// Win conditions are checked: after vote (good might have eliminated all wolves), +// and at the start of day (wolf kill at night might tip the balance). +// +// The cleanest approach: each role that resolves deaths includes gameOver flag. +// Moderator checks it to route to game-end. + +// Let me just rewrite moderator and tweak the roles to include routing metadata. + +// ── Revised Moderator (final) ────────────────────────────────── + +function werewolfModeratorFinal( + output: WerewolfInput, + _topicId: string, + remainingRounds?: number, +): keyof WerewolfRoles | typeof END { + const emergency = remainingRounds !== undefined && remainingRounds <= 1; + if (emergency) 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': + // After witch action, check if night deaths end the game + 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; + default: + return END; + } +} + +// ── Revised mock roles with routing metadata ─────────────────── + +// We need roles to compute game-over and hunter-triggered flags. +// The witch-action role resolves night deaths, so it checks win condition. +// The vote role resolves day deaths, so it checks win condition + hunter trigger. +// The hunter-shot role resolves hunter death, so it checks win condition. + +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; +} + +// Re-implement witch-action to resolve dawn deaths and check win condition +const mockWitchAction: Role = async (chain) => { + const state = parseGameState(chain); + const witch = state.alive.find(p => p.identity.name === '女巫'); + + let saved = false; + let poisonTarget: string | null = null; + const witchId = witch?.id ?? ''; + + if (witch) { + if (state.lastKill && state.witchPotion && Math.random() < 0.5) { + saved = true; + } + } + + // Simulate dawn: compute who actually dies + const nightDead: string[] = []; + if (state.lastKill && !saved) nightDead.push(state.lastKill); + if (poisonTarget) nightDead.push(poisonTarget); + + // Check win condition after night deaths + 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: `[女巫行动] ${saved ? '使用了解药' : '未使用解药'}`, + meta: { + phase: 'witch-action', + saved, + poisonTarget, + visibleTo: witchId ? [witchId] : [], + witchPotion: saved ? false : state.witchPotion, + witchPoison: poisonTarget ? false : state.witchPoison, + gameOver, + } as any, + }; +}; + +const mockVote: Role = async (chain) => { + // First resolve dawn deaths from last night + const statePreDawn = parseGameState(chain); + + // We need to account for dawn deaths. The chain should already have witch-action + // which has saved/not-saved info. parseGameState handles lastKill resolution + // at dawn phase, but we haven't emitted a dawn event. + // Actually, parseGameState only processes dawn phase if there's a message with phase:'dawn'. + // Since we don't emit that, we need to handle it differently. + + // Let's resolve: after witch-action, the wolf kill is still pending in lastKill + // unless saved. We need to consider it as death for voting purposes. + + // Compute effective alive list (after night) + const nightDead: string[] = []; + if (statePreDawn.lastKill) { + // Check if witch saved + const witchMsg = [...chain].reverse().find(m => + (m.meta as any)?.phase === 'witch-action'); + const witchSaved = (witchMsg?.meta as any)?.saved === true; + if (!witchSaved) nightDead.push(statePreDawn.lastKill); + } + + const deadSet = new Set([...statePreDawn.dead.map(d => d.id), ...nightDead]); + const effectiveAlive = statePreDawn.players.filter(p => !deadSet.has(p.id)); + + const votes: Record = {}; + for (const p of effectiveAlive) { + const others = effectiveAlive.filter(o => o.id !== p.id); + if (others.length > 0) votes[p.id] = pick(others).id; + } + + const tally: Record = {}; + for (const target of Object.values(votes)) { + tally[target] = (tally[target] || 0) + 1; + } + + let eliminatedId: string | null = null; + if (Object.keys(tally).length > 0) { + const maxVotes = Math.max(...Object.values(tally)); + const topIds = Object.entries(tally).filter(([, v]) => v === maxVotes).map(([k]) => k); + eliminatedId = topIds.length === 1 ? topIds[0] : pick(topIds); + } + + const aliveAfterVote = effectiveAlive.filter(p => p.id !== eliminatedId); + const gameOver = checkGameOver(aliveAfterVote); + + const eliminated = effectiveAlive.find(p => p.id === eliminatedId); + const hunterTriggered = eliminated?.identity.name === '猎人'; + + return { + content: `[投票结果] ${eliminated?.name ?? '无人'} 被放逐出局`, + meta: { + phase: 'vote', + votes, + eliminatedId, + hunterTriggered, + gameOver, + } as any, + }; +}; + +const mockHunterShot: Role = async (chain) => { + const state = parseGameState(chain); + // Hunter can't shoot themselves; pick from alive (hunter is already dead from vote) + const target = pick(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, + } as any, + }; +}; + +// ── Factory ──────────────────────────────────────────────────── + +export type CreateWerewolfWorkflowOpts = { + wolfNightFn?: Role; + seerCheckFn?: Role; + witchActionFn?: Role; + daySpeechFn?: Role; + voteFn?: Role; + hunterShotFn?: Role; + gameEndFn?: Role; +}; + +export function createWerewolfWorkflow( + opts?: CreateWerewolfWorkflowOpts, +): WorkflowType { + return { + name: 'werewolf', + roles: { + 'wolf-night': opts?.wolfNightFn ?? defaultWolfNight, + 'seer-check': opts?.seerCheckFn ?? defaultSeerCheck, + 'witch-action': opts?.witchActionFn ?? (mockWitchAction as any), + 'day-speech': opts?.daySpeechFn ?? defaultDaySpeech, + 'vote': opts?.voteFn ?? (mockVote as any), + 'hunter-shot': opts?.hunterShotFn ?? (mockHunterShot as any), + 'game-end': opts?.gameEndFn ?? defaultGameEnd, + }, + moderator: werewolfModeratorFinal, + limits: { maxRounds: 50 }, + }; +} + +export { checkGameOver };