From 624f95dde402118d76dde66188534e7d163d970b Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:04:40 +1100 Subject: [PATCH] Connect staged group Nexus negotiations --- components/game-table.tsx | 15 ++- docs/CURRENT_STATUS.md | 16 ++- docs/OPTIONAL_ALLIANCE_VICTORY.md | 19 +++ docs/RULE_DECISIONS.md | 6 + game/bots.ts | 15 ++- game/engine.ts | 101 +++++++++++++++- game/reference.ts | 8 +- tests/coalition-nexus-engine.test.ts | 165 +++++++++++++++++++++++++++ tests/nexus-offer-controls.test.tsx | 17 +++ 9 files changed, 348 insertions(+), 14 deletions(-) create mode 100644 tests/coalition-nexus-engine.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index bb176e49..046b956f 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -284,6 +284,7 @@ import { MoritaniRetention } from './moritani-retention'; import { MoritaniAssassinate, MoritaniAssassinateHistory } from './moritani-assassinate'; import { MoritaniExtortion, MoritaniExtortionNotice } from './moritani-extortion'; import { AdvancedAllySeparation } from './advanced-ally-separation'; +import { CoalitionNexus } from './coalition-nexus'; import { NexusEmperorPayment } from './nexus-emperor-payment'; import { NexusEmperorSellerPurchase } from './nexus-emperor-seller-purchase'; import { NexusEmperorAmbassadorPurchase } from './nexus-emperor-ambassador-purchase'; @@ -301,6 +302,8 @@ export function GameTable({ exitDisabled?: boolean; }) { const me = g.players.find((p) => p.id === g.me)!; + const groupNexus = g.coalitionNexus; + const inGroup = groupNexus?.groups.some(group => group.members.length === 3 && group.members.includes(me.id)); const outgoingAlliance = g.allianceOffers[me.id]; const outgoingAllianceName = g.players.find((p) => p.id === outgoingAlliance)?.name; const shipmentAvailable = nexusGuildShipmentAvailable(g); @@ -4350,6 +4353,14 @@ export function GameTable({
A sandworm has opened a Nexus. Alliances may change now.
@@ -4367,7 +4378,8 @@ export function GameTable({ > Choose a player {g.players - .filter((p) => p.id !== me.id) + .filter((p) => p.id !== me.id && !groupNexus?.groups.some(group => + group.members.length === 3 && group.members.includes(p.id))) .map((p) => ( @@ -4385,6 +4397,7 @@ export function GameTable({ }, !target || !!g.homeworldAllianceBlocks?.[target])} {me.ally && actionButton('Break alliance', { type: 'alliance' })} + >} > )} {g.phase === 1 && !g.nexus && !g.spiceWindow && ( diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 6940e823..1ebc5d7c 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -33,8 +33,12 @@ member of an unexposed group, including a concealed zero marker. Focused engine cases stage the group and forces because live Nexus formation is not yet connected; they exercise real shipment, movement and turn-end actions. The related eight-file batch passes 53 cases, including existing pair behavior. -The three-member option remains unexposed: consent, faction benefits, funding, -movement and battle paths are not yet integrated for groups. +Staged Basic classic Nexus games now connect unanimous proposal/answer/leave, +Nexus readiness, table controls and minimal bot responses. These tests stage +the group ledger; no lobby exposes it. The related nine-file batch passes 61 +cases. The three-member option remains +unavailable: faction benefits, funding, and other group interactions still +need integration. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-nexus-negotiation--28-october-2026). **Basic allied No-Field transit — 28 October. Prototyped / Partial.** Richese may offer its ally an immediately revealed shipment into a Richese-occupied @@ -126,12 +130,12 @@ benefits, victory and minimal legal AI. Consent, leaving behavior and shared benefit allocation are provisional online conventions. No group option is enabled or claimed complete by the standalone foundation. Shipment entry and visitor consequences now use the group-aware receipt and physical baseline. -Nexus, funding and other engine paths still need integration before the option -can be offered. -Unconnected Nexus controls and a funding quote now model unanimous consent, +The staged Nexus negotiation is connected; group funding, faction benefits +and other engine paths still need integration before the option can be offered. +Connected staged Nexus controls and a funding quote model unanimous consent, explicit donor allocations and edge-bound pledge authorizations. The engine still must issue and revoke unique edge authorizations, settle and refund -escrow atomically, and connect these controls to native actions and AI. +escrow atomically, and connect group funding controls to native actions and AI. The separate printed unlimited-size / three-stronghold variant remains missing; the current group model deliberately caps membership at three. The combined earlier batch passed 189 cases; the shipment batch passed 58 cases. The latest diff --git a/docs/OPTIONAL_ALLIANCE_VICTORY.md b/docs/OPTIONAL_ALLIANCE_VICTORY.md index bc05afa4..a8d954a3 100644 --- a/docs/OPTIONAL_ALLIANCE_VICTORY.md +++ b/docs/OPTIONAL_ALLIANCE_VICTORY.md @@ -1,5 +1,24 @@ # Optional three-stronghold alliance victory +## Group Nexus negotiation — 28 October 2026 + +**Integrated groundwork, not a selectable game mode.** In a staged Basic +classic-faction Nexus with the group ledger present, a pair member or an +unallied entrant can propose adding the entrant. Every participant must +explicitly agree; a decline cancels the invitation and preserves the pair. +Each proposal has a fresh event and revision, and an unanswered invitation +holds the Nexus. Leaving a three-member group retains the other pair and its +original edge age. Pair offers involving the newly formed group are cleared. +The staged table renders the group controls and minimal bots can answer. + +Unanimity, cancellation, the pending-Nexus hold and the surviving pair are +provisional online procedures, not publisher rulings. This scope excludes +expansion decks/factions, Advanced, optional modules, live Truthtrance +commitments and pledged aid. No lobby creates the group ledger: faction ally +benefits, group funding and other interactions must be connected before any +player can start the printed larger-alliance option. The separate unlimited +alliance variant remains missing. + **Prototyped / Partial — 28 October 2026.** The host can select a shorter two-member alliance game in the lobby. With at least three physical controllers, allied factions need three distinct qualifying strongholds diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 77ce6650..1654cf6f 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,11 @@ # Rule decision index +## Group Nexus negotiations — 28 October 2026 + +For the unexposed Basic group foundation, unanimous consent, a held Nexus +while an invitation is pending, and retaining the remaining pair on departure +are provisional online procedures. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-nexus-negotiation--28-october-2026). + ## Residual Poison and Semuta with Basic independent cards — 28 October 2026 The existing provisional public-discard reaction also applies to the clean diff --git a/game/bots.ts b/game/bots.ts index 0c29a2ae..0eff17f9 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -3183,6 +3183,15 @@ function policyActions(g: GameView): Action[] { return []; } if (g.phase === 1) { + const group = g.coalitionNexus; + if (group?.proposal) { + const proposal = group.proposal; + const participants = [...proposal.original.members, proposal.entrant]; + return participants.includes(me.id) && !proposal.approvals.includes(me.id) + ? [{type: 'coalitionAnswer', event: proposal.event, nexus: proposal.nexus, + turn: proposal.turn, accept: true, expectedRevision: group.revision}] + : []; + } const thumper = me.hand?.find((c) => c.effect === 'thumper'); const prior = g.spiceDiscardTop[0]; if ( @@ -3217,7 +3226,8 @@ function policyActions(g: GameView): Action[] { const card = me.hand?.find((c) => c.effect === 'harvester'); if (card) return [{ type: 'card', card: card.id }]; } - if (g.nexus && !g.spiceWindow && !me.ally) { + if (g.nexus && !g.spiceWindow && !me.ally && + !group?.groups.some(alliance => alliance.members.includes(me.id))) { const offer = g.players.find( (player) => player.id !== me.id && g.allianceOffers[player.id] === me.id && @@ -3227,7 +3237,8 @@ function policyActions(g: GameView): Action[] { if (level > 0 && !g.ready.includes(me.id) && !g.allianceOffers[me.id]) { const target = g.players.find( (player) => player.id !== me.id && - !player.ally && !g.homeworldAllianceBlocks?.[player.id], + !player.ally && !g.homeworldAllianceBlocks?.[player.id] && + !group?.groups.some(alliance => alliance.members.length === 3 && alliance.members.includes(player.id)), ); if (target) return [{ type: 'alliance', target: target.id }]; } diff --git a/game/engine.ts b/game/engine.ts index b3e19304..c7f4a35a 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -750,7 +750,8 @@ import { } from './advisors'; import { isAlliedShipmentVisit, makeBasicAlliedShipment, quoteBasicAlliedShipment, basicAlliedShipmentHasWitness, type BasicAlliedShipment } from './allied-separation'; -import type { CoalitionState } from './coalition-alliance'; +import { answerCoalitionAdmission, beginCoalitionAdmission, coalitionGroups, coalitionOf, + leaveCoalition, CoalitionAllianceError, type CoalitionState, type CoalitionTransition } from './coalition-alliance'; import { casualtyOptions, maxCombatDial, maxCombatSupport, validCombatForces, type Casualties, type CombatForces } from './combat'; import { FACTIONS, faction, type FactionId } from './catalog'; import { quoteLobbyBotConfiguration } from './lobby-bot-configuration'; @@ -15982,6 +15983,35 @@ function allianceQuestionWindow(g: Game): boolean { return !!allianceOpportunity(g) && !g.spiceWindow && !g.spiceResolution && !g.response && !g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama && !g.pendingKull; } +/** Group negotiation is deliberately unexposed until its other alliance + * mechanics are integrated. This narrow profile keeps existing pair games + * and unrelated commitments on their original paths. */ +function coalitionNexusAvailable(g: Game): boolean { + return g.coalitionState !== undefined && g.status === 'playing' && !g.advanced && + g.phase === 1 && !!g.nexus && !g.spiceWindow && !g.spiceResolution && + !g.response && !g.decision && !g.truthtrance && !g.phaseOpening && + !g.pendingKarama && !g.pendingTreacheryDiscard && !g.pendingKull && + !g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens && + !g.strongholdCards && !g.ecazTreachery && !g.discoveryEnabled && + g.expansions.length === 0 && g.players.every(seat => + ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction)) && + Object.values(g.aid).every(credit => credit.amount === 0) && + !(g.alliancePromises?.length || g.allianceExpressionPromises?.length || + g.bribePromises?.length || g.bribeExpressionPromises?.length || g.turnActionPromises?.length); +} +function coalitionRule(work: () => T): T { + try { return work(); } + catch (error) { if (error instanceof CoalitionAllianceError) throw new RuleError(error.message); throw error; } +} +function applyCoalitionTransition(g: Game, result: CoalitionTransition) { + g.coalitionState = result.state; + for (const [id, ally] of Object.entries(result.allyUpdates)) getPlayer(g, id).ally = ally; + for (const [id, turn] of Object.entries(result.allySinceUpdates)) { + const player = getPlayer(g, id); + if (turn === null) delete player.allySinceTurn; + else player.allySinceTurn = turn; + } +} /** The original native reciprocal quote owns identity and pairing legality. */ function normalNexusAllianceQuote(g: Game, p: Player, target: string | null) { if (target !== null) { @@ -40576,6 +40606,12 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi factionDraftIntegrity(state); factionSwapIntegrity(state); publicDealIntegrity(state); + if (state.coalitionState?.proposal) { + requireRule(coalitionNexusAvailable(state) && state.coalitionState.proposal.turn === state.turn, + 'The group invitation no longer belongs to this Nexus.'); + requireRule(action?.type === 'coalitionAnswer' || action?.type === 'advanceBots', + 'Resolve the group invitation before another action.'); + } if (state.factionSwap) requireRule( ['factionSwapRespond', 'factionSwapCancel', 'advanceBots'].includes(action?.type), 'Resolve the faction swap before changing the lobby.', @@ -44217,12 +44253,66 @@ function applyActionInner( } return g; } + if (t === 'coalitionPropose' || t === 'coalitionAnswer' || t === 'coalitionLeave') { + requireRule(coalitionNexusAvailable(g), 'Group negotiations are unavailable in this Nexus.'); + const revision = integer(action.expectedRevision, 0, Number.MAX_SAFE_INTEGER, 'Coalition revision'); + if (t === 'coalitionPropose') { + const result = coalitionRule(() => beginCoalitionAdmission(g.players, g.coalitionState, { + event: crypto.randomUUID(), nexus: crypto.randomUUID(), turn: g.turn, + groupId: stringField(action.groupId), entrant: stringField(action.entrant), + proposer: id, expectedRevision: revision, + })); + applyCoalitionTransition(g, result); + log(g, `${p.name} proposed a group alliance; every participant must agree.`); + } else if (t === 'coalitionAnswer') { + requireRule(typeof action.accept === 'boolean', 'Accept or decline this group invitation.'); + const offer = g.coalitionState!.proposal; + const result = coalitionRule(() => answerCoalitionAdmission(g.players, g.coalitionState, { + event: stringField(action.event), nexus: stringField(action.nexus), + turn: integer(action.turn, 1, Number.MAX_SAFE_INTEGER, 'Invitation turn'), + actor: id, accept: action.accept as boolean, expectedRevision: revision, + })); + applyCoalitionTransition(g, result); + const formed = !!offer && !!action.accept && !result.state.proposal; + if (formed) { + const members = new Set([...offer!.original.members, offer!.entrant]); + g.allianceOffers = Object.fromEntries(Object.entries(g.allianceOffers) + .filter(([from, to]) => !members.has(from) && !members.has(to))); + } + log(g, formed ? 'All three players agreed to the group alliance.' + : action.accept ? `${p.name} approved the group invitation.` + : `${p.name} declined the group invitation; the existing alliance remains.`); + } else { + const result = coalitionRule(() => leaveCoalition(g.players, g.coalitionState, + {actor: id, expectedRevision: revision})); + applyCoalitionTransition(g, result); + log(g, `${p.name} left the group alliance. Remaining partners stay allied if two remain.`); + } + g.ready = []; + return g; + } if (t === 'alliance') { requireRule( g.phase === 1 && g.nexus && !g.spiceWindow && !g.spiceResolution, 'Alliances change only during a Nexus.', ); const target = action.target ? stringField(action.target) : null; + if (g.coalitionState !== undefined) { + requireRule(!g.coalitionState.proposal, 'Resolve the group invitation before changing alliances.'); + const own = coalitionRule(() => coalitionOf(g.players, g.coalitionState, id)); + const otherGroup = target ? coalitionRule(() => coalitionOf(g.players, g.coalitionState, target)) : null; + requireRule(own?.members.length !== 3 && otherGroup?.members.length !== 3, + 'Group members must use the group alliance controls.'); + if (target === null && own && g.coalitionState.groups.some(group => group.id === own.id)) { + requireRule(coalitionNexusAvailable(g), 'Group negotiations are unavailable in this Nexus.'); + applyCoalitionTransition(g, coalitionRule(() => leaveCoalition(g.players, g.coalitionState, + {actor: id, expectedRevision: g.coalitionState!.revision}))); + delete g.allianceOffers[id]; + g.ready = []; + log(g, `${p.name} left the alliance.`); + return g; + } + } const formerAlly = p.ally ? getPlayer(g, p.ally).name : null; const formerOffer = g.allianceOffers[id]; const formerOfferName = g.players.find((player) => player.id === formerOffer)?.name; @@ -45590,6 +45680,8 @@ function applyActionInner( return g; } if (t === 'ready') { + requireRule(!(g.phase === 1 && g.nexus && g.coalitionState?.proposal), + 'Resolve the group invitation before finishing the Nexus.'); if (g.phase === 2) { if (g.charityPromises) charityPromiseRule(() => guardCharityReady(g.charityPromises!,id,g.turn,charityPromiseCanClaim(g,id))); const orderBlock = nativeCharityOrderBlock(g, id); @@ -46411,6 +46503,13 @@ export function viewGame(state: Game, id: string, now?: number) { !g.nexus, spiceDiscardTop: g.spiceDiscard.map((pile) => pile.at(-1) ?? null), allianceOffers: g.allianceOffers, + coalitionNexus: coalitionNexusAvailable(g) ? { + me: id, + players: g.players.map(({id: seatId, name}) => ({id: seatId, name})), + groups: coalitionRule(() => coalitionGroups(g.players, g.coalitionState)), + proposal: g.coalitionState!.proposal, + revision: g.coalitionState!.revision, + } : null, homeworldAllianceBlocks: Object.fromEntries(g.players.filter((p) => p.id !== id) .flatMap((p) => { const reason = homeworldAllianceReason(g, id, p.id); return reason ? [[p.id, reason]] : []; })), ecazSpice: diff --git a/game/reference.ts b/game/reference.ts index 1dfb0ef5..8b401547 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2698,11 +2698,11 @@ export const RULE_TOPICS: RuleTopic[] = [ ], related: ['spice-blow', 'alliance-funding', 'table-discussion', 'privacy'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Existing ordinary reciprocal offers, withdrawal and break preserve readiness and configured alliance restrictions. Broader special diplomacy remains unfinished.', evidence: ['game/nexus-alliance.ts', 'game/engine.ts'] }, - { area: 'Player controls', status: 'Implemented', detail: 'Named outgoing offers now have live withdrawal, alongside existing proposal, acceptance and break controls.', evidence: ['components/game-table.tsx'] }, - { area: 'AI', status: 'Partial', detail: 'All four existing profiles can continue legally after withdrawal. Full diplomacy and difficulty tuning remain deferred.' }, + { area: 'Implementation', status: 'Partial', detail: 'Existing ordinary reciprocal offers, withdrawal and break preserve readiness and configured alliance restrictions. Staged Basic group Nexus actions add unanimous admission and leave; the option is not yet available in the lobby and broader group mechanics remain unfinished.', evidence: ['game/nexus-alliance.ts', 'game/coalition-alliance.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Named outgoing offers have live withdrawal. The group panel is connected in staged games only; no lobby enables group play yet.', evidence: ['components/game-table.tsx', 'components/coalition-nexus.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'All four existing profiles can continue legally after withdrawal and answer staged group invitations. Full diplomacy and difficulty tuning remain deferred.' }, { area: 'Documentation', status: 'Implemented', detail: 'Offer versus alliance, incoming invitations, readiness, refresh and concurrent choices are explained.', evidence: ['docs/NEXUS_OFFERS.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Full-table rendering, JSON continuation, pending-window rejection and SQLite acceptance/withdrawal races are covered. All-configuration and full visual acceptance remain open.', evidence: ['tests/nexus-offer-controls.test.tsx', 'tests/nexus-offer-recovery.test.ts', 'tests/nexus-alliance.test.ts'] }, + { area: 'Verification', status: 'Partial', detail: 'Full-table rendering, JSON continuation, pending-window rejection and SQLite acceptance/withdrawal races are covered. Staged group cases exercise actual proposals, answers, leaving, table rendering and legal bot responses. All-configuration and full visual acceptance remain open.', evidence: ['tests/coalition-nexus-engine.test.ts', 'tests/nexus-offer-controls.test.tsx', 'tests/nexus-offer-recovery.test.ts', 'tests/nexus-alliance.test.ts'] }, ], }, { diff --git a/tests/coalition-nexus-engine.test.ts b/tests/coalition-nexus-engine.test.ts new file mode 100644 index 00000000..0189e6da --- /dev/null +++ b/tests/coalition-nexus-engine.test.ts @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { coalitionGroups, coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; + +/** Controlled Basic Nexus and empty group ledger; no host-facing group option is + * enabled. Pair formation and all subsequent negotiations use real actions. */ +function fixture(): Game { + const game = createGame('GROUPNEXUS', newPlayer('a', 'Atreides', 'atreides'), false); + game.players.push(newPlayer('b', 'Emperor', 'emperor'), + newPlayer('c', 'Fremen', 'fremen'), newPlayer('d', 'Harkonnen', 'harkonnen')); + game.status = 'playing'; game.phase = 1; game.turn = 2; game.nexus = true; + game.order = ['a', 'b', 'c', 'd']; game.ready = []; + game.coalitionState = emptyCoalitionState(); + return game; +} +function act(game: Game, id: string, action: Action): Game { + return applyAction(game, id, action); +} +function originalPair(): Game { + let game = fixture(); + game = act(game, 'a', {type: 'alliance', target: 'b'}); + game = act(game, 'b', {type: 'alliance', target: 'a'}); + assert.equal(game.players[0].ally, 'b'); + assert.equal(game.players[1].ally, 'a'); + return game; +} +function propose(game: Game): Game { + const pair = coalitionOf(game.players, game.coalitionState, 'a'); + assert.ok(pair); + return act(game, 'a', {type: 'coalitionPropose', groupId: pair.id, + entrant: 'c', expectedRevision: game.coalitionState!.revision}); +} +function answer(game: Game, id: string, accept = true): Game { + const proposal = game.coalitionState!.proposal; + assert.ok(proposal); + return act(game, id, {type: 'coalitionAnswer', event: proposal.event, + nexus: proposal.nexus, turn: proposal.turn, accept, + expectedRevision: game.coalitionState!.revision}); +} +function rejected(game: Game, id: string, action: Action): void { + const before = structuredClone(game); + assert.throws(() => act(game, id, action)); + assert.deepEqual(game, before, 'A rejected negotiation leaves its original state unchanged.'); +} + +void test('native pair expands only after three separate group approvals', () => { + const original = originalPair(); + let game = propose(original); + const proposal = game.coalitionState!.proposal; assert.ok(proposal); + assert.ok(proposal.event && proposal.nexus); + assert.equal(proposal.turn, game.turn); + assert.deepEqual(proposal.approvals, ['a']); + assert.equal(coalitionGroups(game.players, game.coalitionState)[0].members.length, 2); + game = answer(game, 'b'); + assert.deepEqual(game.coalitionState!.proposal!.approvals, ['a', 'b']); + assert.equal(coalitionGroups(game.players, game.coalitionState)[0].members.length, 2); + game = answer(game, 'c'); + assert.equal(game.coalitionState!.proposal, null); + assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a', 'b', 'c']); + assert.deepEqual(game.players.slice(0, 3).map(player => player.ally), [null, null, null]); + assert.equal(game.players[3].ally, null); + assert.deepEqual(game.ready, []); +}); + +void test('any participant may decline and the original pair remains unchanged', () => { + const original = originalPair(); + const pending = propose(original); + const declined = answer(pending, 'c', false); + assert.equal(declined.coalitionState!.proposal, null); + assert.deepEqual(coalitionOf(declined.players, declined.coalitionState, 'a')!.members, ['a', 'b']); + assert.equal(declined.players[0].ally, 'b'); + assert.equal(declined.players[1].ally, 'a'); + assert.equal(declined.players[2].ally, null); +}); + +void test('stale revision, wrong event, outsider and duplicate approval cannot mutate an invitation', () => { + let pending = propose(originalPair()); + const invitation = pending.coalitionState!.proposal!; + const expectedRevision = pending.coalitionState!.revision; + const action = {type: 'coalitionAnswer', event: invitation.event, nexus: invitation.nexus, + turn: invitation.turn, accept: true, expectedRevision}; + rejected(pending, 'b', {...action, expectedRevision: expectedRevision - 1}); + rejected(pending, 'b', {...action, event: 'stale-event'}); + rejected(pending, 'b', {...action, nexus: 'stale-nexus'}); + rejected(pending, 'd', action); + rejected(pending, 'a', action); + pending = answer(pending, 'b'); + rejected(pending, 'c', action); +}); + +void test('a pending invitation blocks Nexus readiness and other ordinary alliance actions', () => { + const pending = propose(originalPair()); + rejected(pending, 'a', {type: 'ready'}); + rejected(pending, 'b', {type: 'alliance'}); + rejected(pending, 'd', {type: 'alliance', target: 'c'}); +}); + +void test('leaving a triple keeps the other two allied, and ordinary pair break remains valid', () => { + let game = propose(originalPair()); + game = answer(game, 'b'); game = answer(game, 'c'); + const triple = coalitionOf(game.players, game.coalitionState, 'a')!; + const age = triple.edges.find(edge => edge.members[0] === 'a' && edge.members[1] === 'b')!.formedTurn; + game = act(game, 'c', {type: 'coalitionLeave', expectedRevision: game.coalitionState!.revision}); + const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair); + assert.deepEqual(pair.members, ['a', 'b']); + assert.equal(pair.edges[0].formedTurn, age); + assert.equal(game.players[0].ally, 'b'); assert.equal(game.players[1].ally, 'a'); + assert.equal(game.players[2].ally, null); + game = act(game, 'a', {type: 'alliance'}); + assert.equal(coalitionOf(game.players, game.coalitionState, 'a'), null); + assert.equal(game.players[1].ally, null); +}); + +void test('each seated player sees the public group negotiation and its current revision', () => { + const pending = propose(originalPair()); + const proposal = pending.coalitionState!.proposal!; + for (const seat of pending.players) { + const view = viewGame(pending, seat.id).coalitionNexus; + assert.ok(view); + assert.equal(view.me, seat.id); + assert.equal(view.revision, pending.coalitionState!.revision); + assert.deepEqual(view.proposal, proposal); + assert.deepEqual(view.groups.map(group => group.members), [['a', 'b']]); + assert.deepEqual(view.players.map(player => player.id), ['a', 'b', 'c', 'd']); + } +}); + +for (const difficulty of DIFFICULTIES) void test(`${difficulty} gives a legal group answer and uninvolved bots wait`, () => { + const pending = propose(originalPair()); + const proposal = pending.coalitionState!.proposal!; + for (const id of ['b', 'c']) { + const view = viewGame(pending, id); + view.players.find(player => player.id === id)!.bot = difficulty; + const action = botActions(view)[0]; + assert.ok(action); + assert.deepEqual(action, {type: 'coalitionAnswer', event: proposal.event, + nexus: proposal.nexus, turn: proposal.turn, accept: true, + expectedRevision: pending.coalitionState!.revision}); + const after = act(pending, id, action); + assert.ok(after.coalitionState!.proposal?.approvals.includes(id)); + } + for (const id of ['a', 'd']) { + const view = viewGame(pending, id); + view.players.find(player => player.id === id)!.bot = difficulty; + assert.deepEqual(botActions(view), [], 'The approved proposer and outsider wait while the invitation is pending.'); + } + const approved = answer(pending, 'b'); + const view = viewGame(approved, 'b'); + view.players.find(player => player.id === 'b')!.bot = difficulty; + assert.deepEqual(botActions(view), [], 'A player who already consented waits for the remaining participant.'); +}); + +void test('closed or earlier Nexus cannot be resumed using an otherwise current group invitation', () => { + const pending = propose(originalPair()); + const invitation = pending.coalitionState!.proposal!; + const action: Action = {type: 'coalitionAnswer', event: invitation.event, nexus: invitation.nexus, + turn: invitation.turn, accept: true, expectedRevision: pending.coalitionState!.revision}; + const closed = structuredClone(pending); closed.nexus = false; + rejected(closed, 'b', action); + const laterTurn = structuredClone(pending); laterTurn.turn++; + rejected(laterTurn, 'b', action); +}); diff --git a/tests/nexus-offer-controls.test.tsx b/tests/nexus-offer-controls.test.tsx index d2e2d1b3..b6d9f137 100644 --- a/tests/nexus-offer-controls.test.tsx +++ b/tests/nexus-offer-controls.test.tsx @@ -8,6 +8,7 @@ import { applyAction, viewGame, type Game } from '../game/engine'; import { nexusOfferGame } from './fixture-nexus-offer'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; +import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; const aliases = registerHooks({ resolve(specifier, context, next) { @@ -26,6 +27,22 @@ function markup(g: Game, id = 'a', busy = false) { return renderToStaticMarkup(createElement(GameTable, { game: viewGame(g, id), busy, send: async () => {}, onExit() {} })); } +void test('staged group mode renders its real Nexus invitation and hides conflicting pair controls', () => { + let game = nexusOfferGame(); + game.coalitionState = emptyCoalitionState(); + game = applyAction(game, 'a', {type: 'alliance', target: 'e'}); + game = applyAction(game, 'e', {type: 'alliance', target: 'a'}); + const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair); + assert.match(markup(game, 'a'), /Invite Harkonnen observer to Atreides offerer, Emperor recipient/); + game = applyAction(game, 'a', {type: 'coalitionPropose', groupId: pair.id, + entrant: 'h', expectedRevision: game.coalitionState!.revision}); + const html = markup(game, 'h'); + assert.match(html, /Accept invitation/); + assert.match(html, /Decline and cancel invitation/); + assert.doesNotMatch(html, /Propose \/ accept alliance/); + assert.match(markup(game, 'h', true), /disabled=""[^>]*>Accept invitation/); +}); + void test('live outgoing offer is named and withdrawable only by its sender, separately from an incoming invitation', () => { const g = applyAction(nexusOfferGame(), 'a', { type: 'alliance', target: 'e' }); const html = markup(g);