Connect staged group Nexus negotiations
Verify and publish checkpoint container / container (push) Successful in 10m24s
Verify and publish checkpoint container / container (push) Successful in 10m24s
This commit is contained in:
1 parent
4798a3579b
commit
624f95dde4
9 files changed
+348
-14
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user