从零实现狼人杀 workflow

This commit is contained in:
小橘 2026-04-18 08:14:04 +00:00
parent c4c5ce7246
commit 4974e90c4f
4 changed files with 949 additions and 2 deletions

View File

@ -40,9 +40,14 @@ describe('ping-pong workflow', () => {
expect(r1.executed).toMatchObject([{ topicId: 't1', role: 'pong' }]); expect(r1.executed).toMatchObject([{ topicId: 't1', role: 'pong' }]);
expect(r1.executed[0].content).toBe('pong: ping'); expect(r1.executed[0].content).toBe('pong: ping');
// No more work
const r2 = await rule.tick(); const r2 = await rule.tick();
expect(r2.executed).toEqual([]); expect(r2.executed).toMatchObject([{ topicId: 't1', role: '__end__' }]);
const r3 = await rule.tick();
expect(r3.executed).toEqual([]);
const events = await store.getAfter(0);
expect(events.some((e) => e.kind === 'ping-pong.__end__')).toBe(true);
store.close(); store.close();
}); });

View File

@ -8,6 +8,8 @@ import { END, START, type Role, type WorkflowType } from '@uncaged/pulse';
type PingPongRoles = { type PingPongRoles = {
pong: Role<{ echo: true }>; pong: Role<{ echo: true }>;
/** Emits `ping-pong.__end__` for e2e meta-tester (adapter never writes END). */
__end__: Role<{ done: true }>;
}; };
export const pingPong: WorkflowType<PingPongRoles> = { export const pingPong: WorkflowType<PingPongRoles> = {
@ -20,9 +22,14 @@ export const pingPong: WorkflowType<PingPongRoles> = {
meta: { echo: true as const }, meta: { echo: true as const },
}; };
}, },
__end__: async () => ({
content: 'ok',
meta: { done: true as const },
}),
}, },
moderator: (output) => { moderator: (output) => {
if (output.role === START) return 'pong'; if (output.role === START) return 'pong';
if (output.role === 'pong') return '__end__';
return END; return END;
}, },
}; };

View File

