diff --git a/README.md b/README.md index 3d66ae3f..ce8ea883 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ npm run dev -- --host 0.0.0.0 Open `http://localhost:3000`. Create a room, share its eight-character invitation, and add human or AI seats. The host can configure Easy, Medium, Hard and Brutal AI opponents. Current calibration demonstrates a substantially weaker Easy policy; it does not yet demonstrate a consistent ordering among the upper three levels. +Before starting, open **Configure** on an AI seat to change its difficulty, +faction or player circle without replacing it. Changes save immediately and +clear human readiness. See [lobby AI configuration](docs/LOBBY_AI_CONFIGURATION.md). + Full AI implementation and tuning wait until all non-AI game features are complete. The [AI development plan](docs/AI_DEVELOPMENT_PLAN.md) then targets approximately 75% higher-tier wins for Medium/Easy, Hard/Medium and Brutal/Hard. diff --git a/app/globals.css b/app/globals.css index 831e8dd5..cb87c76c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1167,6 +1167,9 @@ footer { border: 1px solid var(--border); padding: 14px; } + .player-card:has(.lobby-bot-configuration[open]) { + flex: 0 0 min(20rem, calc(100vw - 3rem)); + } .resource-readout { margin: 0; min-width: 120px; @@ -1660,6 +1663,13 @@ footer { border-radius: 5px; padding: 8px; } +.lobby-bot-configuration select { + background: #18201d; + color: #eee4ce; + border: 1px solid #50432d; + border-radius: 5px; + padding: 8px; +} .private-battle-plan { margin: 1rem 0; diff --git a/components/game-table.tsx b/components/game-table.tsx index cbb2d21c..ef4bcada 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1,5 +1,6 @@ 'use client'; import { EcazSetup } from './ecaz-setup'; +import { LobbyBotControls } from './lobby-bot-controls'; import { IxRicheseTechnology } from './ix-richese-technology'; import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options'; import { @@ -1085,15 +1086,7 @@ export function GameTable({ )} {p.bot && g.status === 'lobby' && g.host === me.id && ( - + )}
{p.reserves} reserves diff --git a/components/lobby-bot-controls.tsx b/components/lobby-bot-controls.tsx new file mode 100644 index 00000000..ea24df94 --- /dev/null +++ b/components/lobby-bot-controls.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useId } from 'react'; +import { Button } from '@/components/ui/button'; +import { BOT_DESCRIPTIONS, DIFFICULTIES } from '@/game/bot-profiles'; +import { FACTIONS } from '@/game/catalog'; +import type { Action, GameView } from '@/game/engine'; +import { + quoteLobbyBotConfiguration, + type LobbyBotConfiguration, +} from '@/game/lobby-bot-configuration'; +import { PLAYER_CIRCLE_SECTORS } from '@/game/player-positions'; + +export function LobbyBotControls({ + g, + target, + busy, + act, +}: { + g: GameView; + target: string; + busy: boolean; + act: (action: Action) => void; +}) { + const id = useId(); + const player = g.players.find((entry) => entry.id === target); + if (g.status !== 'lobby' || g.host !== g.me || !player?.bot) return null; + const current: LobbyBotConfiguration = { + target, + difficulty: player.bot, + faction: player.faction, + position: g.playerPositions[target], + }; + const context = { ...g, positions: g.playerPositions }; + const quote = (change: Partial) => + quoteLobbyBotConfiguration(context, g.me, { + type: 'configureBot', + ...current, + ...change, + }); + const change = (update: Partial) => { + const result = quote(update); + if (!busy && result.ok && result.changed) + act({ type: 'configureBot', ...result.configuration }); + }; + + return ( +
+ + Configure {player.name} + +
+

+ Changes save immediately. Human players must confirm readiness again. +

+ +

+ {BOT_DESCRIPTIONS[current.difficulty]} +

+ + +

+ Circles determine storm order. +

+ +
+
+ ); +} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 4883b0bf..0027057c 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,21 +6,22 @@ gated.** Prototypes do not open completion or publication gates. ## Current checkpoint and work -Latest delivered checkpoint: `e13ad45`, [movement and Traitor practice](INTERACTIVE_INTRODUCTION.md). -Types, lint, **4,899 offline tests** and production build passed. Seven lessons -and five exercises connect shared rules and migrated browser-local saves. All -1,174 baseline rooms and 2,456 seats stayed unchanged; no restart was needed. -The preceding `54c8030` connects [Sandmaster worm rides](SANDMASTER_WORM.md). +Latest delivered checkpoint: `b21b2bc`, [one-use AI permission](SEAT_AI_PERMISSION.md). +Types, lint, **4,906 offline tests**, production build and **45 HTTP tests** passed. +All 1,174 baseline rooms and 2,456 seats stayed unchanged; the new preservation +baseline contains 1,262 rooms and 2,644 seats. No restart was needed. +The preceding `e13ad45` connects [movement and Traitor practice](INTERACTIVE_INTRODUCTION.md). The `8f7a62c` policy checkpoint removed the usage cutoff and deferred AI refinement until feature completion, with three adjacent 75% win-rate targets. Earlier evidence remains in the [implementation history](IMPLEMENTATION_STATUS.md). -Current work: [one-use AI permission](SEAT_AI_PERMISSION.md) connects optional -named-player activation at the owner's chosen difficulty, retained ownership, -both session fences and saved exact retries. Final independent review, -verification and delivery belong to the source-bound private report and Git -message. Broader disconnected-seat acceptance remains unfinished. +Current work: [lobby AI configuration](LOBBY_AI_CONFIGURATION.md) connects +host controls for an existing AI seat's difficulty, faction and player circle, +preserving identity and resetting human readiness. It uses the existing profiles +without strategic refinement. Final independent review, verification and delivery +belong to the source-bound private report and Git message. Broader disconnected-seat +acceptance remains unfinished. A bounded remaining-effect audit found that the reviewed faction/Nexus candidates still cross recorded pending rulings. Continue independent features rather than diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 50f327ee..852309e8 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,16 @@ # Dune implementation status +## 20 September 2026 — Configure existing lobby AI seats + +[Lobby AI configuration](LOBBY_AI_CONFIGURATION.md) connects host-only difficulty, +faction and player-circle controls to a shared strict quote and authoritative +lobby action. Seat identity persists, faction rosters follow existing setup, +actual changes clear human readiness, and public history explains each change. +Focused engine, SQLite/restart/concurrent-start and HTTP tests cover the bounded +contract. Final independent review, browser, source-bound check/build/HTTP and +saved-game preservation evidence belong to the private checkpoint and Git message. +Existing AI strategies and mode/publication gates remain unchanged. + ## 20 September 2026 — One-use named-player AI permission The [AI permission prototype](SEAT_AI_PERMISSION.md) connects optional owner diff --git a/docs/LOBBY_AI_CONFIGURATION.md b/docs/LOBBY_AI_CONFIGURATION.md new file mode 100644 index 00000000..7753e3e1 --- /dev/null +++ b/docs/LOBBY_AI_CONFIGURATION.md @@ -0,0 +1,35 @@ +# Lobby AI configuration + +The host can add, remove and configure permanent AI seats before starting. Each +seat's Configure panel offers its existing difficulty, an available enabled +faction and an unoccupied player circle. Changes save immediately; human players +must confirm readiness again, while AI seats stay ready. Changing faction also +updates the public AI name. The same seat ID survives every change and refresh. + +The shared `quoteLobbyBotConfiguration` validates the complete action for both +the controls and server: exact fields, authenticated host, lobby status, +permanent AI target, one of four existing profiles, enabled unoccupied faction, +and integer circle 1–6. Human seats, including those using autopilot, cannot be +configured through this action. Faction changes initialize leaders and Ixian +elite reserves consistently with existing lobby selection. Difficulty and circle +changes preserve those rosters. Public history names the changed fields and +explains the readiness reset. + +Controls do not send unchanged settings. An identical direct server action +preserves players, readiness and history, but advances the room version through +the existing action store. Concurrent and stale requests retain that store's +version/session fences. Configuration cannot overwrite a simultaneous successful +start, and starting cannot overwrite a successful configuration. If configuration +wins, cleared human readiness blocks starting until reconfirmed. + +This is an integrated lobby capability, not AI strategy refinement or complete +multiplayer acceptance. It preserves the existing profiles, mode/expansion start +gates and the [post-feature AI development plan](AI_DEVELOPMENT_PLAN.md). + +Evidence includes the focused engine/quote tests in +`tests/lobby-bot-configuration.test.ts`, real SQLite/restart/concurrency checks in +`tests/lobby-bot-configuration-recovery.test.ts`, and authenticated HTTP checks in +`tests/lobby-bot-configuration-http.test.ts`. Browser, independent review and +final source-bound checks/preservation belong to the private checkpoint report +and Git message. Full rules completion, final accessibility review and AI +calibration remain separate gates. diff --git a/game/engine.ts b/game/engine.ts index 03013071..de6adc25 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -506,6 +506,7 @@ import { } from './advisors'; import { casualtyOptions, maxCombatDial, maxCombatSupport, validCombatForces, type Casualties, type CombatForces } from './combat'; import { FACTIONS, faction, type FactionId } from './catalog'; +import { quoteLobbyBotConfiguration } from './lobby-bot-configuration'; import { treacheryDeck, normalizeLegacyCardNames, @@ -22370,6 +22371,59 @@ function applyActionInner( return g; } if (g.status === 'lobby') { + if (t === 'configureBot') { + const positions = normalizedPlayerPositions(g); + const quote = quoteLobbyBotConfiguration( + { + status: g.status, + host: g.host, + players: g.players, + expansions: g.expansions, + positions, + }, + id, + action, + ); + if (!quote.ok) requireRule(false, quote.reason); + if (!quote.changed) return g; + const configuration = quote.configuration; + const bot = getPlayer(g, configuration.target); + const previous = { + name: bot.name, + difficulty: bot.bot!, + faction: bot.faction, + position: positions[bot.id], + }; + bot.bot = configuration.difficulty; + if (bot.faction !== configuration.faction) { + bot.faction = configuration.faction; + bot.name = `${faction(bot.faction).name} AI`; + bot.elites = + bot.faction === 'ixians' + ? { reserves: 7, tanks: 0, forces: {}, revived: 0 } + : undefined; + bot.leaders = leaders(bot.faction); + } + positions[bot.id] = configuration.position; + g.playerPositions = positions; + g.players.forEach((player) => (player.ready = !!player.bot)); + const changes: string[] = []; + if (previous.difficulty !== bot.bot) + changes.push(`difficulty ${previous.difficulty} to ${bot.bot}`); + if (previous.faction !== bot.faction) + changes.push( + `faction ${faction(previous.faction).name} to ${faction(bot.faction).name}`, + ); + if (previous.position !== configuration.position) + changes.push( + `player circle ${previous.position} to ${configuration.position}`, + ); + log( + g, + `${p.name} configured ${previous.name}: ${changes.join('; ')}. Human readiness was cleared; AI seats remain ready.`, + ); + return g; + } if (t === 'seatPosition') { requireRule( action.target === undefined || action.target === id, diff --git a/game/lobby-bot-configuration.ts b/game/lobby-bot-configuration.ts new file mode 100644 index 00000000..bec34296 --- /dev/null +++ b/game/lobby-bot-configuration.ts @@ -0,0 +1,112 @@ +import { DIFFICULTIES, type Difficulty } from './bot-profiles'; +import { FACTIONS, faction, type FactionId } from './catalog'; + +export type LobbyBotConfiguration = Readonly<{ + target: string; + difficulty: Difficulty; + faction: FactionId; + position: number; +}>; + +export type LobbyBotConfigurationContext = Readonly<{ + status: string; + host: string; + players: ReadonlyArray< + Readonly<{ + id: string; + faction: FactionId; + bot?: Difficulty; + }> + >; + expansions: readonly string[]; + positions: Readonly>; +}>; + +export type LobbyBotConfigurationQuote = + | Readonly<{ + ok: true; + configuration: LobbyBotConfiguration; + changed: boolean; + }> + | Readonly<{ ok: false; reason: string }>; + +const rejection = (reason: string): LobbyBotConfigurationQuote => ({ + ok: false, + reason, +}); + +export function quoteLobbyBotConfiguration( + context: LobbyBotConfigurationContext, + actorId: string, + input: unknown, +): LobbyBotConfigurationQuote { + if (context.status !== 'lobby') + return rejection('AI seats can be configured only in the lobby.'); + if (actorId !== context.host) + return rejection('Only the host can configure AI seats.'); + if (!input || typeof input !== 'object' || Array.isArray(input)) + return rejection('Choose an AI seat configuration.'); + const value = input as Record; + const keys = ['type', 'target', 'difficulty', 'faction', 'position']; + if ( + Object.keys(value).length !== keys.length || + !keys.every((key) => Object.hasOwn(value, key)) || + value.type !== 'configureBot' + ) + return rejection( + 'Choose only the AI seat, difficulty, faction and player circle.', + ); + if (typeof value.target !== 'string') + return rejection('Choose a permanent AI seat.'); + const target = context.players.find((player) => player.id === value.target); + if (!target?.bot) return rejection('Choose a permanent AI seat.'); + if ( + typeof value.difficulty !== 'string' || + !DIFFICULTIES.some((difficulty) => difficulty === value.difficulty) + ) + return rejection('Choose a valid AI difficulty.'); + if ( + typeof value.faction !== 'string' || + !FACTIONS.some((entry) => entry.id === value.faction) + ) + return rejection('Choose a valid faction.'); + const selectedFaction = value.faction as FactionId; + const expansion = faction(selectedFaction).expansion; + if (expansion !== 'base' && !context.expansions.includes(expansion)) + return rejection('That faction expansion is disabled.'); + if ( + context.players.some( + (player) => player.id !== target.id && player.faction === selectedFaction, + ) + ) + return rejection('That faction is taken.'); + if ( + typeof value.position !== 'number' || + !Number.isSafeInteger(value.position) || + value.position < 1 || + value.position > 6 + ) + return rejection('Player circle must be an integer from 1 to 6.'); + if ( + context.players.some( + (player) => + player.id !== target.id && + context.positions[player.id] === value.position, + ) + ) + return rejection('That player circle is occupied.'); + const configuration: LobbyBotConfiguration = { + target: target.id, + difficulty: value.difficulty as Difficulty, + faction: selectedFaction, + position: value.position, + }; + return { + ok: true, + configuration, + changed: + target.bot !== configuration.difficulty || + target.faction !== configuration.faction || + context.positions[target.id] !== configuration.position, + }; +} diff --git a/game/reference.ts b/game/reference.ts index c0fcf70a..8f6c2b1e 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1904,11 +1904,12 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: 'The host can fill open faction seats with Easy, Medium, Hard or Brutal AI players.', steps: [ - 'In the lobby, choose an unoccupied faction and difficulty, then select Add AI player. The host can remove an AI seat before starting. AI seats mark themselves ready.', + 'In the lobby, choose an unoccupied faction and difficulty, then select Add AI player. Open Configure on an AI seat to change its difficulty, faction or player circle, or remove it before starting. Changes save immediately, keep the same seat and clear human readiness; AI seats remain ready.', 'Easy uses varied destinations, small deployments and low bids. Medium balances strongholds and spice. Hard uses more conservative battle estimates and targeted Karama responses. Brutal spends more and prioritizes disrupting rivals holding several strongholds.', 'AI decisions use the same private player view and legal-action checks as human decisions. Difficulty never grants extra spice, hidden cards or access to an opponent’s sealed plan.', 'Online rooms schedule one AI action at a time with a 1.5-second interval. A response that needs your input waits for you, even when every other seat is AI. Reconnecting preserves the saved AI deadline and seat control; see Online AI action pacing for its verification boundary.', 'These policies complete Basic games. A balanced study found Easy weaker than the other levels, but did not establish a reliable strength ordering among Medium, Hard and Brutal. Advanced and expansion decisions are still being developed.', + 'Full AI strategy development and strength tuning wait until all other game features are complete. Final targets are approximately 75% wins for Medium against Easy, Hard against Medium, and Brutal against Hard in balanced pairwise matches; these targets have not yet been demonstrated.', ], related: [ 'setup', @@ -1917,6 +1918,43 @@ export const RULE_TOPICS: RuleTopic[] = [ 'ai-pacing', 'implementation-checklist', ], + checklist: [ + { + area: 'Implementation', + status: 'Partial', + detail: + 'Integrated: lobby AI seats keep their identity and saved settings. Basic legal participation is connected; final strategies across every faction and module remain unfinished.', + }, + { + area: 'Player controls', + status: 'Implemented', + detail: + 'Integrated: hosts add, remove and configure permanent AI seats in the lobby. Shared validation preserves identity, available factions/circles and readiness. Final AI strategy and calibration remain missing.', + }, + { + area: 'AI', + status: 'Partial', + detail: + 'Existing four profiles remain available. Full strategic implementation and adjacent 75% strength targets wait until all non-AI features are complete.', + }, + { + area: 'Documentation', + status: 'Implemented', + detail: + 'Lobby configuration, readiness resets and the distinction between current profiles and final strength targets are explained here.', + }, + { + area: 'Verification', + status: 'Partial', + detail: + 'Focused host authority, private views, saved settings, JSON restoration and concurrent-start checks cover configuration. Full AI strategy, multiplayer and calibrated strength acceptance remain unfinished.', + evidence: [ + 'tests/lobby-bot-configuration.test.ts', + 'tests/lobby-bot-configuration-recovery.test.ts', + 'tests/lobby-bot-configuration-http.test.ts', + ], + }, + ], }, { id: 'privacy', diff --git a/tests/lobby-bot-configuration-http.test.ts b/tests/lobby-bot-configuration-http.test.ts new file mode 100644 index 00000000..7cb0525c --- /dev/null +++ b/tests/lobby-bot-configuration-http.test.ts @@ -0,0 +1,129 @@ +// @dune-suite integration +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { GameView } from '../game/engine'; + +const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000'; +async function request( + path: string, + body?: unknown, + cookie?: string, + origin?: string, +) { + const response = await fetch(base + path, { + method: body === undefined ? 'GET' : 'POST', + signal: AbortSignal.timeout(15_000), + headers: { + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + ...(cookie ? { cookie } : {}), + ...(origin ? { origin } : {}), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + return { + status: response.status, + data: (await response + .json() + .catch(() => ({ error: 'Non-JSON response' }))) as GameView & { + error?: string; + }, + cookie: response.headers.get('set-cookie')?.split(';')[0], + }; +} + +void test('lobby AI configuration HTTP keeps host authority, saved identity, readiness and private views', async () => { + const host = await request('/api/rooms', { + name: 'Configuration host', + faction: 'atreides', + advanced: false, + expansions: [], + }); + assert.equal(host.status, 201); + assert.ok(host.cookie); + const path = `/api/rooms/${host.data.code}`; + const peer = await request(path, { + type: 'join', + name: 'Configuration peer', + faction: 'emperor', + }); + assert.equal(peer.status, 200); + assert.ok(peer.cookie); + let result = await request( + path, + { + version: peer.data.version, + action: { type: 'addBot', faction: 'harkonnen', difficulty: 'Easy' }, + }, + host.cookie, + ); + assert.equal(result.status, 200); + const bot = result.data.players.find((player) => player.bot)!; + for (const cookie of [host.cookie, peer.cookie]) { + result = await request( + path, + { version: result.data.version, action: { type: 'ready' } }, + cookie, + ); + assert.equal(result.status, 200); + } + const version = result.data.version; + const action = { + type: 'configureBot', + target: bot.id, + difficulty: 'Hard', + faction: 'guild', + position: 6, + }; + for (const rejected of [ + await request(path, { version, action }), + await request(path, { version, action }, peer.cookie), + await request( + path, + { version, action }, + host.cookie, + 'https://other.invalid', + ), + await request( + path, + { version, action: { ...action, target: peer.data.me } }, + host.cookie, + ), + await request(path, { version: version + 1, action }, host.cookie), + ]) + assert.ok(rejected.status >= 400); + assert.equal( + (await request(path, undefined, host.cookie)).data.version, + version, + ); + const changed = await request(path, { version, action }, host.cookie); + assert.equal(changed.status, 200); + assert.equal(changed.data.version, version + 1); + assert.ok( + (await request(path, { version, action }, host.cookie)).status >= 400, + ); + for (const [cookie, me] of [ + [host.cookie, host.data.me], + [peer.cookie, peer.data.me], + ]) { + const restored = await request(path, undefined, cookie); + assert.equal(restored.status, 200); + assert.equal(restored.data.me, me); + const configured = restored.data.players.find( + (player) => player.id === bot.id, + )!; + assert.equal(configured.faction, 'guild'); + assert.equal(configured.bot, 'Hard'); + assert.equal(configured.ready, true); + assert.equal(restored.data.playerPositions[bot.id], 6); + assert.ok( + restored.data.players + .filter((player) => !player.bot) + .every((player) => !player.ready), + ); + for (const rival of restored.data.players.filter( + (player) => player.id !== me, + )) + for (const secret of ['hand', 'spice', 'traitors', 'traitorChoices']) + assert.equal(Object.hasOwn(rival, secret), false); + } +}); diff --git a/tests/lobby-bot-configuration-recovery.test.ts b/tests/lobby-bot-configuration-recovery.test.ts new file mode 100644 index 00000000..050d32b5 --- /dev/null +++ b/tests/lobby-bot-configuration-recovery.test.ts @@ -0,0 +1,162 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { unitStore } from './fixture-nexus-room-store'; + +async function fixture() { + const store = unitStore(); + assert.ok(store.sqlite instanceof DatabaseSync); + const host = await store.rooms.createRoom('Host', 'atreides', false, []); + const code = host.view.code; + const peer = await store.rooms.joinRoom(code, 'Peer', 'emperor'); + assert.ok(peer.token); + const hostAuth = await store.rooms.authenticate(code, host.token); + const peerAuth = await store.rooms.authenticate(code, peer.token); + let current = await store.rooms.readRoom(code); + await store.rooms.act(code, hostAuth, current.version, { + type: 'addBot', + faction: 'harkonnen', + difficulty: 'Easy', + }); + for (const auth of [hostAuth, peerAuth]) { + current = await store.rooms.readRoom(code); + await store.rooms.act(code, auth, current.version, { type: 'ready' }); + } + current = await store.rooms.readRoom(code); + const bot = current.players.find((player) => player.bot)!; + const action = { + type: 'configureBot', + target: bot.id, + difficulty: 'Brutal', + faction: 'guild', + position: 6, + }; + return { ...store, code, hostAuth, peerAuth, current, bot, action }; +} + +void test('AI configuration persists the same seat and roster, clears readiness and survives a room module restart', async () => { + const f = await fixture(); + try { + const seats = f.sqlite.prepare('SELECT * FROM seats').all(); + const result = await f.rooms.act( + f.code, + f.hostAuth, + f.current.version, + f.action, + ); + assert.equal(result.version, f.current.version + 1); + const restarted = f.restart(); + const restored = await restarted.readRoom(f.code); + const bot = restored.players.find((player) => player.id === f.bot.id)!; + assert.equal(bot.name, 'Spacing Guild AI'); + assert.equal(bot.bot, 'Brutal'); + assert.equal(bot.faction, 'guild'); + assert.equal(restored.playerPositions![bot.id], 6); + assert.ok(bot.leaders.every((leader) => leader.id.startsWith('guild-'))); + assert.equal(bot.ready, true); + assert.ok( + restored.players + .filter((player) => !player.bot) + .every((player) => !player.ready), + ); + assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats); + for (const auth of [f.hostAuth, f.peerAuth]) { + const view = await restarted.readSeatView(f.code, auth); + assert.equal( + view.players.find((player) => player.id === bot.id)!.bot, + 'Brutal', + ); + for (const rival of view.players.filter( + (player) => player.id !== auth.playerId, + )) { + for (const secret of ['spice', 'hand', 'traitors', 'traitorChoices']) + assert.equal(Object.hasOwn(rival, secret), false); + } + } + await assert.rejects( + restarted.act(f.code, f.hostAuth, restored.version, { type: 'start' }), + /ready/i, + ); + assert.deepEqual(await restarted.readRoom(f.code), restored); + for (const auth of [f.hostAuth, f.peerAuth]) { + const before = await restarted.readRoom(f.code); + await restarted.act(f.code, auth, before.version, { type: 'ready' }); + } + const beforeStart = await restarted.readRoom(f.code); + await restarted.act(f.code, f.hostAuth, beforeStart.version, { + type: 'start', + }); + const started = await restarted.readRoom(f.code); + assert.equal(started.status, 'setup'); + assert.equal( + started.players.find((player) => player.id === bot.id)!.bot, + 'Brutal', + ); + await assert.rejects( + restarted.act(f.code, f.hostAuth, started.version, f.action), + ); + assert.deepEqual(await restarted.readRoom(f.code), started); + } finally { + f.sqlite.close(); + } +}); + +void test('unauthorized, stale, future and human-target configuration cannot mutate saved lobby state', async () => { + const f = await fixture(); + try { + for (const [auth, version, action] of [ + [f.peerAuth, f.current.version, f.action], + [f.hostAuth, f.current.version - 1, f.action], + [f.hostAuth, f.current.version + 1, f.action], + [ + f.hostAuth, + f.current.version, + { ...f.action, target: f.peerAuth.playerId }, + ], + ] as const) { + await assert.rejects(f.rooms.act(f.code, auth, version, action)); + assert.deepEqual(await f.rooms.readRoom(f.code), f.current); + } + const noChange = { + ...f.action, + difficulty: f.bot.bot, + faction: f.bot.faction, + position: f.current.playerPositions![f.bot.id], + }; + await f.rooms.act(f.code, f.hostAuth, f.current.version, noChange); + const after = await f.restart().readRoom(f.code); + assert.equal(after.version, f.current.version + 1); + assert.deepEqual(after.players, f.current.players); + assert.deepEqual(after.log, f.current.log); + } finally { + f.sqlite.close(); + } +}); + +void test('configuration and start races cannot overwrite the winning saved state in either order', async () => { + for (const first of ['configure', 'start']) { + const f = await fixture(); + try { + const winner = first === 'configure' ? f.action : { type: 'start' }; + const loser = first === 'configure' ? { type: 'start' } : f.action; + f.hooks.beforeWrite = async () => { + f.hooks.beforeWrite = undefined; + await f.rooms.act(f.code, f.hostAuth, f.current.version, winner); + }; + await assert.rejects( + f.rooms.act(f.code, f.hostAuth, f.current.version, loser), + /changed|again|stale/i, + ); + const saved = await f.restart().readRoom(f.code); + assert.equal(saved.version, f.current.version + 1); + assert.equal(saved.status, first === 'configure' ? 'lobby' : 'setup'); + assert.equal( + saved.players.find((player) => player.id === f.bot.id)!.bot, + first === 'configure' ? 'Brutal' : 'Easy', + ); + assert.equal(f.writes.at(-1)!.changes, 0); + } finally { + f.sqlite.close(); + } + } +}); diff --git a/tests/lobby-bot-configuration.test.ts b/tests/lobby-bot-configuration.test.ts new file mode 100644 index 00000000..161592df --- /dev/null +++ b/tests/lobby-bot-configuration.test.ts @@ -0,0 +1,303 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { FACTIONS, type FactionId } from '../game/catalog'; +import { + applyAction, + createGame, + joinGame, + newPlayer, + type Game, +} from '../game/engine'; +import { quoteLobbyBotConfiguration } from '../game/lobby-bot-configuration'; + +const allExpansions = ['ix', 'choam', 'ecaz']; + +function lobby() { + const game = createGame( + 'BOTCONFIG', + newPlayer('host', 'Host', 'atreides'), + false, + allExpansions, + ); + const bot = newPlayer('bot', 'Harkonnen AI', 'harkonnen'); + bot.bot = 'Easy'; + joinGame(game, bot); + const human = newPlayer('human', 'Human', 'emperor'); + joinGame(game, human); + const otherBot = newPlayer('other-bot', 'Fremen AI', 'fremen'); + otherBot.bot = 'Medium'; + joinGame(game, otherBot); + return game; +} + +function action( + overrides: Partial<{ + type: string; + target: string; + difficulty: string; + faction: string; + position: number; + }> = {}, +) { + return { + type: 'configureBot', + target: 'bot', + difficulty: 'Hard', + faction: 'harkonnen', + position: 2, + ...overrides, + }; +} + +function context(game: Game) { + return { + status: game.status, + host: game.host, + players: game.players, + expansions: game.expansions, + positions: game.playerPositions!, + }; +} + +void test('quote admits every faction with its expansion and all four current AI profiles', () => { + for (const [index, entry] of FACTIONS.entries()) { + const target = { + id: 'bot', + faction: entry.id, + bot: DIFFICULTIES[index % DIFFICULTIES.length], + }; + const quote = quoteLobbyBotConfiguration( + { + status: 'lobby', + host: 'host', + players: [ + { + id: 'host', + faction: entry.id === 'atreides' ? 'guild' : 'atreides', + }, + target, + ], + expansions: allExpansions, + positions: { host: 1, bot: 2 }, + }, + 'host', + action({ + difficulty: DIFFICULTIES[(index + 1) % DIFFICULTIES.length], + faction: entry.id, + }), + ); + assert.equal(quote.ok, true, entry.id); + if (quote.ok) { + assert.equal(quote.configuration.faction, entry.id); + assert.equal(quote.changed, true); + } + } +}); + +void test('quote is strict, host-only, lobby-only and requires a permanent bot', () => { + const game = lobby(); + const valid = action(); + const checks: Array<[unknown, string, string]> = [ + [valid, 'human', 'Only the host'], + [{ ...valid, extra: true }, 'host', 'only the AI seat'], + [{ ...valid, type: 'ready' }, 'host', 'only the AI seat'], + [{ ...valid, target: 'human' }, 'host', 'permanent AI'], + [{ ...valid, target: 'missing' }, 'host', 'permanent AI'], + [{ ...valid, difficulty: 'Impossible' }, 'host', 'valid AI difficulty'], + [{ ...valid, faction: 'unknown' }, 'host', 'valid faction'], + [{ ...valid, position: 2.5 }, 'host', 'integer'], + [{ ...valid, position: 7 }, 'host', 'integer'], + ]; + for (const [input, actor, reason] of checks) { + const quote = quoteLobbyBotConfiguration(context(game), actor, input); + assert.equal(quote.ok, false); + if (!quote.ok) assert.match(quote.reason, new RegExp(reason, 'i')); + } + for (const status of ['setup', 'playing', 'finished']) { + const quote = quoteLobbyBotConfiguration( + { ...context(game), status }, + 'host', + valid, + ); + assert.deepEqual(quote, { + ok: false, + reason: 'AI seats can be configured only in the lobby.', + }); + } +}); + +void test('quote permits the bot current faction and circle but rejects occupied or disabled choices', () => { + const game = lobby(); + const same = quoteLobbyBotConfiguration( + context(game), + 'host', + action({ difficulty: 'Easy' }), + ); + assert.deepEqual(same, { + ok: true, + configuration: { + target: 'bot', + difficulty: 'Easy', + faction: 'harkonnen', + position: 2, + }, + changed: false, + }); + for (const [input, reason] of [ + [action({ faction: 'emperor' }), 'taken'], + [action({ position: 3 }), 'occupied'], + ] as const) { + const quote = quoteLobbyBotConfiguration(context(game), 'host', input); + assert.equal(quote.ok, false); + if (!quote.ok) assert.match(quote.reason, new RegExp(reason, 'i')); + } + const disabled = quoteLobbyBotConfiguration( + { ...context(game), expansions: [] }, + 'host', + action({ faction: 'ixians' }), + ); + assert.equal(disabled.ok, false); + if (!disabled.ok) assert.match(disabled.reason, /disabled/i); +}); + +void test('engine applies all configured fields, preserves custody and records readiness publicly', () => { + const game = lobby(); + const target = game.players.find((player) => player.id === 'bot')!; + target.spice = 17; + target.reserves = 13; + target.tanks = 2; + target.forces = { 'arrakeen:9': 5 }; + target.traitors = ['atreides-0']; + target.ready = false; + game.players.find((player) => player.id === 'host')!.ready = true; + game.players.find((player) => player.id === 'human')!.ready = true; + game.players.find((player) => player.id === 'other-bot')!.ready = false; + const beforeOtherBot = structuredClone( + game.players.find((player) => player.id === 'other-bot')!, + ); + const configured = applyAction( + game, + 'host', + action({ difficulty: 'Brutal', faction: 'ixians', position: 5 }), + ); + const changed = configured.players.find((player) => player.id === 'bot')!; + assert.equal(changed.id, target.id); + assert.equal(changed.name, 'Ixians AI'); + assert.equal(changed.bot, 'Brutal'); + assert.equal(changed.faction, 'ixians'); + assert.equal(configured.playerPositions![changed.id], 5); + assert.deepEqual( + { + spice: changed.spice, + reserves: changed.reserves, + tanks: changed.tanks, + forces: changed.forces, + traitors: changed.traitors, + }, + { + spice: 17, + reserves: 13, + tanks: 2, + forces: { 'arrakeen:9': 5 }, + traitors: ['atreides-0'], + }, + ); + assert.equal( + configured.players.find((player) => player.id === 'host')!.ready, + false, + ); + assert.equal( + configured.players.find((player) => player.id === 'human')!.ready, + false, + ); + assert.equal(changed.ready, true); + assert.deepEqual( + configured.players.find((player) => player.id === 'other-bot'), + { ...beforeOtherBot, ready: true }, + ); + assert.match( + configured.log.at(-1)!.text, + /Host configured Harkonnen AI: difficulty Easy to Brutal; faction Harkonnen to Ixians; player circle 2 to 5\. Human readiness was cleared; AI seats remain ready\./, + ); + assert.notDeepEqual(configured, game); + assert.equal(target.faction, 'harkonnen'); + assert.equal(target.bot, 'Easy'); +}); + +void test('every faction change installs the same physical leader and elite roster as a new seat', () => { + for (const entry of FACTIONS) { + const hostFaction: FactionId = + entry.id === 'atreides' ? 'harkonnen' : 'atreides'; + const initialFaction: FactionId = + entry.id === 'emperor' ? 'fremen' : 'emperor'; + const game = createGame( + `ROSTER${entry.id}`, + newPlayer('host', 'Host', hostFaction), + false, + allExpansions, + ); + const bot = newPlayer('bot', `${initialFaction} AI`, initialFaction); + bot.bot = 'Medium'; + joinGame(game, bot); + const configured = applyAction( + game, + 'host', + action({ faction: entry.id, difficulty: 'Medium' }), + ); + const changed = configured.players.find((player) => player.id === 'bot')!; + const fresh = newPlayer('fresh', 'Fresh', entry.id); + assert.deepEqual(changed.leaders, fresh.leaders, entry.id); + assert.deepEqual(changed.elites, fresh.elites, entry.id); + } +}); + +void test('difficulty or circle changes preserve the bot name until its faction changes', () => { + const game = lobby(); + game.players.find((player) => player.id === 'bot')!.name = 'Custom AI name'; + const configured = applyAction( + game, + 'host', + action({ difficulty: 'Brutal', position: 5 }), + ); + const bot = configured.players.find((player) => player.id === 'bot')!; + assert.equal(bot.name, 'Custom AI name'); + assert.match( + configured.log.at(-1)!.text, + /difficulty Easy to Brutal; player circle 2 to 5/, + ); + assert.doesNotMatch(configured.log.at(-1)!.text, /faction/i); +}); + +void test('identical configuration is a clone-only no-op and rejected engine actions are immutable', () => { + const game = lobby(); + game.players.find((player) => player.id === 'host')!.ready = true; + game.players.find((player) => player.id === 'bot')!.ready = false; + const same = applyAction( + game, + 'host', + action({ difficulty: 'Easy', faction: 'harkonnen', position: 2 }), + ); + assert.notEqual(same, game); + assert.deepEqual(same, game); + + for (const [actor, attempted] of [ + ['human', action()], + ['host', action({ target: 'human' })], + ['host', { ...action(), extra: true }], + ] as const) { + const before = JSON.stringify(game); + assert.throws( + () => applyAction(game, actor, attempted), + /host|permanent|only/i, + ); + assert.equal(JSON.stringify(game), before); + } + for (const status of ['setup', 'playing', 'finished'] as const) { + const outside = structuredClone(game); + outside.status = status; + const before = JSON.stringify(outside); + assert.throws(() => applyAction(outside, 'host', action())); + assert.equal(JSON.stringify(outside), before); + } +});