🗺️ Catan Perfection Audit

Generated by: Ultrathink Workflowz Council Swarm Lenses: Rules & Logic, Platform Integration, Security, Svelte Performance, Accessibility

🚨 Critical Priority

Unconditional phase advancement on roll of 7 bypasses robber blocking

Evidence: In GameController.ts, handleRoll() checks if (total === 7) and dispatches onSevenRoll(ctx), but then unconditionally calls this.nextPhase() immediately after the if/else block. This forcefully transitions the game into the ‘trade’ phase while the robber movement and half-card discards are still pending asynchronously, allowing players to trade resources before resolving the robber.

Recommendation: Guard this.nextPhase() so it is only called if total !== 7. Advancing to the ‘trade’ phase should be delegated to the robber controller or the onSevenRoll completion handler once all discards and the robber movement are resolved.

Global GameStore bleeds state across sessions due to missing unmount cleanup

Evidence: Because the manifest specifies “integrationType”: “svelte-component”, the game runs directly in the host platform’s DOM without iframe isolation. The game instance from GameState.svelte.ts is an exported global singleton. When a user leaves a match and joins another, CatanGame.svelte remounts but retains the previous match’s data (e.g., winner, started flags). This will instantly trigger false ‘finished’ or ‘playing’ hostUpdates upon mounting the new match.

Recommendation: Import onDestroy from svelte and call game.reset() when the entry point component unmounts.

Network build events bypass placement and resource validations

Evidence: In GameController.ts, the applyBuildFromNetwork method explicitly passes false to the validate parameter of applyBuild. Because of this, incoming OpCode.BUILD payloads skip the canBuild check and are completely trusted by receiving clients. A malicious peer can spoof build events to construct roads, settlements, or cities anywhere on the board without spending resources, or deduct resources from other players’ accounts by passing their playerId.

  applyBuildFromNetwork(playerId: string, type: string, locationId: string) {
    this.applyBuild(playerId, type, locationId, false, true);
  }

Unauthenticated turn skipping allows denial of service and state hijacking

Evidence: In GameController.ts, the endTurn method accepts an endingPlayerId parameter and transitions the game state to the next player. It does not verify that the endingPlayerId matches the currently active player (game.state.turn.playerId). A malicious client can broadcast OpCode.END_TURN payloads for other players, instantly ending their turns and forcing the game to advance back to the attacker’s turn.

