Support AI opponents in two-faction games

This commit is contained in:
Quintine committed 2026-10-28 00:02:02 +11:00
1 parent aa6f77372f
commit b00d720e40
13 files changed
+395 -28

No files matched your search

+4 -3
View File
@@ -18,9 +18,10 @@ For the provisional two-person setup, the host enables **Two players, two
factions each** in the lobby. Each person joins with one faction, then adds a
second and uses **Active faction** to switch seats. Ready each faction before
the host starts. The factions are independent unless they form an alliance;
the ordinary victory target is four strongholds per side. The prototype
requires human-controlled seats and cannot be disabled after a second faction
is added. Counts for optional modules and wider interactions remain provisional.
the ordinary victory target is four strongholds per side. The host may add an
AI opponent and choose its two factions; each uses its own current AI policy
and difficulty. The mode cannot be disabled while a controller has a second
faction. Counts for optional modules and wider interactions remain provisional.
See [the implementation scope](docs/TWO_PLAYER_TWO_FACTIONS.md).
New ordinary-auction lots show a **ten-second bidding countdown**. Native
+2
View File
@@ -861,6 +861,8 @@ export default function Home() {
onModeChange={(enabled) => void send({ type: 'twoPlayerMode', enabled })}
onSwitch={switchFaction}
onAdd={(name, factionId) => void addControlledFaction(name, factionId)}
onAddBot={(faction, difficulty) => void send({ type: 'addBot', faction, difficulty })}
onAddControlledBot={(controller, faction, difficulty) => void send({ type: 'addControlledBot', controller, faction, difficulty })}
onRetry={() => { if (twoPlayerEntry) void dispatchControlledFaction(twoPlayerEntry, false); }}
retryDisabled={busy || needsReconcile || checkingConnection || handoverPending || aiPermissionPending || claimUncertain || !!twoPlayerEntryProblem || twoPlayerEntry?.room !== game.code}
/></div>}
+31 -3
View File
@@ -2,13 +2,14 @@
import { useState } from 'react';
import { FACTIONS } from '@/game/catalog';
import { DIFFICULTIES, type Difficulty } from '@/game/bot-profiles';
import type { GameView } from '@/game/engine';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
export function TwoPlayerControls({
game, disabled, pendingEntry, entryRoom, entryProblem, retryDisabled,
onModeChange, onSwitch, onAdd, onRetry,
onModeChange, onSwitch, onAdd, onRetry, onAddBot, onAddControlledBot,
}: {
game: GameView;
disabled: boolean;
@@ -20,12 +21,22 @@ export function TwoPlayerControls({
onSwitch: (seat: string) => void;
onAdd: (name: string, faction: string) => void;
onRetry: () => void;
onAddBot: (faction: string, difficulty: Difficulty) => void;
onAddControlledBot: (controllerId: string, faction: string, difficulty: Difficulty) => void;
}) {
const [name, setName] = useState(() => game.players.find((player) => player.id === game.me)?.name ?? '');
const [choice, setChoice] = useState('');
const [botChoice, setBotChoice] = useState('');
const [botDifficulty, setBotDifficulty] = useState<Difficulty>('Medium');
const mode = game.twoPlayerControllers;
const controlled = game.controlledFactionSeats ?? [];
const modeLocked = !!mode?.controllers.some((controller) => controller.seats.length > 1);
const botController = mode?.controllers.find((controller) =>
controller.seats.length === 1 && game.players.some((player) =>
player.id === controller.id && !!player.bot));
const canAddBotController = !!mode && mode.controllers.length < 2;
const showBotForm = game.status === 'lobby' && game.host === game.me &&
!!mode && (canAddBotController || !!botController);
const available = FACTIONS.filter((item) =>
(item.expansion === 'base' || game.expansions.includes(item.expansion)) &&
!game.players.some((player) => player.faction === item.id),
@@ -39,13 +50,13 @@ export function TwoPlayerControls({
Two players, two factions each
</label>
)}
{game.status === 'lobby' && game.host === game.me && modeLocked && <p className="fine">This mode cannot be turned off after a second faction has been added.</p>}
{game.status === 'lobby' && game.host === game.me && modeLocked && <p className="fine">This mode cannot be turned off while a player controls two factions.</p>}
{mode && <>
<h2>Two-player factions</h2>
<p>Each player controls two separate factions. They act independently and are not automatically allied; this interpretation is provisional. Switch between your faction seats to make their decisions and mark each one ready.</p>
{game.status === 'lobby' && <ul>
{mode.controllers.map((controller, index) => <li key={controller.id}>
Player {index + 1}: {controller.seats.map((id) => {
Player {index + 1}{game.players.some((player) => player.id === controller.id && !!player.bot) ? ' (AI)' : ''}: {controller.seats.map((id) => {
const player = game.players.find((item) => item.id === id);
return player ? `${FACTIONS.find((item) => item.id === player.faction)?.name ?? player.faction}${player.ready ? ' (ready)' : ' (not ready)'}` : 'unknown faction';
}).join(', ')}{controller.seats.length < 2 ? ' — needs a second faction' : ''}
@@ -72,6 +83,23 @@ export function TwoPlayerControls({
</select></label>
<Button type="submit" disabled={disabled || !name.trim() || !available.some((item) => item.id === choice)}>Add second faction</Button>
</form>}
{showBotForm && <form onSubmit={(event) => {
event.preventDefault();
if (disabled || !available.some((item) => item.id === botChoice)) return;
if (botController) onAddControlledBot(botController.id, botChoice, botDifficulty);
else if (canAddBotController) onAddBot(botChoice, botDifficulty);
}}>
<h3>{botController ? 'Add the AI player’s second faction' : 'Add an AI player'}</h3>
<p className="fine">The AI plays each faction independently. Choose a difficulty for this faction; its settings can be changed in the lobby.</p>
<label>AI faction <select value={botChoice} disabled={disabled} onChange={(event) => setBotChoice(event.target.value)}>
<option value="">Choose a faction</option>
{available.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select></label>
<label>AI difficulty <select value={botDifficulty} disabled={disabled} onChange={(event) => setBotDifficulty(event.target.value as Difficulty)}>
{DIFFICULTIES.map((difficulty) => <option key={difficulty} value={difficulty}>{difficulty}</option>)}
</select></label>
<Button type="submit" disabled={disabled || !available.some((item) => item.id === botChoice)}>{botController ? 'Add AI second faction' : 'Add AI player'}</Button>
</form>}
</>}
{pendingEntry && <div>
<output>A second-faction request for room {entryRoom} is saved in this tab. Retry the same request to confirm its result; it is never retried automatically.</output>
+16 -3
View File
@@ -1,6 +1,6 @@
# Current development status
Updated 27 October 2026. **The full twelve-faction goal is unfinished. Basic
Updated 28 October 2026. **The full twelve-faction goal is unfinished. Basic
is playable. Advanced, expansion-faction and supported optional-module starts
are labelled previews; complete rules/publication acceptance remains gated.**
@@ -16,6 +16,18 @@ open mode gates.
## Current checkpoint and work
**AI opponent in the four-faction setup — 28 October. Prototyped / Partial.**
The host can add two AI faction seats under one AI controller. Existing lobby
configuration applies separately; removing either bot keeps or promotes the
remaining seat correctly. Mixed human/AI controller groups are rejected, and
bot IDs grant no human seat access. Seven related unit files pass 75 cases; an
isolated HTTP case covers room setup, credential rejection and start. Bots use
their existing independent legal policies; coordinated strategy, strength
calibration, complete games and deployed acceptance remain open.
An isolated browser room also exercised adding both AI seats, removing and
promoting the original primary, and adding a replacement.
[Scope](TWO_PLAYER_TWO_FACTIONS.md).
**Truthtrance and Atreides ordinary auction knowledge — 27 October. Prototyped /
Partial.** A structured fact compares the current successfully inspected
ordinary auction card with a public printed name. Single/compound controls,
@@ -40,8 +52,9 @@ and actions. Exactly two controllers with two seats each are required to start;
ordinary victory uses the four-stronghold target. Independent factions and no
automatic alliance are provisional. Focused setup, victory, UI and isolated
HTTP cases pass; complete games, player-count interactions, comprehensive
custody review and deployed acceptance remain open. Human seats are required;
handover into a browser holding another active room seat remains unsupported.
custody review and deployed acceptance remain open. The current AI-opponent
follow-up above adds bot seats. Handover into a browser holding another active
room seat remains unsupported.
[A dedicated isolated browser room](TWO_PLAYER_TWO_FACTIONS.md) also exercised
adding, switching and independently readying the host's two factions.
[Scope](TWO_PLAYER_TWO_FACTIONS.md).
+12
View File
@@ -1,5 +1,17 @@
# Dune implementation status
## 28 October 2026 — AI opponent with two factions
An AI controller can have two distinct bot faction seats in the optional
two-person setup. Hosts may add, configure and remove each seat, with controller
promotion after removing its original bot. The game rejects mixed human/AI
ownership. Seven focused unit files pass 75 cases, including bot-driven setup;
an isolated HTTP case confirms room setup, start and rejected bot-seat
impersonation. An isolated browser room exercised adding both bot seats,
primary removal, promotion and replacement. The bots retain independent legal policies; coordinated strategy,
strength calibration, full games and deployed acceptance remain open.
[Scope](TWO_PLAYER_TWO_FACTIONS.md).
## 27 October 2026 — Truthtrance and Atreides ordinary auction knowledge
A single or compound structured question can compare a named Treachery Card
+9 -4
View File
@@ -95,10 +95,15 @@ the UI explicit about which faction owns each pending decision. Existing
autopilot can operate a faction seat only under its existing authorization;
until full AI refinement, each offered decision needs a minimal legal bot path.
The current prototype requires human seats, permits ordinary existing
seat-autopilot, and does not yet support accepting a handover into a browser
already holding a different active seat for the same room. The mode cannot be
disabled once a secondary faction is added. Recovery replaces an existing
The prototype supports human controllers, an AI controller with two bot seats,
and ordinary existing human-seat autopilot. AI factions use independent legal
policies, not coordinated strategy or calibrated two-faction strength. The host
can add either AI faction, configure each and remove a bot seat; if the primary
bot leaves, its remaining bot is promoted to the controller identity. Each
controller must be entirely human or entirely AI. Accepting a handover into a
browser already holding a different active seat for the same room remains
unsupported. The mode cannot be disabled while a secondary faction is present.
Recovery replaces an existing
scoped credential for the recovered seat without overwriting another valid
legacy seat. Comprehensive recovery, revocation and custody acceptance remain
open.
+33 -9
View File
@@ -5699,8 +5699,10 @@ function twoPlayerControllersIntegrity(g: Game): void {
const assigned = g.twoPlayerControllers.controllers.flatMap(controller => controller.seats);
requireRule(assigned.length === seats.length && seats.every(seat => assigned.includes(seat)),
'Every two-person faction must belong to exactly one controller.');
requireRule(g.players.every(player => !player.bot),
'Two-person setup requires human faction seats.');
requireRule(g.twoPlayerControllers.controllers.every(controller => {
const group = controller.seats.map(seat => g.players.find(player => player.id === seat)!);
return group.every(player => !!player.bot === !!group[0].bot);
}), 'A two-person controller cannot mix human and AI faction seats.');
}
function joinFactionSeat(g: Game, p: Player, controllerId?: string) {
@@ -5719,7 +5721,11 @@ function joinFactionSeat(g: Game, p: Player, controllerId?: string) {
);
let mapping = g.twoPlayerControllers;
if (mapping) {
requireRule(!p.bot, 'Two-person setup requires human faction seats.');
const owner = mapping.controllers.find(controller => controller.id === controllerId);
if (owner) {
const primary = g.players.find(player => player.id === owner.id)!;
requireRule(!!primary.bot === !!p.bot, 'A two-person controller cannot mix human and AI faction seats.');
}
mapping = twoPlayerRule(() => addTwoPlayerControlledSeat(mapping!,
[...g.players.map(player => player.id), p.id], controllerId ?? p.id, p.id));
} else requireRule(controllerId === undefined, 'Enable two-person setup before adding a second faction.');
@@ -5740,6 +5746,8 @@ export function joinControlledFaction(g: Game, p: Player, actorId: string) {
'Enable two-person setup before adding a second faction.');
const controller = g.twoPlayerControllers.controllers.find(entry => entry.seats.includes(actorId));
requireRule(controller, 'This faction has no two-person controller.');
requireRule(!p.bot && !getPlayer(g, actorId).bot,
'Human faction seats must belong to a human controller.');
joinFactionSeat(g, p, controller.id);
}
function setMovementDiscardKnowledge(g: Game, knowledge: MovementDiscardKnowledge | undefined) {
@@ -42870,8 +42878,8 @@ function applyActionInner(
'Choose whether each of two people controls two factions.');
if (action.enabled === !!g.twoPlayerControllers) return g;
if (action.enabled) {
requireRule(g.players.length <= 2 && g.players.every(player => !player.bot),
'Enable two-person setup with at most two human faction seats and no AI seats.');
requireRule(g.players.length <= 2,
'Enable two-person setup with at most two existing faction seats.');
const seats = g.players.map(player => player.id);
let mapping = createTwoPlayerControllers();
for (const seat of seats) mapping = twoPlayerRule(() =>
@@ -42882,7 +42890,7 @@ function applyActionInner(
'Two-person setup cannot be disabled after second faction seats are added.');
delete g.twoPlayerControllers;
}
g.players.forEach(player => (player.ready = false));
g.players.forEach(player => (player.ready = !!player.bot));
log(g, `${p.name} ${action.enabled ? 'enabled' : 'disabled'} the provisional two-person, four-faction setup. Human readiness was cleared.`);
return g;
}
@@ -43037,9 +43045,9 @@ function applyActionInner(
g.players.forEach((player) => (player.ready = !!player.bot));
return g;
}
if (t === 'addBot' || t === 'removeBot') {
if (t === 'addBot' || t === 'addControlledBot' || t === 'removeBot') {
requireRule(id === g.host, 'Only the host can manage AI seats.');
if (t === 'addBot') {
if (t === 'addBot' || t === 'addControlledBot') {
const difficulty = stringField(action.difficulty);
requireRule(
['Easy', 'Medium', 'Hard', 'Brutal'].includes(difficulty),
@@ -43052,10 +43060,26 @@ function applyActionInner(
);
const bot = newPlayer(crypto.randomUUID(), `${faction(f).name} AI`, f);
bot.bot = difficulty as NonNullable<Player['bot']>;
joinGame(g, bot);
if (t === 'addControlledBot') {
const controllerId = stringField(action.controller);
const controller = g.twoPlayerControllers?.controllers.find(entry => entry.id === controllerId);
requireRule(controller && getPlayer(g, controller.id).bot,
'Choose an existing AI controller for this second faction.');
joinFactionSeat(g, bot, controller.id);
} else joinGame(g, bot);
} else {
const target = getPlayer(g, stringField(action.target));
requireRule(target.bot, 'Only an AI seat can be removed here.');
if (g.twoPlayerControllers) {
const owner = g.twoPlayerControllers.controllers.find(controller => controller.seats.includes(target.id));
requireRule(owner, 'The AI faction must belong to a controller.');
const seats = owner.seats.filter(seat => seat !== target.id);
g.twoPlayerControllers = {
version: 1,
controllers: g.twoPlayerControllers.controllers.flatMap(controller =>
controller.id !== owner.id ? [controller] : seats.length ? [{ id: seats[0], seats }] : []),
};
}
g.players = g.players.filter((p) => p.id !== target.id);
if (g.playerPositions) delete g.playerPositions[target.id];
g.players.forEach((p) => (p.ready = false));
+2 -2
View File
@@ -4472,12 +4472,12 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'The host enables the option before adding extra factions. Each person joins one seat and adds a second, then switches active faction to ready and play each seat.',
'The provisional interpretation keeps the factions separate and does not automatically ally them. An individual faction or alliance needs four strongholds.',
'This prototype requires human seats, cannot be disabled after a second seat is added, and does not yet establish every player-count interaction.',
'The host can add an AI opponent with two independently configured faction seats. Human and AI seats cannot be mixed within one controller. The mode cannot be disabled while a second faction is present, and not every player-count interaction is established.',
],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Two controllers own four independently authenticated seats; the shared victory quote uses the validated controller count. Other player-count rules remain provisional.', evidence: ['game/two-player-controllers.ts', 'game/engine.ts', 'game/victory-progress.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Lobby controls add and select seats, each with its own view and actions; wider accessibility and complete-game acceptance remain open.', evidence: ['components/two-player-controls.tsx', 'app/page.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Existing authorized human-seat autopilot remains available; independent bot seats and strategic calibration for this mode remain unfinished.', evidence: ['game/engine.ts', 'docs/AI_DEVELOPMENT_PLAN.md'] },
{ area: 'AI', status: 'Partial', detail: 'An AI opponent can own two bot faction seats with independent legal policies; coordinated strategy and strength calibration remain unfinished.', evidence: ['game/engine.ts', 'tests/two-player-bot-engine.test.ts', 'docs/AI_DEVELOPMENT_PLAN.md'] },
{ area: 'Documentation', status: 'Partial', detail: 'The provisional interpretation, sources and limitations are recorded.', evidence: ['docs/TWO_PLAYER_TWO_FACTIONS.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused setup, first-turn, victory, seat selection, action and recovery cases exercise the prototype. Full games, combinations, broader custody and deployed acceptance remain open.', evidence: ['tests/two-player-engine.test.ts', 'tests/two-player-seat-http.test.ts', 'tests/victory-progress.test.ts'] },
],
+2 -2
View File
@@ -1,6 +1,6 @@
/** Explicit two-person setup: faction seats remain separate physical players.
* Controller IDs are their original, independently authenticated first seats;
* this public mapping is not itself a credential or an alliance. */
* Controller IDs are their first seats, independently authenticated for humans
* and internal for bots; this public mapping is not a credential or alliance. */
export type TwoPlayerController = {
id: string;
seats: string[];
+137
View File
@@ -0,0 +1,137 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction, createGame, joinControlledFaction, newPlayer, viewGame, type Game,
} from '../game/engine';
import { runBots } from '../game/bots';
/** One real human controls two separately authenticated faction seats. */
function humanPair(): Game {
let g = createGame('TWOBOT01', newPlayer('human', 'Human', 'atreides'));
g = applyAction(g, 'human', { type: 'twoPlayerMode', enabled: true });
joinControlledFaction(g, newPlayer('second', 'Human second', 'harkonnen'), 'human');
return g;
}
function botPrimary(g: Game): { game: Game; id: string } {
const game = applyAction(g, 'human', { type: 'addBot', faction: 'emperor', difficulty: 'Medium' });
const bot = game.players.find(player => player.faction === 'emperor')!;
assert.ok(bot.bot);
return { game, id: bot.id };
}
function botPair(g: Game): { game: Game; primary: string; secondary: string } {
const first = botPrimary(g);
const game = applyAction(first.game, 'human', {
type: 'addControlledBot', controller: first.id, faction: 'guild', difficulty: 'Medium',
});
const secondary = game.players.find(player => player.faction === 'guild')!;
assert.ok(secondary.bot);
return { game, primary: first.id, secondary: secondary.id };
}
function unchanged(g: Game, action: () => unknown) {
const before = structuredClone(g);
assert.throws(action);
assert.deepEqual(g, before);
}
void test('a preexisting bot remains ready when enabling the two-controller lobby', () => {
let g = createGame('TWOBOT02', newPlayer('human', 'Human', 'atreides'));
g = applyAction(g, 'human', { type: 'addBot', faction: 'emperor', difficulty: 'Easy' });
const bot = g.players.find(player => player.bot)!;
assert.equal(bot.ready, true);
g = applyAction(g, 'human', { type: 'twoPlayerMode', enabled: true });
assert.equal(g.players.find(player => player.id === bot.id)!.ready, true);
assert.equal(g.players.find(player => player.id === 'human')!.ready, false);
assert.deepEqual(g.twoPlayerControllers!.controllers.map(controller => controller.seats), [['human'], [bot.id]]);
g = applyAction(g, 'human', { type: 'addControlledBot', controller: bot.id, faction: 'guild', difficulty: 'Easy' });
assert.equal(g.twoPlayerControllers!.controllers.find(controller => controller.id === bot.id)!.seats.length, 2);
});
void test('a bot controller gets two independent faction seats without giving the human their private views', () => {
const { game: g, primary, secondary } = botPair(humanPair());
assert.deepEqual(g.twoPlayerControllers!.controllers.map(controller => controller.seats), [
['human', 'second'], [primary, secondary],
]);
assert.deepEqual(viewGame(g, 'human').controlledFactionSeats.map(seat => seat.id), ['human', 'second']);
assert.deepEqual(viewGame(g, primary).controlledFactionSeats.map(seat => seat.id), [primary, secondary]);
for (const observer of ['human', 'second', primary, secondary])
for (const seat of viewGame(g, observer).players)
assert.equal('hand' in seat, seat.id === observer);
assert.equal(g.players.find(player => player.id === primary)!.ready, true);
assert.equal(g.players.find(player => player.id === secondary)!.ready, true);
});
void test('only the host can create bots, and bot and human controllers cannot be mixed', () => {
const initial = humanPair();
unchanged(initial, () => applyAction(initial, 'second', {
type: 'addBot', faction: 'emperor', difficulty: 'Easy',
}));
const { game, id } = botPrimary(initial);
unchanged(game, () => applyAction(game, 'second', {
type: 'addControlledBot', controller: id, faction: 'guild', difficulty: 'Easy',
}));
unchanged(game, () => applyAction(game, 'human', {
type: 'addControlledBot', controller: 'human', faction: 'guild', difficulty: 'Easy',
}));
unchanged(game, () => joinControlledFaction(game, newPlayer('forged', 'Forged', 'guild'), id));
unchanged(game, () => applyAction(game, 'human', {
type: 'addControlledBot', controller: id, faction: 'atreides', difficulty: 'Easy',
}));
unchanged(game, () => applyAction(game, 'human', {
type: 'addControlledBot', controller: id, faction: 'guild', difficulty: 'Impossible',
}));
});
void test('removing either bot updates its controller and promotes a surviving secondary', () => {
const { game, primary, secondary } = botPair(humanPair());
let g = applyAction(game, 'human', { type: 'removeBot', target: secondary });
assert.deepEqual(g.twoPlayerControllers!.controllers.find(controller => controller.id === primary)!.seats, [primary]);
assert.equal(g.players.some(player => player.id === secondary), false);
g = applyAction(g, 'human', { type: 'addControlledBot', controller: primary, faction: 'guild', difficulty: 'Easy' });
const replacement = g.players.find(player => player.faction === 'guild')!.id;
g = applyAction(g, 'human', { type: 'removeBot', target: primary });
assert.equal(g.players.some(player => player.id === primary), false);
assert.deepEqual(g.twoPlayerControllers!.controllers.find(controller => controller.id === replacement)!.seats, [replacement]);
g = applyAction(g, 'human', { type: 'addControlledBot', controller: replacement, faction: 'emperor', difficulty: 'Hard' });
assert.equal(g.twoPlayerControllers!.controllers.find(controller => controller.id === replacement)!.seats.length, 2);
assert.equal(viewGame(g, 'human').controlledFactionSeats.length, 2);
});
void test('per-seat bot configuration and human readiness remain usable in the paired lobby', () => {
const { game, primary, secondary } = botPair(humanPair());
const position = game.playerPositions![primary];
let g = applyAction(game, 'human', { type: 'ready' });
g = applyAction(g, 'second', { type: 'ready' });
g = applyAction(g, 'human', { type: 'configureBot', target: primary, faction: 'emperor', difficulty: 'Hard', position });
assert.equal(g.players.find(player => player.id === primary)!.bot, 'Hard');
assert.equal(g.players.find(player => player.id === secondary)!.bot, 'Medium');
assert.equal(g.players.find(player => player.id === 'human')!.ready, false);
assert.equal(g.players.find(player => player.id === 'second')!.ready, false);
assert.equal(g.players.find(player => player.id === primary)!.ready, true);
assert.equal(g.players.find(player => player.id === secondary)!.ready, true);
});
void test('real two-controller setup lets the scheduler make both AI faction decisions', () => {
const partial = botPrimary(humanPair());
let incomplete = applyAction(partial.game, 'human', { type: 'ready' });
incomplete = applyAction(incomplete, 'second', { type: 'ready' });
unchanged(incomplete, () => applyAction(incomplete, 'human', { type: 'start' }));
const complete = applyAction(incomplete, 'human', {
type: 'addControlledBot', controller: partial.id, faction: 'guild', difficulty: 'Medium',
});
const botIds = complete.players.filter(player => player.bot).map(player => player.id);
let g = complete;
for (const id of ['human', 'second']) g = applyAction(g, id, { type: 'ready' });
g = applyAction(g, 'human', { type: 'start' });
assert.equal(g.status, 'setup');
for (let n = 0; n < 8 && botIds.some(id => g.players.find(player => player.id === id)!.traitorChoices.length); n++)
g = runBots(g, 1);
for (const id of botIds) {
const bot = g.players.find(player => player.id === id)!;
assert.equal(bot.traitorChoices.length, 0);
assert.equal(bot.traitors.length, 1);
}
const human = g.players.find(player => player.id === 'human')!;
g = applyAction(g, 'human', { type: 'traitor', leader: human.traitorChoices[0] });
assert.equal(g.status, 'playing');
assert.equal(g.players.every(player => player.ally === null), true);
});
+94
View File
@@ -0,0 +1,94 @@
// @dune-suite integration
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes, randomUUID } from 'node:crypto';
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
const receipt = () => ({ operationId: randomUUID(), sessionToken: randomBytes(32).toString('hex') });
type Wire = {
code: string;
me: string;
version: number;
status: string;
players: { id: string; faction: string; bot?: string; ready: boolean; hand?: unknown[] }[];
twoPlayerControllers?: { controllers: { id: string; seats: string[] }[] } | null;
controlledFactionSeats?: { id: string }[];
error?: string;
};
async function request(path: string, body?: unknown, cookie?: string, selected?: string) {
const response = await fetch(base + path, {
method: body === undefined ? 'GET' : 'POST',
headers: {
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(cookie ? { cookie } : {}),
...(selected ? { 'X-Dune-Seat': selected } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return {
status: response.status,
data: await response.json() as Wire,
cookie: response.headers.get('set-cookie')?.split(';')[0],
};
}
void test('two-faction AI opponent starts with separate human credentials and no bot impersonation', async () => {
const host = await request('/api/rooms', {
name: 'Two-player AI host', faction: 'atreides', advanced: false, expansions: [], entry: receipt(),
});
assert.equal(host.status, 201, JSON.stringify(host.data));
assert.ok(host.cookie);
const path = `/api/rooms/${host.data.code}`;
const enabled = await request(path, {
version: host.data.version, action: { type: 'twoPlayerMode', enabled: true },
}, host.cookie, host.data.me);
assert.equal(enabled.status, 200, JSON.stringify(enabled.data));
const second = await request(path, {
type: 'addControlledFaction', version: enabled.data.version,
name: 'Human second', faction: 'harkonnen', entry: receipt(),
}, host.cookie, host.data.me);
assert.equal(second.status, 200, JSON.stringify(second.data));
assert.ok(second.cookie);
const cookies = `${host.cookie}; ${second.cookie}`;
const primary = await request(path, {
version: second.data.version, action: { type: 'addBot', faction: 'emperor', difficulty: 'Medium' },
}, cookies, host.data.me);
assert.equal(primary.status, 200, JSON.stringify(primary.data));
const bot = primary.data.players.find(player => player.faction === 'emperor' && player.bot === 'Medium');
assert.ok(bot);
const paired = await request(path, {
version: primary.data.version,
action: { type: 'addControlledBot', controller: bot.id, faction: 'guild', difficulty: 'Medium' },
}, cookies, host.data.me);
assert.equal(paired.status, 200, JSON.stringify(paired.data));
const botSecond = paired.data.players.find(player => player.faction === 'guild' && player.bot === 'Medium');
assert.ok(botSecond);
assert.deepEqual(paired.data.twoPlayerControllers?.controllers.map(controller => controller.seats), [
[host.data.me, second.data.me], [bot.id, botSecond.id],
]);
assert.deepEqual(paired.data.controlledFactionSeats?.map(seat => seat.id), [host.data.me, second.data.me]);
for (const botId of [bot.id, botSecond.id]) {
const deniedRead = await request(path, undefined, cookies, botId);
assert.equal(deniedRead.status, 409);
const deniedAction = await request(path, {
version: paired.data.version, action: { type: 'ready' },
}, cookies, botId);
assert.equal(deniedAction.status, 409);
}
const firstReady = await request(path, {
version: paired.data.version, action: { type: 'ready' },
}, cookies, host.data.me);
assert.equal(firstReady.status, 200, JSON.stringify(firstReady.data));
const secondReady = await request(path, {
version: firstReady.data.version, action: { type: 'ready' },
}, cookies, second.data.me);
assert.equal(secondReady.status, 200, JSON.stringify(secondReady.data));
assert.ok(secondReady.data.players.every(player => player.ready));
const started = await request(path, {
version: secondReady.data.version, action: { type: 'start' },
}, cookies, host.data.me);
assert.equal(started.status, 200, JSON.stringify(started.data));
assert.ok(started.data.status === 'setup' || started.data.status === 'playing');
for (const player of started.data.players)
assert.equal(Object.hasOwn(player, 'hand'), player.id === host.data.me);
});
+51
View File
@@ -25,6 +25,7 @@ function render(game: GameView, pendingEntry = false) {
return renderToStaticMarkup(createElement(TwoPlayerControls, {
game, disabled: pendingEntry, pendingEntry, entryRoom: game.code, entryProblem: '',
retryDisabled: false, onModeChange: noop, onSwitch: noop, onAdd: noop, onRetry: noop,
onAddBot: noop, onAddControlledBot: noop,
}));
}
@@ -51,3 +52,53 @@ void test('an unresolved request stays visible with an explicit retry', () => {
assert.match(html, /never retried automatically/);
assert.match(html, /<select[^>]*disabled/);
});
void test('host may add a computer opponent when the second controller slot is empty', () => {
const game = {
...base,
players: [base.players[0]],
twoPlayerControllers: { version: 1 as const, controllers: [{ id: 'a', seats: ['a'] }] },
controlledFactionSeats: [{ id: 'a', name: 'Ali', faction: 'atreides' as const }],
};
const html = render(game);
assert.match(html, /Add an AI player/);
assert.match(html, /The AI plays each faction independently/);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'])
assert.match(html, new RegExp(`value="${difficulty}"`));
assert.match(html, /value="harkonnen"/);
assert.doesNotMatch(html, /<option[^>]*value="atreides"/);
});
void test('host adds the second AI faction only for an incomplete AI controller', () => {
const human = base.players[0];
const ai = { ...base.players[1], bot: 'Hard' as const };
const game = {
...base,
players: [human, ai],
twoPlayerControllers: { version: 1 as const, controllers: [
{ id: 'a', seats: ['a'] }, { id: 'b', seats: ['b'] },
] },
controlledFactionSeats: [{ id: 'a', name: 'Ali', faction: 'atreides' as const }],
};
const html = render(game);
assert.match(html, /Player 2 \(AI\): Harkonnen/);
assert.match(html, /Add the AI player’s second faction/);
assert.match(html, /Add AI second faction/);
assert.doesNotMatch(html, /<option[^>]*value="harkonnen"/);
const nonHost = render({ ...game, me: 'b' });
assert.doesNotMatch(nonHost, /Add AI second faction/);
const complete = render({ ...game, twoPlayerControllers: { version: 1, controllers: [
{ id: 'a', seats: ['a'] }, { id: 'b', seats: ['b', 'c'] },
] } });
assert.doesNotMatch(complete, /Add AI second faction/);
});
void test('a human opponent does not expose controls to add an AI faction to their seat', () => {
const game = {
...base,
twoPlayerControllers: { version: 1 as const, controllers: [
{ id: 'a', seats: ['a', 'c'] }, { id: 'b', seats: ['b'] },
] },
};
assert.doesNotMatch(render(game), /Add AI second faction|Add an AI player/);
});
+2 -2
View File
@@ -46,14 +46,14 @@ void test('two people can independently ready their two physical factions and fi
assert.equal(game.stormPending, 2);
});
void test('lobby preserves separate controller seats and refuses incomplete, excess and AI setup', () => {
void test('lobby preserves separate controller seats and refuses incomplete, excess and mixed ownership', () => {
let game = createGame('TWOPEOPLE', newPlayer('a', 'A', 'atreides'));
game = applyAction(game, 'a', { type: 'twoPlayerMode', enabled: true });
assert.throws(() => joinControlledFaction(game, newPlayer('x', 'X', 'emperor'), 'other'), /controller/);
joinControlledFaction(game, newPlayer('b', 'B', 'emperor'), 'a');
assert.throws(() => joinControlledFaction(game, newPlayer('x', 'X', 'guild'), 'b'), /two faction/);
assert.throws(() => applyAction(game, 'a', { type: 'twoPlayerMode', enabled: false }), /cannot be disabled/);
assert.throws(() => applyAction(game, 'a', { type: 'addBot', faction: 'guild', difficulty: 'Easy' }), /human/);
assert.throws(() => applyAction(game, 'a', { type: 'addControlledBot', controller: 'a', faction: 'guild', difficulty: 'Easy' }), /AI controller/);
game = applyAction(game, 'a', { type: 'ready' });
game = applyAction(game, 'b', { type: 'ready' });
assert.throws(() => applyAction(game, 'a', { type: 'start' }), /exactly two controllers/);