/** * Werewolf (狼人杀) workflow v3 — 9 人局 + 信息可见性过滤。 * * 配置:3 狼、1 预言家、1 女巫、1 猎人、3 平民。 * 阶段 Role:狼人夜 → 预言家验 → 女巫药 → 白天发言 → 投票(→ 猎人开枪)→ 循环直至终局。 * * Trigger: `werewolf.__start__` * * 小橘 (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; /** 固定 9 人:p1–p3 狼,p4 预言家,p5 女巫,p6 猎人,p7–p9 村民 */ 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 ─────────────────────────────────────── 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') { if (visibleTo && visibleTo.length > 0) return visibleTo.includes(playerId); return identity.team === 'wolf'; } if (ph === 'seer-check' || ph === 'witch-action') { return visibleTo ? visibleTo.includes(playerId) : false; } 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; /** 所有存活狼人 id — 仅狼队可见本场刀法讨论与目标 */ visibleTo: 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; }; 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; const good = alive.length - wolves.length; if (good <= 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) ───────────────────────── function aliveWolfIds(state: GameState): string[] { return state.alive .filter((p) => p.identity.team === 'wolf') .sort(byPlayerId) .map((p) => p.id); } export const mockWolfNight: Role = async (chain) => { const state = parseGameState(chain); const goods = state.alive.filter((p) => p.identity.team === 'good').sort(byPlayerId); const target = goods[0]!; return { content: `[狼人夜晚] 狼人决定击杀 ${target.name} (${target.id})`, meta: { phase: 'wolf-night', targetId: target.id, visibleTo: aliveWolfIds(state), }, }; }; export 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], }, }; }; export const mockWitchAction: Role = async (chain) => { const state = parseGameState(chain); const witch = state.alive.find((p) => p.identity.name === '女巫'); const saved = false; 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, }, }; }; export 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 }, }; }; export const mockVote: Role = async (chain) => { const state = parseGameState(chain); const alive = state.alive; const wolves = alive.filter((p) => p.identity.team === 'wolf').sort(byPlayerId); const eliminated = wolves.length > 0 ? wolves[0] : undefined; const 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, }, }; }; export 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, }, }; }; export const mockGameEnd: Role = async (chain) => { const state = parseGameState(chain); const wolves = state.alive.filter((p) => p.identity.team === 'wolf'); const winner: Team = wolves.length === 0 ? 'good' : 'wolf'; 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(', ')}`, }, }; }; export 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; }; /** 默认 mock 实现,可供测试包装为「LLM」调用 */ export const werewolfDefaultMocks: CreateWerewolfWorkflowOpts = { wolfNightFn: mockWolfNight, seerCheckFn: mockSeerCheck, witchActionFn: mockWitchAction, daySpeechFn: mockDaySpeech, voteFn: mockVote, hunterShotFn: mockHunterShot, gameEndFn: mockGameEnd, workflowEndFn: mockWorkflowEnd, }; export function createWerewolfWorkflow( opts?: CreateWerewolfWorkflowOpts, ): WorkflowType { 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 }, }; }