Configure existing AI seats in the lobby

Let the host change an existing permanent AI seat's difficulty, faction and player circle without removing and readding it. Shared strict validation limits configuration to the authenticated host in the lobby, existing profiles, enabled available factions and unoccupied circles. Preserve seat identity and initialize changed faction rosters consistently. Changes reset human readiness, keep bots ready and explain the changed fields in public history.

Use existing AI strategies without calibration or strength changes. Full AI development waits for all non-AI game features; the goal retains the three adjacent 75% targets, mandatory Git checkpoints and selective subagents without a usage cutoff. No mode or publication gate opens.

Independent implementation and review cover the quote, engine, controls and persistence tests. Types, lint, 4916 offline tests, production build and 46 HTTP checks pass on stable final source. Ten new offline cases cover all factions/profiles, strict guards, roster/identity, immutable rejection, restart/privacy and both configuration-versus-start race outcomes. HTTP verifies authentication, host authority, CSRF, stale/future requests, private views and saved settings. Identical direct actions preserve players/readiness/history but retain the generic action store's version increment; controls suppress them.

Browser QA changed difficulty, faction and circle, verified readiness reset and history, restored settings after refresh and used keyboard selection. Expanded mobile cards were widened after discovering cramped controls; measured selects/buttons are 272 by 44 pixels at a 390-pixel viewport without document overflow. Desktop controls measure 169 by 44 pixels. Screenshot capture timed out, so screenshot-based visual acceptance remains unverified. Configuration controls disappear after starting and saved AI settings persist in setup. The final source rerun follows a preliminary run invalidated by the mobile/readability edits.

All 1262 original rooms, 2644 seats and every prior recovery/entry/handover/delegation/migration row remain unchanged. SQLite integrity and foreign keys pass. No database reset, migration, server restart, usage check or automation was performed. Broader rules, components, durable unattended continuation and final AI acceptance remain unfinished.

Verified source tree: 8c1baf4602d000ee37f081b2a03f8a87e89a63ef14a7121f24268c575a1ac281
This commit is contained in:
Quintine committed 2026-09-20 16:57:38 +10:00
1 parent b21b2bc378
commit b9718acda8
13 files changed
+1018 -20

No files matched your search

+54
View File
@@ -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,
+112
View File
@@ -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<Record<string, number>>;
}>;
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<string, unknown>;
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,
};
}
+39 -1
View File
@@ -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',