refactor(games): rework room lifecycle events and remove chess plugin

Consolidate room leave/delete event handling into RoomManager emitter,
remove redundant PLAYER_LEFT publishes from GameServer, and delete the
chess game plugin (board, types, tests) in favor of the new plugin
architecture. Add per-module CLAUDE.md files for leveling, guild-settings,
feature-flags, db, api, and panel to improve agent navigability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-04-05 15:19:51 +02:00
parent ebac1ad6cc
commit 56db5bc998
24 changed files with 206 additions and 921 deletions

40
shared/db/CLAUDE.md Normal file
View File

@@ -0,0 +1,40 @@
# Database Layer
## Column Types
- **Bigint** (`mode: 'bigint'`): All Discord snowflake IDs (`userId`, `guildId`, `roleId`, `channelId`), currency amounts (`balance`, `xp`, transaction `amount`, item `price`, inventory `quantity`). Use `0n` BigInt literals, never `Number()`.
- **Integer**: Small counts and internal IDs — `level`, `daily_streak`, `progress`, `reward_amount` in lootdrops, auto-increment `serial('id')` for items.
## Composite Primary Keys
- `inventory(userId, itemId)` — one stack per user per item
- `userQuests(userId, questId)` — one assignment per user per quest
- `userTimers(userId, type, key)` — one timer per user per type/key combo
## Constraints
- `inventory.quantity > 0` check constraint — never store zero-quantity rows
- `classes.name` and `items.name` are unique
- Cascade deletes on user FK; set null on `relatedUserId` (preserves transaction history)
## JSON Columns (JSONB)
- `quests.requirements`: `{ target: number }`
- `quests.rewards`: `{ xp?: number, balance?: number }`
- `gameSettings` fields: Typed via `.$type<T>()``LevelingConfig`, `EconomyConfig`, etc.
- `guildSettings.featureOverrides`: `Record<string, boolean>` (sparse)
- `guildSettings.colorRoleIds`: `string[]`
- `users.settings`, `userTimers.metadata`, `items.usageData`: Untyped JSONB, default `{}`
## Enums
Defined as TypeScript enums in `shared/lib/constants.ts`, **not** as database enums. Schema columns use `varchar` with length constraints. Key enum types:
- `TimerType`: COOLDOWN, EFFECT, ACCESS, EXAM_SYSTEM, TRIVIA_COOLDOWN
- `TransactionType`: TRANSFER_IN, DAILY_REWARD, etc. (8 values)
- `ModerationCaseType`: warn, timeout, kick, ban, note, prune
- `ItemType`: MATERIAL, CONSUMABLE, EQUIPMENT, QUEST
## Notable Indexes
- `user_timers_lookup_idx`: Composite on (userId, type, key) — fast timer checks
- `user_timers_expires_at_idx`: Expiry-based cleanup queries
- `users_balance_idx` / `users_level_xp_idx`: Leaderboard queries
## Client Setup
- `DrizzleClient` is a singleton in `shared/db/DrizzleClient.ts` (postgres-js driver, no prefetch).
- `withTransaction` utility in `bot/lib/db.ts` wraps Drizzle transactions and tracks count for graceful shutdown. Accepts optional existing `tx` for nested calls.
- No soft deletes anywhere. `moderationCases` uses `active: boolean` + `resolvedAt`/`resolvedBy` for lifecycle, but rows are never deleted.

View File

