Support AI opponents in two-faction games
This commit is contained in:
1 parent
aa6f77372f
commit
b00d720e40
13 files changed
+395
-28
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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'] },
|
||||
],
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
Reference in new issue
Block a user