Add Nexus alliance and shared-victory practice

Expand the internal introduction to nine lessons and seven Basic exercises. The four-player Nexus example teaches incoming consent, outgoing accepted and unanswered offers, withdrawal, unilateral breaks and the ordinary three-solo/four-allied stronghold target. Formation shows prospective progress; victory appears only at an explicit later Mentat check on the unchanged teaching board. Fixed responses demonstrate legal choices without refining live AI.

Extract ordinary pairing into quoteNexusAlliance used by both practice and engine. Keep phase and Homeworld checks, alliance dates, Nexus-card forfeiture, readiness and history in the authoritative engine. Use production strongholdProgress and quoteVictory for the example; physical forces remain separately owned. No mode, expansion or publication gate changes.

Version 4 practice saves migrate all five v1, seven v2 and eight v3 lesson identities and preserve prior auction, movement, combat, Traitor and collection choices. Alliance inputs are whitelisted and semantically replayed, with impossible action sequences and phase order rejected. Nine new regression tests cover helper immutability, live engine side effects, all 24 offered practice combinations, prior-version migration and saved continuation. Existing Nexus-card forfeiture tests also pass.

Independent review found no material defect. Required types, lint, 4934 offline tests and production build pass on stable final source. Browser checks confirm old completed-auction migration, keyboard consent, formed and pending refresh restoration, withdrawal/break, closed-Nexus controls, three-stronghold shortfall and four-stronghold joint win with saved result. Controls fit a 390px viewport at 309px wide and at least44px high; viewport reset. No external lesson links. Screenshot capture remains unavailable, so visual screenshot acceptance remains unverified. Fixed lessons do not certify the full curriculum, live AI strength or all rules interactions.

The local development server was confirmed stopped by refused localhost connection and no matching processes. Started it once against the existing database without migration/reset. The existing K2V47CSN QA seat restored in setup with owning Atreides private hand/Traitors and rival privacy intact; its Hard Guild and circle6 remained visible. All 1337 rooms, 2805 seats and every original recovery/entry/handover/delegation/migration row are unchanged. SQLite integrity and foreign keys pass. No human play was interrupted, no recurring automation installed and no usage check or AI calibration run. HTTP/auth/schema source did not change; offline checks include recovery tests, and no broad HTTP suite was rerun.

Private evidence: 20260921-alliance-practice. Full rules, expansion, multiplayer and presentation goal remains incomplete.
Verified source tree: 9c74a3141aca4e049e0734209b96f68ee0e6d0e127a155a2a7562a79ea8a1a6b
This commit is contained in:
Quintine committed 2026-09-21 08:03:01 +10:00
1 parent b7ec94015a
commit c4ff75a56d
16 files changed
+619 -81

No files matched your search