@@ -1,129 +0,0 @@
import { describe, it, expect } from "bun:test";
import { chessPlugin } from "./plugin";
import { gameRegistry } from "../registry";
const PLAYER_WHITE = "player1";
const PLAYER_BLACK = "player2";
describe("chessPlugin", () => {
describe("metadata", () => {
it("should have correct slug and player counts", () => {
expect(chessPlugin.slug).toBe("chess");
expect(chessPlugin.minPlayers).toBe(2);
expect(chessPlugin.maxPlayers).toBe(2);
});
});
describe("createInitialState", () => {
it("should create initial FEN position", () => {
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
expect(state.fen).toBe("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1");
expect(state.players.white).toBe(PLAYER_WHITE);
expect(state.players.black).toBe(PLAYER_BLACK);
expect(state.moveHistory).toEqual([]);
expect(state.status).toBe("playing");
});
});
describe("handleAction — move", () => {
it("should allow a legal pawn move", () => {
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
const result = chessPlugin.handleAction(state, { type: "move", from: "e2", to: "e4" }, PLAYER_WHITE);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.state.fen).not.toBe(state.fen);
expect(result.state.moveHistory).toEqual(["e4"]);
}
});
it("should reject move when it is not the player's turn", () => {
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
const result = chessPlugin.handleAction(state, { type: "move", from: "e7", to: "e5" }, PLAYER_BLACK);
expect(result.ok).toBe(false);
});
it("should reject an illegal move", () => {
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
const result = chessPlugin.handleAction(state, { type: "move", from: "e2", to: "e5" }, PLAYER_WHITE);
expect(result.ok).toBe(false);
});
it("should reject a non-player's move", () => {
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
const result = chessPlugin.handleAction(state, { type: "move", from: "e2", to: "e4" }, "random_player");
expect(result.ok).toBe(false);
});
it("should detect checkmate", () => {
// Scholar's mate: 1.e4 e5 2.Bc4 Nc6 3.Qh5 Nf6 4.Qxf7#
let state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
const moves = [
{ player: PLAYER_WHITE, from: "e2", to: "e4" },
{ player: PLAYER_BLACK, from: "e7", to: "e5" },
{ player: PLAYER_WHITE, from: "f1", to: "c4" },
{ player: PLAYER_BLACK, from: "b8", to: "c6" },
{ player: PLAYER_WHITE, from: "d1", to: "h5" },
{ player: PLAYER_BLACK, from: "g8", to: "f6" },
{ player: PLAYER_WHITE, from: "h5", to: "f7" },
];
for (const m of moves) {
const result = chessPlugin.handleAction(state, { type: "move", from: m.from, to: m.to }, m.player);
expect(result.ok).toBe(true);
if (result.ok) state = result.state;
}
expect(state.status).toBe("checkmate");
expect(state.winner).toBe(PLAYER_WHITE);
});
});
describe("handleAction — forfeit", () => {
it("should end the game with the other player as winner", () => {
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
const result = chessPlugin.handleAction(state, { type: "forfeit" }, PLAYER_WHITE);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.state.status).toBe("forfeit");
expect(result.state.winner).toBe(PLAYER_BLACK);
}
});
});
describe("getPlayerView", () => {
it("should return full state", () => {
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
const view = chessPlugin.getPlayerView(state, PLAYER_WHITE);
expect(view).toEqual(state);
});
});
describe("isGameOver", () => {
it("should return null for ongoing game", () => {
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
expect(chessPlugin.isGameOver!(state)).toBeNull();
});
it("should return winner for forfeit", () => {
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
state.status = "forfeit";
state.winner = PLAYER_BLACK;
expect(chessPlugin.isGameOver!(state)).toEqual({ winner: PLAYER_BLACK, reason: "forfeit" });
});
});
describe("onPlayerDisconnect", () => {
it("should forfeit the disconnected player", () => {
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
const result = chessPlugin.onPlayerDisconnect!(state, PLAYER_WHITE);
expect(result.status).toBe("forfeit");
expect(result.winner).toBe(PLAYER_BLACK);
});
});
describe("chess registration", () => {
it("should register and retrieve from gameRegistry", () => {
gameRegistry.register(chessPlugin);
expect(gameRegistry.get("chess")).toBe(chessPlugin);
expect(gameRegistry.list()).toContain(chessPlugin);
});
});
});

View File

@@ -1,114 +0,0 @@
import { Chess } from "chess.js";
import type { GamePlugin, GameResult, GameOverResult } from "../types";
import type { ChessState, ChessAction } from "./types";
function getPlayerColor(state: ChessState, playerId: string): "white" | "black" | null {
if (state.players.white === playerId) return "white";
if (state.players.black === playerId) return "black";
return null;
}
function deriveStatus(game: Chess): ChessState["status"] {
if (game.isCheckmate()) return "checkmate";
if (game.isStalemate()) return "stalemate";
if (game.isDraw()) return "draw";
return "playing";
}
export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
slug: "chess",
name: "Chess",
minPlayers: 2,
maxPlayers: 2,
createInitialState(players: string[]): ChessState {
const game = new Chess();
return {
fen: game.fen(),
players: { white: players[0]!, black: players[1]! },
moveHistory: [],
status: "playing",
winner: null,
};
},
handleAction(state: ChessState, action: ChessAction, playerId: string): GameResult<ChessState> {
if (state.status !== "playing") {
return { ok: false, error: "Game is already over" };
}
const solo = state.players.white === state.players.black;
if (action.type === "forfeit") {
if (solo) return { ok: true, state: { ...state, status: "forfeit", winner: null } };
const color = getPlayerColor(state, playerId);
if (!color) return { ok: false, error: "You are not a player in this game" };
const winner = color === "white" ? state.players.black : state.players.white;
return { ok: true, state: { ...state, status: "forfeit", winner } };
}
if (action.type === "move") {
const game = new Chess(state.fen);
const turn = game.turn() === "w" ? "white" : "black";
if (!solo) {
const playerColor = getPlayerColor(state, playerId);
if (!playerColor) return { ok: false, error: "You are not a player in this game" };
if (playerColor !== turn) return { ok: false, error: "It is not your turn" };
}
let move;
try {
move = game.move({
from: action.from,
to: action.to,
promotion: action.promotion ?? "q",
});
} catch {
return { ok: false, error: "Invalid move" };
}
if (!move) return { ok: false, error: "Invalid move" };
const status = deriveStatus(game);
const winner = status === "checkmate"
? (turn === "white" ? state.players.white : state.players.black)
: null;
return {
ok: true,
state: {
...state,
fen: game.fen(),
moveHistory: [...state.moveHistory, move.san],
status,
winner,
},
};
}
return { ok: false, error: "Unknown action type" };
},
getPlayerView(state: ChessState, playerId: string): ChessState {
const playerColor = getPlayerColor(state, playerId);
if (!playerColor) return state;
return state;
},
getSpectatorView(state: ChessState): ChessState { return state; },
isGameOver(state: ChessState): GameOverResult | null {
if (state.status === "playing") return null;
return { winner: state.winner, reason: state.status };
},
onPlayerDisconnect(state: ChessState, playerId: string): ChessState {
if (state.players.white === state.players.black) {
return { ...state, status: "forfeit", winner: null };
}
const color = getPlayerColor(state, playerId);
if (!color) return state;
const winner = color === "white" ? state.players.black : state.players.white;
return { ...state, status: "forfeit", winner };
},
};

