Add random faction deal and prediction draft
This commit is contained in:
1 parent
a244aeb5f8
commit
4dc2df2513
16 files changed
+819
-12
No files matched your search
@@ -14,6 +14,13 @@ 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.
|
||||
|
||||
In the lobby, the host may deal random factions or start a prediction-card
|
||||
faction draft. Draft picks proceed from the highest drawn number, and the new
|
||||
factions are assigned together after every seat chooses. AI seats choose
|
||||
automatically. Both methods clear human readiness; direct selection remains
|
||||
available. Consensual faction swaps are not yet implemented. See
|
||||
[faction allocation](docs/FACTION_ALLOCATION.md).
|
||||
|
||||
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
|
||||
|
||||
+5
-1
@@ -7,6 +7,7 @@ import { SeatConfirmation, type SeatConfirmationKind } from '@/components/seat-c
|
||||
import { RoomEntryConfirmation } from '@/components/room-entry-confirmation';
|
||||
import { RulesetControls } from '@/components/ruleset-controls';
|
||||
import { TwoPlayerControls } from '@/components/two-player-controls';
|
||||
import { FactionAllocation } from '@/components/faction-allocation';
|
||||
import { randomId } from '@/lib/random-id';
|
||||
import {
|
||||
SeatHandoverSetup,
|
||||
@@ -855,6 +856,7 @@ export default function Home() {
|
||||
{!roomUnavailable && <div className="table-shell"><TwoPlayerControls
|
||||
game={game}
|
||||
disabled={busy || needsReconcile || checkingConnection || handoverPending || aiPermissionPending || claimUncertain || !!twoPlayerEntry || !!twoPlayerEntryProblem}
|
||||
draftLocked={!!game.factionDraft}
|
||||
pendingEntry={twoPlayerEntry !== null}
|
||||
entryRoom={twoPlayerEntry?.room}
|
||||
entryProblem={twoPlayerEntryProblem}
|
||||
@@ -866,11 +868,13 @@ export default function Home() {
|
||||
onRetry={() => { if (twoPlayerEntry) void dispatchControlledFaction(twoPlayerEntry, false); }}
|
||||
retryDisabled={busy || needsReconcile || checkingConnection || handoverPending || aiPermissionPending || claimUncertain || !!twoPlayerEntryProblem || twoPlayerEntry?.room !== game.code}
|
||||
/></div>}
|
||||
{!roomUnavailable && <div className="table-shell"><FactionAllocation game={game} send={send}
|
||||
busy={busy || needsReconcile || checkingConnection || handoverPending || aiPermissionPending || claimUncertain || !!twoPlayerEntry || !!twoPlayerEntryProblem} /></div>}
|
||||
{roomUnavailable ? <main className="table-shell"><RemovedRoomNotice code={game.code} busy={busy || checkingConnection} onRetry={() => void reconnect()} onExit={exitTable} exitDisabled={handoverPending || aiPermissionPending || claimUncertain} /></main> : <GameTable
|
||||
key={`${game.code}:${game.me}`}
|
||||
game={game}
|
||||
send={send}
|
||||
busy={busy || needsReconcile || !!twoPlayerEntry || !!twoPlayerEntryProblem}
|
||||
busy={busy || needsReconcile || !!twoPlayerEntry || !!twoPlayerEntryProblem || !!game.factionDraft}
|
||||
onExit={exitTable}
|
||||
exitDisabled={handoverPending || aiPermissionPending}
|
||||
/>}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { FACTIONS } from '@/game/catalog';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/** Lobby allocation uses public seat identities only. The engine owns the
|
||||
* randomized order and validates every pick again at commitment. */
|
||||
export function FactionAllocation({ game, send, busy }: {
|
||||
game: GameView;
|
||||
send: (action: Action) => Promise<void>;
|
||||
busy: boolean;
|
||||
}) {
|
||||
if (game.status !== 'lobby') return null;
|
||||
const draft = game.factionDraft;
|
||||
const isHost = game.host === game.me;
|
||||
const seatName = (id: string) => {
|
||||
const player = game.players.find((candidate) => candidate.id === id);
|
||||
return player ? `${player.name}${player.bot ? ' (AI)' : ''}` : 'Unknown player';
|
||||
};
|
||||
if (!draft) return <section className="notice" aria-label="Faction allocation">
|
||||
<h2>Choose factions</h2>
|
||||
<p>Players can choose their own factions. The host can also randomly deal factions or start a prediction-card draft.</p>
|
||||
{isHost && <>
|
||||
<p className="fine">Random dealing changes the current choices immediately. Draft choices replace the current factions only after everyone has picked. Either option clears human readiness.</p>
|
||||
{game.players.length < 2 && <p className="fine">At least two faction seats are needed to allocate factions.</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" disabled={busy || game.players.length < 2} onClick={() => void send({ type: 'factionAllocation', mode: 'random' })}>
|
||||
Deal random factions
|
||||
</Button>
|
||||
<Button type="button" variant="outline" disabled={busy || game.players.length < 2} onClick={() => void send({ type: 'factionAllocation', mode: 'draft' })}>
|
||||
Draft factions by prediction card
|
||||
</Button>
|
||||
</div>
|
||||
</>}
|
||||
</section>;
|
||||
|
||||
const next = draft.order[draft.picks.length];
|
||||
const picked = new Map(draft.picks.map((pick) => [pick.player, pick.faction]));
|
||||
const used = new Set(draft.picks.map((pick) => pick.faction));
|
||||
const available = FACTIONS.filter((faction) =>
|
||||
(faction.expansion === 'base' || game.expansions.includes(faction.expansion)) && !used.has(faction.id));
|
||||
const isTurn = next?.player === game.me;
|
||||
return <section className="notice" aria-label="Faction draft">
|
||||
<h2>Prediction-card faction draft</h2>
|
||||
<p>Highest prediction card chooses first. Existing factions remain in place until every seat has picked; other lobby changes wait for the draft to finish.</p>
|
||||
<ol>
|
||||
{draft.order.map((entry) => {
|
||||
const choice = picked.get(entry.player);
|
||||
const faction = FACTIONS.find((item) => item.id === choice);
|
||||
return <li key={entry.player}>
|
||||
{seatName(entry.player)} · prediction {entry.card}: {faction ? faction.name : next?.player === entry.player ? 'choosing now' : 'waiting'}
|
||||
</li>;
|
||||
})}
|
||||
</ol>
|
||||
{isTurn ? <>
|
||||
<h3>Choose your faction</h3>
|
||||
<p className="fine">Choose one of the remaining factions. Each faction can be chosen only once.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{available.map((faction) => <Button key={faction.id} type="button" variant="outline" disabled={busy}
|
||||
onClick={() => void send({ type: 'factionDraftPick', event: draft.event, faction: faction.id })}>
|
||||
{faction.name}
|
||||
</Button>)}
|
||||
</div>
|
||||
</> : next && <p>Waiting for {seatName(next.player)} to choose a faction{game.players.find((player) => player.id === next.player)?.bot ? '; the AI will choose automatically' : ''}.</p>}
|
||||
{isHost && <Button type="button" variant="outline" disabled={busy} onClick={() => void send({ type: 'factionDraftCancel', event: draft.event })}>
|
||||
Cancel faction draft
|
||||
</Button>}
|
||||
</section>;
|
||||
}
|
||||
@@ -8,11 +8,12 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
export function TwoPlayerControls({
|
||||
game, disabled, pendingEntry, entryRoom, entryProblem, retryDisabled,
|
||||
game, disabled, draftLocked = false, pendingEntry, entryRoom, entryProblem, retryDisabled,
|
||||
onModeChange, onSwitch, onAdd, onRetry, onAddBot, onAddControlledBot,
|
||||
}: {
|
||||
game: GameView;
|
||||
disabled: boolean;
|
||||
draftLocked?: boolean;
|
||||
pendingEntry: boolean;
|
||||
entryRoom?: string;
|
||||
entryProblem: string;
|
||||
@@ -29,6 +30,7 @@ export function TwoPlayerControls({
|
||||
const [botChoice, setBotChoice] = useState('');
|
||||
const [botDifficulty, setBotDifficulty] = useState<Difficulty>('Medium');
|
||||
const mode = game.twoPlayerControllers;
|
||||
const lobbyLocked = disabled || draftLocked;
|
||||
const controlled = game.controlledFactionSeats ?? [];
|
||||
const modeLocked = !!mode?.controllers.some((controller) => controller.seats.length > 1);
|
||||
const botController = mode?.controllers.find((controller) =>
|
||||
@@ -46,7 +48,7 @@ export function TwoPlayerControls({
|
||||
<section className="notice" aria-label="Two-player faction controls">
|
||||
{game.status === 'lobby' && game.host === game.me && (
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={!!mode} disabled={disabled || modeLocked} onChange={(event) => onModeChange(event.target.checked)} />
|
||||
<input type="checkbox" checked={!!mode} disabled={lobbyLocked || modeLocked} onChange={(event) => onModeChange(event.target.checked)} />
|
||||
Two players, two factions each
|
||||
</label>
|
||||
)}
|
||||
@@ -72,33 +74,33 @@ export function TwoPlayerControls({
|
||||
</label>}
|
||||
{game.status === 'lobby' && controlled.length === 1 && <form onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (choice && name.trim() && available.some((item) => item.id === choice)) onAdd(name.trim(), choice);
|
||||
if (!lobbyLocked && choice && name.trim() && available.some((item) => item.id === choice)) onAdd(name.trim(), choice);
|
||||
}}>
|
||||
<h3>Add your second faction</h3>
|
||||
<label htmlFor="second-faction-seat-name">Seat name</label>
|
||||
<Input id="second-faction-seat-name" value={name} maxLength={32} disabled={disabled} onChange={(event) => setName(event.target.value)} />
|
||||
<label>Faction <select value={choice} disabled={disabled} onChange={(event) => setChoice(event.target.value)}>
|
||||
<Input id="second-faction-seat-name" value={name} maxLength={32} disabled={lobbyLocked} onChange={(event) => setName(event.target.value)} />
|
||||
<label>Faction <select value={choice} disabled={lobbyLocked} onChange={(event) => setChoice(event.target.value)}>
|
||||
<option value="">Choose a faction</option>
|
||||
{available.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select></label>
|
||||
<Button type="submit" disabled={disabled || !name.trim() || !available.some((item) => item.id === choice)}>Add second faction</Button>
|
||||
<Button type="submit" disabled={lobbyLocked || !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 (lobbyLocked || !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)}>
|
||||
<label>AI faction <select value={botChoice} disabled={lobbyLocked} 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)}>
|
||||
<label>AI difficulty <select value={botDifficulty} disabled={lobbyLocked} 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>
|
||||
<Button type="submit" disabled={lobbyLocked || !available.some((item) => item.id === botChoice)}>{botController ? 'Add AI second faction' : 'Add AI player'}</Button>
|
||||
</form>}
|
||||
</>}
|
||||
{pendingEntry && <div>
|
||||
|
||||
@@ -16,6 +16,17 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Faction allocation at setup — 28 October. Prototyped / Partial.** Hosts can
|
||||
deal random enabled factions or start a draft in descending prediction-number
|
||||
order. Human seats choose in order, bots choose automatically, and choices
|
||||
apply together after the draft. Seat identities remain; human readiness clears.
|
||||
Direct selection still works. Nine related unit files pass 43 cases; four
|
||||
isolated HTTP cases include a four-seat/two-controller draft and game start.
|
||||
An isolated browser room exercised a random deal, a human draft choice and an
|
||||
automatic AI pick through completion.
|
||||
Consensual swaps, wider combinations and deployed acceptance remain open.
|
||||
[Scope](FACTION_ALLOCATION.md).
|
||||
|
||||
**Advanced Voice names for ordinary cards — 28 October. Prototyped / Partial.**
|
||||
Native and borrowed Voice can require or forbid an exact ordinary battle-card
|
||||
face in Advanced. Generic Voice and Basic choices remain; matching covers
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Faction allocation in the lobby
|
||||
|
||||
## Sources and scope — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** The user-authorized revised Dune rulebook, physical
|
||||
page 4 (printed page 2), gives two initial methods: randomly deal faction
|
||||
prediction cards, or draw distinct turn-prediction numbers and select factions
|
||||
from highest to lowest. It also permits players to swap factions. The original
|
||||
publisher rulebook describes the same selection methods; this prototype exposes
|
||||
both in Basic and Advanced and retains the existing direct-choice option.
|
||||
|
||||
A host may deal once from all factions belonging to currently enabled families,
|
||||
without replacement. Alternatively, the host deals distinct numbers 1–10 to
|
||||
seated faction seats. The public draft follows descending numbers, rejects a
|
||||
picked or disabled faction and applies all choices together after the last pick.
|
||||
These cards represent the setup draw and are not removed from later Bene Gesserit
|
||||
prediction. Existing temporary faction selections do not reserve faces during a
|
||||
draft. The host may cancel the draft; the previous factions remain. The engine
|
||||
uses the existing cryptographic shuffle, event-bound actions and room version
|
||||
fencing, and clears human readiness when allocation begins and ends.
|
||||
|
||||
For the provisional two-person/four-faction setup, each faction seat receives
|
||||
its own draw and pick. That is a software interpretation of the two-faction
|
||||
case, not a separate publisher clarification. Seats, credentials, host and
|
||||
player-circle positions remain attached to the same participants. AI seats pick
|
||||
a legal available faction automatically, preferring their existing faction
|
||||
when available. They retain their difficulty and a faction-appropriate name.
|
||||
Selection does not bypass any existing expansion/profile start gate.
|
||||
|
||||
Nine related unit files pass 43 cases; four isolated HTTP cases include a
|
||||
four-seat/two-controller draft, seat selection and game start. These do not
|
||||
establish complete setup or deployed acceptance. An isolated browser room also
|
||||
exercised a random deal and a human/AI draft to completion.
|
||||
|
||||
Consensual faction swaps are not implemented yet. A host's random deal is an
|
||||
explicit action and direct selection remains available; neither is a substitute
|
||||
for a participant-approved swap. Broader player-count interpretations, complete
|
||||
setup combinations, saved-game assurance and deployed acceptance remain open.
|
||||
@@ -1,5 +1,16 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 28 October 2026 — Random faction deal and prediction-number draft
|
||||
|
||||
The host can deal enabled factions randomly or start a public descending-number
|
||||
draft, with automatic legal bot picks and all human choices applied together.
|
||||
Seat identity and existing start gates remain. Nine related unit files pass 43
|
||||
cases; four isolated HTTP cases include two controllers selecting for four
|
||||
separately authenticated faction seats and starting the game. Consensual swaps,
|
||||
wider combinations and deployed acceptance remain open. An isolated browser
|
||||
room also exercised random dealing and a human/AI draft to completion.
|
||||
[Scope](FACTION_ALLOCATION.md).
|
||||
|
||||
## 28 October 2026 — Advanced Voice names for ordinary cards
|
||||
|
||||
Advanced Voice can provisionally require or forbid a specific ordinary
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Rule decision index
|
||||
|
||||
## Faction allocation before setup — 28 October 2026
|
||||
|
||||
The supplied revised rulebook, physical p. 4 (printed p. 2), describes random
|
||||
faction dealing or descending turn-prediction-number draft order and permits
|
||||
swaps. The prototype implements the first two methods in the lobby, retaining
|
||||
direct selection and existing start gates. Each faction seat receives its own
|
||||
draw in the two-person/four-faction mode; that composition and the automatic
|
||||
AI pick are software interpretations. Consensual swaps remain unfinished.
|
||||
[Scope](FACTION_ALLOCATION.md).
|
||||
|
||||
## Advanced Voice and ordinary card names — 28 October 2026
|
||||
|
||||
The supplied revised Advanced rules, physical p. 28 (printed p. 26), permit a
|
||||
|
||||
+100
@@ -1,5 +1,7 @@
|
||||
import { createTwoPlayerControllers, validateTwoPlayerControllersLobby, validateTwoPlayerControllersComplete,
|
||||
addTwoPlayerControlledSeat, TwoPlayerControllersError, type TwoPlayerControllers } from './two-player-controllers';
|
||||
import { createFactionDraft, validateFactionDraft, currentFactionDrafter, chooseFaction,
|
||||
FactionDraftError, type FactionDraft } from './faction-draft';
|
||||
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
|
||||
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
|
||||
import { createVictoryContinuation, validateVictoryContinuation, voteVictoryContinuation,
|
||||
@@ -1409,6 +1411,8 @@ type RicheseAllyOffer = {
|
||||
payer: string;
|
||||
};
|
||||
export type Game = {
|
||||
/** Public prediction-number faction selection, present only while in progress. */
|
||||
factionDraft?: FactionDraft;
|
||||
/** Explicit two-human/four-faction setup; absent from ordinary and legacy games. */
|
||||
twoPlayerControllers?: TwoPlayerControllers;
|
||||
/** Explicit final turn after an agreed extension; absent means turn ten. */
|
||||
@@ -5711,6 +5715,7 @@ function twoPlayerControllersIntegrity(g: Game): void {
|
||||
|
||||
function joinFactionSeat(g: Game, p: Player, controllerId?: string) {
|
||||
twoPlayerControllersIntegrity(g);
|
||||
requireRule(!g.factionDraft, 'Finish or cancel the faction draft before adding a seat.');
|
||||
requireRule(g.status === 'lobby', 'This game has already started.');
|
||||
requireRule(g.players.length < 6, 'This table has six players.');
|
||||
requireRule(!g.players.some((x) => x.id === p.id), 'Already seated.');
|
||||
@@ -5754,6 +5759,51 @@ export function joinControlledFaction(g: Game, p: Player, actorId: string) {
|
||||
'Human faction seats must belong to a human controller.');
|
||||
joinFactionSeat(g, p, controller.id);
|
||||
}
|
||||
function factionDraftRule<T>(operation: () => T): T {
|
||||
try { return operation(); }
|
||||
catch (error) {
|
||||
if (error instanceof FactionDraftError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function eligibleLobbyFactions(g: Game): FactionId[] {
|
||||
return FACTIONS.filter(entry => entry.expansion === 'base' || g.expansions.includes(entry.expansion))
|
||||
.map(entry => entry.id);
|
||||
}
|
||||
function factionDraftIntegrity(g: Game): void {
|
||||
if (g.factionDraft === undefined) return;
|
||||
requireRule(g.status === 'lobby', 'The faction draft belongs to the lobby.');
|
||||
factionDraftRule(() => validateFactionDraft(g.factionDraft, g.players.map(player => player.id), eligibleLobbyFactions(g)));
|
||||
requireRule(g.factionDraft.picks.length < g.players.length, 'A completed faction draft must be applied once.');
|
||||
}
|
||||
function assignLobbyFaction(player: Player, chosen: FactionId): void {
|
||||
player.faction = chosen;
|
||||
if (player.bot) player.name = `${faction(chosen).name} AI`;
|
||||
player.elites = chosen === 'ixians' ? { reserves: 7, tanks: 0, forces: {}, revived: 0 } : undefined;
|
||||
player.leaders = leaders(chosen);
|
||||
}
|
||||
/** Bot draft choices are made immediately: online bot workers are not armed in
|
||||
* the lobby. Each choice uses a currently available faction and the real order. */
|
||||
function continueFactionDraft(g: Game): void {
|
||||
while (g.factionDraft) {
|
||||
const draft = g.factionDraft;
|
||||
const actor = factionDraftRule(() => currentFactionDrafter(draft));
|
||||
if (!actor) {
|
||||
for (const pick of draft.picks) assignLobbyFaction(getPlayer(g, pick.player), pick.faction);
|
||||
delete g.factionDraft;
|
||||
g.players.forEach(player => (player.ready = !!player.bot));
|
||||
log(g, 'Faction draft completed. The selected factions are assigned and human readiness was cleared.');
|
||||
return;
|
||||
}
|
||||
const player = getPlayer(g, actor);
|
||||
if (!player.bot) return;
|
||||
const available = eligibleLobbyFactions(g).filter(f => !draft.picks.some(pick => pick.faction === f));
|
||||
const chosen = available.includes(player.faction) ? player.faction : available[0];
|
||||
requireRule(chosen, 'The AI drafter has no enabled faction available.');
|
||||
g.factionDraft = factionDraftRule(() => chooseFaction(draft, g.players.map(seat => seat.id), eligibleLobbyFactions(g), actor, chosen));
|
||||
log(g, `${player.name} selected ${faction(chosen).name} in the faction draft.`);
|
||||
}
|
||||
}
|
||||
function setMovementDiscardKnowledge(g: Game, knowledge: MovementDiscardKnowledge | undefined) {
|
||||
if (knowledge === undefined) delete g.movementDiscardKnowledge;
|
||||
else g.movementDiscardKnowledge = knowledge;
|
||||
@@ -39929,6 +39979,11 @@ function normalizeCardNames(g: Game) {
|
||||
}
|
||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
twoPlayerControllersIntegrity(state);
|
||||
factionDraftIntegrity(state);
|
||||
if (state.factionDraft) requireRule(
|
||||
['factionDraftPick', 'factionDraftCancel', 'advanceBots'].includes(action?.type),
|
||||
'Finish or cancel the faction draft before changing the lobby.',
|
||||
);
|
||||
victoryContinuationIntegrity(state);
|
||||
bidPromiseIntegrity(state);
|
||||
phaseBidIntegrity(state);
|
||||
@@ -40545,6 +40600,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
|
||||
export function normalizeAutomaticGame(state: Game): Game {
|
||||
twoPlayerControllersIntegrity(state);
|
||||
factionDraftIntegrity(state);
|
||||
victoryContinuationIntegrity(state);
|
||||
if (state.victoryContinuation) return state;
|
||||
bidPromiseIntegrity(state);
|
||||
@@ -42871,6 +42927,48 @@ function applyActionInner(
|
||||
return g;
|
||||
}
|
||||
if (g.status === 'lobby') {
|
||||
if (g.factionDraft) {
|
||||
const draft = g.factionDraft;
|
||||
requireRule(action.event === draft.event, 'This faction draft is no longer current.');
|
||||
if (t === 'factionDraftCancel') {
|
||||
requireRule(id === g.host && Object.keys(action).every(key => ['type', 'event'].includes(key)),
|
||||
'Only the host can cancel the current faction draft.');
|
||||
delete g.factionDraft;
|
||||
g.players.forEach(player => (player.ready = !!player.bot));
|
||||
log(g, `${p.name} canceled the faction draft; previous factions remain assigned.`);
|
||||
return g;
|
||||
}
|
||||
requireRule(t === 'factionDraftPick' && Object.keys(action).every(key => ['type', 'event', 'faction'].includes(key)),
|
||||
'Choose the current faction draft action.');
|
||||
requireRule(!p.bot, 'AI faction choices are made automatically.');
|
||||
const chosen = stringField(action.faction) as FactionId;
|
||||
g.factionDraft = factionDraftRule(() => chooseFaction(draft, g.players.map(seat => seat.id), eligibleLobbyFactions(g), id, chosen));
|
||||
log(g, `${p.name} selected ${faction(chosen).name} in the faction draft.`);
|
||||
continueFactionDraft(g);
|
||||
return g;
|
||||
}
|
||||
if (t === 'factionAllocation') {
|
||||
requireRule(id === g.host && Object.keys(action).every(key => ['type', 'mode'].includes(key)) &&
|
||||
(action.mode === 'random' || action.mode === 'draft'),
|
||||
'Only the host can choose a random deal or prediction-number faction draft.');
|
||||
requireRule(g.players.length >= 2, 'At least two seated factions are needed for faction allocation.');
|
||||
const seats = g.players.map(player => player.id);
|
||||
const eligible = eligibleLobbyFactions(g);
|
||||
requireRule(eligible.length >= seats.length, 'Not enough enabled factions are available.');
|
||||
if (action.mode === 'random') {
|
||||
const dealt = shuffle(eligible).slice(0, seats.length);
|
||||
g.players.forEach((player, index) => assignLobbyFaction(player, dealt[index]));
|
||||
g.players.forEach(player => (player.ready = !!player.bot));
|
||||
log(g, `${p.name} dealt random factions. Human readiness was cleared.`);
|
||||
} else {
|
||||
const cards = shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]).slice(0, seats.length);
|
||||
g.factionDraft = factionDraftRule(() => createFactionDraft(seats, cards, crypto.randomUUID()));
|
||||
g.players.forEach(player => (player.ready = !!player.bot));
|
||||
log(g, `${p.name} started a faction draft in descending prediction-card order.`);
|
||||
continueFactionDraft(g);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
if (t === 'twoPlayerMode') {
|
||||
requireRule(id === g.host, 'Only the host can change two-person setup.');
|
||||
requireRule(typeof action.enabled === 'boolean' &&
|
||||
@@ -44793,6 +44891,7 @@ function applyActionInner(
|
||||
}
|
||||
export function viewGame(state: Game, id: string, now?: number) {
|
||||
twoPlayerControllersIntegrity(state);
|
||||
factionDraftIntegrity(state);
|
||||
victoryContinuationIntegrity(state);
|
||||
revivalOrderIntegrity(state);
|
||||
charityOrderIntegrity(state);
|
||||
@@ -45091,6 +45190,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
version: g.version,
|
||||
host: g.host,
|
||||
status: g.status,
|
||||
factionDraft: g.factionDraft ?? null,
|
||||
twoPlayerControllers: g.twoPlayerControllers ?? null,
|
||||
controlledFactionSeats: g.twoPlayerControllers
|
||||
? g.players.filter(player => g.twoPlayerControllers!.controllers.some(controller =>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
|
||||
/** Public prediction-card order and completed faction choices. No hidden deck
|
||||
* or session credential is stored here; the host deals once before selection. */
|
||||
export type FactionDraft = {
|
||||
event: string;
|
||||
order: { player: string; card: number }[];
|
||||
picks: { player: string; faction: FactionId }[];
|
||||
};
|
||||
|
||||
export class FactionDraftError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'FactionDraftError';
|
||||
}
|
||||
}
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||||
const keys = (value: Record<string, unknown>, expected: string): boolean =>
|
||||
Object.keys(value).sort().join(',') === expected;
|
||||
const factionId = (value: unknown): value is FactionId =>
|
||||
typeof value === 'string' && FACTIONS.some(faction => faction.id === value);
|
||||
function requireDraft(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new FactionDraftError(message);
|
||||
}
|
||||
function validateSeats(seats: readonly string[]): void {
|
||||
requireDraft(Array.isArray(seats) && seats.length >= 2 && seats.length <= 6 &&
|
||||
seats.every(identifier) && new Set(seats).size === seats.length,
|
||||
'A faction draft needs two through six distinct seated players.');
|
||||
}
|
||||
function validateEligible(factions: readonly FactionId[], seats: readonly string[]): void {
|
||||
requireDraft(Array.isArray(factions) && factions.length >= seats.length &&
|
||||
factions.length <= FACTIONS.length && factions.every(factionId) &&
|
||||
new Set(factions).size === factions.length,
|
||||
'The draft needs enough distinct enabled factions for every seat.');
|
||||
}
|
||||
|
||||
/** numberCards are the unique prediction numbers dealt in seat order. */
|
||||
export function createFactionDraft(
|
||||
seatIds: readonly string[], numberCards: readonly number[], event: string,
|
||||
): FactionDraft {
|
||||
validateSeats(seatIds);
|
||||
requireDraft(identifier(event), 'The faction draft needs a distinct event.');
|
||||
requireDraft(Array.isArray(numberCards) && numberCards.length === seatIds.length &&
|
||||
numberCards.every(card => Number.isSafeInteger(card) && card >= 1 && card <= 10) &&
|
||||
new Set(numberCards).size === numberCards.length,
|
||||
'Deal one distinct prediction number from 1 through 10 to each seat.');
|
||||
return {
|
||||
event,
|
||||
order: seatIds.map((player, index) => ({ player, card: numberCards[index] }))
|
||||
.sort((left, right) => right.card - left.card),
|
||||
picks: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Validate a saved public draft against its actual seats and allowed faction
|
||||
* pool. Picks are a prefix of the immutable descending-number order. */
|
||||
export function validateFactionDraft(
|
||||
value: unknown, seatIds: readonly string[], eligibleFactions: readonly FactionId[],
|
||||
): asserts value is FactionDraft {
|
||||
validateSeats(seatIds);
|
||||
validateEligible(eligibleFactions, seatIds);
|
||||
requireDraft(record(value) && keys(value, 'event,order,picks') && identifier(value.event) &&
|
||||
Array.isArray(value.order) && value.order.length === seatIds.length && Array.isArray(value.picks) &&
|
||||
value.picks.length <= seatIds.length,
|
||||
'The saved faction draft is malformed.');
|
||||
const order = value.order;
|
||||
const selected = new Set<string>();
|
||||
const dealt = new Set<number>();
|
||||
for (const [index, entry] of order.entries()) {
|
||||
requireDraft(record(entry) && keys(entry, 'card,player') && identifier(entry.player) &&
|
||||
seatIds.includes(entry.player) && !selected.has(entry.player) &&
|
||||
Number.isSafeInteger(entry.card) && (entry.card as number) >= 1 && (entry.card as number) <= 10 &&
|
||||
!dealt.has(entry.card as number) &&
|
||||
(index === 0 || (order[index - 1] as { card: number }).card > (entry.card as number)),
|
||||
'The faction draft must preserve distinct seated players and descending dealt numbers.');
|
||||
selected.add(entry.player);
|
||||
dealt.add(entry.card as number);
|
||||
}
|
||||
const factions = new Set<string>();
|
||||
for (const [index, pick] of value.picks.entries()) {
|
||||
requireDraft(record(pick) && keys(pick, 'faction,player') &&
|
||||
pick.player === (order[index] as { player: string }).player && factionId(pick.faction) &&
|
||||
eligibleFactions.includes(pick.faction) && !factions.has(pick.faction),
|
||||
'Faction choices must follow draft order and use distinct enabled factions.');
|
||||
factions.add(pick.faction);
|
||||
}
|
||||
}
|
||||
|
||||
/** A validated completed draft has no current picker. */
|
||||
export function currentFactionDrafter(draft: FactionDraft): string | null {
|
||||
requireDraft(record(draft) && Array.isArray(draft.order) && Array.isArray(draft.picks),
|
||||
'The saved faction draft is malformed.');
|
||||
const seats = draft.order.map(entry => record(entry) ? entry.player : undefined);
|
||||
requireDraft(seats.every(identifier), 'The saved faction draft has invalid seats.');
|
||||
validateFactionDraft(draft, seats, FACTIONS.map(faction => faction.id));
|
||||
return draft.order[draft.picks.length]?.player ?? null;
|
||||
}
|
||||
|
||||
/** An accepted choice appends exactly once; the caller commits the returned
|
||||
* snapshot through the same authoritative version fence as other actions. */
|
||||
export function chooseFaction(
|
||||
draft: FactionDraft, seatIds: readonly string[], eligibleFactions: readonly FactionId[],
|
||||
actor: string, faction: FactionId,
|
||||
): FactionDraft {
|
||||
validateFactionDraft(draft, seatIds, eligibleFactions);
|
||||
requireDraft(identifier(actor) && currentFactionDrafter(draft) === actor,
|
||||
'Only the current faction drafter may choose.');
|
||||
requireDraft(factionId(faction) && eligibleFactions.includes(faction) &&
|
||||
!draft.picks.some(pick => pick.faction === faction),
|
||||
'Choose an enabled faction that has not already been selected.');
|
||||
return {
|
||||
event: draft.event,
|
||||
order: draft.order.map(entry => ({ ...entry })),
|
||||
picks: [...draft.picks.map(pick => ({ ...pick })), { player: actor, faction }],
|
||||
};
|
||||
}
|
||||
+2
-1
@@ -2446,6 +2446,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Create a room, invite rivals with its code, and complete each faction’s starting choices.',
|
||||
steps: [
|
||||
'Each player chooses a distinct faction and an unoccupied player circle, then marks Ready. The six circles appear around the map and determine storm order. Changing your circle resets readiness. The host starts when everyone is ready.',
|
||||
'The host may instead deal random enabled factions or draw distinct prediction numbers for a faction draft. The highest number picks first; choices apply together after all seats pick. AI seats choose automatically and human readiness clears. Consensual faction swaps are not yet available.',
|
||||
'After positions are fixed, Bene Gesserit seals a secret prediction of a faction and turn. No traitor or Treachery Cards have been dealt yet. Other players wait for this choice; if Bene Gesserit is absent, the table continues automatically.',
|
||||
'Each player receives four private traitor choices and keeps one. Harkonnen keeps all four automatically. Every remaining selection must finish before starting spice and forces are placed. Your starting Treachery hand is still empty.',
|
||||
'Starting spice and fixed forces are then placed. Fremen distributes ten forces among Sietch Tabr, False Wall South and False Wall West, choosing their printed sectors. Basic Bene Gesserit starts with one fighter in Polar Sink. In Advanced setup, Bene Gesserit instead chooses a printed territory and sector after Fremen placement; its advisor becomes a fighter when alone.',
|
||||
@@ -2459,7 +2460,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Base setup enforces prediction, traitors, forces and automatic dealing in order, with private choices and preserved saved tables. Advanced starting advisors and elite forces share the same setup logic. The normal lobby now starts selected expansions and the Leader Skills, Ecaz Treachery Card, Nexus, Discovery, Homeworld, Tech and Stronghold modules through their existing genuine setups as labelled previews; unsupported combinations still reject at start. Fresh native Ecaz/classic World games retain ordinary Arrakis Occupy, with or without supported Skills.',
|
||||
'Lobby random faction dealing and prediction-number drafting retain seat identities and clear readiness; consensual swaps remain unfinished. Base setup enforces prediction, traitors, forces and automatic dealing in order, with private choices and preserved saved tables. Advanced starting advisors and elite forces share the same setup logic. The normal lobby starts selected expansions and optional modules through existing genuine setups as labelled previews; unsupported combinations still reject at start.',
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { FactionAllocation } from '../components/faction-allocation';
|
||||
import type { Action, GameView } from '../game/engine';
|
||||
|
||||
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
|
||||
const elements = (node: ReactNode): Element[] => {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
};
|
||||
const base = {
|
||||
status: 'lobby', host: 'a', me: 'a', expansions: [], factionDraft: null,
|
||||
players: [
|
||||
{ id: 'a', name: 'Alice', faction: 'atreides', ready: true },
|
||||
{ id: 'b', name: 'Basil', faction: 'harkonnen', ready: false },
|
||||
{ id: 'c', name: 'Computer', faction: 'guild', bot: 'Medium', ready: true },
|
||||
],
|
||||
} as unknown as GameView;
|
||||
const draft = {
|
||||
event: 'draft-event-1',
|
||||
order: [{ player: 'b', card: 9 }, { player: 'a', card: 6 }, { player: 'c', card: 2 }],
|
||||
picks: [{ player: 'b', faction: 'atreides' }],
|
||||
};
|
||||
const send = async (_action: Action) => {};
|
||||
const render = (game: GameView, busy = false) =>
|
||||
renderToStaticMarkup(createElement(FactionAllocation, { game, send, busy }));
|
||||
|
||||
void test('host sees both allocation methods and readiness/commit semantics', () => {
|
||||
const html = render(base);
|
||||
assert.match(html, /Deal random factions/);
|
||||
assert.match(html, /Draft factions by prediction card/);
|
||||
assert.match(html, /only after everyone has picked/);
|
||||
assert.match(html, /clears human readiness/);
|
||||
assert.doesNotMatch(render({ ...base, me: 'b' }), /Deal random factions|Draft factions by prediction card/);
|
||||
assert.doesNotMatch(render({ ...base, status: 'playing' }), /Faction allocation/);
|
||||
assert.match(render({ ...base, players: base.players.slice(0, 1) }), /At least two faction seats/);
|
||||
});
|
||||
|
||||
void test('draft shows public numbered order and only unpicked enabled factions to the active player', () => {
|
||||
const active = { ...base, factionDraft: draft } as unknown as GameView;
|
||||
const html = render(active);
|
||||
assert.match(html, /Basil · prediction 9: Atreides/);
|
||||
assert.match(html, /Alice · prediction 6: choosing now/);
|
||||
assert.match(html, /Computer \(AI\) · prediction 2: waiting/);
|
||||
assert.match(html, /Choose your faction/);
|
||||
assert.match(html, />Harkonnen<\/button>/);
|
||||
assert.doesNotMatch(html, />Atreides<\/button>|>Ixians<\/button>/);
|
||||
const expanded = render({ ...active, expansions: ['ix'] });
|
||||
assert.match(expanded, />Ixians<\/button>/);
|
||||
const waiting = render({ ...active, me: 'b' });
|
||||
assert.match(waiting, /Waiting for Alice to choose a faction/);
|
||||
assert.doesNotMatch(waiting, /Choose your faction|Cancel faction draft/);
|
||||
});
|
||||
|
||||
void test('allocation controls send the exact owner-initiated actions and draft event', () => {
|
||||
const calls: Action[] = [];
|
||||
const sendAction = async (action: Action) => { calls.push(action); };
|
||||
const initial = elements(FactionAllocation({ game: base, send: sendAction, busy: false }));
|
||||
const click = (nodes: Element[], text: string) => {
|
||||
const button = nodes.find((node) => node.props.children === text && typeof node.props.onClick === 'function');
|
||||
assert.ok(button, `Expected action ${text}`);
|
||||
(button.props.onClick as () => void)();
|
||||
};
|
||||
click(initial, 'Deal random factions');
|
||||
click(initial, 'Draft factions by prediction card');
|
||||
const inDraft = elements(FactionAllocation({ game: { ...base, factionDraft: draft } as unknown as GameView, send: sendAction, busy: false }));
|
||||
click(inDraft, 'Harkonnen');
|
||||
click(inDraft, 'Cancel faction draft');
|
||||
assert.deepEqual(calls, [
|
||||
{ type: 'factionAllocation', mode: 'random' },
|
||||
{ type: 'factionAllocation', mode: 'draft' },
|
||||
{ type: 'factionDraftPick', event: 'draft-event-1', faction: 'harkonnen' },
|
||||
{ type: 'factionDraftCancel', event: 'draft-event-1' },
|
||||
]);
|
||||
});
|
||||
|
||||
void test('busy draft disables selection and a bot turn explains automatic progress', () => {
|
||||
const game = { ...base, factionDraft: { ...draft, picks: [
|
||||
{ player: 'b', faction: 'atreides' }, { player: 'a', faction: 'emperor' },
|
||||
] } } as unknown as GameView;
|
||||
assert.match(render(game), /Waiting for Computer \(AI\).*AI will choose automatically/);
|
||||
const nodes = elements(FactionAllocation({ game: { ...base, factionDraft: draft } as unknown as GameView, send, busy: true }));
|
||||
for (const button of nodes.filter((node) => typeof node.props.onClick === 'function'))
|
||||
assert.equal(button.props.disabled, true);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FACTIONS, type FactionId } from '../game/catalog';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
|
||||
function lobby(): Game {
|
||||
const game = createGame('ALLOCATE', newPlayer('host', 'Host', 'atreides'));
|
||||
joinGame(game, newPlayer('guest', 'Guest', 'harkonnen'));
|
||||
joinGame(game, newPlayer('other', 'Other', 'emperor'));
|
||||
return game;
|
||||
}
|
||||
function rejectUnchanged(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejected allocation cannot mutate the shared lobby');
|
||||
}
|
||||
function identity(game: Game) {
|
||||
return game.players.map(player => ({ id: player.id, name: player.name, bot: player.bot }));
|
||||
}
|
||||
function selected(game: Game) {
|
||||
return game.players.map(player => ({ id: player.id, faction: player.faction,
|
||||
leaders: player.leaders.map(leader => ({ id: leader.id, faction: leader.faction })) }));
|
||||
}
|
||||
function nextFaction(used: readonly string[]): FactionId {
|
||||
return FACTIONS.find(faction => faction.expansion === 'base' && !used.includes(faction.id))!.id;
|
||||
}
|
||||
|
||||
void test('host random allocation assigns distinct enabled factions while preserving seat identity and clearing readiness', () => {
|
||||
let game = lobby();
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
const beforeSeats = identity(game), beforeHost = game.host, beforePositions = structuredClone(game.playerPositions);
|
||||
rejectUnchanged(game, 'guest', { type: 'factionAllocation', mode: 'random' });
|
||||
rejectUnchanged(game, 'host', { type: 'factionAllocation', mode: 'invalid' });
|
||||
const next = applyAction(game, 'host', { type: 'factionAllocation', mode: 'random' });
|
||||
assert.equal(next.status, 'lobby');
|
||||
assert.equal(next.host, beforeHost);
|
||||
assert.deepEqual(identity(next), beforeSeats);
|
||||
assert.deepEqual(next.playerPositions, beforePositions);
|
||||
assert.equal(new Set(next.players.map(player => player.faction)).size, next.players.length);
|
||||
assert.ok(next.players.every(player => FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base')));
|
||||
assert.ok(next.players.every(player => !player.ready));
|
||||
assert.ok(next.players.every(player => player.leaders.every(leader => leader.faction === player.faction)));
|
||||
assert.ok(!next.factionDraft);
|
||||
let ready = next;
|
||||
for (const player of ready.players) ready = applyAction(ready, player.id, { type: 'ready' });
|
||||
const started = applyAction(ready, 'host', { type: 'start' });
|
||||
assert.equal(started.status, 'setup');
|
||||
});
|
||||
|
||||
void test('draft gives distinct descending order cards and commits faction choices only after the last pick', () => {
|
||||
let game = lobby();
|
||||
const original = selected(game), seats = identity(game), positions = structuredClone(game.playerPositions);
|
||||
game = applyAction(game, 'host', { type: 'factionAllocation', mode: 'draft' });
|
||||
const draft = game.factionDraft!;
|
||||
assert.ok(draft && typeof draft.event === 'string' && draft.event.length);
|
||||
assert.equal(draft.order.length, game.players.length);
|
||||
assert.deepEqual(new Set(draft.order.map(item => item.player)), new Set(game.players.map(player => player.id)));
|
||||
assert.equal(new Set(draft.order.map(item => item.card)).size, draft.order.length);
|
||||
assert.ok(draft.order.every(item => Number.isInteger(item.card) && item.card >= 1 && item.card <= 10));
|
||||
assert.deepEqual(draft.order.map(item => item.card), draft.order.map(item => item.card).sort((a, b) => b - a));
|
||||
assert.deepEqual(draft.picks, []);
|
||||
assert.deepEqual(selected(game), original);
|
||||
assert.ok(game.players.every(player => !player.ready));
|
||||
assert.equal(game.host, 'host');
|
||||
assert.deepEqual(identity(game), seats);
|
||||
assert.deepEqual(game.playerPositions, positions);
|
||||
const current = draft.order[0].player;
|
||||
const other = draft.order.find(item => item.player !== current)!.player;
|
||||
rejectUnchanged(game, other, { type: 'factionDraftPick', event: draft.event, faction: 'guild' });
|
||||
rejectUnchanged(game, current, { type: 'factionDraftPick', event: `${draft.event}:stale`, faction: 'guild' });
|
||||
rejectUnchanged(game, current, { type: 'factionDraftPick', event: draft.event, faction: 'ixians' });
|
||||
rejectUnchanged(game, 'host', { type: 'faction', faction: 'guild' });
|
||||
rejectUnchanged(game, 'host', { type: 'start' });
|
||||
rejectUnchanged(game, 'host', { type: 'rules', advanced: true });
|
||||
const beforeJoin = structuredClone(game);
|
||||
assert.throws(() => joinGame(game, newPlayer('late', 'Late', 'guild')));
|
||||
assert.deepEqual(game, beforeJoin);
|
||||
|
||||
let used: FactionId[] = [];
|
||||
for (let index = 0; index < draft.order.length; index++) {
|
||||
const state = game.factionDraft!;
|
||||
const actor = draft.order[index].player;
|
||||
const chosen = nextFaction(used);
|
||||
game = applyAction(game, actor, { type: 'factionDraftPick', event: state.event, faction: chosen });
|
||||
used = [...used, chosen];
|
||||
if (index + 1 < draft.order.length) {
|
||||
assert.deepEqual(selected(game), original, 'An incomplete draft cannot partly change factions');
|
||||
assert.deepEqual(game.factionDraft!.picks.map(pick => pick.faction), used);
|
||||
const next = draft.order[index + 1].player;
|
||||
rejectUnchanged(game, next, { type: 'factionDraftPick', event: state.event, faction: chosen });
|
||||
}
|
||||
}
|
||||
assert.ok(!game.factionDraft);
|
||||
assert.deepEqual(identity(game), seats);
|
||||
assert.deepEqual(game.playerPositions, positions);
|
||||
for (let index = 0; index < draft.order.length; index++)
|
||||
assert.equal(game.players.find(player => player.id === draft.order[index].player)!.faction, used[index]);
|
||||
assert.ok(game.players.every(player => !player.ready));
|
||||
assert.ok(game.players.every(player => player.leaders.every(leader => leader.faction === player.faction)));
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
assert.equal(applyAction(game, 'host', { type: 'start' }).status, 'setup');
|
||||
});
|
||||
|
||||
void test('host can cancel an in-progress draft without changing factions or transferring seats', () => {
|
||||
let game = lobby();
|
||||
const original = selected(game), seats = identity(game);
|
||||
game = applyAction(game, 'host', { type: 'factionAllocation', mode: 'draft' });
|
||||
const event = game.factionDraft!.event;
|
||||
const actor = game.factionDraft!.order[0].player;
|
||||
game = applyAction(game, actor, { type: 'factionDraftPick', event, faction: 'guild' });
|
||||
rejectUnchanged(game, 'guest', { type: 'factionDraftCancel', event });
|
||||
rejectUnchanged(game, 'host', { type: 'factionDraftCancel', event: `${event}:stale` });
|
||||
const canceled = applyAction(game, 'host', { type: 'factionDraftCancel', event });
|
||||
assert.ok(!canceled.factionDraft);
|
||||
assert.deepEqual(selected(canceled), original);
|
||||
assert.deepEqual(identity(canceled), seats);
|
||||
rejectUnchanged(canceled, actor, { type: 'factionDraftPick', event, faction: 'guild' });
|
||||
});
|
||||
|
||||
void test('bots choose a legal available faction during the same draft and remain ready', () => {
|
||||
let game = createGame('ALLOCBOT', newPlayer('host', 'Host', 'atreides'));
|
||||
game = applyAction(game, 'host', { type: 'addBot', faction: 'harkonnen', difficulty: 'Easy' });
|
||||
joinGame(game, newPlayer('guest', 'Guest', 'emperor'));
|
||||
const bot = game.players.find(player => player.bot)!.id;
|
||||
const seats = game.players.map(player => player.id);
|
||||
game = applyAction(game, 'host', { type: 'factionAllocation', mode: 'draft' });
|
||||
let botPicked = game.factionDraft!.picks.some(pick => pick.player === bot);
|
||||
for (let n = 0; game.factionDraft && n < seats.length; n++) {
|
||||
const draft = game.factionDraft!;
|
||||
const actor = draft.order[draft.picks.length].player;
|
||||
assert.notEqual(actor, bot, 'A pending draft must advance past every automatic AI turn');
|
||||
const botNext = draft.order[draft.picks.length + 1]?.player === bot;
|
||||
const used = draft.picks.map(pick => pick.faction);
|
||||
game = applyAction(game, actor, { type: 'factionDraftPick', event: draft.event, faction: nextFaction(used) });
|
||||
if (botNext) {
|
||||
if (game.factionDraft) assert.ok(game.factionDraft.picks.some(pick => pick.player === bot));
|
||||
botPicked = true;
|
||||
}
|
||||
}
|
||||
assert.equal(botPicked, true);
|
||||
assert.ok(!game.factionDraft);
|
||||
assert.deepEqual(game.players.map(player => player.id), seats);
|
||||
assert.equal(new Set(game.players.map(player => player.faction)).size, game.players.length);
|
||||
assert.equal(game.players.find(player => player.id === bot)!.ready, true);
|
||||
assert.equal(viewGame(game, 'host').players.find(player => player.id === bot)!.bot, 'Easy');
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// @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; ready: boolean }[];
|
||||
factionDraft?: { event: string; order: { player: string; card: number }[]; picks: { player: string; faction: string }[] } | null;
|
||||
controlledFactionSeats?: { id: string; faction: 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 controllers draft with four independently authenticated faction seats', async () => {
|
||||
const host = await request('/api/rooms', { name: 'Draft 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 firstSecond = await request(path, { type: 'addControlledFaction', version: enabled.data.version, name: 'Host second', faction: 'harkonnen', entry: receipt() }, host.cookie, host.data.me);
|
||||
assert.equal(firstSecond.status, 200, JSON.stringify(firstSecond.data));
|
||||
assert.ok(firstSecond.cookie);
|
||||
const firstCookies = `${host.cookie}; ${firstSecond.cookie}`;
|
||||
const other = await request(path, { type: 'join', name: 'Other person', faction: 'emperor', entry: receipt() });
|
||||
assert.equal(other.status, 200, JSON.stringify(other.data));
|
||||
assert.ok(other.cookie);
|
||||
const otherSecond = await request(path, { type: 'addControlledFaction', version: other.data.version, name: 'Other second', faction: 'fremen', entry: receipt() }, other.cookie, other.data.me);
|
||||
assert.equal(otherSecond.status, 200, JSON.stringify(otherSecond.data));
|
||||
assert.ok(otherSecond.cookie);
|
||||
const otherCookies = `${other.cookie}; ${otherSecond.cookie}`;
|
||||
const seats = [
|
||||
{ id: host.data.me, cookie: firstCookies },
|
||||
{ id: firstSecond.data.me, cookie: firstCookies },
|
||||
{ id: other.data.me, cookie: otherCookies },
|
||||
{ id: otherSecond.data.me, cookie: otherCookies },
|
||||
];
|
||||
const original = otherSecond.data.players.map(player => ({ id: player.id, faction: player.faction }));
|
||||
const opened = await request(path, { version: otherSecond.data.version, action: { type: 'factionAllocation', mode: 'draft' } }, firstCookies, host.data.me);
|
||||
assert.equal(opened.status, 200, JSON.stringify(opened.data));
|
||||
assert.ok(opened.data.factionDraft);
|
||||
assert.deepEqual(opened.data.players.map(player => ({ id: player.id, faction: player.faction })), original);
|
||||
const event = opened.data.factionDraft.event;
|
||||
const order = opened.data.factionDraft.order.map(item => item.player);
|
||||
assert.deepEqual(new Set(order), new Set(seats.map(seat => seat.id)));
|
||||
const deniedJoin = await request(path, { type: 'join', name: 'Late player', faction: 'guild', entry: receipt() });
|
||||
assert.equal(deniedJoin.status, 409, JSON.stringify(deniedJoin.data));
|
||||
assert.match(deniedJoin.data.error ?? '', /draft/i);
|
||||
assert.equal(deniedJoin.cookie, undefined);
|
||||
|
||||
let latest = opened.data;
|
||||
const choices = ['guild', 'beneGesserit', 'fremen', 'emperor'];
|
||||
for (let index = 0; index < order.length; index++) {
|
||||
const seat = seats.find(candidate => candidate.id === order[index])!;
|
||||
// Switching is a selected-seat read using the matching HttpOnly credential.
|
||||
const selected = await request(path, undefined, seat.cookie, seat.id);
|
||||
assert.equal(selected.status, 200, JSON.stringify(selected.data));
|
||||
assert.equal(selected.data.me, seat.id);
|
||||
assert.equal(selected.data.version, latest.version);
|
||||
const pick = await request(path, { version: selected.data.version, action: {
|
||||
type: 'factionDraftPick', event, faction: choices[index],
|
||||
} }, seat.cookie, seat.id);
|
||||
assert.equal(pick.status, 200, JSON.stringify(pick.data));
|
||||
assert.equal(pick.data.me, seat.id);
|
||||
latest = pick.data;
|
||||
if (index < order.length - 1)
|
||||
assert.deepEqual(latest.players.map(player => ({ id: player.id, faction: player.faction })), original);
|
||||
}
|
||||
assert.ok(!latest.factionDraft);
|
||||
assert.deepEqual(latest.players.map(player => player.id), original.map(player => player.id));
|
||||
for (let index = 0; index < order.length; index++)
|
||||
assert.equal(latest.players.find(player => player.id === order[index])!.faction, choices[index]);
|
||||
for (const seat of seats) {
|
||||
const ready = await request(path, { version: latest.version, action: { type: 'ready' } }, seat.cookie, seat.id);
|
||||
assert.equal(ready.status, 200, JSON.stringify(ready.data));
|
||||
assert.equal(ready.data.me, seat.id);
|
||||
latest = ready.data;
|
||||
}
|
||||
assert.ok(latest.players.every(player => player.ready));
|
||||
const started = await request(path, { version: latest.version, action: { type: 'start' } }, firstCookies, host.data.me);
|
||||
assert.equal(started.status, 200, JSON.stringify(started.data));
|
||||
assert.ok(started.data.status === 'setup' || started.data.status === 'playing');
|
||||
assert.deepEqual(started.data.players.map(player => player.id), original.map(player => player.id));
|
||||
for (const seat of seats) {
|
||||
const view = await request(path, undefined, seat.cookie, seat.id);
|
||||
assert.equal(view.status, 200, JSON.stringify(view.data));
|
||||
assert.equal(view.data.me, seat.id);
|
||||
assert.ok(view.data.controlledFactionSeats?.some(own => own.id === seat.id));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
chooseFaction, createFactionDraft, currentFactionDrafter,
|
||||
FactionDraftError, validateFactionDraft,
|
||||
} from '../game/faction-draft';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
|
||||
const seats = ['human', 'opponent', 'bot'];
|
||||
const eligible: FactionId[] = ['atreides', 'harkonnen', 'guild', 'fremen'];
|
||||
|
||||
void test('prediction numbers determine descending public draft order without changing inputs', () => {
|
||||
const numbers = [3, 10, 6];
|
||||
const draft = createFactionDraft(seats, numbers, 'draft:one');
|
||||
assert.deepEqual(draft, { event: 'draft:one', order: [
|
||||
{ player: 'opponent', card: 10 }, { player: 'bot', card: 6 }, { player: 'human', card: 3 },
|
||||
], picks: [] });
|
||||
assert.deepEqual(seats, ['human', 'opponent', 'bot']);
|
||||
assert.deepEqual(numbers, [3, 10, 6]);
|
||||
validateFactionDraft(JSON.parse(JSON.stringify(draft)), seats, eligible);
|
||||
assert.equal(currentFactionDrafter(draft), 'opponent');
|
||||
});
|
||||
|
||||
void test('each seated player chooses one distinct enabled faction in actual prediction-card order', () => {
|
||||
const initial = createFactionDraft(seats, [3, 10, 6], 'draft:one');
|
||||
const first = chooseFaction(initial, seats, eligible, 'opponent', 'guild');
|
||||
assert.deepEqual(initial.picks, []);
|
||||
assert.deepEqual(first.picks, [{ player: 'opponent', faction: 'guild' }]);
|
||||
assert.notEqual(first.order, initial.order);
|
||||
assert.notEqual(first.order[0], initial.order[0]);
|
||||
assert.equal(currentFactionDrafter(first), 'bot');
|
||||
const second = chooseFaction(first, seats, eligible, 'bot', 'fremen');
|
||||
const final = chooseFaction(second, seats, eligible, 'human', 'atreides');
|
||||
validateFactionDraft(final, seats, eligible);
|
||||
assert.equal(currentFactionDrafter(final), null);
|
||||
assert.deepEqual(final.picks.map(pick => pick.faction), ['guild', 'fremen', 'atreides']);
|
||||
assert.throws(() => chooseFaction(final, seats, eligible, 'human', 'harkonnen'), FactionDraftError);
|
||||
assert.deepEqual(first.picks, [{ player: 'opponent', faction: 'guild' }]);
|
||||
});
|
||||
|
||||
void test('wrong actor, occupied and disabled faction choices reject without mutating accepted state', () => {
|
||||
const initial = createFactionDraft(seats, [3, 10, 6], 'draft:one');
|
||||
const draft = chooseFaction(initial, seats, eligible, 'opponent', 'guild');
|
||||
const before = structuredClone(draft);
|
||||
assert.throws(() => chooseFaction(draft, seats, eligible, 'human', 'atreides'), FactionDraftError);
|
||||
assert.throws(() => chooseFaction(draft, seats, eligible, 'bot', 'guild'), FactionDraftError);
|
||||
assert.throws(() => chooseFaction(draft, seats, eligible, 'bot', 'ixians'), FactionDraftError);
|
||||
assert.throws(() => chooseFaction(draft, seats, eligible, 'bot', 'nonexistent' as FactionId), FactionDraftError);
|
||||
assert.deepEqual(draft, before);
|
||||
});
|
||||
|
||||
void test('invalid deals reject duplicate seats, repeated numbers and noncanonical ranges', () => {
|
||||
for (const [roster, numbers, event] of [
|
||||
[['a'], [1], 'e'], [['a', 'a'], [1, 2], 'e'], [['a', ' b'], [1, 2], 'e'],
|
||||
[['a', 'b'], [1, 1], 'e'], [['a', 'b'], [0, 2], 'e'],
|
||||
[['a', 'b'], [1, 11], 'e'], [['a', 'b'], [1, 2.5], 'e'],
|
||||
[['a', 'b'], [1], 'e'], [['a', 'b'], [1, 2], ''],
|
||||
] as [string[], number[], string][]) {
|
||||
assert.throws(() => createFactionDraft(roster, numbers, event), FactionDraftError);
|
||||
}
|
||||
assert.throws(() => createFactionDraft(['a', 'b', 'c', 'd', 'e', 'f', 'g'], [1, 2, 3, 4, 5, 6, 7], 'e'), FactionDraftError);
|
||||
});
|
||||
|
||||
void test('saved draft rejects changed roster, ordering, cards, picks and enabled faction pool', () => {
|
||||
const draft = createFactionDraft(seats, [3, 10, 6], 'draft:one');
|
||||
const one = chooseFaction(draft, seats, eligible, 'opponent', 'guild');
|
||||
for (const bad of [
|
||||
{ ...draft, extra: true },
|
||||
{ ...draft, order: [draft.order[1], draft.order[0], draft.order[2]] },
|
||||
{ ...draft, order: [{ player: 'opponent', card: 10 }, { player: 'bot', card: 10 }, draft.order[2]] },
|
||||
{ ...draft, order: [{ player: 'opponent', card: 10 }, { player: 'foreign', card: 6 }, draft.order[2]] },
|
||||
{ ...draft, picks: [{ player: 'bot', faction: 'guild' }] },
|
||||
{ ...draft, picks: [{ player: 'opponent', faction: 'ixians' }] },
|
||||
{ ...one, picks: [...one.picks, { player: 'bot', faction: 'guild' }] },
|
||||
{ ...one, picks: [...one.picks, { player: 'bot', faction: 'fremen', leaked: true }] },
|
||||
]) assert.throws(() => validateFactionDraft(bad, seats, eligible), FactionDraftError);
|
||||
assert.throws(() => validateFactionDraft(one, ['opponent', 'bot', 'else'], eligible), FactionDraftError);
|
||||
assert.throws(() => validateFactionDraft(one, seats, ['atreides', 'harkonnen']), FactionDraftError);
|
||||
assert.throws(() => validateFactionDraft(one, seats, ['atreides', 'guild', 'guild']), FactionDraftError);
|
||||
assert.throws(() => validateFactionDraft(one, seats, ['atreides', 'harkonnen', 'fremen']), FactionDraftError);
|
||||
});
|
||||
@@ -53,6 +53,17 @@ void test('an unresolved request stays visible with an explicit retry', () => {
|
||||
assert.match(html, /<select[^>]*disabled/);
|
||||
});
|
||||
|
||||
void test('a faction draft keeps the owned-seat switch usable while locking lobby changes', () => {
|
||||
const html = renderToStaticMarkup(createElement(TwoPlayerControls, {
|
||||
game: base, disabled: false, draftLocked: true, pendingEntry: false,
|
||||
entryRoom: base.code, entryProblem: '', retryDisabled: false,
|
||||
onModeChange: noop, onSwitch: noop, onAdd: noop, onRetry: noop,
|
||||
onAddBot: noop, onAddControlledBot: noop,
|
||||
}));
|
||||
assert.match(html, /Active faction<select(?![^>]*disabled)/);
|
||||
assert.match(html, /type="checkbox"[^>]*disabled/);
|
||||
});
|
||||
|
||||
void test('host may add a computer opponent when the second controller slot is empty', () => {
|
||||
const game = {
|
||||
...base,
|
||||
|
||||
Reference in new issue
Block a user