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:
89
specs/e2e-group-multiplayer.yaml
Normal file
89
specs/e2e-group-multiplayer.yaml
Normal 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.
|
||||
@@ -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;
|
||||
|
||||
29
src/bot/lib/e2ePlayerAllowlist.ts
Normal file
29
src/bot/lib/e2ePlayerAllowlist.ts
Normal 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();
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
205
tests/integration/graphmcp/group-encounter-live.test.ts
Normal file
205
tests/integration/graphmcp/group-encounter-live.test.ts
Normal 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-11–14:
|
||||
// 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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 AC2–AC4 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 AC2–AC4 (RUN_FULL_E2E=1). AC1 needs none of them.
|
||||
// AC2–AC4 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. AC2–AC4 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();
|
||||
}
|
||||
43
tests/unit/e2ePlayerAllowlist.test.ts
Normal file
43
tests/unit/e2ePlayerAllowlist.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
68
tests/unit/messageRouterAllowlist.test.ts
Normal file
68
tests/unit/messageRouterAllowlist.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user