diff --git a/README.md b/README.md index 06792e07..4a2e225a 100644 --- a/README.md +++ b/README.md @@ -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 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 +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). For the provisional two-person setup, the host enables **Two players, two diff --git a/app/page.tsx b/app/page.tsx index fb9d3fb7..f2368be5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -8,6 +8,7 @@ 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 { FactionSwap } from '@/components/faction-swap'; import { randomId } from '@/lib/random-id'; import { SeatHandoverSetup, @@ -856,7 +857,7 @@ export default function Home() { {!roomUnavailable &&
} {!roomUnavailable &&
} + {!roomUnavailable &&
} {roomUnavailable ?
void reconnect()} onExit={exitTable} exitDisabled={handoverPending || aiPermissionPending || claimUncertain} />
: } diff --git a/components/faction-swap.tsx b/components/faction-swap.tsx new file mode 100644 index 00000000..cec7cf01 --- /dev/null +++ b/components/faction-swap.tsx @@ -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; + 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
+

Faction swap offer

+

{seatName(swap.from)} offers to exchange {factionName(swap.fromFaction)} with {seatName(swap.to)}'s {factionName(swap.toFaction)}.

+

Both players must agree. The factions change only if {seatName(swap.to)} accepts.

+

Lobby changes and starting the game are paused until the offer is accepted, declined, or canceled.

+ {game.me === swap.to &&
+ + +
} + {game.me === swap.from && } +
; + + const self = game.players.find((player) => player.id === game.me); + const targets = game.players.filter((player) => player.id !== game.me && !player.bot); + return
+

Swap factions

+

Offer to exchange factions with another player. The other player must accept before either faction changes.

+ {game.factionDraft &&

Finish or cancel the faction draft before offering a swap.

} + {game.players.some((player) => player.bot) &&

AI seats cannot receive swap offers yet.

} + {self && !self.bot && targets.length > 0 ?
+ {targets.map((player) => )} +
:

A second human seat is needed to offer a swap.

} +
; +} diff --git a/components/game-table.tsx b/components/game-table.tsx index 5462ed6f..2ad4f4e0 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -367,7 +367,7 @@ export function GameTable({ : [], ); 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 && !g.response && !g.decision && !g.truthtrance && !g.phaseOpening && !g.automaticContinuationPending && !g.nexusCards?.waiting.length && !g.nexusTraitors?.pending; @@ -1457,7 +1457,7 @@ export function GameTable({ )} - + diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 82a9f6f2..d57a0a3a 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,17 @@ open mode gates. ## 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 deal random enabled factions or start a draft in descending prediction-number 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. 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. +AI swaps, wider combinations and deployed acceptance remain open. [Scope](FACTION_ALLOCATION.md). **Advanced Voice names for ordinary cards — 28 October. Prototyped / Partial.** diff --git a/docs/FACTION_ALLOCATION.md b/docs/FACTION_ALLOCATION.md index dd802330..67798902 100644 --- a/docs/FACTION_ALLOCATION.md +++ b/docs/FACTION_ALLOCATION.md @@ -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 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. +Human seats may offer an exchange of their currently assigned factions. The +recipient accepts or declines and the proposer may cancel; each action is bound +to the current offer. A pending offer freezes other lobby changes and new seats +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. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 6bdba6be..cfba7179 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,18 @@ # 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 The host can deal enabled factions randomly or start a public descending-number diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index e1a63d97..5e4a7660 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -4,10 +4,11 @@ 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 +swaps. The prototype implements these 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. +AI pick are software interpretations. Human seats can exchange factions by +offer and acceptance; AI participation in swaps remains unfinished. [Scope](FACTION_ALLOCATION.md). ## Advanced Voice and ordinary card names — 28 October 2026 diff --git a/game/engine.ts b/game/engine.ts index 1a5716d1..5ea32a3e 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -2,6 +2,8 @@ import { createTwoPlayerControllers, validateTwoPlayerControllersLobby, validate addTwoPlayerControlledSeat, TwoPlayerControllersError, type TwoPlayerControllers } from './two-player-controllers'; import { createFactionDraft, validateFactionDraft, currentFactionDrafter, chooseFaction, 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 { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar'; import { createVictoryContinuation, validateVictoryContinuation, voteVictoryContinuation, @@ -1411,6 +1413,8 @@ type RicheseAllyOffer = { payer: string; }; 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. */ factionDraft?: FactionDraft; /** 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) { twoPlayerControllersIntegrity(g); 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.players.length < 6, 'This table has six players.'); 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))); requireRule(g.factionDraft.picks.length < g.players.length, 'A completed faction draft must be applied once.'); } +function factionSwapRule(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 { player.faction = chosen; 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 { twoPlayerControllersIntegrity(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( ['factionDraftPick', 'factionDraftCancel', 'advanceBots'].includes(action?.type), 'Finish or cancel the faction draft before changing the lobby.', @@ -40601,6 +40626,7 @@ function settleAutomaticContinuations(g: Game) { export function normalizeAutomaticGame(state: Game): Game { twoPlayerControllersIntegrity(state); factionDraftIntegrity(state); + factionSwapIntegrity(state); victoryContinuationIntegrity(state); if (state.victoryContinuation) return state; bidPromiseIntegrity(state); @@ -42927,6 +42953,27 @@ function applyActionInner( return g; } 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) { const draft = g.factionDraft; requireRule(action.event === draft.event, 'This faction draft is no longer current.'); @@ -42947,6 +42994,15 @@ function applyActionInner( continueFactionDraft(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') { requireRule(id === g.host && Object.keys(action).every(key => ['type', 'mode'].includes(key)) && (action.mode === 'random' || action.mode === 'draft'), @@ -44892,6 +44948,7 @@ function applyActionInner( export function viewGame(state: Game, id: string, now?: number) { twoPlayerControllersIntegrity(state); factionDraftIntegrity(state); + factionSwapIntegrity(state); victoryContinuationIntegrity(state); revivalOrderIntegrity(state); charityOrderIntegrity(state); @@ -45191,6 +45248,7 @@ export function viewGame(state: Game, id: string, now?: number) { host: g.host, status: g.status, factionDraft: g.factionDraft ?? null, + factionSwap: g.factionSwap ?? null, twoPlayerControllers: g.twoPlayerControllers ?? null, controlledFactionSeats: g.twoPlayerControllers ? g.players.filter(player => g.twoPlayerControllers!.controllers.some(controller => diff --git a/game/faction-swap.ts b/game/faction-swap.ts new file mode 100644 index 00000000..c2a86034 --- /dev/null +++ b/game/faction-swap.ts @@ -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 => + !!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.'); +} diff --git a/game/reference.ts b/game/reference.ts index a7f8f231..26f2289b 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2446,7 +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.', + '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.', '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.', @@ -2460,7 +2460,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', 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', diff --git a/tests/faction-swap-controls.test.tsx b/tests/faction-swap-controls.test.tsx new file mode 100644 index 00000000..e22a5fe1 --- /dev/null +++ b/tests/faction-swap-controls.test.tsx @@ -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); +}); diff --git a/tests/faction-swap-engine.test.ts b/tests/faction-swap-engine.test.ts new file mode 100644 index 00000000..45442788 --- /dev/null +++ b/tests/faction-swap-engine.test.ts @@ -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: '', 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'); +}); diff --git a/tests/faction-swap-http.test.ts b/tests/faction-swap-http.test.ts new file mode 100644 index 00000000..aab8febc --- /dev/null +++ b/tests/faction-swap-http.test.ts @@ -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: '', 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); +}); diff --git a/tests/faction-swap.test.ts b/tests/faction-swap.test.ts new file mode 100644 index 00000000..a096dcc2 --- /dev/null +++ b/tests/faction-swap.test.ts @@ -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); +});