在 ~/.upulse/engine/ 中重写狼人杀 workflow(werewolf.ts + werewolf.t
This commit is contained in:
parent
4974e90c4f
commit
41b831a2a0
@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Werewolf workflow tests — mock 跑通全流程 + 可见性 + 胜负与阶段。
|
* Werewolf workflow v3 测试:初始化、可见性、阶段、终局、注入 mock LLM。
|
||||||
*
|
*
|
||||||
* 小橘 🍊 (NEKO Team)
|
* 小橘 (NEKO Team)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it } from 'bun:test';
|
import { describe, expect, it } from 'bun:test';
|
||||||
@ -22,74 +22,147 @@ import {
|
|||||||
createWerewolfWorkflow,
|
createWerewolfWorkflow,
|
||||||
filterChainForPlayer,
|
filterChainForPlayer,
|
||||||
parseGameState,
|
parseGameState,
|
||||||
|
werewolfDefaultMocks,
|
||||||
} from './werewolf.js';
|
} from './werewolf.js';
|
||||||
|
|
||||||
describe('werewolf WorkflowType', () => {
|
describe('游戏初始化', () => {
|
||||||
let store: PulseStore;
|
it('9 人局身份配置:3 狼、预言家、女巫、猎人、3 村民', () => {
|
||||||
let tmpDir: string;
|
const players = createPlayers();
|
||||||
|
expect(players).toHaveLength(9);
|
||||||
|
|
||||||
function setup() {
|
const roles = players.map((p) => p.identity.name);
|
||||||
tmpDir = mkdtempSync(join(tmpdir(), 'werewolf-engine-'));
|
expect(roles.filter((r) => r === '狼人')).toHaveLength(3);
|
||||||
store = createStore({
|
expect(roles.filter((r) => r === '村民')).toHaveLength(3);
|
||||||
eventsDbPath: join(tmpDir, 'test.db'),
|
expect(roles).toContain('预言家');
|
||||||
objectsDir: join(tmpDir, 'objects'),
|
expect(roles).toContain('女巫');
|
||||||
|
expect(roles).toContain('猎人');
|
||||||
|
|
||||||
|
const teams = { wolf: players.filter((p) => p.identity.team === 'wolf').length, good: 0 };
|
||||||
|
teams.good = players.length - teams.wolf;
|
||||||
|
expect(teams.wolf).toBe(3);
|
||||||
|
expect(teams.good).toBe(6);
|
||||||
|
|
||||||
|
expect(players[0]!.id).toBe('p1');
|
||||||
|
expect(players[8]!.id).toBe('p9');
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
function cleanup() {
|
describe('信息可见性过滤', () => {
|
||||||
try {
|
it('狼人可见狼夜且互通;预言家仅见验人;女巫仅见用药;平民仅见公开阶段', () => {
|
||||||
store?.close();
|
const players = createPlayers();
|
||||||
} catch {
|
const wolf1 = players[0]!;
|
||||||
/* ignore */
|
const wolf2 = players[1]!;
|
||||||
}
|
const seer = players[3]!;
|
||||||
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
const witch = players[4]!;
|
||||||
}
|
const villager = players[6]!;
|
||||||
|
|
||||||
async function trigger(topicId: string) {
|
const chain: WorkflowMessage[] = [
|
||||||
const hash = await store.putObject('werewolf game start');
|
{
|
||||||
await store.appendEvent({
|
role: 'wolf-night',
|
||||||
occurredAt: Date.now(),
|
content: '狼人杀人',
|
||||||
kind: 'werewolf.__start__',
|
meta: { phase: 'wolf-night', targetId: 'p7', visibleTo: ['p1', 'p2', 'p3'] },
|
||||||
key: topicId,
|
timestamp: 1,
|
||||||
hash,
|
},
|
||||||
meta: JSON.stringify({}),
|
{
|
||||||
});
|
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'],
|
||||||
|
witchPotion: true,
|
||||||
|
witchPoison: true,
|
||||||
|
},
|
||||||
|
timestamp: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'day-speech',
|
||||||
|
content: '白天发言',
|
||||||
|
meta: { phase: 'day-speech', speeches: [] },
|
||||||
|
timestamp: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'vote',
|
||||||
|
content: '投票',
|
||||||
|
meta: { phase: 'vote', votes: {}, eliminatedId: 'p8' },
|
||||||
|
timestamp: 5,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
it('mock 模式能跑完整局', async () => {
|
const w1 = filterChainForPlayer(chain, wolf1.id, wolf1.identity);
|
||||||
setup();
|
expect(w1.some((m) => (m.meta as { phase?: string })?.phase === 'wolf-night')).toBe(true);
|
||||||
try {
|
expect(w1.some((m) => (m.meta as { phase?: string })?.phase === 'seer-check')).toBe(false);
|
||||||
const wf = createWerewolfWorkflow();
|
expect(w1.some((m) => (m.meta as { phase?: string })?.phase === 'witch-action')).toBe(false);
|
||||||
const rule = createWorkflowRule(wf, store);
|
|
||||||
await trigger('g1');
|
|
||||||
|
|
||||||
const order: string[] = [];
|
const w2 = filterChainForPlayer(chain, wolf2.id, wolf2.identity);
|
||||||
for (let i = 0; i < 200; i++) {
|
expect(w2.some((m) => (m.meta as { phase?: string })?.phase === 'wolf-night')).toBe(true);
|
||||||
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');
|
const seerView = filterChainForPlayer(chain, seer.id, seer.identity);
|
||||||
expect(order[order.length - 1]).toBe('__end__');
|
expect(seerView.some((m) => (m.meta as { phase?: string })?.phase === 'seer-check')).toBe(true);
|
||||||
expect(order[order.length - 2]).toBe('game-end');
|
expect(seerView.some((m) => (m.meta as { phase?: string })?.phase === 'wolf-night')).toBe(false);
|
||||||
expect(order.length).toBeGreaterThanOrEqual(7);
|
|
||||||
|
|
||||||
const events = await store.getAfter(0);
|
const witchView = filterChainForPlayer(chain, witch.id, witch.identity);
|
||||||
expect(events.some((e) => e.kind === 'werewolf.__end__')).toBe(true);
|
expect(witchView.some((m) => (m.meta as { phase?: string })?.phase === 'witch-action')).toBe(
|
||||||
} finally {
|
true,
|
||||||
cleanup();
|
);
|
||||||
}
|
|
||||||
|
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('阶段正确轮转', () => {
|
it('无 visibleTo 的狼夜 meta 仍对全体狼人可见(兼容)', () => {
|
||||||
|
const players = createPlayers();
|
||||||
|
const wolf = players[0]!;
|
||||||
|
const chain: WorkflowMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'wolf-night',
|
||||||
|
content: 'x',
|
||||||
|
meta: { phase: 'wolf-night', targetId: 'p7' },
|
||||||
|
timestamp: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
filterChainForPlayer(chain, wolf.id, wolf.identity).length,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('阶段流转', () => {
|
||||||
|
it('moderator:黑夜 → 白天 → 投票;终局或猎人后回到狼夜或结束', () => {
|
||||||
const wf = createWerewolfWorkflow();
|
const wf = createWerewolfWorkflow();
|
||||||
|
|
||||||
expect(wf.moderator({ role: START, meta: null }, 'x')).toBe('wolf-night');
|
expect(wf.moderator({ role: START, meta: null }, 'x')).toBe('wolf-night');
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
wf.moderator(
|
wf.moderator(
|
||||||
{ role: 'wolf-night', meta: { phase: 'wolf-night', targetId: 'p7' } },
|
{
|
||||||
|
role: 'wolf-night',
|
||||||
|
meta: { phase: 'wolf-night', targetId: 'p7', visibleTo: ['p1', 'p2', 'p3'] },
|
||||||
|
},
|
||||||
'x',
|
'x',
|
||||||
),
|
),
|
||||||
).toBe('seer-check');
|
).toBe('seer-check');
|
||||||
@ -151,10 +224,31 @@ describe('werewolf WorkflowType', () => {
|
|||||||
).toBe('wolf-night');
|
).toBe('wolf-night');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('狼人全死 → 好人胜', () => {
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('终局判定', () => {
|
||||||
|
it('狼人全死 → 好人胜局可结束 workflow', () => {
|
||||||
const players = createPlayers();
|
const players = createPlayers();
|
||||||
const wolves = players.filter((p) => p.identity.team === 'wolf');
|
const wolves = players.filter((p) => p.identity.team === 'wolf');
|
||||||
expect(wolves.length).toBe(3);
|
expect(wolves).toHaveLength(3);
|
||||||
|
|
||||||
const aliveWithoutWolves = players.filter((p) => p.identity.team === 'good');
|
const aliveWithoutWolves = players.filter((p) => p.identity.team === 'good');
|
||||||
expect(checkGameOver(aliveWithoutWolves)).toBe(true);
|
expect(checkGameOver(aliveWithoutWolves)).toBe(true);
|
||||||
@ -186,28 +280,19 @@ describe('werewolf WorkflowType', () => {
|
|||||||
).toBe('__end__');
|
).toBe('__end__');
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
wf.moderator(
|
wf.moderator({ role: '__end__', meta: { phase: '__end__' } }, 'x'),
|
||||||
{ role: '__end__', meta: { phase: '__end__' } },
|
|
||||||
'x',
|
|
||||||
),
|
|
||||||
).toBe(END);
|
).toBe(END);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('好人数 <= 狼人数 → 狼人胜', () => {
|
it('好人存活数 ≤ 狼人数 → 狼人胜', () => {
|
||||||
const players = createPlayers();
|
const players = createPlayers();
|
||||||
const alive = [players[0], players[1], players[2], players[6]];
|
const alive = [players[0], players[1], players[2], players[6]];
|
||||||
expect(checkGameOver(alive)).toBe(true);
|
expect(checkGameOver(alive)).toBe(true);
|
||||||
|
|
||||||
const alive2 = [
|
const alive2 = [players[0], players[1], players[2], players[6], players[7]];
|
||||||
players[0],
|
|
||||||
players[1],
|
|
||||||
players[2],
|
|
||||||
players[6],
|
|
||||||
players[7],
|
|
||||||
];
|
|
||||||
expect(checkGameOver(alive2)).toBe(true);
|
expect(checkGameOver(alive2)).toBe(true);
|
||||||
|
|
||||||
const alive3 = [
|
const balanced = [
|
||||||
players[0],
|
players[0],
|
||||||
players[1],
|
players[1],
|
||||||
players[2],
|
players[2],
|
||||||
@ -216,125 +301,17 @@ describe('werewolf WorkflowType', () => {
|
|||||||
players[5],
|
players[5],
|
||||||
players[6],
|
players[6],
|
||||||
];
|
];
|
||||||
expect(checkGameOver(alive3)).toBe(false);
|
expect(checkGameOver(balanced)).toBe(false);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('信息可见性正确', () => {
|
describe('链上状态重建', () => {
|
||||||
const players = createPlayers();
|
it('女巫用完解药后 witchPotion 从 chain 解析一致', () => {
|
||||||
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[] = [
|
const chain: WorkflowMessage[] = [
|
||||||
{
|
{
|
||||||
role: 'wolf-night',
|
role: 'wolf-night',
|
||||||
content: '狼人杀 p7',
|
content: '狼人杀 p7',
|
||||||
meta: { phase: 'wolf-night', targetId: 'p7' },
|
meta: { phase: 'wolf-night', targetId: 'p7', visibleTo: ['p1', 'p2', 'p3'] },
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -359,7 +336,7 @@ describe('werewolf WorkflowType', () => {
|
|||||||
chain.push({
|
chain.push({
|
||||||
role: 'wolf-night',
|
role: 'wolf-night',
|
||||||
content: '狼人杀 p8',
|
content: '狼人杀 p8',
|
||||||
meta: { phase: 'wolf-night', targetId: 'p8' },
|
meta: { phase: 'wolf-night', targetId: 'p8', visibleTo: ['p1', 'p2', 'p3'] },
|
||||||
timestamp: 3,
|
timestamp: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -368,3 +345,118 @@ describe('werewolf WorkflowType', () => {
|
|||||||
expect(state2.lastKill).toBe('p8');
|
expect(state2.lastKill).toBe('p8');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('mock LLM:注入 Role 跑完整 Pulse 局', () => {
|
||||||
|
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 能 tick 到 game-end → __end__', 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('包装 default mocks 模拟「LLM」调用仍跑通全流程', async () => {
|
||||||
|
setup();
|
||||||
|
try {
|
||||||
|
const llmSteps: string[] = [];
|
||||||
|
const wf = createWerewolfWorkflow({
|
||||||
|
wolfNightFn: async (chain, topicId, storeArg) => {
|
||||||
|
llmSteps.push('wolf-night');
|
||||||
|
return werewolfDefaultMocks.wolfNightFn!(chain, topicId, storeArg);
|
||||||
|
},
|
||||||
|
seerCheckFn: async (chain, topicId, storeArg) => {
|
||||||
|
llmSteps.push('seer-check');
|
||||||
|
return werewolfDefaultMocks.seerCheckFn!(chain, topicId, storeArg);
|
||||||
|
},
|
||||||
|
witchActionFn: async (chain, topicId, storeArg) => {
|
||||||
|
llmSteps.push('witch-action');
|
||||||
|
return werewolfDefaultMocks.witchActionFn!(chain, topicId, storeArg);
|
||||||
|
},
|
||||||
|
daySpeechFn: async (chain, topicId, storeArg) => {
|
||||||
|
llmSteps.push('day-speech');
|
||||||
|
return werewolfDefaultMocks.daySpeechFn!(chain, topicId, storeArg);
|
||||||
|
},
|
||||||
|
voteFn: async (chain, topicId, storeArg) => {
|
||||||
|
llmSteps.push('vote');
|
||||||
|
return werewolfDefaultMocks.voteFn!(chain, topicId, storeArg);
|
||||||
|
},
|
||||||
|
hunterShotFn: async (chain, topicId, storeArg) => {
|
||||||
|
llmSteps.push('hunter-shot');
|
||||||
|
return werewolfDefaultMocks.hunterShotFn!(chain, topicId, storeArg);
|
||||||
|
},
|
||||||
|
gameEndFn: async (chain, topicId, storeArg) => {
|
||||||
|
llmSteps.push('game-end');
|
||||||
|
return werewolfDefaultMocks.gameEndFn!(chain, topicId, storeArg);
|
||||||
|
},
|
||||||
|
workflowEndFn: async (chain, topicId, storeArg) => {
|
||||||
|
llmSteps.push('__end__');
|
||||||
|
return werewolfDefaultMocks.workflowEndFn!(chain, topicId, storeArg);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rule = createWorkflowRule(wf, store);
|
||||||
|
await trigger('g2');
|
||||||
|
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const r = await rule.tick();
|
||||||
|
if (r.executed.length === 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(llmSteps.length).toBeGreaterThan(0);
|
||||||
|
expect(llmSteps[0]).toBe('wolf-night');
|
||||||
|
expect(llmSteps.includes('game-end')).toBe(true);
|
||||||
|
expect(llmSteps[llmSteps.length - 1]).toBe('__end__');
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Werewolf (狼人杀) workflow — 9-player game with information asymmetry.
|
* Werewolf (狼人杀) workflow v3 — 9 人局 + 信息可见性过滤。
|
||||||
*
|
*
|
||||||
* Pure roles + START/END automaton. Trigger: werewolf.__start__
|
* 配置:3 狼、1 预言家、1 女巫、1 猎人、3 平民。
|
||||||
|
* 阶段 Role:狼人夜 → 预言家验 → 女巫药 → 白天发言 → 投票(→ 猎人开枪)→ 循环直至终局。
|
||||||
*
|
*
|
||||||
* 阶段 Role 内部遍历玩家;状态从 chain 重建(parseGameState)。
|
* Trigger: `werewolf.__start__`
|
||||||
* 默认全部为 mock;通过 createWerewolfWorkflow(opts) 注入真实 Role。
|
|
||||||
*
|
*
|
||||||
* 小橘 🍊 (NEKO Team)
|
* 小橘 (NEKO Team)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -64,6 +64,7 @@ const PERSONALITIES = [
|
|||||||
'温和',
|
'温和',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/** 固定 9 人:p1–p3 狼,p4 预言家,p5 女巫,p6 猎人,p7–p9 村民 */
|
||||||
const IDENTITIES: Identity[] = [
|
const IDENTITIES: Identity[] = [
|
||||||
{ name: '狼人', team: 'wolf' },
|
{ name: '狼人', team: 'wolf' },
|
||||||
{ name: '狼人', team: 'wolf' },
|
{ name: '狼人', team: 'wolf' },
|
||||||
@ -201,7 +202,7 @@ export function parseGameState(chain: WorkflowMessage[]): GameState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Information visibility filter ──────────────────────────────
|
// ── Information visibility ───────────────────────────────────────
|
||||||
|
|
||||||
export function filterChainForPlayer(
|
export function filterChainForPlayer(
|
||||||
chain: WorkflowMessage[],
|
chain: WorkflowMessage[],
|
||||||
@ -227,7 +228,10 @@ export function filterChainForPlayer(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ph === 'wolf-night') return identity.team === 'wolf';
|
if (ph === 'wolf-night') {
|
||||||
|
if (visibleTo && visibleTo.length > 0) return visibleTo.includes(playerId);
|
||||||
|
return identity.team === 'wolf';
|
||||||
|
}
|
||||||
|
|
||||||
if (ph === 'seer-check' || ph === 'witch-action') {
|
if (ph === 'seer-check' || ph === 'witch-action') {
|
||||||
return visibleTo ? visibleTo.includes(playerId) : false;
|
return visibleTo ? visibleTo.includes(playerId) : false;
|
||||||
@ -259,6 +263,8 @@ ${player.identity.abilities ? `特殊能力:${player.identity.abilities}` : ''
|
|||||||
export type WolfNightMeta = {
|
export type WolfNightMeta = {
|
||||||
phase: 'wolf-night';
|
phase: 'wolf-night';
|
||||||
targetId: string;
|
targetId: string;
|
||||||
|
/** 所有存活狼人 id — 仅狼队可见本场刀法讨论与目标 */
|
||||||
|
visibleTo: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SeerCheckMeta = {
|
export type SeerCheckMeta = {
|
||||||
@ -303,7 +309,6 @@ export type GameEndMeta = {
|
|||||||
summary: string;
|
summary: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Terminal marker — adapter writes `{name}.__end__` for meta-tester e2e. */
|
|
||||||
export type WerewolfEndMeta = {
|
export type WerewolfEndMeta = {
|
||||||
phase: '__end__';
|
phase: '__end__';
|
||||||
};
|
};
|
||||||
@ -321,10 +326,12 @@ export type WerewolfRoles = {
|
|||||||
|
|
||||||
// ── Win condition ──────────────────────────────────────────────
|
// ── Win condition ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 狼人全灭,或好人人数 ≤ 狼人人数(屠边前好人已输) */
|
||||||
export function checkGameOver(alive: Player[]): boolean {
|
export function checkGameOver(alive: Player[]): boolean {
|
||||||
const wolves = alive.filter((p) => p.identity.team === 'wolf');
|
const wolves = alive.filter((p) => p.identity.team === 'wolf');
|
||||||
if (wolves.length === 0) return true;
|
if (wolves.length === 0) return true;
|
||||||
if (wolves.length >= alive.length - wolves.length) return true;
|
const good = alive.length - wolves.length;
|
||||||
|
if (good <= wolves.length) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -367,21 +374,30 @@ function werewolfModerator(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Default mock roles (deterministic, no LLM) ─────────────────
|
// ── Default mock roles (deterministic) ─────────────────────────
|
||||||
|
|
||||||
const mockWolfNight: Role<WolfNightMeta> = async (chain) => {
|
function aliveWolfIds(state: GameState): string[] {
|
||||||
|
return state.alive
|
||||||
|
.filter((p) => p.identity.team === 'wolf')
|
||||||
|
.sort(byPlayerId)
|
||||||
|
.map((p) => p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockWolfNight: Role<WolfNightMeta> = async (chain) => {
|
||||||
const state = parseGameState(chain);
|
const state = parseGameState(chain);
|
||||||
const goods = state.alive
|
const goods = state.alive.filter((p) => p.identity.team === 'good').sort(byPlayerId);
|
||||||
.filter((p) => p.identity.team === 'good')
|
|
||||||
.sort(byPlayerId);
|
|
||||||
const target = goods[0]!;
|
const target = goods[0]!;
|
||||||
return {
|
return {
|
||||||
content: `[狼人夜晚] 狼人决定击杀 ${target.name} (${target.id})`,
|
content: `[狼人夜晚] 狼人决定击杀 ${target.name} (${target.id})`,
|
||||||
meta: { phase: 'wolf-night', targetId: target.id },
|
meta: {
|
||||||
|
phase: 'wolf-night',
|
||||||
|
targetId: target.id,
|
||||||
|
visibleTo: aliveWolfIds(state),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockSeerCheck: Role<SeerCheckMeta> = async (chain) => {
|
export const mockSeerCheck: Role<SeerCheckMeta> = async (chain) => {
|
||||||
const state = parseGameState(chain);
|
const state = parseGameState(chain);
|
||||||
const seer = state.alive.find((p) => p.identity.name === '预言家');
|
const seer = state.alive.find((p) => p.identity.name === '预言家');
|
||||||
if (!seer) {
|
if (!seer) {
|
||||||
@ -395,9 +411,7 @@ const mockSeerCheck: Role<SeerCheckMeta> = async (chain) => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const others = state.alive
|
const others = state.alive.filter((p) => p.id !== seer.id).sort(byPlayerId);
|
||||||
.filter((p) => p.id !== seer.id)
|
|
||||||
.sort(byPlayerId);
|
|
||||||
const target = others[0]!;
|
const target = others[0]!;
|
||||||
return {
|
return {
|
||||||
content: `[预言家查验] ${target.name} 是${target.identity.team === 'wolf' ? '狼人' : '好人'}`,
|
content: `[预言家查验] ${target.name} 是${target.identity.team === 'wolf' ? '狼人' : '好人'}`,
|
||||||
@ -410,14 +424,11 @@ const mockSeerCheck: Role<SeerCheckMeta> = async (chain) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockWitchAction: Role<WitchActionMeta> = async (chain) => {
|
export const mockWitchAction: Role<WitchActionMeta> = async (chain) => {
|
||||||
const state = parseGameState(chain);
|
const state = parseGameState(chain);
|
||||||
const witch = state.alive.find((p) => p.identity.name === '女巫');
|
const witch = state.alive.find((p) => p.identity.name === '女巫');
|
||||||
const saved = false;
|
const saved = false;
|
||||||
/** Mock:每夜毒最小编号存活狼人,加速终局(便于 e2e / meta-tester 在有限 tick 内结束)。 */
|
const wolves = state.alive.filter((p) => p.identity.team === 'wolf').sort(byPlayerId);
|
||||||
const wolves = state.alive
|
|
||||||
.filter((p) => p.identity.team === 'wolf')
|
|
||||||
.sort(byPlayerId);
|
|
||||||
const poisonTarget =
|
const poisonTarget =
|
||||||
witch && state.witchPoison && wolves.length > 0 ? wolves[0]!.id : null;
|
witch && state.witchPoison && wolves.length > 0 ? wolves[0]!.id : null;
|
||||||
const witchId = witch?.id ?? '';
|
const witchId = witch?.id ?? '';
|
||||||
@ -444,7 +455,7 @@ const mockWitchAction: Role<WitchActionMeta> = async (chain) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockDaySpeech: Role<DaySpeechMeta> = async (chain) => {
|
export const mockDaySpeech: Role<DaySpeechMeta> = async (chain) => {
|
||||||
const state = parseGameState(chain);
|
const state = parseGameState(chain);
|
||||||
const speeches = state.alive.map((p) => {
|
const speeches = state.alive.map((p) => {
|
||||||
const visible = filterChainForPlayer(chain, p.id, p.identity);
|
const visible = filterChainForPlayer(chain, p.id, p.identity);
|
||||||
@ -459,15 +470,11 @@ const mockDaySpeech: Role<DaySpeechMeta> = async (chain) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockVote: Role<VoteMeta> = async (chain) => {
|
export const mockVote: Role<VoteMeta> = async (chain) => {
|
||||||
const state = parseGameState(chain);
|
const state = parseGameState(chain);
|
||||||
const alive = state.alive;
|
const alive = state.alive;
|
||||||
const wolves = alive
|
const wolves = alive.filter((p) => p.identity.team === 'wolf').sort(byPlayerId);
|
||||||
.filter((p) => p.identity.team === 'wolf')
|
const eliminated = wolves.length > 0 ? wolves[0] : undefined;
|
||||||
.sort(byPlayerId);
|
|
||||||
/** 狼已全灭时不再放逐(好人胜局已定) */
|
|
||||||
const eliminated =
|
|
||||||
wolves.length > 0 ? wolves[0] : undefined;
|
|
||||||
const eliminatedId = eliminated?.id ?? null;
|
const eliminatedId = eliminated?.id ?? null;
|
||||||
|
|
||||||
const votes: Record<string, string> = {};
|
const votes: Record<string, string> = {};
|
||||||
@ -475,9 +482,7 @@ const mockVote: Role<VoteMeta> = async (chain) => {
|
|||||||
for (const p of alive) votes[p.id] = eliminatedId;
|
for (const p of alive) votes[p.id] = eliminatedId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const aliveAfter = eliminatedId
|
const aliveAfter = eliminatedId ? alive.filter((p) => p.id !== eliminatedId) : alive;
|
||||||
? alive.filter((p) => p.id !== eliminatedId)
|
|
||||||
: alive;
|
|
||||||
const gameOver = checkGameOver(aliveAfter);
|
const gameOver = checkGameOver(aliveAfter);
|
||||||
const hunterTriggered = eliminated?.identity.name === '猎人';
|
const hunterTriggered = eliminated?.identity.name === '猎人';
|
||||||
|
|
||||||
@ -493,11 +498,9 @@ const mockVote: Role<VoteMeta> = async (chain) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockHunterShot: Role<HunterShotMeta> = async (chain) => {
|
export const mockHunterShot: Role<HunterShotMeta> = async (chain) => {
|
||||||
const state = parseGameState(chain);
|
const state = parseGameState(chain);
|
||||||
const wolves = state.alive
|
const wolves = state.alive.filter((p) => p.identity.team === 'wolf').sort(byPlayerId);
|
||||||
.filter((p) => p.identity.team === 'wolf')
|
|
||||||
.sort(byPlayerId);
|
|
||||||
const target = wolves[0] ?? state.alive.sort(byPlayerId)[0]!;
|
const target = wolves[0] ?? state.alive.sort(byPlayerId)[0]!;
|
||||||
const aliveAfter = state.alive.filter((p) => p.id !== target.id);
|
const aliveAfter = state.alive.filter((p) => p.id !== target.id);
|
||||||
const gameOver = checkGameOver(aliveAfter);
|
const gameOver = checkGameOver(aliveAfter);
|
||||||
@ -512,7 +515,7 @@ const mockHunterShot: Role<HunterShotMeta> = async (chain) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockGameEnd: Role<GameEndMeta> = async (chain) => {
|
export const mockGameEnd: Role<GameEndMeta> = async (chain) => {
|
||||||
const state = parseGameState(chain);
|
const state = parseGameState(chain);
|
||||||
const wolves = state.alive.filter((p) => p.identity.team === 'wolf');
|
const wolves = state.alive.filter((p) => p.identity.team === 'wolf');
|
||||||
const winner: Team = wolves.length === 0 ? 'good' : 'wolf';
|
const winner: Team = wolves.length === 0 ? 'good' : 'wolf';
|
||||||
@ -526,7 +529,7 @@ const mockGameEnd: Role<GameEndMeta> = async (chain) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockWorkflowEnd: Role<WerewolfEndMeta> = async () => ({
|
export const mockWorkflowEnd: Role<WerewolfEndMeta> = async () => ({
|
||||||
content: '[workflow finished]',
|
content: '[workflow finished]',
|
||||||
meta: { phase: '__end__' as const },
|
meta: { phase: '__end__' as const },
|
||||||
});
|
});
|
||||||
@ -544,6 +547,18 @@ export type CreateWerewolfWorkflowOpts = {
|
|||||||
workflowEndFn?: Role<WerewolfEndMeta>;
|
workflowEndFn?: Role<WerewolfEndMeta>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 默认 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(
|
export function createWerewolfWorkflow(
|
||||||
opts?: CreateWerewolfWorkflowOpts,
|
opts?: CreateWerewolfWorkflowOpts,
|
||||||
): WorkflowType<WerewolfRoles> {
|
): WorkflowType<WerewolfRoles> {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user