Connect staged group Nexus negotiations
Verify and publish checkpoint container / container (push) Successful in 10m24s

This commit is contained in:
Quintine committed 2026-10-28 23:04:40 +11:00
1 parent 4798a3579b
commit 624f95dde4
9 files changed
+348 -14

No files matched your search

+14 -1
View File
@@ -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({
<p className="muted">
A sandworm has opened a Nexus. Alliances may change now.
</p>
{groupNexus && <CoalitionNexus view={groupNexus} disabled={busy} onIntent={(intent) => {
if (intent.kind === 'propose') act({type: 'coalitionPropose', groupId: intent.groupId,
entrant: intent.entrant, expectedRevision: intent.expectedRevision});
else if (intent.kind === 'answer') act({type: 'coalitionAnswer', event: intent.event,
nexus: intent.nexus, turn: intent.turn, accept: intent.accept, expectedRevision: intent.expectedRevision});
else act({type: 'coalitionLeave', expectedRevision: intent.expectedRevision});
}} />}
{!groupNexus?.proposal && !inGroup && <>
{outgoingAlliance && !me.ally && (
<div className="nexus-offer">
<p className="notice">
@@ -4367,7 +4378,8 @@ export function GameTable({
>
<option value="">Choose a player</option>
{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) => (
<option value={p.id} key={p.id}
disabled={!!g.homeworldAllianceBlocks?.[p.id]}>
@@ -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 && (
+10 -6
View File
@@ -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
+19
View File
@@ -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
+6
View File
@@ -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
+13 -2
View File
@@ -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 }];
}
+100 -1
View File
@@ -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<T>(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:
+4 -4
View File
@@ -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'] },
],
},
{
+165
View File
@@ -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);
});
+17
View File
@@ -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);