feat(e2e): multiplayer live E2E MVP — second player bot + anti-loop allowlist

Implement the multiplayer E2E MVP story: a second player-side bot in the live
E2E flow so two distinct players can run a real group encounter.

Production code (TDD, red→green):
- src/bot/lib/e2ePlayerAllowlist.ts: runtime Set of player-bot userIds, populated
  by connectLiveBots after login. A runtime Set (not a config field) because
  config.ts parses env once at import — the ids are only known after login.
- src/bot/handlers/messageRouter.ts: anti-loop guard branches on
  config.E2E_ALLOW_PLAYER_BOTS + isE2EPlayerBot(id); allowlisted player-bot
  messages route as player turns. Default false → prod unchanged.
- src/config.ts: E2E_ALLOW_PLAYER_BOTS (boolean, default false).

Tests:
- tests/unit/e2ePlayerAllowlist.test.ts (5) + messageRouterAllowlist.test.ts (3):
  cover both anti-loop branches without going live.
- tests/integration/graphmcp/group-encounter-live.test.ts: gated MVP live test
  (lobby gating, 2 real chat turns, group check N=2). Skipped by default — CI-safe.
- liveBots.ts: N-player connectLiveBots (player1=E2E_DRIVER_TOKEN, player2=
  E2E_PLAYER2_TOKEN) with driverBot alias preserved; populates the allowlist.
- fakes.ts: fakeButton now carries userId/username (back-compat 2-arg signature).

Fixture: specs/e2e-group-multiplayer.yaml (minPlayers:2, group Stealth check,
passive reveal, e2e- encounterId for flushRedisForGuild cleanup).

Verified: tsc --noEmit clean; 535 unit tests pass (+8 new); live test skips
cleanly without env. The 4 gap-case ACs (FR-11-14) are a follow-up story.

No token values committed; .env stays gitignored.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Kaysser Kayyali
2026-06-22 20:15:19 +00:00
parent ba3b2deecb
commit fcea0a30bc
9 changed files with 521 additions and 17 deletions

View File