Recommendation: ```suggestion endTurn( endingPlayerId: string = game.state.turn.playerId, shouldSend: boolean = true, ) { if (endingPlayerId !== game.state.turn.playerId) return;

// Send to network
if (shouldSend) {
  nakamaManager.sendMatchData(OpCode.END_TURN, {
    playerId: endingPlayerId,
  });
}

### Invalid `.svelte` extension on Svelte 5 rune module import
**Evidence**: `import { game } from "./GameState.svelte";` in `GameController.ts` and `CatanGame.svelte` imports `GameState.svelte.ts`. In Svelte 5, the `.svelte` extension strictly maps to UI components compiled with a default class export. Importing a `.svelte.ts` rune module using `.svelte` causes Vite to compile it as a component, resulting in a "game is not exported" fatal build error.

**Recommendation**: Change the import to use the correct `.svelte.ts` extension: `import { game } from "./GameState.svelte.ts";`

### Stale state initialization for Game Controller extensions
**Evidence**: `GameController` is instantiated at the module level, invoking `initExtensions()` synchronously which statically reads `game.state.config.scenario` (defaulting to `'base'`). When `CatanGame.svelte` merges dynamic `initialConfig` props inside `onMount()` to change the scenario, `initExtensions()` is never invoked again. Scenario-specific logic like `seafarersExt` remains `null`, causing actions like `moveShip` to silently fail.

**Recommendation**: Re-invoke `gameController.initExtensions()` inside `CatanGame.svelte`'s `onMount` block after `initialConfig` is merged to ensure extensions dynamically synchronize with the correct platform scenario.

## 🚨 High Priority

### `checkVictory` ignores active turn and arbitrary iteration order allows incorrect player to win
**Evidence**: In `GameController.ts`, `checkVictory()` loops through `Object.entries(game.state.players)` and returns the first player with `victoryPoints >= goal`. This violates the Catan rule that a player can only win on their turn. If a road is broken on Player A's turn, granting Longest Road to Player B and C simultaneously, dictionary insertion order will determine the winner, allowing an off-turn instant win.

**Recommendation**: Ensure `checkVictory` first checks if the active player (`game.state.turn.playerId`) has reached the required VP. If not, no one wins, or restrict victory declarations strictly to the active player's turn.

### `endTurn` lacks protection during Special Build Phase, enabling turn order hijacking
**Evidence**: In `GameController.ts`, `endTurn()` has no guard to check if `this.specialBuildPhaseActive` is true. If a malicious client sends an `OpCode.END_TURN` during their Special Build turn instead of triggering `endSpecialBuild()`, the `endTurn()` logic will overwrite `this.nextRegularTurnPlayer` and re-invoke `this.startSpecialBuildPhase(endingPlayerId)`. This resets the special build order and traps the game in an infinite loop of special builds, permanently stalling the normal turn order.

**Recommendation**: Add a guard at the top of `endTurn()`: `if (this.isSpecialBuildPhase()) { this.endSpecialBuild(); return; }` to ensure end-turn opcodes correctly route to the special build phase logic when active.

### Double dispatch of 'playing' hostUpdate overwrites transition payload
**Evidence**: In `CatanGame.svelte`, when transitioning to the 'playing' state, the first `$effect` block sends a `hostUpdate` with `players: playerCount` and sets `prevStarted = true`. Because it fails to update `prevTurnPlayer` at the same time, the subsequent 'Turn changed' block evaluates to true immediately after (since `game.state.turn.playerId !== null`). This instantly fires a second `hostUpdate` with only the `turn` property, overwriting the initial payload and stripping the player count from the platform UI state.

**Recommendation**: Set `prevTurnPlayer = game.state.turn.playerId;` inside the transition block to prevent the subsequent turn-change block from firing.

### GameController initializes with default scenario and ignores initialConfig overrides
**Evidence**: `GameController`'s constructor calls `initExtensions()` at module import time, reading the default 'base' scenario from `GameState`. When `CatanGame.svelte` mounts and applies the platform's `initialConfig` (e.g., changing the scenario to 'seafarers'), the controller is not notified. The game will report 'seafarers' to the platform but execute with base rules, breaking ship movement and special phases because `seafarersExt` remains null.

**Recommendation**: Import `gameController` in `CatanGame.svelte` and call `gameController.initExtensions()` immediately after merging `initialConfig` in `onMount`.

### Client-generated dice rolls bypass phase checks and bounds validation
**Evidence**: In `GameController.ts`, the `handleRoll` method trusts the `dice` payload completely without validating that it is the correct phase (e.g., `roll` phase) or that the dice values are mathematically possible (1-6). A malicious client can repeatedly broadcast spoofed `OpCode.ROLL` events out of turn to trigger resource distributions or the robber indefinitely. Invalid dice arrays (like `[100, 100]`) can also be injected to cause out-of-bounds yields.

```suggestion
  handleRoll(dice: [number, number], shouldSend: boolean = true) {
    if (game.state.turn.phase !== "roll") return;
    if (dice[0] < 1 || dice[0] > 6 || dice[1] < 1 || dice[1] > 6) return;

    // Send to network (optimistic update)
    if (shouldSend) {
      nakamaManager.sendMatchData(OpCode.ROLL, {
        playerId: game.state.turn.playerId,
        dice,
      });
    }
  }

Reactive $state mutation inside $effect causes over-rendering cycles

Evidence: In CatanGame.svelte, prevStarted, prevWinner, and prevTurnPlayer are declared as $state() runes. Inside the $effect block, they are evaluated (e.g., if (!game.state.started && prevStarted)) and subsequently mutated (e.g., prevStarted = true;). Modifying a $state property that the effect also tracks invalidates the dependency graph and forces the effect to redundantly re-execute.

Recommendation: Convert the local tracking variables to standard non-reactive declarations since they are not used in the template: let prevStarted = false; let prevWinner: string | null = null; let prevTurnPlayer: string | null = null;

Missing aria-live announcements for turn changes and game state

Evidence: In src/CatanGame.svelte, critical game state transitions such as turn changes (game.state.turn.playerId !== prevTurnPlayer) and game conclusion trigger hostUpdate, but there is no aria-live region in the DOM to announce these events to screen readers.

Recommendation: Implement a visually hidden <div aria-live="polite"> in CatanGame.svelte that updates its text contextually (e.g., “It is now your turn”) when reactive state transitions occur.

🚨 Medium Priority

Missing accessibility and control metadata in manifest

Evidence: The MANIFEST file does not declare any accessibility features, control remapping support, or supported input types (e.g., keyboard, touch, screen-reader) under its metadata or requirements objects.

Recommendation: Add explicit accessibility (e.g., colorblind modes, screen-reader support) and inputTypes fields to the manifest so the platform can correctly filter and expose the game to users with specific needs.

No root-level focus management or keyboard trap configuration

Evidence: The CatanGame.svelte root component simply mounts <App /> without establishing a focus boundary, tabindex management, or top-level keyboard event listeners. Keyboard users risk losing focus to the surrounding browser chrome or platform shell.

Recommendation: Wrap the <App> inside a container with tabindex="-1", programmatically focus it on initialization, and implement a focus trap to securely maintain keyboard interactions within the game context.

Ask Docs

AI assistant to help answer questions about the documentation. Answers are read-only and cite docs/source.

Hi! How can I help you with the documentation today? Answers are read-only and cite docs/source.

Ctrl+Enter to send