Support consensual lobby faction swaps
This commit is contained in:
1 parent
4dc2df2513
commit
c6b4a165bd
15 files changed
+680
-14
No files matched your search
@@ -18,7 +18,10 @@ 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
|
faction draft. Draft picks proceed from the highest drawn number, and the new
|
||||||
factions are assigned together after every seat chooses. AI seats choose
|
factions are assigned together after every seat chooses. AI seats choose
|
||||||
automatically. Both methods clear human readiness; direct selection remains
|
automatically. Both methods clear human readiness; direct selection remains
|
||||||
available. Consensual faction swaps are not yet implemented. See
|
available. Human seats can offer a faction swap, which takes effect only when
|
||||||
|
the recipient accepts. Pending offers pause other lobby changes and can be
|
||||||
|
declined by the recipient or canceled by the proposer. Accepted swaps clear
|
||||||
|
human readiness; AI seats cannot participate yet. See
|
||||||
[faction allocation](docs/FACTION_ALLOCATION.md).
|
[faction allocation](docs/FACTION_ALLOCATION.md).
|
||||||
|
|
||||||
For the provisional two-person setup, the host enables **Two players, two
|
For the provisional two-person setup, the host enables **Two players, two
|
||||||
|
|||||||
+5
-2
@@ -8,6 +8,7 @@ import { RoomEntryConfirmation } from '@/components/room-entry-confirmation';
|
|||||||
import { RulesetControls } from '@/components/ruleset-controls';
|
import { RulesetControls } from '@/components/ruleset-controls';
|
||||||
import { TwoPlayerControls } from '@/components/two-player-controls';
|
import { TwoPlayerControls } from '@/components/two-player-controls';
|
||||||
import { FactionAllocation } from '@/components/faction-allocation';
|
import { FactionAllocation } from '@/components/faction-allocation';
|
||||||
|
import { FactionSwap } from '@/components/faction-swap';
|
||||||
import { randomId } from '@/lib/random-id';
|
import { randomId } from '@/lib/random-id';
|
||||||
import {
|
import {
|
||||||
SeatHandoverSetup,
|
SeatHandoverSetup,
|
||||||
@@ -856,7 +857,7 @@ export default function Home() {
|
|||||||
{!roomUnavailable && <div className="table-shell"><TwoPlayerControls
|
{!roomUnavailable && <div className="table-shell"><TwoPlayerControls
|
||||||
game={game}
|
game={game}
|
||||||
disabled={busy || needsReconcile || checkingConnection || handoverPending || aiPermissionPending || claimUncertain || !!twoPlayerEntry || !!twoPlayerEntryProblem}
|
disabled={busy || needsReconcile || checkingConnection || handoverPending || aiPermissionPending || claimUncertain || !!twoPlayerEntry || !!twoPlayerEntryProblem}
|
||||||
draftLocked={!!game.factionDraft}
|
draftLocked={!!game.factionDraft || !!game.factionSwap}
|
||||||
pendingEntry={twoPlayerEntry !== null}
|
pendingEntry={twoPlayerEntry !== null}
|
||||||
entryRoom={twoPlayerEntry?.room}
|
entryRoom={twoPlayerEntry?.room}
|
||||||
entryProblem={twoPlayerEntryProblem}
|
entryProblem={twoPlayerEntryProblem}
|
||||||
@@ -869,12 +870,14 @@ export default function Home() {
|
|||||||
retryDisabled={busy || needsReconcile || checkingConnection || handoverPending || aiPermissionPending || claimUncertain || !!twoPlayerEntryProblem || twoPlayerEntry?.room !== game.code}
|
retryDisabled={busy || needsReconcile || checkingConnection || handoverPending || aiPermissionPending || claimUncertain || !!twoPlayerEntryProblem || twoPlayerEntry?.room !== game.code}
|
||||||
/></div>}
|
/></div>}
|
||||||
{!roomUnavailable && <div className="table-shell"><FactionAllocation game={game} send={send}
|
{!roomUnavailable && <div className="table-shell"><FactionAllocation game={game} send={send}
|
||||||
|
busy={busy || needsReconcile || checkingConnection || handoverPending || aiPermissionPending || claimUncertain || !!twoPlayerEntry || !!twoPlayerEntryProblem || !!game.factionSwap} /></div>}
|
||||||
|
{!roomUnavailable && <div className="table-shell"><FactionSwap game={game} send={send}
|
||||||
busy={busy || needsReconcile || checkingConnection || handoverPending || aiPermissionPending || claimUncertain || !!twoPlayerEntry || !!twoPlayerEntryProblem} /></div>}
|
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
|
{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}`}
|
key={`${game.code}:${game.me}`}
|
||||||
game={game}
|
game={game}
|
||||||
send={send}
|
send={send}
|
||||||
busy={busy || needsReconcile || !!twoPlayerEntry || !!twoPlayerEntryProblem || !!game.factionDraft}
|
busy={busy || needsReconcile || !!twoPlayerEntry || !!twoPlayerEntryProblem}
|
||||||
onExit={exitTable}
|
onExit={exitTable}
|
||||||
exitDisabled={handoverPending || aiPermissionPending}
|
exitDisabled={handoverPending || aiPermissionPending}
|
||||||
/>}
|
/>}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { FACTIONS } from '@/game/catalog';
|
||||||
|
import type { Action, GameView } from '@/game/engine';
|
||||||
|
|
||||||
|
/** Swaps require the recipient's explicit consent; the server owns the offer. */
|
||||||
|
export function FactionSwap({ game, send, busy }: {
|
||||||
|
game: GameView;
|
||||||
|
send: (action: Action) => Promise<void>;
|
||||||
|
busy: boolean;
|
||||||
|
}) {
|
||||||
|
if (game.status !== 'lobby') return null;
|
||||||
|
|
||||||
|
const factionName = (id: string) => FACTIONS.find((faction) => faction.id === id)?.name ?? id;
|
||||||
|
const seatName = (id: string) => game.players.find((player) => player.id === id)?.name ?? 'Unknown player';
|
||||||
|
const swap = game.factionSwap;
|
||||||
|
if (swap) return <section className="notice" aria-label="Faction swap offer">
|
||||||
|
<h2>Faction swap offer</h2>
|
||||||
|
<p>{seatName(swap.from)} offers to exchange {factionName(swap.fromFaction)} with {seatName(swap.to)}'s {factionName(swap.toFaction)}.</p>
|
||||||
|
<p>Both players must agree. The factions change only if {seatName(swap.to)} accepts.</p>
|
||||||
|
<p>Lobby changes and starting the game are paused until the offer is accepted, declined, or canceled.</p>
|
||||||
|
{game.me === swap.to && <div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" disabled={busy} onClick={() => void send({ type: 'factionSwapRespond', event: swap.event, accept: true })}>
|
||||||
|
Accept swap
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" disabled={busy} onClick={() => void send({ type: 'factionSwapRespond', event: swap.event, accept: false })}>
|
||||||
|
Decline swap
|
||||||
|
</Button>
|
||||||
|
</div>}
|
||||||
|
{game.me === swap.from && <Button type="button" variant="outline" disabled={busy}
|
||||||
|
onClick={() => void send({ type: 'factionSwapCancel', event: swap.event })}>
|
||||||
|
Cancel swap offer
|
||||||
|
</Button>}
|
||||||
|
</section>;
|
||||||
|
|
||||||
|
const self = game.players.find((player) => player.id === game.me);
|
||||||
|
const targets = game.players.filter((player) => player.id !== game.me && !player.bot);
|
||||||
|
return <section className="notice" aria-label="Faction swaps">
|
||||||
|
<h2>Swap factions</h2>
|
||||||
|
<p>Offer to exchange factions with another player. The other player must accept before either faction changes.</p>
|
||||||
|
{game.factionDraft && <p>Finish or cancel the faction draft before offering a swap.</p>}
|
||||||
|
{game.players.some((player) => player.bot) && <p className="fine">AI seats cannot receive swap offers yet.</p>}
|
||||||
|
{self && !self.bot && targets.length > 0 ? <div className="flex flex-wrap gap-2">
|
||||||
|
{targets.map((player) => <Button key={player.id} type="button" variant="outline" disabled={busy || !!game.factionDraft}
|
||||||
|
onClick={() => void send({ type: 'factionSwapOffer', target: player.id })}>
|
||||||
|
{`Offer swap to ${player.name} (${factionName(player.faction)})`}
|
||||||
|
</Button>)}
|
||||||
|
</div> : <p className="fine">A second human seat is needed to offer a swap.</p>}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -367,7 +367,7 @@ export function GameTable({
|
|||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
const reactionBusy =
|
const reactionBusy =
|
||||||
transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot;
|
transportBusy || !!g.factionDraft || !!g.factionSwap || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot;
|
||||||
const ixianBetrayalOwnsControls = !!g.nexusIxianBetrayalReaction &&
|
const ixianBetrayalOwnsControls = !!g.nexusIxianBetrayalReaction &&
|
||||||
!g.response && !g.decision && !g.truthtrance && !g.phaseOpening &&
|
!g.response && !g.decision && !g.truthtrance && !g.phaseOpening &&
|
||||||
!g.automaticContinuationPending && !g.nexusCards?.waiting.length && !g.nexusTraitors?.pending;
|
!g.automaticContinuationPending && !g.nexusCards?.waiting.length && !g.nexusTraitors?.pending;
|
||||||
@@ -1457,7 +1457,7 @@ export function GameTable({
|
|||||||
<SpiceCardInspector card={g.spicePeek} context="Private foresight" />
|
<SpiceCardInspector card={g.spicePeek} context="Private foresight" />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
<SeatAutopilot game={g} act={act} busy={transportBusy} />
|
<SeatAutopilot game={g} act={act} busy={transportBusy || !!g.factionDraft || !!g.factionSwap} />
|
||||||
<DukeVidal game={g} />
|
<DukeVidal game={g} />
|
||||||
<EcazLoyaltyCard loyalty={g.ecazLoyalty} />
|
<EcazLoyaltyCard loyalty={g.ecazLoyalty} />
|
||||||
<MoritaniTerrorSupply game={g} />
|
<MoritaniTerrorSupply game={g} />
|
||||||
|
|||||||
+12
-1
@@ -16,6 +16,17 @@ open mode gates.
|
|||||||
|
|
||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
**Consensual faction swaps — 28 October. Prototyped / Partial.** Human seats can
|
||||||
|
offer, accept, decline or cancel a faction exchange. Offers bind current seats
|
||||||
|
and factions; conflicting lobby changes wait while seat switching and exit
|
||||||
|
remain available. Acceptance clears human readiness and preserves seat identity,
|
||||||
|
credentials, circles and host. Ten related unit files pass 48 cases; two
|
||||||
|
isolated HTTP cases cover authenticated four-seat swaps and faction allocation.
|
||||||
|
An isolated browser room exercised offer, seat switching and acceptance, and
|
||||||
|
confirmed the exit control remains available during an offer.
|
||||||
|
AI swaps, wider combinations and deployed acceptance remain open.
|
||||||
|
[Scope](FACTION_ALLOCATION.md).
|
||||||
|
|
||||||
**Faction allocation at setup — 28 October. Prototyped / Partial.** Hosts can
|
**Faction allocation at setup — 28 October. Prototyped / Partial.** Hosts can
|
||||||
deal random enabled factions or start a draft in descending prediction-number
|
deal random enabled factions or start a draft in descending prediction-number
|
||||||
order. Human seats choose in order, bots choose automatically, and choices
|
order. Human seats choose in order, bots choose automatically, and choices
|
||||||
@@ -24,7 +35,7 @@ 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.
|
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
|
An isolated browser room exercised a random deal, a human draft choice and an
|
||||||
automatic AI pick through completion.
|
automatic AI pick through completion.
|
||||||
Consensual swaps, wider combinations and deployed acceptance remain open.
|
AI swaps, wider combinations and deployed acceptance remain open.
|
||||||
[Scope](FACTION_ALLOCATION.md).
|
[Scope](FACTION_ALLOCATION.md).
|
||||||
|
|
||||||
**Advanced Voice names for ordinary cards — 28 October. Prototyped / Partial.**
|
**Advanced Voice names for ordinary cards — 28 October. Prototyped / Partial.**
|
||||||
|
|||||||
@@ -32,7 +32,19 @@ four-seat/two-controller draft, seat selection and game start. These do not
|
|||||||
establish complete setup or deployed acceptance. An isolated browser room also
|
establish complete setup or deployed acceptance. An isolated browser room also
|
||||||
exercised a random deal and a human/AI draft to completion.
|
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
|
Human seats may offer an exchange of their currently assigned factions. The
|
||||||
explicit action and direct selection remains available; neither is a substitute
|
recipient accepts or declines and the proposer may cancel; each action is bound
|
||||||
for a participant-approved swap. Broader player-count interpretations, complete
|
to the current offer. A pending offer freezes other lobby changes and new seats
|
||||||
setup combinations, saved-game assurance and deployed acceptance remain open.
|
until resolved, but players can switch between their own seats or exit. Only
|
||||||
|
acceptance changes the assignments and clears human readiness. Seat IDs,
|
||||||
|
credentials, circles and host remain attached to their participants; faction
|
||||||
|
leaders and Ixian elites follow the new faction. AI seats cannot offer or
|
||||||
|
receive swaps yet. Broader player-count interpretations, AI swap policy,
|
||||||
|
complete setup combinations, saved-game assurance and deployed acceptance
|
||||||
|
remain open.
|
||||||
|
|
||||||
|
For the swap checkpoint, ten related unit files pass 48 cases and two isolated
|
||||||
|
HTTP cases exercise allocation and authenticated swaps across independently
|
||||||
|
controlled seats. This evidence does not certify every setup combination.
|
||||||
|
An isolated browser room also exercised an offer between two controlled seats,
|
||||||
|
including switching to accept, cleared readiness and an available exit control.
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 28 October 2026 — Consensual lobby faction swaps
|
||||||
|
|
||||||
|
Human seats can offer an exchange, with recipient acceptance or decline and
|
||||||
|
proposer cancellation. The offer freezes conflicting lobby changes; on
|
||||||
|
acceptance factions and their initial assets change while seat identities,
|
||||||
|
credentials, circles and host stay put and human readiness clears. Ten related
|
||||||
|
unit files pass 48 cases, and two isolated HTTP cases exercise the existing
|
||||||
|
allocation and authenticated swaps including separately controlled seats.
|
||||||
|
An isolated browser room exercised offer, seat switching and acceptance, with
|
||||||
|
exit available during the offer and readiness reset on acceptance.
|
||||||
|
AI swap policy, broader setup combinations and deployed acceptance remain open.
|
||||||
|
[Scope](FACTION_ALLOCATION.md).
|
||||||
|
|
||||||
## 28 October 2026 — Random faction deal and prediction-number draft
|
## 28 October 2026 — Random faction deal and prediction-number draft
|
||||||
|
|
||||||
The host can deal enabled factions randomly or start a public descending-number
|
The host can deal enabled factions randomly or start a public descending-number
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
|
|
||||||
The supplied revised rulebook, physical p. 4 (printed p. 2), describes random
|
The supplied revised rulebook, physical p. 4 (printed p. 2), describes random
|
||||||
faction dealing or descending turn-prediction-number draft order and permits
|
faction dealing or descending turn-prediction-number draft order and permits
|
||||||
swaps. The prototype implements the first two methods in the lobby, retaining
|
swaps. The prototype implements these methods in the lobby, retaining
|
||||||
direct selection and existing start gates. Each faction seat receives its own
|
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
|
draw in the two-person/four-faction mode; that composition and the automatic
|
||||||
AI pick are software interpretations. Consensual swaps remain unfinished.
|
AI pick are software interpretations. Human seats can exchange factions by
|
||||||
|
offer and acceptance; AI participation in swaps remains unfinished.
|
||||||
[Scope](FACTION_ALLOCATION.md).
|
[Scope](FACTION_ALLOCATION.md).
|
||||||
|
|
||||||
## Advanced Voice and ordinary card names — 28 October 2026
|
## Advanced Voice and ordinary card names — 28 October 2026
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { createTwoPlayerControllers, validateTwoPlayerControllersLobby, validate
|
|||||||
addTwoPlayerControlledSeat, TwoPlayerControllersError, type TwoPlayerControllers } from './two-player-controllers';
|
addTwoPlayerControlledSeat, TwoPlayerControllersError, type TwoPlayerControllers } from './two-player-controllers';
|
||||||
import { createFactionDraft, validateFactionDraft, currentFactionDrafter, chooseFaction,
|
import { createFactionDraft, validateFactionDraft, currentFactionDrafter, chooseFaction,
|
||||||
FactionDraftError, type FactionDraft } from './faction-draft';
|
FactionDraftError, type FactionDraft } from './faction-draft';
|
||||||
|
import { createFactionSwap, validateFactionSwap, answerFactionSwap, cancelFactionSwap,
|
||||||
|
FactionSwapError, type FactionSwap } from './faction-swap';
|
||||||
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
|
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
|
||||||
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
|
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
|
||||||
import { createVictoryContinuation, validateVictoryContinuation, voteVictoryContinuation,
|
import { createVictoryContinuation, validateVictoryContinuation, voteVictoryContinuation,
|
||||||
@@ -1411,6 +1413,8 @@ type RicheseAllyOffer = {
|
|||||||
payer: string;
|
payer: string;
|
||||||
};
|
};
|
||||||
export type Game = {
|
export type Game = {
|
||||||
|
/** A human-to-human lobby offer, pending the recipient's consent. */
|
||||||
|
factionSwap?: FactionSwap;
|
||||||
/** Public prediction-number faction selection, present only while in progress. */
|
/** Public prediction-number faction selection, present only while in progress. */
|
||||||
factionDraft?: FactionDraft;
|
factionDraft?: FactionDraft;
|
||||||
/** Explicit two-human/four-faction setup; absent from ordinary and legacy games. */
|
/** Explicit two-human/four-faction setup; absent from ordinary and legacy games. */
|
||||||
@@ -5716,6 +5720,7 @@ function twoPlayerControllersIntegrity(g: Game): void {
|
|||||||
function joinFactionSeat(g: Game, p: Player, controllerId?: string) {
|
function joinFactionSeat(g: Game, p: Player, controllerId?: string) {
|
||||||
twoPlayerControllersIntegrity(g);
|
twoPlayerControllersIntegrity(g);
|
||||||
requireRule(!g.factionDraft, 'Finish or cancel the faction draft before adding a seat.');
|
requireRule(!g.factionDraft, 'Finish or cancel the faction draft before adding a seat.');
|
||||||
|
requireRule(!g.factionSwap, 'Resolve the faction swap before adding a seat.');
|
||||||
requireRule(g.status === 'lobby', 'This game has already started.');
|
requireRule(g.status === 'lobby', 'This game has already started.');
|
||||||
requireRule(g.players.length < 6, 'This table has six players.');
|
requireRule(g.players.length < 6, 'This table has six players.');
|
||||||
requireRule(!g.players.some((x) => x.id === p.id), 'Already seated.');
|
requireRule(!g.players.some((x) => x.id === p.id), 'Already seated.');
|
||||||
@@ -5776,6 +5781,21 @@ function factionDraftIntegrity(g: Game): void {
|
|||||||
factionDraftRule(() => validateFactionDraft(g.factionDraft, g.players.map(player => player.id), eligibleLobbyFactions(g)));
|
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.');
|
requireRule(g.factionDraft.picks.length < g.players.length, 'A completed faction draft must be applied once.');
|
||||||
}
|
}
|
||||||
|
function factionSwapRule<T>(operation: () => T): T {
|
||||||
|
try { return operation(); }
|
||||||
|
catch (error) {
|
||||||
|
if (error instanceof FactionSwapError) throw new RuleError(error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function factionSwapIntegrity(g: Game): void {
|
||||||
|
if (g.factionSwap === undefined) return;
|
||||||
|
requireRule(g.status === 'lobby' && !g.factionDraft, 'A faction swap belongs to the lobby outside a draft.');
|
||||||
|
factionSwapRule(() => validateFactionSwap(g.factionSwap, g.players));
|
||||||
|
requireRule(g.players.some(player => player.id === g.factionSwap!.from && !player.bot) &&
|
||||||
|
g.players.some(player => player.id === g.factionSwap!.to && !player.bot),
|
||||||
|
'Faction swap participants must be human seats.');
|
||||||
|
}
|
||||||
function assignLobbyFaction(player: Player, chosen: FactionId): void {
|
function assignLobbyFaction(player: Player, chosen: FactionId): void {
|
||||||
player.faction = chosen;
|
player.faction = chosen;
|
||||||
if (player.bot) player.name = `${faction(chosen).name} AI`;
|
if (player.bot) player.name = `${faction(chosen).name} AI`;
|
||||||
@@ -39980,6 +40000,11 @@ function normalizeCardNames(g: Game) {
|
|||||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||||
twoPlayerControllersIntegrity(state);
|
twoPlayerControllersIntegrity(state);
|
||||||
factionDraftIntegrity(state);
|
factionDraftIntegrity(state);
|
||||||
|
factionSwapIntegrity(state);
|
||||||
|
if (state.factionSwap) requireRule(
|
||||||
|
['factionSwapRespond', 'factionSwapCancel', 'advanceBots'].includes(action?.type),
|
||||||
|
'Resolve the faction swap before changing the lobby.',
|
||||||
|
);
|
||||||
if (state.factionDraft) requireRule(
|
if (state.factionDraft) requireRule(
|
||||||
['factionDraftPick', 'factionDraftCancel', 'advanceBots'].includes(action?.type),
|
['factionDraftPick', 'factionDraftCancel', 'advanceBots'].includes(action?.type),
|
||||||
'Finish or cancel the faction draft before changing the lobby.',
|
'Finish or cancel the faction draft before changing the lobby.',
|
||||||
@@ -40601,6 +40626,7 @@ function settleAutomaticContinuations(g: Game) {
|
|||||||
export function normalizeAutomaticGame(state: Game): Game {
|
export function normalizeAutomaticGame(state: Game): Game {
|
||||||
twoPlayerControllersIntegrity(state);
|
twoPlayerControllersIntegrity(state);
|
||||||
factionDraftIntegrity(state);
|
factionDraftIntegrity(state);
|
||||||
|
factionSwapIntegrity(state);
|
||||||
victoryContinuationIntegrity(state);
|
victoryContinuationIntegrity(state);
|
||||||
if (state.victoryContinuation) return state;
|
if (state.victoryContinuation) return state;
|
||||||
bidPromiseIntegrity(state);
|
bidPromiseIntegrity(state);
|
||||||
@@ -42927,6 +42953,27 @@ function applyActionInner(
|
|||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
if (g.status === 'lobby') {
|
if (g.status === 'lobby') {
|
||||||
|
if (g.factionSwap) {
|
||||||
|
const offer = g.factionSwap;
|
||||||
|
requireRule(action.event === offer.event, 'This faction swap is no longer current.');
|
||||||
|
if (t === 'factionSwapCancel') {
|
||||||
|
requireRule(Object.keys(action).every(key => ['type', 'event'].includes(key)), 'Invalid faction swap cancellation.');
|
||||||
|
factionSwapRule(() => cancelFactionSwap(g.players, offer, id));
|
||||||
|
delete g.factionSwap;
|
||||||
|
log(g, `${p.name} canceled the faction swap offer.`);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
requireRule(t === 'factionSwapRespond' && typeof action.accept === 'boolean' &&
|
||||||
|
Object.keys(action).every(key => ['type', 'event', 'accept'].includes(key)), 'Choose whether to accept the faction swap.');
|
||||||
|
const result = factionSwapRule(() => answerFactionSwap(g.players, offer, id, action.accept as boolean));
|
||||||
|
delete g.factionSwap;
|
||||||
|
if (result.accepted) {
|
||||||
|
for (const assignment of result.assignments) assignLobbyFaction(getPlayer(g, assignment.player), assignment.faction);
|
||||||
|
g.players.forEach(player => (player.ready = !!player.bot));
|
||||||
|
log(g, `${p.name} accepted the faction swap. Human readiness was cleared.`);
|
||||||
|
} else log(g, `${p.name} declined the faction swap offer.`);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
if (g.factionDraft) {
|
if (g.factionDraft) {
|
||||||
const draft = g.factionDraft;
|
const draft = g.factionDraft;
|
||||||
requireRule(action.event === draft.event, 'This faction draft is no longer current.');
|
requireRule(action.event === draft.event, 'This faction draft is no longer current.');
|
||||||
@@ -42947,6 +42994,15 @@ function applyActionInner(
|
|||||||
continueFactionDraft(g);
|
continueFactionDraft(g);
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
if (t === 'factionSwapOffer') {
|
||||||
|
requireRule(Object.keys(action).every(key => ['type', 'target'].includes(key)) &&
|
||||||
|
typeof action.target === 'string' && !p.bot, 'Choose another human seat for the faction swap.');
|
||||||
|
const target = g.players.find(player => player.id === action.target);
|
||||||
|
requireRule(target && !target.bot, 'Choose another human seat for the faction swap.');
|
||||||
|
g.factionSwap = factionSwapRule(() => createFactionSwap(g.players, id, target.id, crypto.randomUUID()));
|
||||||
|
log(g, `${p.name} offered to exchange factions with ${target.name}.`);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
if (t === 'factionAllocation') {
|
if (t === 'factionAllocation') {
|
||||||
requireRule(id === g.host && Object.keys(action).every(key => ['type', 'mode'].includes(key)) &&
|
requireRule(id === g.host && Object.keys(action).every(key => ['type', 'mode'].includes(key)) &&
|
||||||
(action.mode === 'random' || action.mode === 'draft'),
|
(action.mode === 'random' || action.mode === 'draft'),
|
||||||
@@ -44892,6 +44948,7 @@ function applyActionInner(
|
|||||||
export function viewGame(state: Game, id: string, now?: number) {
|
export function viewGame(state: Game, id: string, now?: number) {
|
||||||
twoPlayerControllersIntegrity(state);
|
twoPlayerControllersIntegrity(state);
|
||||||
factionDraftIntegrity(state);
|
factionDraftIntegrity(state);
|
||||||
|
factionSwapIntegrity(state);
|
||||||
victoryContinuationIntegrity(state);
|
victoryContinuationIntegrity(state);
|
||||||
revivalOrderIntegrity(state);
|
revivalOrderIntegrity(state);
|
||||||
charityOrderIntegrity(state);
|
charityOrderIntegrity(state);
|
||||||
@@ -45191,6 +45248,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
|||||||
host: g.host,
|
host: g.host,
|
||||||
status: g.status,
|
status: g.status,
|
||||||
factionDraft: g.factionDraft ?? null,
|
factionDraft: g.factionDraft ?? null,
|
||||||
|
factionSwap: g.factionSwap ?? null,
|
||||||
twoPlayerControllers: g.twoPlayerControllers ?? null,
|
twoPlayerControllers: g.twoPlayerControllers ?? null,
|
||||||
controlledFactionSeats: g.twoPlayerControllers
|
controlledFactionSeats: g.twoPlayerControllers
|
||||||
? g.players.filter(player => g.twoPlayerControllers!.controllers.some(controller =>
|
? g.players.filter(player => g.twoPlayerControllers!.controllers.some(controller =>
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { FACTIONS, type FactionId } from './catalog';
|
||||||
|
|
||||||
|
/** A public offer, bound to both seats' current faction assignments. */
|
||||||
|
export type FactionSwap = {
|
||||||
|
event: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
fromFaction: FactionId;
|
||||||
|
toFaction: FactionId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FactionSwapSeat = { id: string; faction: FactionId };
|
||||||
|
export type FactionSwapAssignment = { player: string; faction: FactionId };
|
||||||
|
|
||||||
|
export class FactionSwapError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'FactionSwapError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifier = (value: unknown): value is string =>
|
||||||
|
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||||||
|
const factionId = (value: unknown): value is FactionId =>
|
||||||
|
typeof value === 'string' && FACTIONS.some(faction => faction.id === value);
|
||||||
|
const record = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
|
||||||
|
function requireSwap(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) throw new FactionSwapError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRoster(players: readonly FactionSwapSeat[]): void {
|
||||||
|
requireSwap(Array.isArray(players) && players.length >= 2 && players.length <= 6 &&
|
||||||
|
players.every(player => record(player) && identifier(player.id) && factionId(player.faction)) &&
|
||||||
|
new Set(players.map(player => player.id)).size === players.length &&
|
||||||
|
new Set(players.map(player => player.faction)).size === players.length,
|
||||||
|
'A faction swap needs two through six distinct seats with distinct factions.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offer an exchange without changing the players or their assignments. */
|
||||||
|
export function createFactionSwap(
|
||||||
|
players: readonly FactionSwapSeat[], actor: string, target: string, event: string,
|
||||||
|
): FactionSwap {
|
||||||
|
validateRoster(players);
|
||||||
|
requireSwap(identifier(event), 'A faction swap needs a distinct event.');
|
||||||
|
requireSwap(identifier(actor) && identifier(target) && actor !== target,
|
||||||
|
'A faction swap needs two different players.');
|
||||||
|
const from = players.find(player => player.id === actor);
|
||||||
|
const to = players.find(player => player.id === target);
|
||||||
|
requireSwap(from && to, 'Both players must be seated.');
|
||||||
|
return { event, from: from.id, to: to.id, fromFaction: from.faction, toFaction: to.faction };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A saved offer remains valid only while both seats retain its exact factions. */
|
||||||
|
export function validateFactionSwap(value: unknown, players: readonly FactionSwapSeat[]): asserts value is FactionSwap {
|
||||||
|
validateRoster(players);
|
||||||
|
requireSwap(record(value) && Object.keys(value).sort().join(',') === 'event,from,fromFaction,to,toFaction' &&
|
||||||
|
identifier(value.event) && identifier(value.from) && identifier(value.to) && value.from !== value.to &&
|
||||||
|
factionId(value.fromFaction) && factionId(value.toFaction) && value.fromFaction !== value.toFaction,
|
||||||
|
'The saved faction swap is malformed.');
|
||||||
|
requireSwap(players.some(player => player.id === value.from && player.faction === value.fromFaction) &&
|
||||||
|
players.some(player => player.id === value.to && player.faction === value.toFaction),
|
||||||
|
'The faction swap no longer matches the seated players.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the recipient can accept or reject; the caller applies an acceptance atomically. */
|
||||||
|
export function answerFactionSwap(
|
||||||
|
players: readonly FactionSwapSeat[], offer: unknown, actor: string, accept: boolean,
|
||||||
|
): { accepted: boolean; assignments: FactionSwapAssignment[] } {
|
||||||
|
validateFactionSwap(offer, players);
|
||||||
|
requireSwap(actor === offer.to, 'Only the recipient may answer a faction swap.');
|
||||||
|
requireSwap(typeof accept === 'boolean', 'A faction swap answer must accept or reject.');
|
||||||
|
return {
|
||||||
|
accepted: accept,
|
||||||
|
assignments: accept ? [
|
||||||
|
{ player: offer.from, faction: offer.toFaction },
|
||||||
|
{ player: offer.to, faction: offer.fromFaction },
|
||||||
|
] : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the proposer may withdraw an offer. */
|
||||||
|
export function cancelFactionSwap(players: readonly FactionSwapSeat[], offer: unknown, actor: string): void {
|
||||||
|
validateFactionSwap(offer, players);
|
||||||
|
requireSwap(actor === offer.from, 'Only the proposer may cancel a faction swap.');
|
||||||
|
}
|
||||||
+2
-2
@@ -2446,7 +2446,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'Create a room, invite rivals with its code, and complete each faction’s starting choices.',
|
'Create a room, invite rivals with its code, and complete each faction’s starting choices.',
|
||||||
steps: [
|
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.',
|
'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.',
|
'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. Human seats may offer to swap their factions; the recipient must accept, and human readiness then clears. Other lobby changes wait for the offer to be accepted, declined or canceled. AI seats cannot swap yet.',
|
||||||
'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.',
|
'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.',
|
'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.',
|
'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.',
|
||||||
@@ -2460,7 +2460,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
area: 'Implementation',
|
area: 'Implementation',
|
||||||
status: 'Partial',
|
status: 'Partial',
|
||||||
detail:
|
detail:
|
||||||
'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.',
|
'Lobby random faction dealing, prediction-number drafting and human-to-human consensual swaps retain seat identities and clear readiness on assignment; AI 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',
|
area: 'Player controls',
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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 { FactionSwap } from '../components/faction-swap';
|
||||||
|
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', me: 'a', factionDraft: null, factionSwap: null,
|
||||||
|
players: [
|
||||||
|
{ id: 'a', name: 'Alice', faction: 'atreides' },
|
||||||
|
{ id: 'b', name: 'Basil', faction: 'harkonnen' },
|
||||||
|
{ id: 'c', name: 'Chani', faction: 'fremen' },
|
||||||
|
{ id: 'd', name: 'Computer', faction: 'guild', bot: 'Medium' },
|
||||||
|
],
|
||||||
|
} as unknown as GameView;
|
||||||
|
const swap = { event: 'swap-1', from: 'a', to: 'b', fromFaction: 'atreides', toFaction: 'harkonnen' };
|
||||||
|
const send = async (_action: Action) => {};
|
||||||
|
const render = (game: GameView, busy = false) => renderToStaticMarkup(createElement(FactionSwap, { game, send, busy }));
|
||||||
|
const controls = (game: GameView, sendAction = send, busy = false) =>
|
||||||
|
elements(FactionSwap({ game, send: sendAction, busy })).filter((element) => typeof element.props.onClick === 'function');
|
||||||
|
const click = (nodes: Element[], label: string) => {
|
||||||
|
const button = nodes.find((node) => node.props.children === label);
|
||||||
|
assert.ok(button, `Expected action ${label}`);
|
||||||
|
(button.props.onClick as () => void)();
|
||||||
|
};
|
||||||
|
|
||||||
|
void test('offers target other human seats and require the recipient to accept', () => {
|
||||||
|
const calls: Action[] = [];
|
||||||
|
const nodes = controls(base, async (action) => { calls.push(action); });
|
||||||
|
assert.deepEqual(nodes.map((node) => node.props.children), [
|
||||||
|
'Offer swap to Basil (Harkonnen)', 'Offer swap to Chani (Fremen)',
|
||||||
|
]);
|
||||||
|
assert.match(render(base), /other player must accept/);
|
||||||
|
assert.match(render(base), /AI seats cannot receive swap offers/);
|
||||||
|
assert.doesNotMatch(render({ ...base, status: 'playing' }), /Swap factions/);
|
||||||
|
click(nodes, 'Offer swap to Basil (Harkonnen)');
|
||||||
|
assert.deepEqual(calls, [{ type: 'factionSwapOffer', target: 'b' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('offers are unavailable during a draft or while busy', () => {
|
||||||
|
const draft = { event: 'draft-1', order: [], picks: [] };
|
||||||
|
const inDraft = { ...base, factionDraft: draft } as unknown as GameView;
|
||||||
|
assert.match(render(inDraft), /Finish or cancel the faction draft/);
|
||||||
|
for (const node of [...controls(inDraft), ...controls(base, send, true)]) assert.equal(node.props.disabled, true);
|
||||||
|
const alone = { ...base, players: [base.players[0], base.players[3]] };
|
||||||
|
assert.match(render(alone), /second human seat/);
|
||||||
|
assert.equal(controls(alone).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('the pending offer is visible to everyone, with consent and cancellation limited to each party', () => {
|
||||||
|
const calls: Action[] = [];
|
||||||
|
const record = async (action: Action) => { calls.push(action); };
|
||||||
|
const pending = { ...base, factionSwap: swap } as unknown as GameView;
|
||||||
|
for (const me of ['a', 'b', 'c']) {
|
||||||
|
const html = render({ ...pending, me });
|
||||||
|
assert.match(html, /Alice offers to exchange Atreides with Basil's Harkonnen/);
|
||||||
|
assert.match(html, /change only if Basil accepts/);
|
||||||
|
}
|
||||||
|
const sender = controls(pending, record);
|
||||||
|
const recipient = controls({ ...pending, me: 'b' }, record);
|
||||||
|
assert.deepEqual(sender.map((node) => node.props.children), ['Cancel swap offer']);
|
||||||
|
assert.deepEqual(recipient.map((node) => node.props.children), ['Accept swap', 'Decline swap']);
|
||||||
|
assert.equal(controls({ ...pending, me: 'c' }, record).length, 0);
|
||||||
|
click(sender, 'Cancel swap offer');
|
||||||
|
click(recipient, 'Accept swap');
|
||||||
|
click(recipient, 'Decline swap');
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
{ type: 'factionSwapCancel', event: 'swap-1' },
|
||||||
|
{ type: 'factionSwapRespond', event: 'swap-1', accept: true },
|
||||||
|
{ type: 'factionSwapRespond', event: 'swap-1', accept: false },
|
||||||
|
]);
|
||||||
|
for (const node of controls({ ...pending, me: 'b' }, record, true)) assert.equal(node.props.disabled, true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
applyAction, createGame, joinControlledFaction, joinGame, newPlayer, viewGame,
|
||||||
|
type Action, type Game,
|
||||||
|
} from '../game/engine';
|
||||||
|
|
||||||
|
function lobby(): Game {
|
||||||
|
const game = createGame('SWAPTEST', newPlayer('host', 'Host', 'atreides'));
|
||||||
|
joinGame(game, newPlayer('guest', 'Guest', 'harkonnen'));
|
||||||
|
joinGame(game, newPlayer('third', 'Third', 'emperor'));
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readyAll(game: Game): Game {
|
||||||
|
for (const player of game.players.filter(player => !player.bot)) {
|
||||||
|
game = applyAction(game, player.id, { type: 'ready' });
|
||||||
|
}
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
function proposal(game: Game) {
|
||||||
|
assert.ok(game.factionSwap);
|
||||||
|
return game.factionSwap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejected(game: Game, actor: string, action: Action): void {
|
||||||
|
const before = structuredClone(game);
|
||||||
|
assert.throws(() => applyAction(game, actor, action));
|
||||||
|
assert.deepEqual(game, before, `${action.type} must not mutate its input when rejected`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function identities(game: Game) {
|
||||||
|
return game.players.map(({ id, name, faction, ready }) => ({ id, name, faction, ready }));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('an accepted offer exchanges faction assets, preserves seats and circles, and can start', () => {
|
||||||
|
let game = readyAll(lobby());
|
||||||
|
const positions = structuredClone(game.playerPositions);
|
||||||
|
const initial = identities(game);
|
||||||
|
game = applyAction(game, 'guest', { type: 'factionSwapOffer', target: 'host' });
|
||||||
|
const offer = proposal(game);
|
||||||
|
assert.equal(typeof offer.event, 'string');
|
||||||
|
assert.ok(offer.event.length > 0);
|
||||||
|
assert.deepEqual({ ...offer, event: '<event>' }, {
|
||||||
|
event: '<event>', from: 'guest', to: 'host', fromFaction: 'harkonnen', toFaction: 'atreides',
|
||||||
|
});
|
||||||
|
assert.deepEqual(identities(game), initial);
|
||||||
|
assert.deepEqual(viewGame(game, 'third').factionSwap, offer);
|
||||||
|
|
||||||
|
game = applyAction(game, 'host', { type: 'factionSwapRespond', event: offer.event, accept: true });
|
||||||
|
assert.equal(game.factionSwap, undefined);
|
||||||
|
assert.equal(game.host, 'host');
|
||||||
|
assert.deepEqual(game.playerPositions, positions);
|
||||||
|
assert.deepEqual(game.players.map(({ id, name, faction, ready }) => ({ id, name, faction, ready })), [
|
||||||
|
{ id: 'host', name: 'Host', faction: 'harkonnen', ready: false },
|
||||||
|
{ id: 'guest', name: 'Guest', faction: 'atreides', ready: false },
|
||||||
|
{ id: 'third', name: 'Third', faction: 'emperor', ready: false },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(game.players[0].leaders, newPlayer('x', 'X', 'harkonnen').leaders);
|
||||||
|
assert.deepEqual(game.players[1].leaders, newPlayer('x', 'X', 'atreides').leaders);
|
||||||
|
game = readyAll(game);
|
||||||
|
game = applyAction(game, 'host', { type: 'start' });
|
||||||
|
assert.equal(game.status, 'setup');
|
||||||
|
assert.equal(game.players.find(player => player.id === 'host')!.faction, 'harkonnen');
|
||||||
|
assert.equal(game.players.find(player => player.id === 'guest')!.faction, 'atreides');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('declining and cancelling preserve factions and existing readiness', () => {
|
||||||
|
let game = readyAll(lobby());
|
||||||
|
const before = identities(game);
|
||||||
|
game = applyAction(game, 'guest', { type: 'factionSwapOffer', target: 'host' });
|
||||||
|
const first = proposal(game).event;
|
||||||
|
game = applyAction(game, 'host', { type: 'factionSwapRespond', event: first, accept: false });
|
||||||
|
assert.equal(game.factionSwap, undefined);
|
||||||
|
assert.deepEqual(identities(game), before);
|
||||||
|
game = applyAction(game, 'guest', { type: 'factionSwapOffer', target: 'host' });
|
||||||
|
const second = proposal(game).event;
|
||||||
|
assert.notEqual(first, second);
|
||||||
|
rejected(game, 'host', { type: 'factionSwapRespond', event: first, accept: true });
|
||||||
|
rejected(game, 'guest', { type: 'factionSwapCancel', event: first });
|
||||||
|
game = applyAction(game, 'guest', { type: 'factionSwapCancel', event: second });
|
||||||
|
assert.equal(game.factionSwap, undefined);
|
||||||
|
assert.deepEqual(identities(game), before);
|
||||||
|
rejected(game, 'host', { type: 'factionSwapRespond', event: second, accept: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('swap actions reject forged parties, payloads, stale events and AI seats without mutation', () => {
|
||||||
|
let game = lobby();
|
||||||
|
game = applyAction(game, 'host', { type: 'addBot', faction: 'guild', difficulty: 'Easy' });
|
||||||
|
const bot = game.players.find(player => player.bot)!;
|
||||||
|
for (const [actor, action] of [
|
||||||
|
['host', { type: 'factionSwapOffer', target: 'host' }],
|
||||||
|
['host', { type: 'factionSwapOffer', target: 'missing' }],
|
||||||
|
['host', { type: 'factionSwapOffer', target: bot.id }],
|
||||||
|
[bot.id, { type: 'factionSwapOffer', target: 'host' }],
|
||||||
|
['missing', { type: 'factionSwapOffer', target: 'host' }],
|
||||||
|
['host', { type: 'factionSwapOffer', target: 'guest', from: 'third' }],
|
||||||
|
['guest', { type: 'factionSwapRespond', event: 'missing', accept: true }],
|
||||||
|
['host', { type: 'factionSwapCancel', event: 'missing' }],
|
||||||
|
] as Array<[string, Action]>) rejected(game, actor, action);
|
||||||
|
|
||||||
|
game = applyAction(game, 'host', { type: 'factionSwapOffer', target: 'guest' });
|
||||||
|
const event = proposal(game).event;
|
||||||
|
for (const [actor, action] of [
|
||||||
|
['host', { type: 'factionSwapRespond', event, accept: true }],
|
||||||
|
['third', { type: 'factionSwapRespond', event, accept: true }],
|
||||||
|
['guest', { type: 'factionSwapCancel', event }],
|
||||||
|
['third', { type: 'factionSwapCancel', event }],
|
||||||
|
['guest', { type: 'factionSwapRespond', event: 'forged', accept: true }],
|
||||||
|
['guest', { type: 'factionSwapRespond', event, accept: 'true' }],
|
||||||
|
['guest', { type: 'factionSwapRespond', event, accept: true, target: 'third' }],
|
||||||
|
['host', { type: 'factionSwapCancel', event, target: 'third' }],
|
||||||
|
['third', { type: 'factionSwapOffer', target: 'guest' }],
|
||||||
|
] as Array<[string, Action]>) rejected(game, actor, action);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('an outstanding offer freezes conflicting lobby actions and joining', () => {
|
||||||
|
let game = readyAll(lobby());
|
||||||
|
game = applyAction(game, 'host', { type: 'factionSwapOffer', target: 'guest' });
|
||||||
|
for (const [actor, action] of [
|
||||||
|
['guest', { type: 'faction', faction: 'guild' }],
|
||||||
|
['third', { type: 'faction', faction: 'fremen' }],
|
||||||
|
['host', { type: 'factionAllocation', mode: 'random' }],
|
||||||
|
['host', { type: 'factionAllocation', mode: 'draft' }],
|
||||||
|
['host', { type: 'addBot', faction: 'guild', difficulty: 'Easy' }],
|
||||||
|
['host', { type: 'seatPosition', position: 6 }],
|
||||||
|
['host', { type: 'start' }],
|
||||||
|
] as Array<[string, Action]>) rejected(game, actor, action);
|
||||||
|
const before = structuredClone(game);
|
||||||
|
assert.throws(() => joinGame(game, newPlayer('new', 'New', 'guild')));
|
||||||
|
assert.deepEqual(game, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Ixian elite reserves and faction leaders follow the faction on acceptance', () => {
|
||||||
|
let game = createGame('SWAPIX', newPlayer('host', 'Host', 'atreides'), false, ['ix']);
|
||||||
|
joinGame(game, newPlayer('ix', 'Ix', 'ixians'));
|
||||||
|
game = applyAction(game, 'host', { type: 'factionSwapOffer', target: 'ix' });
|
||||||
|
game = applyAction(game, 'ix', { type: 'factionSwapRespond', event: proposal(game).event, accept: true });
|
||||||
|
assert.deepEqual(game.players[0].elites, { reserves: 7, tanks: 0, forces: {}, revived: 0 });
|
||||||
|
assert.equal(game.players[1].elites, undefined);
|
||||||
|
assert.deepEqual(game.players[0].leaders, newPlayer('x', 'X', 'ixians').leaders);
|
||||||
|
assert.deepEqual(game.players[1].leaders, newPlayer('x', 'X', 'atreides').leaders);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('two seats with one controller may exchange factions without changing their ownership', () => {
|
||||||
|
let game = createGame('SWAPTWO', newPlayer('a', 'A', 'atreides'));
|
||||||
|
game = applyAction(game, 'a', { type: 'twoPlayerMode', enabled: true });
|
||||||
|
joinControlledFaction(game, newPlayer('b', 'A second', 'emperor'), 'a');
|
||||||
|
joinGame(game, newPlayer('c', 'C', 'guild'));
|
||||||
|
joinControlledFaction(game, newPlayer('d', 'C second', 'harkonnen'), 'c');
|
||||||
|
const controllers = structuredClone(game.twoPlayerControllers);
|
||||||
|
const positions = structuredClone(game.playerPositions);
|
||||||
|
game = applyAction(game, 'a', { type: 'factionSwapOffer', target: 'b' });
|
||||||
|
game = applyAction(game, 'b', { type: 'factionSwapRespond', event: proposal(game).event, accept: true });
|
||||||
|
assert.deepEqual(game.twoPlayerControllers, controllers);
|
||||||
|
assert.deepEqual(game.playerPositions, positions);
|
||||||
|
assert.equal(game.players.find(player => player.id === 'a')!.faction, 'emperor');
|
||||||
|
assert.equal(game.players.find(player => player.id === 'b')!.faction, 'atreides');
|
||||||
|
assert.deepEqual(viewGame(game, 'b').controlledFactionSeats.map(seat => seat.id), ['a', 'b']);
|
||||||
|
game = readyAll(game);
|
||||||
|
game = applyAction(game, 'a', { type: 'start' });
|
||||||
|
assert.equal(game.status, 'setup');
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// @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;
|
||||||
|
players: { id: string; faction: string; ready: boolean }[];
|
||||||
|
factionSwap?: { event: string; from: string; to: string; fromFaction: string; toFaction: 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('faction swaps require the recipient seat credential and preserve controlled seat ownership', async () => {
|
||||||
|
const host = await request('/api/rooms', { name: 'Swap 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 hostSecond = await request(path, { type: 'addControlledFaction', version: enabled.data.version, name: 'Host second', faction: 'harkonnen', entry: receipt() }, host.cookie, host.data.me);
|
||||||
|
assert.equal(hostSecond.status, 200, JSON.stringify(hostSecond.data));
|
||||||
|
assert.ok(hostSecond.cookie);
|
||||||
|
const hostCookies = `${host.cookie}; ${hostSecond.cookie}`;
|
||||||
|
const guest = await request(path, { type: 'join', name: 'Other person', faction: 'emperor', entry: receipt() });
|
||||||
|
assert.equal(guest.status, 200, JSON.stringify(guest.data));
|
||||||
|
assert.ok(guest.cookie);
|
||||||
|
const guestSecond = await request(path, { type: 'addControlledFaction', version: guest.data.version, name: 'Other second', faction: 'fremen', entry: receipt() }, guest.cookie, guest.data.me);
|
||||||
|
assert.equal(guestSecond.status, 200, JSON.stringify(guestSecond.data));
|
||||||
|
assert.ok(guestSecond.cookie);
|
||||||
|
const guestCookies = `${guest.cookie}; ${guestSecond.cookie}`;
|
||||||
|
const seats = [
|
||||||
|
{ id: host.data.me, cookies: hostCookies }, { id: hostSecond.data.me, cookies: hostCookies },
|
||||||
|
{ id: guest.data.me, cookies: guestCookies }, { id: guestSecond.data.me, cookies: guestCookies },
|
||||||
|
];
|
||||||
|
let latest = guestSecond.data;
|
||||||
|
for (const seat of seats) {
|
||||||
|
const ready = await request(path, { version: latest.version, action: { type: 'ready' } }, seat.cookies, seat.id);
|
||||||
|
assert.equal(ready.status, 200, JSON.stringify(ready.data));
|
||||||
|
latest = ready.data;
|
||||||
|
}
|
||||||
|
assert.ok(latest.players.every(player => player.ready));
|
||||||
|
const initial = latest.players.map(({ id, faction, ready }) => ({ id, faction, ready }));
|
||||||
|
const offered = await request(path, { version: latest.version, action: { type: 'factionSwapOffer', target: guest.data.me } }, hostCookies, host.data.me);
|
||||||
|
assert.equal(offered.status, 200, JSON.stringify(offered.data));
|
||||||
|
assert.ok(offered.data.factionSwap);
|
||||||
|
const offer = offered.data.factionSwap;
|
||||||
|
assert.deepEqual({ ...offer, event: '<event>' }, {
|
||||||
|
event: '<event>', from: host.data.me, to: guest.data.me, fromFaction: 'atreides', toFaction: 'emperor',
|
||||||
|
});
|
||||||
|
assert.deepEqual(offered.data.players.map(({ id, faction, ready }) => ({ id, faction, ready })), initial);
|
||||||
|
|
||||||
|
// A seat selector is not a credential; neither a forged selector nor another
|
||||||
|
// authenticated seat is allowed to accept the recipient's offer.
|
||||||
|
const response = { version: offered.data.version, action: { type: 'factionSwapRespond', event: offer.event, accept: true } };
|
||||||
|
const forged = await request(path, response, hostCookies, guest.data.me);
|
||||||
|
assert.equal(forged.status, 409, JSON.stringify(forged.data));
|
||||||
|
const wrongSeat = await request(path, response, guestCookies, guestSecond.data.me);
|
||||||
|
assert.equal(wrongSeat.status, 409, JSON.stringify(wrongSeat.data));
|
||||||
|
const stillOffered = await request(path, undefined, guestCookies, guest.data.me);
|
||||||
|
assert.equal(stillOffered.status, 200, JSON.stringify(stillOffered.data));
|
||||||
|
assert.equal(stillOffered.data.version, offered.data.version);
|
||||||
|
assert.deepEqual(stillOffered.data.factionSwap, offer);
|
||||||
|
const accepted = await request(path, response, guestCookies, guest.data.me);
|
||||||
|
assert.equal(accepted.status, 200, JSON.stringify(accepted.data));
|
||||||
|
assert.ok(!accepted.data.factionSwap);
|
||||||
|
assert.deepEqual(accepted.data.players.map(player => player.id), initial.map(player => player.id));
|
||||||
|
assert.equal(accepted.data.players.find(player => player.id === host.data.me)?.faction, 'emperor');
|
||||||
|
assert.equal(accepted.data.players.find(player => player.id === guest.data.me)?.faction, 'atreides');
|
||||||
|
assert.equal(accepted.data.players.find(player => player.id === hostSecond.data.me)?.faction, 'harkonnen');
|
||||||
|
assert.equal(accepted.data.players.find(player => player.id === guestSecond.data.me)?.faction, 'fremen');
|
||||||
|
assert.ok(accepted.data.players.every(player => !player.ready));
|
||||||
|
for (const seat of seats) {
|
||||||
|
const view = await request(path, undefined, seat.cookies, 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single controller still switches to the other independently authenticated
|
||||||
|
// seat to respond; declining does not alter the assignments or readiness.
|
||||||
|
latest = accepted.data;
|
||||||
|
for (const seat of seats) {
|
||||||
|
const ready = await request(path, { version: latest.version, action: { type: 'ready' } }, seat.cookies, seat.id);
|
||||||
|
assert.equal(ready.status, 200, JSON.stringify(ready.data));
|
||||||
|
latest = ready.data;
|
||||||
|
}
|
||||||
|
const beforeDecline = latest.players.map(({ id, faction, ready }) => ({ id, faction, ready }));
|
||||||
|
const secondOffer = await request(path, { version: latest.version, action: { type: 'factionSwapOffer', target: hostSecond.data.me } }, hostCookies, host.data.me);
|
||||||
|
assert.equal(secondOffer.status, 200, JSON.stringify(secondOffer.data));
|
||||||
|
assert.ok(secondOffer.data.factionSwap);
|
||||||
|
const decline = { version: secondOffer.data.version, action: { type: 'factionSwapRespond', event: secondOffer.data.factionSwap.event, accept: false } };
|
||||||
|
const missingCredential = await request(path, decline, host.cookie, hostSecond.data.me);
|
||||||
|
assert.equal(missingCredential.status, 409, JSON.stringify(missingCredential.data));
|
||||||
|
const declined = await request(path, decline, hostCookies, hostSecond.data.me);
|
||||||
|
assert.equal(declined.status, 200, JSON.stringify(declined.data));
|
||||||
|
assert.ok(!declined.data.factionSwap);
|
||||||
|
assert.deepEqual(declined.data.players.map(({ id, faction, ready }) => ({ id, faction, ready })), beforeDecline);
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
answerFactionSwap, cancelFactionSwap, createFactionSwap, FactionSwapError,
|
||||||
|
validateFactionSwap, type FactionSwapSeat,
|
||||||
|
} from '../game/faction-swap';
|
||||||
|
|
||||||
|
const roster: FactionSwapSeat[] = [
|
||||||
|
{ id: 'one', faction: 'atreides' },
|
||||||
|
{ id: 'two', faction: 'harkonnen' },
|
||||||
|
{ id: 'three', faction: 'guild' },
|
||||||
|
];
|
||||||
|
|
||||||
|
void test('an offer snapshots the public identities and current faction assignments', () => {
|
||||||
|
const players = structuredClone(roster);
|
||||||
|
const offer = createFactionSwap(players, 'one', 'two', 'swap:1');
|
||||||
|
assert.deepEqual(offer, {
|
||||||
|
event: 'swap:1', from: 'one', to: 'two', fromFaction: 'atreides', toFaction: 'harkonnen',
|
||||||
|
});
|
||||||
|
assert.deepEqual(players, roster);
|
||||||
|
validateFactionSwap(JSON.parse(JSON.stringify(offer)), players);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('only the recipient may accept or reject and acceptance swaps precisely two factions', () => {
|
||||||
|
const players = structuredClone(roster);
|
||||||
|
const offer = createFactionSwap(players, 'one', 'two', 'swap:1');
|
||||||
|
const original = structuredClone(offer);
|
||||||
|
assert.deepEqual(answerFactionSwap(players, offer, 'two', true), {
|
||||||
|
accepted: true, assignments: [
|
||||||
|
{ player: 'one', faction: 'harkonnen' },
|
||||||
|
{ player: 'two', faction: 'atreides' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.deepEqual(answerFactionSwap(players, offer, 'two', false), { accepted: false, assignments: [] });
|
||||||
|
assert.throws(() => answerFactionSwap(players, offer, 'one', true), FactionSwapError);
|
||||||
|
assert.throws(() => answerFactionSwap(players, offer, 'three', false), FactionSwapError);
|
||||||
|
assert.throws(() => answerFactionSwap(players, offer, 'two', 'yes' as unknown as boolean), FactionSwapError);
|
||||||
|
assert.deepEqual(players, roster);
|
||||||
|
assert.deepEqual(offer, original);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('only the proposer can cancel an offer', () => {
|
||||||
|
const offer = createFactionSwap(roster, 'one', 'two', 'swap:1');
|
||||||
|
assert.doesNotThrow(() => cancelFactionSwap(roster, offer, 'one'));
|
||||||
|
assert.throws(() => cancelFactionSwap(roster, offer, 'two'), FactionSwapError);
|
||||||
|
assert.throws(() => cancelFactionSwap(roster, offer, 'three'), FactionSwapError);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('invalid rosters and participants cannot create an offer', () => {
|
||||||
|
assert.throws(() => createFactionSwap(roster, 'one', 'one', 'swap:1'), FactionSwapError);
|
||||||
|
assert.throws(() => createFactionSwap(roster, 'one', 'missing', 'swap:1'), FactionSwapError);
|
||||||
|
assert.throws(() => createFactionSwap(roster, 'one', 'two', ' '), FactionSwapError);
|
||||||
|
assert.throws(() => createFactionSwap([roster[0], roster[0]], 'one', 'two', 'swap:1'), FactionSwapError);
|
||||||
|
assert.throws(() => createFactionSwap([roster[0], { ...roster[1], faction: 'atreides' }], 'one', 'two', 'swap:1'), FactionSwapError);
|
||||||
|
assert.throws(() => createFactionSwap([roster[0]], 'one', 'two', 'swap:1'), FactionSwapError);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('saved offers cannot authorize stale or malformed faction assignments', () => {
|
||||||
|
const offer = createFactionSwap(roster, 'one', 'two', 'swap:1');
|
||||||
|
const changed = [roster[0], { ...roster[1], faction: 'fremen' as const }, roster[2]];
|
||||||
|
assert.throws(() => answerFactionSwap(changed, offer, 'two', true), FactionSwapError);
|
||||||
|
assert.throws(() => validateFactionSwap(offer, [roster[0], roster[2]]), FactionSwapError);
|
||||||
|
assert.throws(() => validateFactionSwap({ ...offer, event: '' }, roster), FactionSwapError);
|
||||||
|
assert.throws(() => validateFactionSwap({ ...offer, to: 'one' }, roster), FactionSwapError);
|
||||||
|
assert.throws(() => validateFactionSwap({ ...offer, toFaction: 'invalid' }, roster), FactionSwapError);
|
||||||
|
assert.throws(() => validateFactionSwap({ ...offer, secret: 'extra' }, roster), FactionSwapError);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user