+28 -21
View File
@@ -58,6 +58,7 @@ import { quoteJunctionTransport } from './junction-transport';
import { homeworldMovementForesightBlock, homeworldSpiritualAdvisorLimit, homeworldMobileStrongholdMovementBlock, homeworldNoFieldMovementBlock } from './homeworld-mobility';
import { currentJunctionOffer, junctionOfferEvent, junctionOfferIntegrity, junctionSponsor, type JunctionOffer } from './junction-offer';
import { homeworldAllianceBlock } from './homeworld-alliance';
import { quoteNexusAlliance } from './nexus-alliance';
import { quoteHomeworldSubstitution } from './homeworld-substitution';
import { combatArmy, combatLocations, combatLocationName, homeworldBattleLocation, quoteCombatBoard, quoteCombatBoardContinuation } from './combat-location';
import { battleChooserEvent, quoteBattleChoosers, reorderBattleChoosers, validateBattleChooserOrder, BattleChooserOrderError, type BattleChooserOrder } from './battle-chooser-order';
@@ -22790,30 +22791,36 @@ function applyActionInner(
g.phase === 1 && g.nexus && !g.spiceWindow && !g.spiceResolution,
'Alliances change only during a Nexus.',
);
if (!action.target) {
if (p.ally) getPlayer(g, p.ally).ally = null;
p.ally = null;
delete g.allianceOffers[id];
log(g, `${p.name} is unallied.`);
} else {
const other = getPlayer(g, stringField(action.target));
const target = action.target ? stringField(action.target) : null;
let other: Player | null = null;
if (target !== null) {
other = getPlayer(g, target);
requireRule(other.id !== id, 'Choose another player.');
const blocked = homeworldAllianceReason(g, id, other.id);
requireRule(!blocked, blocked ?? 'This alliance is unavailable.');
requireRule(
!p.ally && !other.ally,
'Break existing alliances before forming a new one.',
);
g.allianceOffers[id] = other.id;
if (g.allianceOffers[other.id] === id) {
p.ally = other.id;
other.ally = id;
p.allySinceTurn = other.allySinceTurn = g.turn;
discardAllianceNexusCards(g, p, other);
delete g.allianceOffers[id];
delete g.allianceOffers[other.id];
log(g, `${p.name} and ${other.name} formed an alliance.`);
}
}
const quote = nexusRule(() =>
quoteNexusAlliance(
{
players: g.players.map((player) => ({
id: player.id,
ally: player.ally,
})),
offers: g.allianceOffers,
actor: id,
target,
},
),
);
for (const player of g.players) player.ally = quote.allies[player.id];
g.allianceOffers = quote.offers;
if (target === null) {
log(g, `${p.name} is unallied.`);
} else if (quote.formed) {
requireRule(other, 'The alliance partner is no longer seated.');
p.allySinceTurn = other.allySinceTurn = g.turn;
discardAllianceNexusCards(g, p, other);
log(g, `${p.name} and ${other.name} formed an alliance.`);
}
g.ready = [];
return g;
+69
View File
@@ -0,0 +1,69 @@
import { quoteNexusAlliance } from './nexus-alliance';
import { strongholdProgress } from './victory-progress';
import { quoteVictory } from './victory-quote';
import type { BoardSeat } from './board-resolution-quote';
export type IntroductionAllianceChoice = {
allianceScenario: 'invited' | 'accepts' | 'waits';
allianceHoldings: 1 | 2;
allianceActions: Array<'offer' | 'leave'>;
allianceClosed: boolean;
allianceChecked: boolean;
};
export const INTRODUCTION_ALLIANCE_DEFAULTS: IntroductionAllianceChoice = {
allianceScenario: 'invited', allianceHoldings: 2, allianceActions: [],
allianceClosed: false, allianceChecked: false,
};
/** Four-player, ordinary Basic examples. No room, hidden hand or AI strategy. */
export function introductionAlliance(s: IntroductionAllianceChoice) {
if (!['invited', 'accepts', 'waits'].includes(s.allianceScenario) ||
![1, 2].includes(s.allianceHoldings) || !Array.isArray(s.allianceActions) ||
s.allianceActions.length > 2 ||
s.allianceActions.some((a, i) => !['offer', 'leave'].includes(a) ||
(i === 1 && (a !== 'leave' || s.allianceActions[0] !== 'offer'))) ||
typeof s.allianceClosed !== 'boolean' || typeof s.allianceChecked !== 'boolean' ||
(s.allianceChecked && !s.allianceClosed)) throw new Error('Invalid alliance practice.');
let players: BoardSeat[] = [
{ id: 'you', faction: 'atreides', ally: null, forces: { 'arrakeen:10': 1, 'carthag:11': 1 } },
{ id: 'emperor', faction: 'emperor', ally: null,
forces: { 'sietch_tabr:14': 1, ...(s.allianceHoldings === 2 ? { 'habbanya_ridge_sietch:17': 1 } : {}) } },
{ id: 'harkonnen', faction: 'harkonnen', ally: null, forces: { 'tueks_sietch:5': 1 } },
{ id: 'guild', faction: 'guild', ally: null, forces: { 'polar_sink:0': 1 } },
];
let offers: Record<string, string> = {};
const actions: Array<{ actor: string; target: string | null }> = [];
const transcript: string[] = [];
const apply = (actor: string, target: string | null) => {
const before = players.find(p => p.id === actor)!;
const quote = quoteNexusAlliance({ players, offers, actor, target });
players = players.map(p => ({ ...p, ally: quote.allies[p.id] }));
offers = quote.offers;
actions.push({ actor, target });
const name = actor === 'you' ? 'You' : 'The Emperor';
transcript.push(quote.formed ? 'You and the Emperor formed an alliance. Both players agreed.'
: target ? `${name} offered an alliance.`
: before.ally ? `${name} broke the alliance. Both players are now unallied.`
: `${name} stayed unallied${actions.some(a => a.actor === actor && a.target) ? ' and withdrew the offer' : ''}.`);
};
if (s.allianceScenario === 'invited') apply('emperor', 'you');
for (const action of s.allianceActions) {
apply('you', action === 'offer' ? 'emperor' : null);
if (action === 'offer' && s.allianceScenario === 'accepts') apply('emperor', 'you');
else if (action === 'offer' && s.allianceScenario === 'waits')
transcript.push('The Emperor has not accepted. An offer alone does not make an alliance.');
}
const board = { advanced: false, storm: 18, order: players.map(p => p.id), players };
const progress = strongholdProgress(board).progress.find(p => p.player === 'you')!;
const winner = s.allianceChecked ? quoteVictory({ ...board, turn: 2, phase: 8,
status: 'playing', winner: [] }).winner : [];
const ended = s.allianceActions.at(-1) === 'leave';
return { players, offers, actions, transcript, progress, winner,
allied: !!players[0].ally,
incoming: offers.emperor === 'you' && !players[0].ally,
outgoing: offers.you === 'emperor' && !players[0].ally,
canOffer: !s.allianceClosed && !s.allianceActions.length,
canLeave: !s.allianceClosed && !ended,
};
}
+18 -10
View File
@@ -4,11 +4,13 @@ import { quoteBattleResolution, type ResolutionCombatant } from './battle-resolu
import { quoteSpiceCollection } from './board-resolution-quote';
import { INTRODUCTION_MOVE_DESTINATIONS, introductionMovement, type IntroductionMovementChoice } from './introduction-movement';
import { introductionBidding, type IntroductionBiddingChoice } from './introduction-bidding';
import { introductionAlliance, INTRODUCTION_ALLIANCE_DEFAULTS, type IntroductionAllianceChoice } from './introduction-alliance';
/** Fixed teaching examples, never a room, seat or multiplayer save. */
export const INTRODUCTION_STORAGE_KEY = 'dune-introduction-v1';
export const INTRODUCTION_STEPS = [
{ title: 'Your place at the table', topic: 'setup' },
{ title: 'Choose an ally at the Nexus', topic: 'spice-blow' },
{ title: 'Bid for a hidden card', topic: 'bidding' },
{ title: 'Ship within your budget', topic: 'movement' },
{ title: 'Move across the board', topic: 'movement' },
@@ -17,8 +19,8 @@ export const INTRODUCTION_STEPS = [
{ title: 'Collect the spice', topic: 'collection' },
{ title: 'Join a table', topic: 'privacy' },
] as const;
export type IntroductionState = IntroductionMovementChoice & IntroductionBiddingChoice & {
version: 3;
export type IntroductionState = IntroductionMovementChoice & IntroductionBiddingChoice & IntroductionAllianceChoice & {
version: 4;
step: number;
shipment: number;
destination: 'stronghold' | 'sand';
@@ -35,7 +37,7 @@ export type IntroductionState = IntroductionMovementChoice & IntroductionBidding
opponentTraitor: boolean;
};
export function newIntroduction(): IntroductionState {
return { version: 3, step: 0, shipment: 4, destination: 'sand', shipped: false,
return { ...INTRODUCTION_ALLIANCE_DEFAULTS, allianceActions: [], version: 4, step: 0, shipment: 4, destination: 'sand', shipped: false,
dial: 2, defense: 'shield', revealed: false, collectors: 3, city: false, collected: false,
moveDestination: 'pasty_mesa:5', moveForces: 3, moveCity: false, moveStorm: false,
moved: false, traitorStage: 'plans', traitorCall: false, opponentTraitor: false,
@@ -47,16 +49,19 @@ export function restoreIntroduction(raw: string | null): IntroductionState | nul
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (!parsed || ![1, 2, 3].includes(parsed.version)) return null;
if (!parsed || ![1, 2, 3, 4].includes(parsed.version)) return null;
// Keep the existing browser key and translate old positions, not just indices.
if (parsed.version === 1 && !integer(parsed.step, 0, 4)) return null;
if (parsed.version === 2 && !integer(parsed.step, 0, 6)) return null;
if (parsed.version === 3 && !integer(parsed.step, 0, 7)) return null;
const auctionDefaults = { auctionScenario: 'contest', auctionHandFull: false, auctionBid: 1, auctionActions: [] };
const s = (parsed.version === 1 ? { ...newIntroduction(), ...parsed, version: 3,
step: [0, 2, 4, 6, 7][parsed.step],
const allianceDefaults = { ...INTRODUCTION_ALLIANCE_DEFAULTS, allianceActions: [] };
const s = (parsed.version === 1 ? { ...newIntroduction(), ...parsed, version: 4,
step: [0, 3, 5, 7, 8][parsed.step],
moveDestination: 'pasty_mesa:5', moveForces: 3, moveCity: false, moveStorm: false,
moved: false, traitorStage: 'plans', traitorCall: false, opponentTraitor: false, ...auctionDefaults }
: parsed.version === 2 ? { ...parsed, version: 3, step: [0, 2, 3, 4, 5, 6, 7][parsed.step], ...auctionDefaults }
moved: false, traitorStage: 'plans', traitorCall: false, opponentTraitor: false, ...auctionDefaults, ...allianceDefaults }
: parsed.version === 2 ? { ...parsed, version: 4, step: [0, 3, 4, 5, 6, 7, 8][parsed.step], ...auctionDefaults, ...allianceDefaults }
: parsed.version === 3 ? { ...parsed, version: 4, step: [0, 2, 3, 4, 5, 6, 7, 8][parsed.step], ...allianceDefaults }
: parsed) as IntroductionState;
if (!integer(s.step, 0, INTRODUCTION_STEPS.length - 1) ||
!integer(s.shipment, 1, 6) || !['stronghold', 'sand'].includes(s.destination) ||
@@ -70,15 +75,18 @@ export function restoreIntroduction(raw: string | null): IntroductionState | nul
(s.traitorStage !== 'resolved' && s.traitorCall) ||
(s.moved && !introductionMovement(s).allowed)) return null;
introductionBidding(s);
introductionAlliance(s);
// Whitelist fields; never retain credentials or arbitrary imported properties.
return { version: 3, step: s.step, shipment: s.shipment, destination: s.destination,
return { version: 4, step: s.step, shipment: s.shipment, destination: s.destination,
shipped: s.shipped, dial: s.dial, defense: s.defense, revealed: s.revealed,
collectors: s.collectors, city: s.city, collected: s.collected,
moveDestination: s.moveDestination, moveForces: s.moveForces, moveCity: s.moveCity,
moveStorm: s.moveStorm, moved: s.moved, traitorStage: s.traitorStage,
traitorCall: s.traitorCall, opponentTraitor: s.opponentTraitor,
auctionScenario: s.auctionScenario, auctionHandFull: s.auctionHandFull,
auctionBid: s.auctionBid, auctionActions: [...s.auctionActions] };
auctionBid: s.auctionBid, auctionActions: [...s.auctionActions],
allianceScenario: s.allianceScenario, allianceHoldings: s.allianceHoldings,
allianceActions: [...s.allianceActions], allianceClosed: s.allianceClosed, allianceChecked: s.allianceChecked };
} catch { return null; }
}
export function introductionShipment(s: Pick<IntroductionState, 'shipment' | 'destination'>) {
+82
View File
@@ -0,0 +1,82 @@
export type NexusAllianceSeat = {
id: string;
ally: string | null;
};
export type NexusAllianceQuote = {
allies: Record<string, string | null>;
offers: Record<string, string>;
formed: [string, string] | null;
};
export class NexusAllianceError extends Error {
constructor(message: string) {
super(message);
this.name = 'NexusAllianceError';
}
}
function requireAlliance(
condition: unknown,
message: string,
): asserts condition {
if (!condition) throw new NexusAllianceError(message);
}
/**
* Quotes only ordinary reciprocal Nexus offers and alliance identities. Phase
* authorization, expansion restrictions, card forfeiture and public effects
* remain with the engine.
*/
export function quoteNexusAlliance(context: {
players: readonly NexusAllianceSeat[];
offers: Readonly<Record<string, string>>;
actor: string;
target: string | null;
}): NexusAllianceQuote {
const ids = context.players.map((player) => player.id);
requireAlliance(
ids.every((id) => typeof id === 'string' && id.length > 0) &&
new Set(ids).size === ids.length,
'Alliance seats require distinct player IDs.',
);
const byId = new Map(context.players.map((player) => [player.id, player]));
const owner = byId.get(context.actor);
requireAlliance(owner, 'Choose a seated player.');
const allies = Object.fromEntries(
context.players.map((player) => [player.id, player.ally]),
);
const offers = { ...context.offers };
if (context.target === null) {
if (owner.ally !== null) {
requireAlliance(
byId.has(owner.ally),
'The current alliance partner is no longer seated.',
);
allies[owner.ally] = null;
}
allies[context.actor] = null;
delete offers[context.actor];
return { allies, offers, formed: null };
}
requireAlliance(context.target !== context.actor, 'Choose another player.');
const other = byId.get(context.target);
requireAlliance(other, 'Choose another seated player.');
requireAlliance(
owner.ally === null && other.ally === null,
'Break existing alliances before forming a new one.',
);
offers[context.actor] = context.target;
if (offers[context.target] !== context.actor)
return { allies, offers, formed: null };
allies[context.actor] = context.target;
allies[context.target] = context.actor;
delete offers[context.actor];
delete offers[context.target];
return { allies, offers, formed: [context.actor, context.target] };
}
+10 -9
View File
@@ -230,22 +230,23 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Getting started',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Practice bidding, shipping, movement, sealed battle plans, Traitor calls and spice collection before joining a table.',
summary: 'Practice alliances, bidding, shipping, movement, sealed battle plans, Traitor calls and spice collection before joining a table.',
steps: [
'Choose Learn to play in the lobby or at the top of this reference. Eight lessons introduce table information, bidding, shipping budgets, movement, battle choices, Traitor calls, collection and saved-seat recovery.',
'The six practice positions are separate Basic examples. Compare movement range with city access, storm and stronghold limits; reveal a matching Traitor or decline after both plans appear. Bidding, shipping, combat and collection use the same rule calculations as live games.',
'Choose Learn to play in the lobby or at the top of this reference. Nine lessons introduce table information, ordinary Nexus alliances, bidding, shipping budgets, movement, battle choices, Traitor calls, collection and saved-seat recovery.',
'The seven practice positions are separate Basic examples. Compare movement range with city access, storm and stronghold limits; reveal a matching Traitor or decline after both plans appear. Bidding, shipping, combat and collection use the same rule calculations as live games.',
'Bidding lets you raise, pass and re-enter while the card remains for sale. Only the winner pays; a purchased card stays private. Try a full hand or opponents who all pass, then restart the example.',
'At the Nexus, offer an alliance or accept the Emperor’s offer. An unanswered offer does not form an alliance. Break it or finish the Nexus, then compare three versus four shared strongholds at a later Mentat Pause. Changing practice options restarts that example.',
'The opponent uses a fixed teaching plan, revealed only after you seal yours. Try another plan to compare a Shield with a Snooper, or a low dial with the aggressor’s winning tie. This is an exercise, not an AI match.',
'Lesson progress stays in this browser, including a revealed plan. No room, multiplayer action or saved-seat credential is created or read. If browser storage is unavailable, practice still works for the current visit.',
'Faction lessons, Advanced combat, alliances, broader storm exceptions and expansions remain outside this introduction. The full reference and phase guidance explain more; existing mode gates still apply.',
'Faction lessons, Advanced combat, alliance powers, broader storm exceptions and expansions remain outside this introduction. The full reference and phase guidance explain more; existing mode gates still apply.',
],
related: ['setup', 'bidding', 'movement', 'battle', 'collection', 'privacy', 'implementation-checklist'],
related: ['setup', 'spice-blow', 'alliance-funding', 'bidding', 'movement', 'battle', 'collection', 'privacy', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Eight lessons and six interactive Basic examples reuse production bidding, shipment, map/occupancy, combat and collection calculations. The complete teaching curriculum remains unfinished.', evidence: ['game/introduction.ts', 'game/introduction-movement.ts', 'game/introduction-bidding.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Bid/pass, full-hand and all-pass choices, lesson navigation, keyboard force sliders and battle wheel, card inspectors, seal/reveal, retry and browser-local continuation are connected.', evidence: ['components/introduction.tsx', 'components/introduction-practice.tsx', 'components/introduction-bidding.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Fixed bidding scripts and an opponent battle plan provide reproducible teaching examples. The introduction is not an AI game and does not demonstrate opponent strategy or calibration.' },
{ area: 'Implementation', status: 'Partial', detail: 'Nine lessons and seven interactive Basic examples reuse production alliance/victory, bidding, shipment, map/occupancy, combat and collection calculations. The complete teaching curriculum remains unfinished.', evidence: ['game/introduction.ts', 'game/introduction-movement.ts', 'game/introduction-bidding.ts', 'game/introduction-alliance.ts', 'game/nexus-alliance.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Alliance offer/accept/withdraw/break and later victory, bid/pass, full-hand and all-pass choices, lesson navigation, keyboard force sliders and battle wheel, card inspectors, seal/reveal, retry and browser-local continuation are connected.', evidence: ['components/introduction.tsx', 'components/introduction-practice.tsx', 'components/introduction-bidding.tsx', 'components/introduction-alliance.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Fixed alliance responses, bidding scripts and an opponent battle plan provide reproducible teaching examples. The introduction is not an AI game and does not demonstrate opponent strategy or calibration.' },
{ area: 'Documentation', status: 'Partial', detail: 'Internal links connect each lesson to existing rules; the setup and saved-seat guidance distinguish practice from a live multiplayer game.', evidence: ['docs/INTERACTIVE_INTRODUCTION.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused checks cover bidding/re-entry against the live engine, full hands, payment/card custody, pricing, all offered plans, weapon survival, ties, losses, collection limits, 96 movement comparisons against the engine, single/mutual/declined Traitor calls and saved-choice migration/validation. This does not certify all rules or the complete learning journey.', evidence: ['tests/introduction.test.ts', 'tests/introduction-practice.test.ts', 'tests/introduction-bidding.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused checks cover alliance consent/break and later victory across 24 engine comparisons, bidding/re-entry against the live engine, full hands, payment/card custody, pricing, all offered plans, weapon survival, ties, losses, collection limits, 96 movement comparisons against the engine, single/mutual/declined Traitor calls and saved-choice migration/validation. This does not certify all rules or the complete learning journey.', evidence: ['tests/introduction.test.ts', 'tests/introduction-practice.test.ts', 'tests/introduction-bidding.test.ts', 'tests/introduction-alliance.test.ts', 'tests/nexus-alliance.test.ts'] },
],
},
{