@ -0,0 +1,370 @@
/**
* Werewolf workflow tests mock + +
*
* 🍊 (NEKO Team)
*/
import { describe, expect, it } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
createStore,
createWorkflowRule,
END,
START,
type PulseStore,
type WorkflowMessage,
} from '@uncaged/pulse';
import {
checkGameOver,
createPlayers,
createWerewolfWorkflow,
filterChainForPlayer,
parseGameState,
} from './werewolf.js';
describe('werewolf WorkflowType', () => {
let store: PulseStore;
let tmpDir: string;
function setup() {
tmpDir = mkdtempSync(join(tmpdir(), 'werewolf-engine-'));
store = createStore({
eventsDbPath: join(tmpDir, 'test.db'),
objectsDir: join(tmpDir, 'objects'),
});
}
function cleanup() {
try {
store?.close();
} catch {
/* ignore */
}
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
}
async function trigger(topicId: string) {
const hash = await store.putObject('werewolf game start');
await store.appendEvent({
occurredAt: Date.now(),
kind: 'werewolf.__start__',
key: topicId,
hash,
meta: JSON.stringify({}),
});
}
it('mock 模式能跑完整局', async () => {
setup();
try {
const wf = createWerewolfWorkflow();
const rule = createWorkflowRule(wf, store);
await trigger('g1');
const order: string[] = [];
for (let i = 0; i < 200; i++) {
const r = await rule.tick();
if (r.executed.length === 0) break;
order.push(...r.executed.map((x) => x.role));
}
expect(order[0]).toBe('wolf-night');
expect(order[order.length - 1]).toBe('__end__');
expect(order[order.length - 2]).toBe('game-end');
expect(order.length).toBeGreaterThanOrEqual(7);
const events = await store.getAfter(0);
expect(events.some((e) => e.kind === 'werewolf.__end__')).toBe(true);
} finally {
cleanup();
}
});
it('阶段正确轮转', () => {
const wf = createWerewolfWorkflow();
expect(wf.moderator({ role: START, meta: null }, 'x')).toBe('wolf-night');
expect(
wf.moderator(
{ role: 'wolf-night', meta: { phase: 'wolf-night', targetId: 'p7' } },
'x',
),
).toBe('seer-check');
expect(
wf.moderator(
{
role: 'seer-check',
meta: {
phase: 'seer-check',
targetId: 'p1',
isWolf: true,
visibleTo: ['p4'],
},
},
'x',
),
).toBe('witch-action');
expect(
wf.moderator(
{
role: 'witch-action',
meta: {
phase: 'witch-action',
saved: false,
poisonTarget: null,
visibleTo: ['p5'],
witchPotion: true,
witchPoison: true,
gameOver: false,
},
},
'x',
),
).toBe('day-speech');
expect(
wf.moderator(
{ role: 'day-speech', meta: { phase: 'day-speech', speeches: [] } },
'x',
),
).toBe('vote');
expect(
wf.moderator(
{
role: 'vote',
meta: {
phase: 'vote',
votes: {},
eliminatedId: 'p7',
gameOver: false,
hunterTriggered: false,
},
},
'x',
),
).toBe('wolf-night');
});
it('狼人全死 → 好人胜', () => {
const players = createPlayers();
const wolves = players.filter((p) => p.identity.team === 'wolf');
expect(wolves.length).toBe(3);
const aliveWithoutWolves = players.filter((p) => p.identity.team === 'good');
expect(checkGameOver(aliveWithoutWolves)).toBe(true);
const wf = createWerewolfWorkflow();
expect(
wf.moderator(
{
role: 'vote',
meta: {
phase: 'vote',
votes: {},
eliminatedId: 'p3',
gameOver: true,
},
},
'x',
),
).toBe('game-end');
expect(
wf.moderator(
{
role: 'game-end',
meta: { phase: 'game-end', winner: 'good', summary: '' },
},
'x',
),
).toBe('__end__');
expect(
wf.moderator(
{ role: '__end__', meta: { phase: '__end__' } },
'x',
),
).toBe(END);
});
it('好人数 <= 狼人数 → 狼人胜', () => {
const players = createPlayers();
const alive = [players[0], players[1], players[2], players[6]];
expect(checkGameOver(alive)).toBe(true);
const alive2 = [
players[0],
players[1],
players[2],
players[6],
players[7],
];
expect(checkGameOver(alive2)).toBe(true);
const alive3 = [
players[0],
players[1],
players[2],
players[3],
players[4],
players[5],
players[6],
];
expect(checkGameOver(alive3)).toBe(false);
});
it('信息可见性正确', () => {
const players = createPlayers();
const wolf = players[0];
const seer = players[3];
const villager = players[6];
const chain: WorkflowMessage[] = [
{
role: 'wolf-night',
content: '狼人杀人',
meta: { phase: 'wolf-night', targetId: 'p7' },
timestamp: 1,
},
{
role: 'seer-check',
content: '预言家验人',
meta: {
phase: 'seer-check',
targetId: 'p1',
isWolf: true,
visibleTo: ['p4'],
},
timestamp: 2,
},
{
role: 'witch-action',
content: '女巫行动',
meta: {
phase: 'witch-action',
saved: false,
poisonTarget: null,
visibleTo: ['p5'],
},
timestamp: 3,
},
{
role: 'day-speech',
content: '白天发言',
meta: { phase: 'day-speech', speeches: [] },
timestamp: 4,
},
{
role: 'vote',
content: '投票',
meta: { phase: 'vote', votes: {}, eliminatedId: 'p8' },
timestamp: 5,
},
];
const wolfView = filterChainForPlayer(chain, wolf.id, wolf.identity);
expect(wolfView.some((m) => (m.meta as { phase?: string })?.phase === 'wolf-night')).toBe(
true,
);
expect(wolfView.some((m) => (m.meta as { phase?: string })?.phase === 'seer-check')).toBe(
false,
);
expect(wolfView.some((m) => (m.meta as { phase?: string })?.phase === 'witch-action')).toBe(
false,
);
expect(wolfView.some((m) => (m.meta as { phase?: string })?.phase === 'day-speech')).toBe(
true,
);
expect(wolfView.some((m) => (m.meta as { phase?: string })?.phase === 'vote')).toBe(true);
const seerView = filterChainForPlayer(chain, seer.id, seer.identity);
expect(seerView.some((m) => (m.meta as { phase?: string })?.phase === 'seer-check')).toBe(
true,
);
expect(seerView.some((m) => (m.meta as { phase?: string })?.phase === 'wolf-night')).toBe(
false,
);
expect(seerView.some((m) => (m.meta as { phase?: string })?.phase === 'witch-action')).toBe(
false,
);
const villagerView = filterChainForPlayer(chain, villager.id, villager.identity);
expect(
villagerView.some((m) => (m.meta as { phase?: string })?.phase === 'wolf-night'),
).toBe(false);
expect(
villagerView.some((m) => (m.meta as { phase?: string })?.phase === 'seer-check'),
).toBe(false);
expect(
villagerView.some((m) => (m.meta as { phase?: string })?.phase === 'witch-action'),
).toBe(false);
expect(
villagerView.some((m) => (m.meta as { phase?: string })?.phase === 'day-speech'),
).toBe(true);
expect(villagerView.some((m) => (m.meta as { phase?: string })?.phase === 'vote')).toBe(true);
});
it('猎人被投票出局触发开枪', () => {
const wf = createWerewolfWorkflow();
expect(
wf.moderator(
{
role: 'vote',
meta: {
phase: 'vote',
votes: {},
eliminatedId: 'p6',
gameOver: false,
hunterTriggered: true,
},
},
'x',
),
).toBe('hunter-shot');
});
it('女巫用完解药后状态持久化到 chain', () => {
const chain: WorkflowMessage[] = [
{
role: 'wolf-night',
content: '狼人杀 p7',
meta: { phase: 'wolf-night', targetId: 'p7' },
timestamp: 1,
},
{
role: 'witch-action',
content: '女巫救人',
meta: {
phase: 'witch-action',
saved: true,
poisonTarget: null,
visibleTo: ['p5'],
witchPotion: false,
witchPoison: true,
},
timestamp: 2,
},
];
const state = parseGameState(chain);
expect(state.witchPotion).toBe(false);
expect(state.witchPoison).toBe(true);
chain.push({
role: 'wolf-night',
content: '狼人杀 p8',
meta: { phase: 'wolf-night', targetId: 'p8' },
timestamp: 3,
});
const state2 = parseGameState(chain);
expect(state2.witchPotion).toBe(false);
expect(state2.lastKill).toBe('p8');
});
});