@@ -0,0 +1,89 @@
encounterId: "e2e-group-multiplayer-001"
title: "E2E Group Multiplayer Fixture"
tone: "tense"
# E2E fixture — minPlayers:2 so two player-bots satisfy the lobby gate.
# encounterId is prefixed e2e- so flushRedisForGuild tears down its session.
# Not intended for production play; exists to exercise the lobby, multi-player
# message routing, and the group skill-check scoreboard end-to-end live.
minPlayers: 2
setting:
location: "E2E fixture — abandoned warehouse approach"
mood: >
A quiet, tense approach. Two guards patrol the warehouse perimeter with
slow, measured steps, lantern light sweeping the gravel. The party must
move as one to avoid detection. This is a live E2E fixture spec — minimal
prose, exercising the group-encounter mechanics, not a polished encounter.
ambientNpcs: >
Two warehouse guards walking a fixed patrol, lanterns swinging at their sides.
openingNarrative: >
The warehouse looms ahead, its windows dark. Two guards patrol the perimeter
with slow, measured steps, lantern light sweeping the gravel with every turn.
To reach the side door unseen, the party will need to move as one — a single
stumble and every lantern swings your way. How does the group approach?
npcs:
- id: "e2e-warehouse-patrol"
name: "The Patrol"
role: "Two warehouse guards on perimeter patrol"
persona: >
Alert and predictable. They walk a fixed route and react to noise. For this
E2E fixture they are a single narrative obstacle, not a conversational NPC.
goals:
hidden: true
primary:
- id: "slip_past"
label: >
The party slips past the patrol to the side door without being detected
(group Stealth success).
- id: "door_opened"
label: >
The party reaches and opens the side door (group Athletics or Thieves' Tools).
secondary:
- id: "spotted"
label: >
The party is spotted and the alarm is raised — the approach fails.
sportsmanshipRules:
- "No splitting the party mid-approach for this fixture — move as a group."
- >
If a player attempts something absurd, respond in-character to redirect, or
break character with: "⚠️ Let's keep this grounded for the test scenario."
skillChecks:
group_stealth_dc: 13
group_stealth_skill: "Stealth"
group_stealth_note: >
A coordinated group Stealth check to slip past the patrol. Emit as a GROUP
check via skill_check_group_emit targeting all joined players, with
successRule: majority (and optionally durationSeconds: 60). Failure means
the patrol notices the movement.
door_entry_dc: 12
door_entry_skill: "Athletics or Thieves' Tools"
door_entry_note: >
Forcing or picking the side door once the party has slipped past.
# Passive reveal — bot-applied at encounter start, group-visible, attributed.
passiveReveals:
- skill: "Insight"
threshold: 15
revealText: >
Notices one guard limping slightly on every other pass — a brief gap in
the patrol rhythm the party could exploit.
tools:
- skill_check_emit
- skill_check_group_emit
- encounter_resolve
- context_recall
dmNotes: >
E2E fixture spec for the multiplayer live E2E MVP. Its encounterId is prefixed
e2e- so flushRedisForGuild cleans its session. Exists to exercise the lobby
(minPlayers:2), multi-player message routing through messageRouter, and the
group skill-check scoreboard end-to-end against real Discord. Not a polished
encounter — keep minimal.

View File

@@ -12,6 +12,7 @@ import { scheduleLLMTurn } from './generationQueue.js';
import { filterLLMResponse, logFiltered, detectMissedSkillCheck } from './responseFilter.js';
import { registerScheduled, drainPending, clearPending, upgradeToProcessing, upgradeToComplete, cleanupReactions } from './reactionManager.js';
import { isBurstCapped, incrementBurst, resetBurst, sendDropNotice } from './queueCap.js';
import { isE2EPlayerBot } from '../lib/e2ePlayerAllowlist.js';
import { log } from '../../lib/logger.js';
import type { ChatMessage, SessionState } from '../../types/index.js';
@@ -30,7 +31,13 @@ function isAllowedChannel(parentId: string | null): boolean {
// ---------------------------------------------------------------------------
export async function handleMessage(message: Message, client: Client): Promise<void> {
if (message.author.bot) return;
if (message.author.bot) {
// Live multiplayer E2E: route allowlisted player-bot messages as player
// turns. Gated by config.E2E_ALLOW_PLAYER_BOTS (default false) + the
// runtime e2ePlayerAllowlist populated by connectLiveBots after login.
// Production (flag off) is byte-for-byte unchanged.
if (!(config.E2E_ALLOW_PLAYER_BOTS && isE2EPlayerBot(message.author.id))) return;
}
if (!message.channel.isThread()) return;
const thread = message.channel as ThreadChannel;

View File

@@ -0,0 +1,29 @@
// Runtime allowlist of player-bot userIds for live E2E.
//
// The live multiplayer E2E harness (tests/integration/graphmcp/support/liveBots.ts)
// logs in a second player-side bot and needs its real gateway messages to route
// through messageRouter as player turns. The bot-under-test normally ignores
// bot-authored messages (anti-loop guard, messageRouter.ts). When
// config.E2E_ALLOW_PLAYER_BOTS is true, messageRouter consults this allowlist
// instead of skipping.
//
// Why a runtime Set and not a config field: config.ts parses process.env ONCE
// at import (`export const config = EnvSchema.parse(process.env)`), but the
// player-bot userIds are only known AFTER `client.login()` resolves — long
// after config is parsed. So connectLiveBots calls addPlayerBotIds() after
// login and clearE2EPlayerBots() on disconnect. In production the flag is
// false, so this set is never consulted and stays empty.
const playerBotIds = new Set<string>();
export function addPlayerBotIds(ids: string[]): void {
for (const id of ids) playerBotIds.add(id);
}
export function isE2EPlayerBot(id: string): boolean {
return playerBotIds.has(id);
}
export function clearE2EPlayerBots(): void {
playerBotIds.clear();
}

View File

@@ -84,6 +84,15 @@ const EnvSchema = z.object({
// returns [] until the relay ships the endpoint. Flip + run the integration
// test at cutover.
FOUNDRY_CONDITIONS_ENABLED: z.coerce.boolean().default(false),
// ── Live multiplayer E2E ──────────────────────────────────────────────────
// When true, the anti-loop guard in messageRouter routes messages from
// player-bot ids in the runtime e2ePlayerAllowlist (src/bot/lib/) as player
// turns, so the live E2E harness can drive a second player-bot through the
// real messageCreate path. Default false — production behavior is unchanged.
// The id allowlist itself is a runtime Set (populated by connectLiveBots
// after login), NOT a config field, because config is parsed once at import.
E2E_ALLOW_PLAYER_BOTS: z.coerce.boolean().default(false),
});
export { EnvSchema };

View File

@@ -0,0 +1,205 @@
// Live multiplayer E2E — two player-bots run a real group encounter.
//
// Gate: RUN_FULL_E2E=1 AND E2E_PLAYER2_TOKEN set. Requires the full live stack:
// DISCORD_TOKEN, E2E_DRIVER_TOKEN, E2E_PLAYER2_TOKEN, E2E_TEST_GUILD_ID,
// E2E_TEST_CHANNEL_ID, E2E_ALLOW_PLAYER_BOTS=1, plus Redis + GraphMCP + LLM up.
// Skipped by default → CI-safe.
//
// MVP scope: lobby gating (minPlayers:2), 2 real chat turns routed through
// messageRouter via the e2ePlayerAllowlist, and a group skill check N=2
// finalizing with the real successRule. The 4 gap-case ACs (FR-1114:
// simultaneous fan-out, successRule N>1 matrix, per-user ephemeral,
// second-claimant rejection) are a follow-up story — NOT covered here.
//
// NOTE: this test cannot run in CI/here — it needs the real env above. It is
// written to be correct against the live handlers and CI-safe (skipIf). Verify
// changes by keeping `npx vitest run tests/unit` green and `npm run build` clean;
// run it live only in the dedicated test guild with the env set.
import './support/env.js';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { ThreadChannel } from 'discord.js';
import { execute } from '../../../src/bot/commands/encounter.js';
import { handleLobbyInteraction } from '../../../src/bot/handlers/lobbyHandler.js';
import { handleRollInteraction } from '../../../src/bot/handlers/rollHandler.js';
import { sessionManager } from '../../../src/session/sessionManager.js';
import { getLobby } from '../../../src/session/lobbyManager.js';
import { connectLiveBots, disconnectLiveBots, type LiveBots } from './support/liveBots.js';
import { fakeInteraction, fakeButton, parseThreadIdFromReply } from './support/fakes.js';
import { flushRedisForGuild, disconnectRedis, deleteThread, deleteSession } from './support/cleanup.js';
import { waitFor } from './support/poll.js';
import type { PendingGroupCheck } from '../../../src/types/index.js';
const runE2E = process.env.RUN_FULL_E2E === '1' && !!process.env.E2E_PLAYER2_TOKEN;
const specName = 'e2e-group-multiplayer';
describe.skipIf(!runE2E)('Multiplayer live E2E — MVP (2 player-bots)', () => {
let bots: LiveBots;
let player1Id: string;
let player2Id: string;
let starterId: string;
let threadId: string | null = null;
let thread: ThreadChannel | null = null;
beforeAll(async () => {
bots = await connectLiveBots();
expect(bots.players.length, 'multiplayer E2E needs 2 player-bots').toBeGreaterThanOrEqual(2);
player1Id = bots.players[0].user!.id;
player2Id = bots.players[1].user!.id;
starterId = process.env.E2E_DRIVER_USER_ID ?? player1Id;
await flushRedisForGuild(bots.guild.id);
}, 120_000);
afterAll(async () => {
try {
if (threadId) {
await deleteSession(threadId);
await deleteThread(bots.channel, threadId);
}
} finally {
await disconnectRedis();
await disconnectLiveBots(bots);
}
}, 120_000);
// AC-8 — lobby gating + start ------------------------------------------------
it('lobby gates at minPlayers:2, then starts on a full roster', async () => {
const { interaction, lastText } = fakeInteraction({
subcommand: 'start',
stringOptions: { spec: specName },
channel: bots.channel,
guildId: bots.guild.id,
userId: starterId,
username: 'E2E Starter',
});
await execute(interaction);
threadId = parseThreadIdFromReply(lastText());
expect(threadId, 'start must reply with the created thread reference').toBeTruthy();
thread = await bots.channel.threads.fetch(threadId!);
expect(thread, 'lobby thread must exist on the real gateway').toBeTruthy();
// player1 joins — below minPlayers, Start is not ready.
await handleLobbyInteraction(
fakeButton(thread!, 'lobby_join', player1Id, 'Player1').interaction,
bots.botClient,
);
let lobby = await waitFor(() => getLobby(threadId!).then(l => l ?? null), {
timeoutMs: 15_000, intervalMs: 500,
});
expect(lobby!.joined.length, 'one join → below minPlayers:2').toBe(1);
expect(lobby!.joined.length >= lobby!.minPlayers).toBe(false);
// player2 joins — meets minPlayers, Start is ready.
await handleLobbyInteraction(
fakeButton(thread!, 'lobby_join', player2Id, 'Player2').interaction,
bots.botClient,
);
lobby = await waitFor(() => getLobby(threadId!).then(l => l ?? null), {
timeoutMs: 15_000, intervalMs: 500,
});
expect(lobby!.joined.length, 'two joins → meets minPlayers:2').toBe(2);
expect(lobby!.joined.length >= lobby!.minPlayers).toBe(true);
// Start the encounter from the lobby (re-resolves the spec, builds the roster
// from joined players, posts the opening, opens the session).
await handleLobbyInteraction(
fakeButton(thread!, 'lobby_start', starterId, 'E2E Starter').interaction,
bots.botClient,
);
const session = await waitFor(
async () => (await sessionManager.get(threadId!)) ?? null,
{ timeoutMs: 30_000, intervalMs: 1_000 },
);
expect(session, 'encounter session must be persisted after lobby start').toBeTruthy();
expect(session!.phase).toBe('open');
expect(Object.keys(session!.players).length, 'both joined players are in the roster').toBe(2);
}, 150_000);
// AC-9 — 2 real chat turns routed as player turns --------------------------
it('two players post real gateway messages that route as player turns', async () => {
expect(threadId, 'depends on lobby start').toBeTruthy();
thread = thread ?? (await bots.channel.threads.fetch(threadId!));
// The player bots post REAL gateway messages into the thread. These route
// through handleMessage → processEncounterMessage because
// E2E_ALLOW_PLAYER_BOTS=1 and their ids are in the e2ePlayerAllowlist
// (populated by connectLiveBots after login).
const p1Thread = (await bots.players[0].channels.fetch(threadId!)) as ThreadChannel;
const p2Thread = (await bots.players[1].channels.fetch(threadId!)) as ThreadChannel;
const before1 = (await sessionManager.get(threadId!))!.history.length;
await p1Thread.send('Player1: I ready my tools and watch the patrol rhythm.');
await waitFor(
async () => {
const s = await sessionManager.get(threadId!);
return s && s.history.length > before1 ? s : null;
},
{ timeoutMs: 30_000, intervalMs: 1_000 },
);
const before2 = (await sessionManager.get(threadId!))!.history.length;
await p2Thread.send('Player2: I flank left while they are distracted.');
const grown = await waitFor(
async () => {
const s = await sessionManager.get(threadId!);
return s && s.history.length > before2 ? s : null;
},
{ timeoutMs: 30_000, intervalMs: 1_000 },
);
expect(grown!.history.length, 'player2 turn must append to history').toBeGreaterThan(before2);
}, 150_000);
// AC-10 — group check N=2 finalizes with the real successRule --------------
it('a group skill check with 2 rollers finalizes with the real successRule', async () => {
expect(threadId, 'depends on lobby start').toBeTruthy();
thread = thread ?? (await bots.channel.threads.fetch(threadId!));
// Post a real scoreboard message, then set up a pending group check
// targeting both players (deterministic — does not rely on the LLM emitting
// skill_check_group_emit; see PRD OQ-2). successRule: majority.
const scoreboard = await thread!.send({ content: 'Group Stealth check — DC 13 (scoreboard)' });
const gc: PendingGroupCheck = {
skill: 'Stealth',
prompt: 'Slip the party past the patrol',
dc: 13,
messageId: scoreboard.id,
successRule: { kind: 'majority' },
rolls: [
{ discordId: player1Id, dndName: 'Player1', rolled: false, modifier: 0 },
{ discordId: player2Id, dndName: 'Player2', rolled: false, modifier: 0 },
],
};
await sessionManager.atomicMutate(threadId!, () => ({ pendingGroupCheck: gc }));
// Each player clicks Roll. submitGroupRoll records the roll atomically; on
// the second roll allRolled → finalizeGroupCheck appends [GROUP CHECK RESULT]
// and clears pendingGroupCheck.
await handleRollInteraction(
fakeButton(thread!, 'sc_roll', player1Id, 'Player1').interaction,
bots.botClient,
);
await handleRollInteraction(
fakeButton(thread!, 'sc_roll', player2Id, 'Player2').interaction,
bots.botClient,
);
const finalized = await waitFor(
async () => {
const s = await sessionManager.get(threadId!);
if (!s) return null;
const hasResult = s.history.some(
m => typeof m.content === 'string' && m.content.startsWith('[GROUP CHECK RESULT]'),
);
return hasResult && !s.pendingGroupCheck ? s : null;
},
{ timeoutMs: 30_000, intervalMs: 1_000 },
);
expect(finalized, 'group check must finalize: [GROUP CHECK RESULT] appended + pendingGroupCheck cleared').toBeTruthy();
const resultMsg = finalized!.history.find(
m => typeof m.content === 'string' && m.content.startsWith('[GROUP CHECK RESULT]'),
)!;
expect(resultMsg.content, 'successRule kind recorded in the result').toContain('Rule: majority');
}, 150_000);
});

View File

@@ -106,23 +106,36 @@ export function parseThreadIdFromReply(text: string | undefined): string | null
export interface FakeButton {
interaction: import('discord.js').ButtonInteraction;
updates: unknown[];
replies: CapturedReply[];
}
export function fakeButton(channel: ThreadChannel, customId: string): FakeButton {
// `userId`/`username` are optional so existing 2-arg callers (skill-check.test,
// long-encounter.test) keep working. Multiplayer E2E passes each player-bot's
// real userId so submitGroupRoll / handleJoin can identify the clicker.
export function fakeButton(
channel: ThreadChannel,
customId: string,
userId?: string,
username?: string,
): FakeButton {
const updates: unknown[] = [];
const replies: CapturedReply[] = [];
const interaction = {
isButton: () => true,
isModalSubmit: () => false,
isStringSelectMenu: () => false,
customId,
channel,
user: { id: userId ?? 'e2e-driver-user', username: username ?? 'E2E Driver', bot: false },
async update(payload: unknown) {
updates.push(payload);
return {};
},
async reply(_payload: unknown) {
async reply(payload: unknown) {
const entry = typeof payload === 'string' ? { content: payload } : payload;
replies.push(entry as CapturedReply);
return {};
},
} as unknown as import('discord.js').ButtonInteraction;
return { interaction, updates };
return { interaction, updates, replies };
}

View File

@@ -1,35 +1,59 @@
// Real connected discord.js Client fixtures.
//
// This suite deliberately exercises the REAL Discord gateway (no message mocks
// on the under-test bot). Two clients are involved:
// on the under-test bot). The clients involved:
// - botClient : the bot under test, logged in with DISCORD_TOKEN, used both
// as the `client` passed to command.execute() / handleMessage()
// and to fetch real channel/thread objects.
// - driverBot : a SECOND bot (E2E_DRIVER_TOKEN) that posts real chat messages
// into the encounter thread, firing the bot's real messageCreate
// path through the live gateway. (Bots cannot invoke each other's
// slash commands, so this is how we drive conversation turns.)
// - players[] : player-side bots that act as PLAYERS in a multiplayer
// encounter. players[0] is the E2E_DRIVER_TOKEN bot (formerly
// the "driver"); players[1] is E2E_PLAYER2_TOKEN (the second
// player bot, multiplayer E2E only). Their real gateway messages
// route through the live messageRouter as player turns because
// config.E2E_ALLOW_PLAYER_BOTS=1 and their userIds are in the
// runtime e2ePlayerAllowlist (populated below after login).
// - driverBot : alias for players[0], preserved so existing AC2AC4 tests
// that reference bots.driverBot keep working.
//
// Bots cannot invoke each other's slash commands or click each other's buttons,
// so slash commands and button interactions are driven via fakeInteraction /
// fakeButton (synthesized, but backed by REAL channel/thread objects + real
// Redis state). Chat TURNS, however, are driven by the player bots posting real
// gateway messages — the multiplayer point of this harness.
//
// Requires in env:
// DISCORD_TOKEN — token for the bot under test
// E2E_DRIVER_TOKEN — token for the driver bot
// E2E_DRIVER_TOKEN — token for player bot #1 (the former "driver")
// E2E_PLAYER2_TOKEN — token for player bot #2 (multiplayer E2E only)
// E2E_TEST_GUILD_ID — the dedicated test guild
// E2E_TEST_CHANNEL_ID — the channel to start encounters in
// E2E_ALLOW_PLAYER_BOTS=1 — opt-in flag that lets messageRouter route the
// player bots' messages as player turns
//
// All four are only needed for AC2AC4 (RUN_FULL_E2E=1). AC1 needs none of them.
// AC2AC4 need DISCORD_TOKEN + E2E_DRIVER_TOKEN + the guild/channel. The
// multiplayer MVP additionally needs E2E_PLAYER2_TOKEN + E2E_ALLOW_PLAYER_BOTS=1.
import { Client, GatewayIntentBits, type TextChannel, type Guild } from 'discord.js';
import { addPlayerBotIds, clearE2EPlayerBots } from '../../../../src/bot/lib/e2ePlayerAllowlist.js';
export interface LiveBots {
botClient: Client;
driverBot: Client;
driverBot: Client; // alias for players[0] (back-compat)
players: Client[]; // player-side bots that act as players
guild: Guild;
channel: TextChannel;
}
const INTENTS = [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
];
export async function connectLiveBots(): Promise<LiveBots> {
const botToken = process.env.DISCORD_TOKEN;
const driverToken = process.env.E2E_DRIVER_TOKEN;
const player2Token = process.env.E2E_PLAYER2_TOKEN;
const guildId = process.env.E2E_TEST_GUILD_ID;
const channelId = process.env.E2E_TEST_CHANNEL_ID;
for (const [k, v] of [
@@ -41,19 +65,36 @@ export async function connectLiveBots(): Promise<LiveBots> {
if (!v) throw new Error(`Live E2E requires env ${k} (set, or unset RUN_FULL_E2E).`);
}
const botClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] });
const driverBot = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] });
// Player-side bots: player1 (driver) always; player2 only when multiplayer is
// opted in. AC2AC4 get players.length === 1; the multiplayer MVP gets 2.
const playerTokens = [driverToken!, player2Token].filter(
(t): t is string => typeof t === 'string' && t.length > 0,
);
await Promise.all([botClient.login(botToken!), driverBot.login(driverToken!)]);
const botClient = new Client({ intents: INTENTS });
const players = playerTokens.map(t => new Client({ intents: INTENTS }));
await Promise.all([
botClient.login(botToken!),
...players.map(p => p.login(playerTokens[players.indexOf(p)]!)),
]);
// Populate the anti-loop allowlist with the player-bot userIds so messageRouter
// routes their real messages as player turns (gated by E2E_ALLOW_PLAYER_BOTS).
const playerIds = players
.map(p => p.user?.id)
.filter((id): id is string => typeof id === 'string');
addPlayerBotIds(playerIds);
const guild = await botClient.guilds.fetch(guildId!);
const channel = (await botClient.channels.fetch(channelId!)) as TextChannel;
if (!channel?.isTextBased() || channel.isThread()) {
throw new Error(`E2E_TEST_CHANNEL_ID must resolve to a guild text channel.`);
}
return { botClient, driverBot, guild, channel };
return { botClient, driverBot: players[0], players, guild, channel };
}
export async function disconnectLiveBots(b: LiveBots): Promise<void> {
await Promise.allSettled([b.botClient.destroy(), b.driverBot.destroy()]);
await Promise.allSettled([b.botClient.destroy(), ...b.players.map(p => p.destroy())]);
clearE2EPlayerBots();
}

View File

@@ -0,0 +1,43 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
addPlayerBotIds,
isE2EPlayerBot,
clearE2EPlayerBots,
} from '../../src/bot/lib/e2ePlayerAllowlist.js';
// e2ePlayerAllowlist is a runtime Set populated by the live E2E harness
// (connectLiveBots) after each player-bot client logs in, and read by
// messageRouter's anti-loop guard. It is gated by config.E2E_ALLOW_PLAYER_BOTS,
// so in production (flag off) it is never consulted.
describe('e2ePlayerAllowlist', () => {
beforeEach(() => clearE2EPlayerBots());
it('is empty by default — no id is a player bot', () => {
expect(isE2EPlayerBot('any-id')).toBe(false);
});
it('treats an added id as a player bot', () => {
addPlayerBotIds(['player-bot-1']);
expect(isE2EPlayerBot('player-bot-1')).toBe(true);
});
it('does not treat an unrelated id as a player bot after an add', () => {
addPlayerBotIds(['player-bot-1']);
expect(isE2EPlayerBot('player-bot-2')).toBe(false);
});
it('clears all ids', () => {
addPlayerBotIds(['player-bot-1']);
clearE2EPlayerBots();
expect(isE2EPlayerBot('player-bot-1')).toBe(false);
});
it('accepts multiple ids at once and is additive across calls', () => {
addPlayerBotIds(['player-bot-1', 'player-bot-2']);
addPlayerBotIds(['player-bot-3']);
expect(isE2EPlayerBot('player-bot-1')).toBe(true);
expect(isE2EPlayerBot('player-bot-2')).toBe(true);
expect(isE2EPlayerBot('player-bot-3')).toBe(true);
});
});

