Compare commits
No commits in common. "4974e90c4f6494ed6f369b4997e264ff308506f1" and "a2b6731aa5b91720effd3c3b52e0a44c3e4c9e54" have entirely different histories.
4974e90c4f
...
a2b6731aa5
@ -1,215 +0,0 @@
|
|||||||
# 狼人杀 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)
|
|
||||||
@ -28,8 +28,8 @@ describe('ping-pong workflow', () => {
|
|||||||
const rule = createWorkflowRule(pingPong, store);
|
const rule = createWorkflowRule(pingPong, store);
|
||||||
|
|
||||||
// Trigger
|
// Trigger
|
||||||
const hash = await store.putObject('ping');
|
const hash = store.putObject('ping');
|
||||||
await store.appendEvent({
|
store.appendEvent({
|
||||||
occurredAt: Date.now(),
|
occurredAt: Date.now(),
|
||||||
kind: 'ping-pong.__start__',
|
kind: 'ping-pong.__start__',
|
||||||
key: 't1',
|
key: 't1',
|
||||||
@ -40,14 +40,9 @@ 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).toMatchObject([{ topicId: 't1', role: '__end__' }]);
|
expect(r2.executed).toEqual([]);
|
||||||
|
|
||||||
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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,12 +4,10 @@
|
|||||||
* 小橘 🍊 (NEKO Team)
|
* 小橘 🍊 (NEKO Team)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { END, START, type Role, type WorkflowType } from '@uncaged/pulse';
|
import { END, START, type Role, type WorkflowType } from '@uncaged/pulse/src/workflows/workflow-type.js';
|
||||||
|
|
||||||
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> = {
|
||||||
@ -22,14 +20,9 @@ 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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,370 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,565 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user