565
src/workflows/werewolf.ts Normal file
View File

@ -0,0 +1,565 @@
/**
* Werewolf () workflow 9-player game with information asymmetry.
*
* Pure roles + START/END automaton. Trigger: werewolf.__start__
*
* Role chain parseGameState
* mock createWerewolfWorkflow(opts) Role
*
* 🍊 (NEKO Team)
*/
import {
END,
type ModeratorInput,
type Role,
START,
type WorkflowMessage,
type WorkflowType,
} from '@uncaged/pulse';
// ── Types ──────────────────────────────────────────────────────
export type Team = 'wolf' | 'good';
export interface Identity {
name: string;
team: Team;
abilities?: string;
}
export interface Player {
id: string;
name: string;
identity: Identity;
personality: string;
}
export interface DeadPlayer extends Player {
cause: 'wolf-kill' | 'vote' | 'poison' | 'hunter-shot';
day: number;
}
export interface GameState {
players: Player[];
alive: Player[];
dead: DeadPlayer[];
day: number;
phase: string;
witchPotion: boolean;
witchPoison: boolean;
lastKill: string | null;
lastDeath: DeadPlayer | null;
}
const PERSONALITIES = [
'谨慎',
'激进',
'善于伪装',
'逻辑型',
'冷静',
'情绪化',
'理性',
'直觉型',
'温和',
] as const;
const IDENTITIES: Identity[] = [
{ name: '狼人', team: 'wolf' },
{ name: '狼人', team: 'wolf' },
{ name: '狼人', team: 'wolf' },
{ name: '预言家', team: 'good', abilities: '每晚可查验一名玩家身份' },
{ name: '女巫', team: 'good', abilities: '有一瓶解药和一瓶毒药' },
{ name: '猎人', team: 'good', abilities: '死亡时可开枪带走一人' },
{ name: '村民', team: 'good' },
{ name: '村民', team: 'good' },
{ name: '村民', team: 'good' },
];
export function createPlayers(): Player[] {
return IDENTITIES.map((id, i) => ({
id: `p${i + 1}`,
name: `玩家${i + 1}`,
identity: id,
personality: PERSONALITIES[i % PERSONALITIES.length],
}));
}
function byPlayerId(a: Player, b: Player): number {
return a.id.localeCompare(b.id);
}
// ── Game state from chain (event sourcing) ─────────────────────
export function parseGameState(chain: WorkflowMessage[]): GameState {
const players = createPlayers();
const dead: DeadPlayer[] = [];
let day = 1;
let witchPotion = true;
let witchPoison = true;
let lastKill: string | null = null;
let lastDeath: DeadPlayer | null = null;
let phase = '';
for (const msg of chain) {
const meta = msg.meta as Record<string, unknown> | null;
if (!meta) continue;
if (meta.phase === 'wolf-night') {
phase = 'wolf-night';
if (meta.targetId) lastKill = String(meta.targetId);
}
if (meta.phase === 'witch-action') {
phase = 'witch-action';
if (meta.saved === true) {
lastKill = null;
witchPotion = false;
} else if (lastKill) {
const p = players.find((pp) => pp.id === lastKill);
if (p && !dead.some((d) => d.id === lastKill)) {
const dp: DeadPlayer = { ...p, cause: 'wolf-kill', day };
dead.push(dp);
lastDeath = dp;
}
lastKill = null;
}
if (meta.poisonTarget) {
witchPoison = false;
const pid = String(meta.poisonTarget);
const p = players.find((pp) => pp.id === pid);
if (p && !dead.some((d) => d.id === pid)) {
const dp: DeadPlayer = { ...p, cause: 'poison', day };
dead.push(dp);
lastDeath = dp;
}
}
}
if (meta.phase === 'dawn') {
if (lastKill) {
const p = players.find((pp) => pp.id === lastKill);
if (p && !dead.some((d) => d.id === lastKill)) {
const dp: DeadPlayer = { ...p, cause: 'wolf-kill', day };
dead.push(dp);
lastDeath = dp;
}
}
lastKill = null;
}
if (meta.phase === 'day-speech') phase = 'day-speech';
if (meta.phase === 'vote') {
phase = 'vote';
if (meta.eliminatedId) {
const pid = String(meta.eliminatedId);
const p = players.find((pp) => pp.id === pid);
if (p && !dead.some((d) => d.id === pid)) {
const dp: DeadPlayer = { ...p, cause: 'vote', day };
dead.push(dp);
lastDeath = dp;
}
}
}
if (meta.phase === 'hunter-shot') {
phase = 'hunter-shot';
if (meta.shotTarget) {
const pid = String(meta.shotTarget);
const p = players.find((pp) => pp.id === pid);
if (p && !dead.some((d) => d.id === pid)) {
const dp: DeadPlayer = { ...p, cause: 'hunter-shot', day };
dead.push(dp);
lastDeath = dp;
}
}
}
if (meta.phase === 'new-night') {
day++;
lastKill = null;
}
if (meta.witchPotion === false) witchPotion = false;
if (meta.witchPoison === false) witchPoison = false;
}
const deadIds = new Set(dead.map((d) => d.id));
const alive = players.filter((p) => !deadIds.has(p.id));
return {
players,
alive,
dead,
day,
phase,
witchPotion,
witchPoison,
lastKill,
lastDeath,
};
}
// ── Information visibility filter ──────────────────────────────
export function filterChainForPlayer(
chain: WorkflowMessage[],
playerId: string,
identity: Identity,
): WorkflowMessage[] {
return chain.filter((msg) => {
const meta = msg.meta as Record<string, unknown> | null;
if (!meta) return true;
const ph = meta.phase as string | undefined;
const visibleTo = meta.visibleTo as string[] | undefined;
if (
ph === 'day-speech' ||
ph === 'vote' ||
ph === 'death' ||
ph === 'dawn' ||
ph === 'hunter-shot' ||
ph === 'game-end' ||
ph === '__end__' ||
ph === 'new-night'
) {
return true;
}
if (ph === 'wolf-night') return identity.team === 'wolf';
if (ph === 'seer-check' || ph === 'witch-action') {
return visibleTo ? visibleTo.includes(playerId) : false;
}
if (ph === 'system') {
return visibleTo ? visibleTo.includes(playerId) : true;
}
return true;
});
}
export function buildPlayerPrompt(player: Player): string {
return `你是玩家 ${player.name},身份是【${player.identity.name}】。
${player.personality}
${player.identity.team === 'wolf' ? '狼人阵营' : '好人阵营'}
${player.identity.team === 'wolf' ? '淘汰所有神民与村民(好人)' : '找出并淘汰所有狼人'}
${player.identity.abilities ? `特殊能力:${player.identity.abilities}` : ''}
-
-
- `;
}
// ── Role Meta types ─────────────────────────────────────────────
export type WolfNightMeta = {
phase: 'wolf-night';
targetId: string;
};
export type SeerCheckMeta = {
phase: 'seer-check';
targetId: string;
isWolf: boolean;
visibleTo: string[];
};
export type WitchActionMeta = {
phase: 'witch-action';
saved: boolean;
poisonTarget: string | null;
visibleTo: string[];
witchPotion: boolean;
witchPoison: boolean;
gameOver?: boolean;
};
export type DaySpeechMeta = {
phase: 'day-speech';
speeches: Array<{ playerId: string; speech: string }>;
};
export type VoteMeta = {
phase: 'vote';
votes: Record<string, string>;
eliminatedId: string | null;
hunterTriggered?: boolean;
gameOver?: boolean;
};
export type HunterShotMeta = {
phase: 'hunter-shot';
shotTarget: string;
gameOver?: boolean;
};
export type GameEndMeta = {
phase: 'game-end';
winner: Team;
summary: string;
};
/** Terminal marker — adapter writes `{name}.__end__` for meta-tester e2e. */
export type WerewolfEndMeta = {
phase: '__end__';
};
export type WerewolfRoles = {
'wolf-night': Role<WolfNightMeta>;
'seer-check': Role<SeerCheckMeta>;
'witch-action': Role<WitchActionMeta>;
'day-speech': Role<DaySpeechMeta>;
vote: Role<VoteMeta>;
'hunter-shot': Role<HunterShotMeta>;
'game-end': Role<GameEndMeta>;
__end__: Role<WerewolfEndMeta>;
};
// ── Win condition ──────────────────────────────────────────────
export function checkGameOver(alive: Player[]): boolean {
const wolves = alive.filter((p) => p.identity.team === 'wolf');
if (wolves.length === 0) return true;
if (wolves.length >= alive.length - wolves.length) return true;
return false;
}
type WerewolfInput = ModeratorInput<WerewolfRoles>;
function werewolfModerator(
output: WerewolfInput,
_topicId: string,
): keyof WerewolfRoles | typeof END {
if (output.role === START) return 'wolf-night';
const meta = output.meta as Record<string, unknown> | null;
const gameOver = meta?.gameOver === true;
switch (output.role) {
case 'wolf-night':
return 'seer-check';
case 'seer-check':
return 'witch-action';
case 'witch-action':
if (gameOver) return 'game-end';
return 'day-speech';
case 'day-speech':
return 'vote';
case 'vote': {
if (gameOver) return 'game-end';
if (meta?.hunterTriggered === true) return 'hunter-shot';
return 'wolf-night';
}
case 'hunter-shot': {
if (gameOver) return 'game-end';
return 'wolf-night';
}
case 'game-end':
return '__end__';
case '__end__':
return END;
default:
return END;
}
}
// ── Default mock roles (deterministic, no LLM) ─────────────────
const mockWolfNight: Role<WolfNightMeta> = async (chain) => {
const state = parseGameState(chain);
const goods = state.alive
.filter((p) => p.identity.team === 'good')
.sort(byPlayerId);
const target = goods[0]!;
return {
content: `[狼人夜晚] 狼人决定击杀 ${target.name} (${target.id})`,
meta: { phase: 'wolf-night', targetId: target.id },
};
};
const mockSeerCheck: Role<SeerCheckMeta> = async (chain) => {
const state = parseGameState(chain);
const seer = state.alive.find((p) => p.identity.name === '预言家');
if (!seer) {
return {
content: '[预言家已死,跳过验人]',
meta: {
phase: 'seer-check',
targetId: '',
isWolf: false,
visibleTo: [],
},
};
}
const others = state.alive
.filter((p) => p.id !== seer.id)
.sort(byPlayerId);
const target = others[0]!;
return {
content: `[预言家查验] ${target.name}${target.identity.team === 'wolf' ? '狼人' : '好人'}`,
meta: {
phase: 'seer-check',
targetId: target.id,
isWolf: target.identity.team === 'wolf',
visibleTo: [seer.id],
},
};
};
const mockWitchAction: Role<WitchActionMeta> = async (chain) => {
const state = parseGameState(chain);
const witch = state.alive.find((p) => p.identity.name === '女巫');
const saved = false;
/** Mock:每夜毒最小编号存活狼人,加速终局(便于 e2e / meta-tester 在有限 tick 内结束)。 */
const wolves = state.alive
.filter((p) => p.identity.team === 'wolf')
.sort(byPlayerId);
const poisonTarget =
witch && state.witchPoison && wolves.length > 0 ? wolves[0]!.id : null;
const witchId = witch?.id ?? '';
const nightDead: string[] = [];
if (state.lastKill && !saved) nightDead.push(state.lastKill);
if (poisonTarget) nightDead.push(poisonTarget);
const deadIds = new Set([...state.dead.map((d) => d.id), ...nightDead]);
const aliveAfter = state.players.filter((p) => !deadIds.has(p.id));
const gameOver = checkGameOver(aliveAfter);
return {
content: `[女巫行动] 未使用解药;${poisonTarget ? `毒杀 ${poisonTarget}` : '未用毒药'}`,
meta: {
phase: 'witch-action',
saved,
poisonTarget,
visibleTo: witchId ? [witchId] : [],
witchPotion: saved ? false : state.witchPotion,
witchPoison: poisonTarget ? false : state.witchPoison,
gameOver,
},
};
};
const mockDaySpeech: Role<DaySpeechMeta> = async (chain) => {
const state = parseGameState(chain);
const speeches = state.alive.map((p) => {
const visible = filterChainForPlayer(chain, p.id, p.identity);
return {
playerId: p.id,
speech: `(可见消息 ${visible.length} 条)${buildPlayerPrompt(p).split('\n')[0]} — 我认为需要结合投票阶段再判断。`,
};
});
return {
content: speeches.map((s) => `${s.playerId}${s.speech}`).join('\n'),
meta: { phase: 'day-speech', speeches },
};
};
const mockVote: Role<VoteMeta> = 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<string, string> = {};
if (eliminatedId) {
for (const p of alive) votes[p.id] = eliminatedId;
}
const aliveAfter = eliminatedId
? alive.filter((p) => p.id !== eliminatedId)
: alive;
const gameOver = checkGameOver(aliveAfter);
const hunterTriggered = eliminated?.identity.name === '猎人';
return {
content: `[投票结果] ${eliminated?.name ?? '无人'} 被放逐`,
meta: {
phase: 'vote',
votes,
eliminatedId,
hunterTriggered,
gameOver,
},
};
};
const mockHunterShot: Role<HunterShotMeta> = async (chain) => {
const state = parseGameState(chain);
const wolves = state.alive
.filter((p) => p.identity.team === 'wolf')
.sort(byPlayerId);
const target = wolves[0] ?? state.alive.sort(byPlayerId)[0]!;
const aliveAfter = state.alive.filter((p) => p.id !== target.id);
const gameOver = checkGameOver(aliveAfter);
return {
content: `[猎人开枪] 带走了 ${target.name}`,
meta: {
phase: 'hunter-shot',
shotTarget: target.id,
gameOver,
},
};
};
const mockGameEnd: Role<GameEndMeta> = async (chain) => {
const state = parseGameState(chain);
const wolves = state.alive.filter((p) => p.identity.team === 'wolf');
const winner: Team = wolves.length === 0 ? 'good' : 'wolf';
return {
content: `[游戏结束] ${winner === 'good' ? '好人阵营' : '狼人阵营'}获胜`,
meta: {
phase: 'game-end',
winner,
summary: `存活: ${state.alive.map((p) => p.name).join(', ')};死亡: ${state.dead.map((d) => `${d.name}(${d.cause})`).join(', ')}`,
},
};
};
const mockWorkflowEnd: Role<WerewolfEndMeta> = async () => ({
content: '[workflow finished]',
meta: { phase: '__end__' as const },
});
// ── Factory ────────────────────────────────────────────────────
export type CreateWerewolfWorkflowOpts = {
wolfNightFn?: Role<WolfNightMeta>;
seerCheckFn?: Role<SeerCheckMeta>;
witchActionFn?: Role<WitchActionMeta>;
daySpeechFn?: Role<DaySpeechMeta>;
voteFn?: Role<VoteMeta>;
hunterShotFn?: Role<HunterShotMeta>;
gameEndFn?: Role<GameEndMeta>;
workflowEndFn?: Role<WerewolfEndMeta>;
};
export function createWerewolfWorkflow(
opts?: CreateWerewolfWorkflowOpts,
): WorkflowType<WerewolfRoles> {
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 },
};
}