View File

@@ -0,0 +1,68 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import type { Message } from 'discord.js';
// Controllable config — messageRouter reads DISCORD_ALLOWED_CHANNELS (via
// isAllowedChannel) and E2E_ALLOW_PLAYER_BOTS (the new anti-loop flag).
const { mockConfig } = vi.hoisted(() => ({
mockConfig: {
DISCORD_ALLOWED_CHANNELS: ['parent-1'],
E2E_ALLOW_PLAYER_BOTS: false,
},
}));
vi.mock('../../src/config.js', () => ({ config: mockConfig }));
// Only sessionManager.get is reached on the path under test (handleMessage →
// line 39). Returning undefined makes it return at the !session guard before
// processEncounterMessage, so no other dep is exercised.
const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() }));
vi.mock('../../src/session/sessionManager.js', () => ({
sessionManager: { get: mockGet },
}));
vi.mock('../../src/lib/logger.js', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { handleMessage } from '../../src/bot/handlers/messageRouter.js';
import { addPlayerBotIds, clearE2EPlayerBots } from '../../src/bot/lib/e2ePlayerAllowlist.js';
// A bot-authored message in a thread whose parent is allowlisted.
function fakeBotMessage(authorId: string, parentId = 'parent-1'): Message {
return {
author: { bot: true, id: authorId },
channel: { isThread: () => true, parentId, id: 'thread-1' },
react: vi.fn().mockResolvedValue(undefined),
} as unknown as Message;
}
describe('handleMessage — E2E player-bot anti-loop allowlist', () => {
beforeEach(() => {
clearE2EPlayerBots();
mockGet.mockReset();
mockConfig.E2E_ALLOW_PLAYER_BOTS = false;
});
it('skips a bot-authored message when E2E_ALLOW_PLAYER_BOTS is off (prod default)', async () => {
await handleMessage(fakeBotMessage('player-bot-1'), {} as never);
expect(mockGet).not.toHaveBeenCalled();
});
it('routes an allowlisted player-bot message when the flag is on', async () => {
mockConfig.E2E_ALLOW_PLAYER_BOTS = true;
addPlayerBotIds(['player-bot-1']);
mockGet.mockResolvedValue(undefined); // no session → returns at !session guard
await handleMessage(fakeBotMessage('player-bot-1'), {} as never);
expect(mockGet).toHaveBeenCalledWith('thread-1');
});
it('skips a bot-authored message when the flag is on but the author is not allowlisted', async () => {
mockConfig.E2E_ALLOW_PLAYER_BOTS = true;
addPlayerBotIds(['player-bot-1']);
await handleMessage(fakeBotMessage('some-other-bot'), {} as never);
expect(mockGet).not.toHaveBeenCalled();
});
});