View File

@@ -1,11 +0,0 @@
export interface ChessState {
fen: string;
players: { white: string; black: string };
moveHistory: string[];
status: "playing" | "checkmate" | "stalemate" | "draw" | "forfeit";
winner: string | null;
}
export type ChessAction =
| { type: "move"; from: string; to: string; promotion?: string }
| { type: "forfeit" };

View File

@@ -0,0 +1,9 @@
# Feature Flags Module
- **No caching.** Every `isFlagEnabled()` and `hasAccess()` call hits the database directly.
- `isFlagEnabled(flagName)` checks global on/off state. `hasAccess(flagName, context)` checks both global state AND per-entity access records (guild, user, or role).
- Access logic: flag must be globally enabled AND user must have an explicit access grant. Grants can target guildId, userId, or roleId independently.
- Commands declare `beta: true` and optionally `featureFlag: string` in the Command interface. `CommandHandler` intercepts beta commands and calls `hasAccess()` before execution.
- If a command has no explicit `featureFlag`, the command name (`interaction.commandName`) is used as the flag name fallback.
- Flag names are case-sensitive. Convention is snake_case or camelCase — no enforcement.
- Admin management via `/featureflags` command: CRUD on flags and access grants/revokes.

View File

@@ -0,0 +1,10 @@
# Guild Settings Module
- `updateSetting()` uses a hardcoded `keyMap` to map friendly key names to DB columns. Use exact key names (e.g., `"studentRole"` not `"studentRoleId"`). Unknown keys throw `UserError`.
- Type coercion per column: Discord IDs → BigInt automatically; `colorRoleIds` must be array; `featureOverrides` must be object; `moderationDmOnWarn` must be boolean; `moderationAutoTimeoutThreshold` must be number. Null values set columns to NULL.
- **Caching:** `getGuildConfig()` (in `shared/lib/config.ts`) caches transformed settings for 60 seconds. Every mutation (`upsertSettings`, `updateSetting`, `addColorRole`, `removeColorRole`) calls `invalidateGuildConfigCache(guildId)` immediately.
- If settings don't exist for a guild, the cache returns safe defaults — no errors thrown.
- `featureOverrides` is a sparse `Record<string, boolean>` — no keys are predefined. Consumers must check key existence.
- **No Discord validation:** The service does not verify that role/channel IDs actually exist in Discord. Invalid IDs are stored silently.
- `addColorRole()` / `removeColorRole()` fetch the full settings, mutate the array in JS, then upsert — this is not atomic and can race under concurrent requests.
- `terminalMessageId` and `terminalChannelId` are separate DB columns but grouped as `terminal: { channelId, messageId }` in the cached config. Setting one without the other can create orphaned data.

View File

@@ -0,0 +1,9 @@
# Leveling Module
- **Level is derived, not stored.** Total XP is the source of truth. `getLevelFromXp()` recalculates level from cumulative XP on every `addXp()` call. Levels are monotonic — they never decrease.
- XP curve is a power law: `xpForLevel(n) = floor(base * n^exponent)` where defaults are `base: 100`, `exponent: 1.5`. Config comes from `gameSettingsService` (30s cache TTL).
- Chat XP (`processChatXp()`) awards random XP between `minXp` (5) and `maxXp` (15) per message, gated by a 60-second per-user cooldown (`TimerType.COOLDOWN`, key `TimerKey.CHAT_XP`). The cooldown is upserted atomically.
- Quest/reward XP uses `addXp()` directly — it bypasses the chat cooldown.
- XP boost multipliers come from active `TimerType.EFFECT` timers with key `'xp_boost'` (metadata field: `multiplier`).
- All XP values are `bigint` in the DB but converted to `Number` for arithmetic. Watch for overflow at extremely high XP values.
- `addXp()` and `processChatXp()` run inside transactions. They emit `XP_GAINED` (fire-and-forget) which the quest system listens to — the weight equals the XP amount.