This commit is contained in:
@@ -0,0 +1,280 @@
|
|||||||
|
/**
|
||||||
|
* Werewolf (狼人杀) WorkflowType tests.
|
||||||
|
*
|
||||||
|
* 小橘 🍊 (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, type PulseStore } from '../store.js';
|
||||||
|
import {
|
||||||
|
createWerewolfWorkflow,
|
||||||
|
createPlayers,
|
||||||
|
parseGameState,
|
||||||
|
filterChainForPlayer,
|
||||||
|
checkGameOver,
|
||||||
|
type GameState,
|
||||||
|
type WolfNightMeta,
|
||||||
|
type SeerCheckMeta,
|
||||||
|
type WitchActionMeta,
|
||||||
|
type DaySpeechMeta,
|
||||||
|
type VoteMeta,
|
||||||
|
type HunterShotMeta,
|
||||||
|
type GameEndMeta,
|
||||||
|
} from './werewolf.js';
|
||||||
|
import { createWorkflowRule } from './workflow-rule-adapter.js';
|
||||||
|
import { END, START, type WorkflowMessage, type Role } from './workflow-type.js';
|
||||||
|
|
||||||
|
describe('werewolf WorkflowType', () => {
|
||||||
|
let store: PulseStore;
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), 'werewolf-'));
|
||||||
|
store = createStore({
|
||||||
|
eventsDbPath: join(tmpDir, 'test.db'),
|
||||||
|
objectsDir: join(tmpDir, 'objects'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
try {
|
||||||
|
store?.close();
|
||||||
|
} catch {}
|
||||||
|
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function trigger(topicId: string) {
|
||||||
|
const hash = store.putObject('werewolf game start');
|
||||||
|
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);
|
||||||
|
trigger('g1');
|
||||||
|
|
||||||
|
const order: string[] = [];
|
||||||
|
// Max 200 ticks to allow the game to finish
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game must have started and ended
|
||||||
|
expect(order[0]).toBe('wolf-night');
|
||||||
|
expect(order[order.length - 1]).toBe('game-end');
|
||||||
|
expect(order.length).toBeGreaterThanOrEqual(6); // at least one full cycle + game-end
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('阶段正确轮转', () => {
|
||||||
|
const wf = createWerewolfWorkflow();
|
||||||
|
|
||||||
|
// START → wolf-night
|
||||||
|
expect(wf.moderator({ role: START, meta: null }, 'x')).toBe('wolf-night');
|
||||||
|
|
||||||
|
// wolf-night → seer-check
|
||||||
|
expect(wf.moderator(
|
||||||
|
{ role: 'wolf-night', meta: { phase: 'wolf-night', targetId: 'p7' } },
|
||||||
|
'x',
|
||||||
|
)).toBe('seer-check');
|
||||||
|
|
||||||
|
// seer-check → witch-action
|
||||||
|
expect(wf.moderator(
|
||||||
|
{ role: 'seer-check', meta: { phase: 'seer-check', targetId: 'p1', isWolf: true, visibleTo: ['p4'] } },
|
||||||
|
'x',
|
||||||
|
)).toBe('witch-action');
|
||||||
|
|
||||||
|
// witch-action (no game over) → day-speech
|
||||||
|
expect(wf.moderator(
|
||||||
|
{ role: 'witch-action', meta: { phase: 'witch-action', saved: false, poisonTarget: null, visibleTo: ['p5'], witchPotion: true, witchPoison: true, gameOver: false } as any },
|
||||||
|
'x',
|
||||||
|
)).toBe('day-speech');
|
||||||
|
|
||||||
|
// day-speech → vote
|
||||||
|
expect(wf.moderator(
|
||||||
|
{ role: 'day-speech', meta: { phase: 'day-speech', speeches: [] } },
|
||||||
|
'x',
|
||||||
|
)).toBe('vote');
|
||||||
|
|
||||||
|
// vote (no game over, no hunter) → wolf-night (next cycle)
|
||||||
|
expect(wf.moderator(
|
||||||
|
{ role: 'vote', meta: { phase: 'vote', votes: {}, eliminatedId: 'p7', gameOver: false, hunterTriggered: false } as any },
|
||||||
|
'x',
|
||||||
|
)).toBe('wolf-night');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('狼人全死 → 好人胜', () => {
|
||||||
|
const players = createPlayers();
|
||||||
|
// 3 wolves are p1, p2, p3
|
||||||
|
const wolves = players.filter(p => p.identity.team === 'wolf');
|
||||||
|
expect(wolves.length).toBe(3);
|
||||||
|
|
||||||
|
// All wolves dead
|
||||||
|
const aliveWithoutWolves = players.filter(p => p.identity.team === 'good');
|
||||||
|
expect(checkGameOver(aliveWithoutWolves)).toBe(true);
|
||||||
|
|
||||||
|
// Verify via moderator: vote with gameOver=true → game-end
|
||||||
|
const wf = createWerewolfWorkflow();
|
||||||
|
expect(wf.moderator(
|
||||||
|
{ role: 'vote', meta: { phase: 'vote', votes: {}, eliminatedId: 'p3', gameOver: true } as any },
|
||||||
|
'x',
|
||||||
|
)).toBe('game-end');
|
||||||
|
|
||||||
|
// game-end → END
|
||||||
|
expect(wf.moderator(
|
||||||
|
{ role: 'game-end', meta: { phase: 'game-end', winner: 'good', summary: '' } },
|
||||||
|
'x',
|
||||||
|
)).toBe(END);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('好人数 <= 狼人数 → 狼人胜', () => {
|
||||||
|
const players = createPlayers();
|
||||||
|
// 3 wolves + 1 good alive → wolves >= good → game over
|
||||||
|
const alive = [
|
||||||
|
players[0], // wolf
|
||||||
|
players[1], // wolf
|
||||||
|
players[2], // wolf
|
||||||
|
players[6], // villager
|
||||||
|
];
|
||||||
|
expect(checkGameOver(alive)).toBe(true);
|
||||||
|
|
||||||
|
// 3 wolves + 2 good → wolves >= good-wolves → still over
|
||||||
|
const alive2 = [
|
||||||
|
players[0], players[1], players[2], // 3 wolves
|
||||||
|
players[6], players[7], // 2 good
|
||||||
|
];
|
||||||
|
// 3 >= 2 → true
|
||||||
|
expect(checkGameOver(alive2)).toBe(true);
|
||||||
|
|
||||||
|
// 3 wolves + 4 good → not over
|
||||||
|
const alive3 = [
|
||||||
|
players[0], players[1], players[2], // 3 wolves
|
||||||
|
players[3], players[4], players[5], players[6], // 4 good
|
||||||
|
];
|
||||||
|
expect(checkGameOver(alive3)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('信息可见性正确', () => {
|
||||||
|
const players = createPlayers();
|
||||||
|
const wolf = players[0]; // p1, wolf
|
||||||
|
const seer = players[3]; // p4, 预言家
|
||||||
|
const villager = players[6]; // p7, 村民
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Wolf can see wolf-night, day-speech, vote
|
||||||
|
const wolfView = filterChainForPlayer(chain, wolf.id, wolf.identity);
|
||||||
|
expect(wolfView.some(m => (m.meta as any)?.phase === 'wolf-night')).toBe(true);
|
||||||
|
expect(wolfView.some(m => (m.meta as any)?.phase === 'seer-check')).toBe(false);
|
||||||
|
expect(wolfView.some(m => (m.meta as any)?.phase === 'witch-action')).toBe(false);
|
||||||
|
expect(wolfView.some(m => (m.meta as any)?.phase === 'day-speech')).toBe(true);
|
||||||
|
expect(wolfView.some(m => (m.meta as any)?.phase === 'vote')).toBe(true);
|
||||||
|
|
||||||
|
// Seer can see seer-check but not wolf-night or witch-action
|
||||||
|
const seerView = filterChainForPlayer(chain, seer.id, seer.identity);
|
||||||
|
expect(seerView.some(m => (m.meta as any)?.phase === 'seer-check')).toBe(true);
|
||||||
|
expect(seerView.some(m => (m.meta as any)?.phase === 'wolf-night')).toBe(false);
|
||||||
|
expect(seerView.some(m => (m.meta as any)?.phase === 'witch-action')).toBe(false);
|
||||||
|
|
||||||
|
// Villager can only see day-speech and vote
|
||||||
|
const villagerView = filterChainForPlayer(chain, villager.id, villager.identity);
|
||||||
|
expect(villagerView.some(m => (m.meta as any)?.phase === 'wolf-night')).toBe(false);
|
||||||
|
expect(villagerView.some(m => (m.meta as any)?.phase === 'seer-check')).toBe(false);
|
||||||
|
expect(villagerView.some(m => (m.meta as any)?.phase === 'witch-action')).toBe(false);
|
||||||
|
expect(villagerView.some(m => (m.meta as any)?.phase === 'day-speech')).toBe(true);
|
||||||
|
expect(villagerView.some(m => (m.meta as any)?.phase === 'vote')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('猎人被投票出局触发开枪', () => {
|
||||||
|
const wf = createWerewolfWorkflow();
|
||||||
|
// Vote eliminates hunter → hunterTriggered
|
||||||
|
expect(wf.moderator(
|
||||||
|
{ role: 'vote', meta: { phase: 'vote', votes: {}, eliminatedId: 'p6', gameOver: false, hunterTriggered: true } as any },
|
||||||
|
'x',
|
||||||
|
)).toBe('hunter-shot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('女巫用完药后不能再用', () => {
|
||||||
|
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);
|
||||||
|
// After using potion, witchPotion should be false
|
||||||
|
expect(state.witchPotion).toBe(false);
|
||||||
|
expect(state.witchPoison).toBe(true);
|
||||||
|
|
||||||
|
// Second night, add another wolf kill
|
||||||
|
chain.push({
|
||||||
|
role: 'wolf-night',
|
||||||
|
content: '狼人杀 p8',
|
||||||
|
meta: { phase: 'wolf-night', targetId: 'p8' },
|
||||||
|
timestamp: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now create a mock witch role that respects potion state
|
||||||
|
// The mock witch checks state.witchPotion — it should be false, so no save
|
||||||
|
const state2 = parseGameState(chain);
|
||||||
|
expect(state2.witchPotion).toBe(false);
|
||||||
|
expect(state2.lastKill).toBe('p8');
|
||||||
|
// If witchPotion is false, the mock witch cannot save
|
||||||
|
// (The mock uses state.witchPotion in the condition)
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,693 @@
|
|||||||
|
/**
|
||||||
|
* Werewolf (狼人杀) workflow — 9-player game with information asymmetry.
|
||||||
|
*
|
||||||
|
* Pure roles + START/END automaton. Trigger: werewolf.__start__
|
||||||
|
*
|
||||||
|
* Roles are per-phase (not per-player). Each phase role iterates over
|
||||||
|
* the relevant players internally. Game state is rebuilt from chain
|
||||||
|
* (event sourcing via parseGameState).
|
||||||
|
*
|
||||||
|
* Default: all mock roles (no LLM). Inject real implementations via
|
||||||
|
* CreateWerewolfWorkflowOpts.
|
||||||
|
*
|
||||||
|
* 小橘 🍊 (NEKO Team)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
END,
|
||||||
|
type ModeratorInput,
|
||||||
|
type Role,
|
||||||
|
START,
|
||||||
|
type WorkflowMessage,
|
||||||
|
type WorkflowType,
|
||||||
|
} from './workflow-type.js';
|
||||||
|
|
||||||
|
// ── 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; // 今晚被狼人杀的 id
|
||||||
|
lastDeath: DeadPlayer | null; // 最近一次死亡
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Player setup (9 players) ───────────────────────────────────
|
||||||
|
|
||||||
|
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 (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 = meta.targetId as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.phase === 'witch-action') {
|
||||||
|
phase = 'witch-action';
|
||||||
|
if (meta.saved === true) {
|
||||||
|
lastKill = null;
|
||||||
|
witchPotion = false;
|
||||||
|
}
|
||||||
|
if (meta.poisonTarget) {
|
||||||
|
witchPoison = false;
|
||||||
|
const pid = meta.poisonTarget as string;
|
||||||
|
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') {
|
||||||
|
// Wolf kill resolves at dawn (after witch action)
|
||||||
|
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 = meta.eliminatedId as string;
|
||||||
|
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 = meta.shotTarget as string;
|
||||||
|
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; // system messages without meta are public
|
||||||
|
const phase = meta.phase as string | undefined;
|
||||||
|
const visibleTo = meta.visibleTo as string[] | undefined;
|
||||||
|
|
||||||
|
// Public phases
|
||||||
|
if (phase === 'day-speech' || phase === 'vote' || phase === 'death' ||
|
||||||
|
phase === 'dawn' || phase === 'hunter-shot' || phase === 'game-end' ||
|
||||||
|
phase === 'new-night') return true;
|
||||||
|
|
||||||
|
// Wolf night: only wolves see
|
||||||
|
if (phase === 'wolf-night') return identity.team === 'wolf';
|
||||||
|
|
||||||
|
// Seer / witch: only visible to target player
|
||||||
|
if (phase === 'seer-check' || phase === 'witch-action') {
|
||||||
|
return visibleTo ? visibleTo.includes(playerId) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// System messages with explicit visibility
|
||||||
|
if (phase === 'system') {
|
||||||
|
return visibleTo ? visibleTo.includes(playerId) : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Role Meta types ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type WolfNightMeta = {
|
||||||
|
phase: 'wolf-night';
|
||||||
|
targetId: string;
|
||||||
|
visibleTo?: undefined; // wolves see via team filter
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DaySpeechMeta = {
|
||||||
|
phase: 'day-speech';
|
||||||
|
speeches: Array<{ playerId: string; speech: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VoteMeta = {
|
||||||
|
phase: 'vote';
|
||||||
|
votes: Record<string, string>; // voterId → targetId
|
||||||
|
eliminatedId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HunterShotMeta = {
|
||||||
|
phase: 'hunter-shot';
|
||||||
|
shotTarget: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GameEndMeta = {
|
||||||
|
phase: 'game-end';
|
||||||
|
winner: Team;
|
||||||
|
summary: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// helper: dawn message injected by moderator indirectly via roles
|
||||||
|
type DawnMeta = { phase: 'dawn'; died: string[] };
|
||||||
|
type NewNightMeta = { phase: 'new-night'; day: number };
|
||||||
|
|
||||||
|
// ── Roles record ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
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>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function pick<T>(arr: T[]): T {
|
||||||
|
return arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default mock implementations ────────────────────────────────
|
||||||
|
|
||||||
|
const defaultWolfNight: Role<WolfNightMeta> = async (chain) => {
|
||||||
|
const state = parseGameState(chain);
|
||||||
|
const goodAlive = state.alive.filter(p => p.identity.team === 'good');
|
||||||
|
const target = pick(goodAlive);
|
||||||
|
return {
|
||||||
|
content: `[狼人夜晚] 狼人决定击杀 ${target.name}`,
|
||||||
|
meta: { phase: 'wolf-night', targetId: target.id },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultSeerCheck: 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 = pick(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 defaultWitchAction: Role<WitchActionMeta> = async (chain) => {
|
||||||
|
const state = parseGameState(chain);
|
||||||
|
const witch = state.alive.find(p => p.identity.name === '女巫');
|
||||||
|
if (!witch) {
|
||||||
|
// Witch dead — still emit dawn event with kill resolved
|
||||||
|
const died: string[] = [];
|
||||||
|
if (state.lastKill) died.push(state.lastKill);
|
||||||
|
return {
|
||||||
|
content: `[女巫已死,跳过] 天亮了${died.length ? ',有人死亡' : ''}`,
|
||||||
|
meta: {
|
||||||
|
phase: 'witch-action',
|
||||||
|
saved: false,
|
||||||
|
poisonTarget: null,
|
||||||
|
visibleTo: [],
|
||||||
|
witchPotion: state.witchPotion,
|
||||||
|
witchPoison: state.witchPoison,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let saved = false;
|
||||||
|
let poisonTarget: string | null = null;
|
||||||
|
|
||||||
|
// 50% chance to save if someone was killed and potion available
|
||||||
|
if (state.lastKill && state.witchPotion && Math.random() < 0.5) {
|
||||||
|
saved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: `[女巫行动] ${saved ? '使用了解药' : '未使用解药'}${poisonTarget ? `,毒杀了${poisonTarget}` : ''}`,
|
||||||
|
meta: {
|
||||||
|
phase: 'witch-action',
|
||||||
|
saved,
|
||||||
|
poisonTarget,
|
||||||
|
visibleTo: [witch.id],
|
||||||
|
witchPotion: saved ? false : state.witchPotion,
|
||||||
|
witchPoison: poisonTarget ? false : state.witchPoison,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultDaySpeech: 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 defaultVote: 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);
|
||||||
|
votes[p.id] = pick(others).id;
|
||||||
|
}
|
||||||
|
// Tally
|
||||||
|
const tally: Record<string, number> = {};
|
||||||
|
for (const target of Object.values(votes)) {
|
||||||
|
tally[target] = (tally[target] || 0) + 1;
|
||||||
|
}
|
||||||
|
const maxVotes = Math.max(...Object.values(tally));
|
||||||
|
const topIds = Object.entries(tally).filter(([, v]) => v === maxVotes).map(([k]) => k);
|
||||||
|
const eliminatedId = topIds.length === 1 ? topIds[0] : pick(topIds);
|
||||||
|
|
||||||
|
const eliminated = state.alive.find(p => p.id === eliminatedId);
|
||||||
|
return {
|
||||||
|
content: `[投票结果] ${eliminated?.name ?? eliminatedId} 被放逐出局`,
|
||||||
|
meta: { phase: 'vote', votes, eliminatedId },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultHunterShot: Role<HunterShotMeta> = async (chain) => {
|
||||||
|
const state = parseGameState(chain);
|
||||||
|
// Hunter shoots a random alive player
|
||||||
|
const target = pick(state.alive);
|
||||||
|
return {
|
||||||
|
content: `[猎人开枪] 猎人带走了 ${target.name}`,
|
||||||
|
meta: { phase: 'hunter-shot', shotTarget: target.id },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultGameEnd: 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(', ')}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Moderator ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type WerewolfInput = ModeratorInput<WerewolfRoles>;
|
||||||
|
|
||||||
|
function werewolfModerator(
|
||||||
|
output: WerewolfInput,
|
||||||
|
_topicId: string,
|
||||||
|
remainingRounds?: number,
|
||||||
|
): keyof WerewolfRoles | typeof END {
|
||||||
|
const emergency = remainingRounds !== undefined && remainingRounds <= 1;
|
||||||
|
|
||||||
|
if (output.role === START) return 'wolf-night';
|
||||||
|
|
||||||
|
// Build state from meta for win-condition checks
|
||||||
|
const meta = output.meta as Record<string, unknown> | null;
|
||||||
|
|
||||||
|
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': {
|
||||||
|
// Check if game should end after vote
|
||||||
|
if (meta?.gameOver === true || emergency) return 'game-end';
|
||||||
|
// Check if hunter was eliminated
|
||||||
|
if (meta?.hunterTriggered === true) return 'hunter-shot';
|
||||||
|
return 'wolf-night';
|
||||||
|
}
|
||||||
|
case 'hunter-shot': {
|
||||||
|
if (meta?.gameOver === true || emergency) return 'game-end';
|
||||||
|
return 'wolf-night';
|
||||||
|
}
|
||||||
|
case 'game-end':
|
||||||
|
return END;
|
||||||
|
default:
|
||||||
|
return END;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait — the moderator doesn't have access to game state from the chain.
|
||||||
|
// It only gets meta from the last role output. So roles need to include
|
||||||
|
// win-condition info in their meta. Let me revise the approach:
|
||||||
|
// The vote role and hunter-shot role should check win conditions and set
|
||||||
|
// meta flags. Similarly wolf-night + witch-action combined produce deaths
|
||||||
|
// that day-speech should account for.
|
||||||
|
//
|
||||||
|
// Actually, looking at the adapter code, moderator only gets last role's meta.
|
||||||
|
// So each role that might end the game needs to put gameOver + hunterTriggered in meta.
|
||||||
|
|
||||||
|
// Let me redesign: make a "smart" moderator that rebuilds state from chain.
|
||||||
|
// But moderator only gets (output, topicId) — no chain access.
|
||||||
|
// The design doc shows moderator reading meta.gameState.
|
||||||
|
// Solution: roles embed necessary routing info in meta.
|
||||||
|
|
||||||
|
// Revised approach: vote/hunter roles add gameOver and hunterTriggered flags.
|
||||||
|
// wolf-night already doesn't end game. witch-action doesn't either (dawn deaths
|
||||||
|
// are checked when entering day-speech... actually no, we need to check after
|
||||||
|
// night phases complete).
|
||||||
|
//
|
||||||
|
// Simpler: day-speech role checks win condition before speeches (night deaths resolved).
|
||||||
|
// If game over, it signals in meta.
|
||||||
|
//
|
||||||
|
// Actually let me look at this more carefully. The moderator transition is:
|
||||||
|
// wolf-night → seer-check → witch-action → day-speech → vote → wolf-night (loop)
|
||||||
|
// Win conditions are checked: after vote (good might have eliminated all wolves),
|
||||||
|
// and at the start of day (wolf kill at night might tip the balance).
|
||||||
|
//
|
||||||
|
// The cleanest approach: each role that resolves deaths includes gameOver flag.
|
||||||
|
// Moderator checks it to route to game-end.
|
||||||
|
|
||||||
|
// Let me just rewrite moderator and tweak the roles to include routing metadata.
|
||||||
|
|
||||||
|
// ── Revised Moderator (final) ──────────────────────────────────
|
||||||
|
|
||||||
|
function werewolfModeratorFinal(
|
||||||
|
output: WerewolfInput,
|
||||||
|
_topicId: string,
|
||||||
|
remainingRounds?: number,
|
||||||
|
): keyof WerewolfRoles | typeof END {
|
||||||
|
const emergency = remainingRounds !== undefined && remainingRounds <= 1;
|
||||||
|
if (emergency) 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':
|
||||||
|
// After witch action, check if night deaths end the game
|
||||||
|
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;
|
||||||
|
default:
|
||||||
|
return END;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Revised mock roles with routing metadata ───────────────────
|
||||||
|
|
||||||
|
// We need roles to compute game-over and hunter-triggered flags.
|
||||||
|
// The witch-action role resolves night deaths, so it checks win condition.
|
||||||
|
// The vote role resolves day deaths, so it checks win condition + hunter trigger.
|
||||||
|
// The hunter-shot role resolves hunter death, so it checks win condition.
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-implement witch-action to resolve dawn deaths and check win condition
|
||||||
|
const mockWitchAction: Role<WitchActionMeta & { gameOver?: boolean }> = async (chain) => {
|
||||||
|
const state = parseGameState(chain);
|
||||||
|
const witch = state.alive.find(p => p.identity.name === '女巫');
|
||||||
|
|
||||||
|
let saved = false;
|
||||||
|
let poisonTarget: string | null = null;
|
||||||
|
const witchId = witch?.id ?? '';
|
||||||
|
|
||||||
|
if (witch) {
|
||||||
|
if (state.lastKill && state.witchPotion && Math.random() < 0.5) {
|
||||||
|
saved = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate dawn: compute who actually dies
|
||||||
|
const nightDead: string[] = [];
|
||||||
|
if (state.lastKill && !saved) nightDead.push(state.lastKill);
|
||||||
|
if (poisonTarget) nightDead.push(poisonTarget);
|
||||||
|
|
||||||
|
// Check win condition after night deaths
|
||||||
|
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: `[女巫行动] ${saved ? '使用了解药' : '未使用解药'}`,
|
||||||
|
meta: {
|
||||||
|
phase: 'witch-action',
|
||||||
|
saved,
|
||||||
|
poisonTarget,
|
||||||
|
visibleTo: witchId ? [witchId] : [],
|
||||||
|
witchPotion: saved ? false : state.witchPotion,
|
||||||
|
witchPoison: poisonTarget ? false : state.witchPoison,
|
||||||
|
gameOver,
|
||||||
|
} as any,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockVote: Role<VoteMeta & { hunterTriggered?: boolean; gameOver?: boolean }> = async (chain) => {
|
||||||
|
// First resolve dawn deaths from last night
|
||||||
|
const statePreDawn = parseGameState(chain);
|
||||||
|
|
||||||
|
// We need to account for dawn deaths. The chain should already have witch-action
|
||||||
|
// which has saved/not-saved info. parseGameState handles lastKill resolution
|
||||||
|
// at dawn phase, but we haven't emitted a dawn event.
|
||||||
|
// Actually, parseGameState only processes dawn phase if there's a message with phase:'dawn'.
|
||||||
|
// Since we don't emit that, we need to handle it differently.
|
||||||
|
|
||||||
|
// Let's resolve: after witch-action, the wolf kill is still pending in lastKill
|
||||||
|
// unless saved. We need to consider it as death for voting purposes.
|
||||||
|
|
||||||
|
// Compute effective alive list (after night)
|
||||||
|
const nightDead: string[] = [];
|
||||||
|
if (statePreDawn.lastKill) {
|
||||||
|
// Check if witch saved
|
||||||
|
const witchMsg = [...chain].reverse().find(m =>
|
||||||
|
(m.meta as any)?.phase === 'witch-action');
|
||||||
|
const witchSaved = (witchMsg?.meta as any)?.saved === true;
|
||||||
|
if (!witchSaved) nightDead.push(statePreDawn.lastKill);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadSet = new Set([...statePreDawn.dead.map(d => d.id), ...nightDead]);
|
||||||
|
const effectiveAlive = statePreDawn.players.filter(p => !deadSet.has(p.id));
|
||||||
|
|
||||||
|
const votes: Record<string, string> = {};
|
||||||
|
for (const p of effectiveAlive) {
|
||||||
|
const others = effectiveAlive.filter(o => o.id !== p.id);
|
||||||
|
if (others.length > 0) votes[p.id] = pick(others).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tally: Record<string, number> = {};
|
||||||
|
for (const target of Object.values(votes)) {
|
||||||
|
tally[target] = (tally[target] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let eliminatedId: string | null = null;
|
||||||
|
if (Object.keys(tally).length > 0) {
|
||||||
|
const maxVotes = Math.max(...Object.values(tally));
|
||||||
|
const topIds = Object.entries(tally).filter(([, v]) => v === maxVotes).map(([k]) => k);
|
||||||
|
eliminatedId = topIds.length === 1 ? topIds[0] : pick(topIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const aliveAfterVote = effectiveAlive.filter(p => p.id !== eliminatedId);
|
||||||
|
const gameOver = checkGameOver(aliveAfterVote);
|
||||||
|
|
||||||
|
const eliminated = effectiveAlive.find(p => p.id === eliminatedId);
|
||||||
|
const hunterTriggered = eliminated?.identity.name === '猎人';
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: `[投票结果] ${eliminated?.name ?? '无人'} 被放逐出局`,
|
||||||
|
meta: {
|
||||||
|
phase: 'vote',
|
||||||
|
votes,
|
||||||
|
eliminatedId,
|
||||||
|
hunterTriggered,
|
||||||
|
gameOver,
|
||||||
|
} as any,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockHunterShot: Role<HunterShotMeta & { gameOver?: boolean }> = async (chain) => {
|
||||||
|
const state = parseGameState(chain);
|
||||||
|
// Hunter can't shoot themselves; pick from alive (hunter is already dead from vote)
|
||||||
|
const target = pick(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,
|
||||||
|
} as any,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 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>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createWerewolfWorkflow(
|
||||||
|
opts?: CreateWerewolfWorkflowOpts,
|
||||||
|
): WorkflowType<WerewolfRoles> {
|
||||||
|
return {
|
||||||
|
name: 'werewolf',
|
||||||
|
roles: {
|
||||||
|
'wolf-night': opts?.wolfNightFn ?? defaultWolfNight,
|
||||||
|
'seer-check': opts?.seerCheckFn ?? defaultSeerCheck,
|
||||||
|
'witch-action': opts?.witchActionFn ?? (mockWitchAction as any),
|
||||||
|
'day-speech': opts?.daySpeechFn ?? defaultDaySpeech,
|
||||||
|
'vote': opts?.voteFn ?? (mockVote as any),
|
||||||
|
'hunter-shot': opts?.hunterShotFn ?? (mockHunterShot as any),
|
||||||
|
'game-end': opts?.gameEndFn ?? defaultGameEnd,
|
||||||
|
},
|
||||||
|
moderator: werewolfModeratorFinal,
|
||||||
|
limits: { maxRounds: 50 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { checkGameOver };
|
||||||
Reference in New Issue
Block a user