From 910e1f9b408b7681f746b351e011367722f08caa Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:25:38 +1100 Subject: [PATCH] Allow faction swaps with AI seats --- README.md | 5 ++- components/faction-swap.tsx | 14 ++++--- docs/CURRENT_STATUS.md | 14 ++++++- docs/FACTION_ALLOCATION.md | 12 +++++- docs/IMPLEMENTATION_STATUS.md | 11 +++++ docs/RULE_DECISIONS.md | 3 +- game/engine.ts | 19 +++++++-- game/reference.ts | 4 +- tests/faction-swap-controls.test.tsx | 23 +++++++--- tests/faction-swap-engine.test.ts | 63 +++++++++++++++++++++++++++- tests/faction-swap-http.test.ts | 48 ++++++++++++++++++++- 11 files changed, 188 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 4a2e225a..eec1a504 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@ factions are assigned together after every seat chooses. AI seats choose automatically. Both methods clear human readiness; direct selection remains 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 +declined by the recipient or canceled by the proposer. A human may also offer +to swap with an AI seat, which currently accepts immediately. Accepted swaps +clear human readiness. See [faction allocation](docs/FACTION_ALLOCATION.md). For the provisional two-person setup, the host enables **Two players, two diff --git a/components/faction-swap.tsx b/components/faction-swap.tsx index cec7cf01..6ac2484a 100644 --- a/components/faction-swap.tsx +++ b/components/faction-swap.tsx @@ -4,7 +4,7 @@ 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. */ +/** Human recipients consent explicitly; AI recipients accept immediately. */ export function FactionSwap({ game, send, busy }: { game: GameView; send: (action: Action) => Promise; @@ -35,17 +35,19 @@ export function FactionSwap({ game, send, busy }: { ; const self = game.players.find((player) => player.id === game.me); - const targets = game.players.filter((player) => player.id !== game.me && !player.bot); + const targets = game.players.filter((player) => player.id !== game.me); return

Swap factions

-

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

+

Offer to exchange factions with another player. A human recipient 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.

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

AI seats accept immediately. The exchange clears human readiness.

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

A second human seat is needed to offer a swap.

} + :

{self?.bot ? 'Only human players can offer a swap.' : 'A second seat is needed to offer a swap.'}

}
; } diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index d57a0a3a..31d9f144 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,16 @@ open mode gates. ## Current checkpoint and work +**Human-to-AI faction swaps — 28 October. Prototyped / Partial.** A human may +exchange factions with an AI seat, which accepts immediately under a provisional +policy. The action resets human readiness and preserves seats, controller mapping, +player circles and AI difficulty. Human-to-human consent remains unchanged. Ten +related unit files pass 50 cases; three isolated HTTP cases cover swaps and +allocation. An isolated browser room exercised an immediate AI exchange and +confirmed its retained difficulty and cleared readiness. AI-initiated offers, +strategy and deployed acceptance remain open. +[Scope](FACTION_ALLOCATION.md). + **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 @@ -24,7 +34,7 @@ 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. +AI-initiated swaps, wider combinations and deployed acceptance remain open. [Scope](FACTION_ALLOCATION.md). **Faction allocation at setup — 28 October. Prototyped / Partial.** Hosts can @@ -35,7 +45,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. -AI swaps, wider combinations and deployed acceptance remain open. +AI-initiated 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 67798902..751479ec 100644 --- a/docs/FACTION_ALLOCATION.md +++ b/docs/FACTION_ALLOCATION.md @@ -38,8 +38,11 @@ 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, +leaders and Ixian elites follow the new faction. A human may offer to swap +with an AI seat; the AI immediately accepts as a provisional software policy. +The AI seat keeps its difficulty, identity and controller, while its name and +faction assets follow the new faction. AI-initiated offers remain unfinished. +Broader player-count interpretations, AI strategy, complete setup combinations, saved-game assurance and deployed acceptance remain open. @@ -48,3 +51,8 @@ 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. + +The AI-target follow-up passes 50 cases in ten related unit files and three +isolated HTTP cases covering allocation and authenticated human/AI swaps. +An isolated browser room confirmed an immediate human/Hard-AI swap, retained AI +difficulty and the human readiness reset. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index cfba7179..feebff2b 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,16 @@ # Dune implementation status +## 28 October 2026 — Human-to-AI faction swaps + +Humans can exchange with AI faction seats, which automatically accept under a +provisional policy. One atomic action keeps the AI seat, difficulty and +controller, updates faction assets and names, and clears human readiness. Ten +related unit files pass 50 cases; three isolated HTTP cases cover allocation, +human-to-human and human-to-AI swaps. AI-initiated negotiation, strategic +policy and deployed acceptance remain open. An isolated browser room confirmed +the immediate AI exchange, retained difficulty and readiness reset. +[Scope](FACTION_ALLOCATION.md). + ## 28 October 2026 — Consensual lobby faction swaps Human seats can offer an exchange, with recipient acceptance or decline and diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 5e4a7660..9bdc5a5d 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -8,7 +8,8 @@ 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. Human seats can exchange factions by -offer and acceptance; AI participation in swaps remains unfinished. +offer and acceptance. A human offer to an AI seat is accepted immediately as a +provisional software policy; AI-initiated offers remain 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 5ea32a3e..0ff8a615 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -42996,11 +42996,22 @@ function applyActionInner( } 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.'); + typeof action.target === 'string' && !p.bot, 'Choose another 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}.`); + requireRule(target, 'Choose another seat for the faction swap.'); + const offer = factionSwapRule(() => createFactionSwap(g.players, id, target.id, crypto.randomUUID())); + if (target.bot) { + // Lobby bots do not run asynchronous decision workers. Their provisional + // policy accepts a human's offer in the same authoritative action. + const result = factionSwapRule(() => answerFactionSwap(g.players, offer, target.id, true)); + const oldName = target.name; + 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} exchanged factions with ${oldName}, which accepted automatically. Human readiness was cleared.`); + } else { + g.factionSwap = offer; + log(g, `${p.name} offered to exchange factions with ${target.name}.`); + } return g; } if (t === 'factionAllocation') { diff --git a/game/reference.ts b/game/reference.ts index 26f2289b..987f215b 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. 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.', + '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 a faction swap; a human recipient must accept, while an AI currently accepts immediately. Human readiness then clears. Other lobby changes wait for a pending human offer to be accepted, declined or canceled.', '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, 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.', + 'Lobby random faction dealing, prediction-number drafting and human-initiated faction swaps retain seat identities and clear readiness on assignment. AI targets automatically accept under a provisional policy; AI-initiated offers 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 index e22a5fe1..ed7ec2e5 100644 --- a/tests/faction-swap-controls.test.tsx +++ b/tests/faction-swap-controls.test.tsx @@ -31,17 +31,23 @@ const click = (nodes: Element[], label: string) => { (button.props.onClick as () => void)(); }; -void test('offers target other human seats and require the recipient to accept', () => { +void test('offers distinguish human consent from immediate AI acceptance', () => { 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)', + 'Swap immediately with Computer (Spacing Guild AI)', ]); - assert.match(render(base), /other player must accept/); - assert.match(render(base), /AI seats cannot receive swap offers/); + assert.match(render(base), /human recipient must accept/); + assert.match(render(base), /AI seats accept immediately/); + assert.match(render(base), /exchange clears human readiness/); assert.doesNotMatch(render({ ...base, status: 'playing' }), /Swap factions/); click(nodes, 'Offer swap to Basil (Harkonnen)'); - assert.deepEqual(calls, [{ type: 'factionSwapOffer', target: 'b' }]); + click(nodes, 'Swap immediately with Computer (Spacing Guild AI)'); + assert.deepEqual(calls, [ + { type: 'factionSwapOffer', target: 'b' }, + { type: 'factionSwapOffer', target: 'd' }, + ]); }); void test('offers are unavailable during a draft or while busy', () => { @@ -49,9 +55,14 @@ void test('offers are unavailable during a draft or while busy', () => { 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/); + const withBot = { ...base, players: [base.players[0], base.players[3]] }; + assert.equal(controls(withBot).length, 1); + const alone = { ...base, players: [base.players[0]] }; + assert.match(render(alone), /second seat/); assert.equal(controls(alone).length, 0); + const asBot = { ...base, me: 'd' }; + assert.match(render(asBot), /Only human players can offer a swap/); + assert.equal(controls(asBot).length, 0); }); void test('the pending offer is visible to everyone, with consent and cancellation limited to each party', () => { diff --git a/tests/faction-swap-engine.test.ts b/tests/faction-swap-engine.test.ts index 45442788..eac8f2f7 100644 --- a/tests/faction-swap-engine.test.ts +++ b/tests/faction-swap-engine.test.ts @@ -85,14 +85,13 @@ void test('declining and cancelling preserve factions and existing readiness', ( rejected(game, 'host', { type: 'factionSwapRespond', event: second, accept: true }); }); -void test('swap actions reject forged parties, payloads, stale events and AI seats without mutation', () => { +void test('swap actions reject forged parties, payloads, stale events and AI proposers 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' }], @@ -115,6 +114,66 @@ void test('swap actions reject forged parties, payloads, stale events and AI sea ] as Array<[string, Action]>) rejected(game, actor, action); }); +void test('a human offer to an AI seat is accepted immediately with faction assets and readiness updated', () => { + let game = createGame('SWAPAI', newPlayer('host', 'Host', 'atreides')); + joinGame(game, newPlayer('guest', 'Guest', 'harkonnen')); + game = applyAction(game, 'host', { type: 'addBot', faction: 'guild', difficulty: 'Hard' }); + const bot = game.players.find(player => player.bot)!; + const positions = structuredClone(game.playerPositions); + game = readyAll(game); + const original = structuredClone(game); + + game = applyAction(game, 'guest', { type: 'factionSwapOffer', target: bot.id }); + assert.equal(game.factionSwap, undefined, 'AI consent completes within the offer action'); + assert.deepEqual(game.playerPositions, positions); + assert.equal(game.host, 'host'); + assert.deepEqual(game.players.map(player => player.id), original.players.map(player => player.id)); + assert.equal(game.players.find(player => player.id === 'guest')!.faction, 'guild'); + assert.equal(game.players.find(player => player.id === 'guest')!.name, 'Guest'); + const swappedBot = game.players.find(player => player.id === bot.id)!; + assert.equal(swappedBot.faction, 'harkonnen'); + assert.equal(swappedBot.name, 'Harkonnen AI'); + assert.equal(swappedBot.bot, 'Hard'); + assert.deepEqual(swappedBot.leaders, newPlayer('x', 'X', 'harkonnen').leaders); + assert.deepEqual(game.players.find(player => player.id === 'guest')!.leaders, newPlayer('x', 'X', 'guild').leaders); + assert.equal(swappedBot.ready, true); + assert.ok(game.players.filter(player => !player.bot).every(player => !player.ready)); + assert.ok(game.log.some(entry => /accept/i.test(entry.text)), 'the automatic acceptance is recorded'); + rejected(game, bot.id, { type: 'factionSwapOffer', target: 'guest' }); + rejected(game, 'guest', { type: 'factionSwapRespond', event: 'missing', accept: true }); +}); + +void test('a two-faction AI controller keeps both seats and receives Ixian elites when swapped', () => { + let game = createGame('SWAPAI2', newPlayer('human', 'Human', 'ixians'), false, ['ix']); + game = applyAction(game, 'human', { type: 'twoPlayerMode', enabled: true }); + joinControlledFaction(game, newPlayer('second', 'Human second', 'harkonnen'), 'human'); + game = applyAction(game, 'human', { type: 'addBot', faction: 'emperor', difficulty: 'Medium' }); + const firstBot = game.players.find(player => player.bot)!; + game = applyAction(game, 'human', { type: 'addControlledBot', controller: firstBot.id, faction: 'guild', difficulty: 'Easy' }); + const secondBot = game.players.find(player => player.faction === 'guild')!; + const mapping = structuredClone(game.twoPlayerControllers); + const positions = structuredClone(game.playerPositions); + game = readyAll(game); + + game = applyAction(game, 'human', { type: 'factionSwapOffer', target: secondBot.id }); + assert.equal(game.factionSwap, undefined); + assert.deepEqual(game.twoPlayerControllers, mapping); + assert.deepEqual(game.playerPositions, positions); + const human = game.players.find(player => player.id === 'human')!; + const bot = game.players.find(player => player.id === secondBot.id)!; + assert.equal(human.faction, 'guild'); + assert.equal(human.elites, undefined); + assert.equal(bot.faction, 'ixians'); + assert.equal(bot.name, 'Ixians AI'); + assert.equal(bot.bot, 'Easy'); + assert.deepEqual(bot.elites, { reserves: 7, tanks: 0, forces: {}, revived: 0 }); + assert.deepEqual(bot.leaders, newPlayer('x', 'X', 'ixians').leaders); + assert.equal(game.players.find(player => player.id === firstBot.id)!.faction, 'emperor'); + assert.ok(game.players.filter(player => player.bot).every(player => player.ready)); + assert.ok(game.players.filter(player => !player.bot).every(player => !player.ready)); + assert.deepEqual(viewGame(game, 'human').controlledFactionSeats.map(seat => seat.id), ['human', 'second']); +}); + void test('an outstanding offer freezes conflicting lobby actions and joining', () => { let game = readyAll(lobby()); game = applyAction(game, 'host', { type: 'factionSwapOffer', target: 'guest' }); diff --git a/tests/faction-swap-http.test.ts b/tests/faction-swap-http.test.ts index aab8febc..cd9253dc 100644 --- a/tests/faction-swap-http.test.ts +++ b/tests/faction-swap-http.test.ts @@ -9,7 +9,7 @@ type Wire = { code: string; me: string; version: number; - players: { id: string; faction: string; ready: boolean }[]; + players: { id: string; name: string; faction: string; ready: boolean; bot?: string }[]; factionSwap?: { event: string; from: string; to: string; fromFaction: string; toFaction: string } | null; controlledFactionSeats?: { id: string; faction: string }[]; error?: string; @@ -114,3 +114,49 @@ void test('faction swaps require the recipient seat credential and preserve cont assert.ok(!declined.data.factionSwap); assert.deepEqual(declined.data.players.map(({ id, faction, ready }) => ({ id, faction, ready })), beforeDecline); }); + +void test('a human can exchange factions with an AI through their own authenticated seat', async () => { + const host = await request('/api/rooms', { + name: 'AI 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 added = await request(path, { + version: host.data.version, action: { type: 'addBot', faction: 'harkonnen', difficulty: 'Hard' }, + }, host.cookie, host.data.me); + assert.equal(added.status, 200, JSON.stringify(added.data)); + const bot = added.data.players.find(player => player.faction === 'harkonnen' && player.bot === 'Hard'); + assert.ok(bot); + const ready = await request(path, { + version: added.data.version, action: { type: 'ready' }, + }, host.cookie, host.data.me); + assert.equal(ready.status, 200, JSON.stringify(ready.data)); + assert.ok(ready.data.players.every(player => player.ready)); + const originalIds = ready.data.players.map(player => player.id); + + // Selecting the AI seat does not grant a credential to speak for it. + const forged = await request(path, { + version: ready.data.version, action: { type: 'factionSwapOffer', target: host.data.me }, + }, host.cookie, bot.id); + assert.equal(forged.status, 409, JSON.stringify(forged.data)); + const accepted = await request(path, { + version: ready.data.version, action: { type: 'factionSwapOffer', target: bot.id }, + }, host.cookie, host.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), originalIds); + const human = accepted.data.players.find(player => player.id === host.data.me); + const swappedBot = accepted.data.players.find(player => player.id === bot.id); + assert.equal(human?.faction, 'harkonnen'); + assert.equal(human?.ready, false); + assert.equal(swappedBot?.faction, 'atreides'); + assert.equal(swappedBot?.bot, 'Hard'); + assert.equal(swappedBot?.name, 'Atreides AI'); + assert.equal(swappedBot?.ready, true); + const refreshed = await request(path, undefined, host.cookie, host.data.me); + assert.equal(refreshed.status, 200, JSON.stringify(refreshed.data)); + assert.equal(refreshed.data.me, host.data.me); + assert.deepEqual(refreshed.data.players, accepted.data.players); + assert.ok(!refreshed.data.factionSwap); +});