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

+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'] },
],
},
{