581 lines
17 KiB
TypeScript
581 lines
17 KiB
TypeScript
/**
|
|
* Werewolf (狼人杀) workflow v3 — 9 人局 + 信息可见性过滤。
|
|
*
|
|
* 配置:3 狼、1 预言家、1 女巫、1 猎人、3 平民。
|
|
* 阶段 Role:狼人夜 → 预言家验 → 女巫药 → 白天发言 → 投票(→ 猎人开枪)→ 循环直至终局。
|
|
*
|
|
* Trigger: `werewolf.__start__`
|
|
*
|
|
* 小橘 (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;
|
|
|
|
/** 固定 9 人:p1–p3 狼,p4 预言家,p5 女巫,p6 猎人,p7–p9 村民 */
|
|
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 ───────────────────────────────────────
|
|
|
|
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') {
|
|
if (visibleTo && visibleTo.length > 0) return visibleTo.includes(playerId);
|
|
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;
|
|
/** 所有存活狼人 id — 仅狼队可见本场刀法讨论与目标 */
|
|
visibleTo: 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;
|
|
};
|
|
|
|
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;
|
|
const good = alive.length - wolves.length;
|
|
if (good <= 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) ─────────────────────────
|
|
|
|
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 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,
|
|
visibleTo: aliveWolfIds(state),
|
|
},
|
|
};
|
|
};
|
|
|
|
export 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],
|
|
},
|
|
};
|
|
};
|
|
|
|
export const mockWitchAction: Role<WitchActionMeta> = async (chain) => {
|
|
const state = parseGameState(chain);
|
|
const witch = state.alive.find((p) => p.identity.name === '女巫');
|
|
const saved = false;
|
|
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,
|
|
},
|
|
};
|
|
};
|
|
|
|
export 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 },
|
|
};
|
|
};
|
|
|
|
export 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,
|
|
},
|
|
};
|
|
};
|
|
|
|
export 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,
|
|
},
|
|
};
|
|
};
|
|
|
|
export 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(', ')}`,
|
|
},
|
|
};
|
|
};
|
|
|
|
export 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>;
|
|
};
|
|
|
|
/** 默认 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(
|
|
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 },
|
|
};
|
|
}
|