clean: remove old werewolf for fresh rewrite
This commit is contained in:
parent
e8771f3e2e
commit
c4c5ce7246
@ -1,354 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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));
|
|
||||||
}
|
|
||||||
@ -1,578 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user