refactor: simplify meta workflow — coder → tester → promoter

This commit is contained in:
小橘 2026-04-18 07:22:27 +00:00
parent a2b6731aa5
commit e8771f3e2e
5 changed files with 1150 additions and 3 deletions

215
docs/werewolf-design.md Normal file
View File

@ -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<WolfNightMeta>; // 狼人讨论+投票杀谁
'seer-check': Role<SeerCheckMeta>; // 预言家验人
'witch-action': Role<WitchActionMeta>; // 女巫用药
// 白天阶段
'day-speech': Role<DaySpeechMeta>; // 所有存活玩家依次发言
'vote': Role<VoteMeta>; // 投票放逐
// 特殊
'hunter-shot': Role<HunterShotMeta>; // 猎人开枪(死亡触发)
// 结算
'game-end': Role<GameEndMeta>; // 生成游戏总结
};
```
### 阶段内多玩家执行
每个阶段 Role 内部对多个玩家分别调 LLM:
```typescript
const daySpeechRole: Role<DaySpeechMeta> = 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<WerewolfRoles>,
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)

View File

@ -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',

View File

@ -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 }>;

View File

@ -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<PulseStore> {
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<string, unknown> | 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<WitchActionMeta> = 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<typeof parseGameState>,
witchSaved: boolean,
poisonId: string | null,
knifedId: string | null,
) {
const deadIds = new Set(state.dead.map((d) => d.id));
const pending = new Set<string>();
if (poisonId) pending.add(poisonId);
if (knifedId && !witchSaved) pending.add(knifedId);
return state.players.filter((p) => !deadIds.has(p.id) && !pending.has(p.id));
}

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

@ -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<string, unknown> | 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<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 === '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<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;
};
export type WerewolfRoles = {
'wolf-night': Role<WolfNightMeta>;
'seer-check': Role<SeerCheckMeta>;
'witch-action': Role<WitchActionMeta>;
'dawn': Role<DawnMeta>;
'day-speech': Role<DaySpeechMeta>;
vote: Role<VoteMeta>;
'hunter-shot': Role<HunterShotMeta>;
'game-end': Role<GameEndMeta>;
};
// ── Helpers ───────────────────────────────────────────────────
function firstById<T extends { id: string }>(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<string>();
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<WolfNightMeta> = 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<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);
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<WitchActionMeta> = 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<DawnMeta> = 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<string, unknown> | 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<DaySpeechMeta> = 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<VoteMeta> = async (chain) => {
const state = parseGameState(chain);
const votes: Record<string, string> = {};
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<string, number> = {};
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<HunterShotMeta> = 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<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(', ')}`,
},
};
};
// ── Moderator ─────────────────────────────────────────────────
type WerewolfModIn = ModeratorInput<WerewolfRoles>;
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<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 '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<WolfNightMeta>;
seerCheckFn?: Role<SeerCheckMeta>;
witchActionFn?: Role<WitchActionMeta>;
dawnFn?: Role<DawnMeta>;
daySpeechFn?: Role<DaySpeechMeta>;
voteFn?: Role<VoteMeta>;
hunterShotFn?: Role<HunterShotMeta>;
gameEndFn?: Role<GameEndMeta>;
};
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,
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 },
};
}