diff --git a/src/workflows/werewolf.test.ts b/src/workflows/werewolf.test.ts deleted file mode 100644 index 95fd1fb..0000000 --- a/src/workflows/werewolf.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -/** - * 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 deleted file mode 100644 index 378775c..0000000 --- a/src/workflows/werewolf.ts +++ /dev/null @@ -1,578 +0,0 @@ -/** - * 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 }, - }; -}