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

+1 -1
View File
@@ -25,7 +25,7 @@ Until that stage, AI work focuses on minimal legal participation and critical
playability fixes.
Choose **Learn to play** for the [interactive introduction](docs/INTERACTIVE_INTRODUCTION.md)
at `/learn`. Practice bidding, shipping, movement, battle plans, Traitor calls and collection before entering a
at `/learn`. Practice alliances, bidding, shipping, movement, battle plans, Traitor calls and collection before entering a
room; lesson progress stays in your browser and does not change saved games.
Local games persist in `.wrangler/state`. Keep this directory across restarts. Apply new additive migrations before running code that depends on them. Never delete the local database to resolve a connection or rules problem. `.openai/hosting.json` belongs to the existing Sites project; do not recreate the hosting project.
+72
View File
@@ -0,0 +1,72 @@
import type { IntroductionState } from '@/game/introduction';
import { introductionAlliance } from '@/game/introduction-alliance';
import { TERRITORIES } from '@/game/board';
import styles from './introduction.module.css';
export function AllianceLesson({ state, update }: {
state: IntroductionState; update: (patch: Partial<IntroductionState>) => void;
}) {
const practice = introductionAlliance(state);
const reset = (patch: Partial<IntroductionState> = {}) => update({
allianceActions: [], allianceClosed: false, allianceChecked: false, ...patch,
});
return <>
<p>Shai-Hulud has brought a Nexus during the Spice Blow phase of turn 2.
This separate four-player Basic example starts after the worm and spice effects.
You are Atreides; the Emperor is a possible partner. Harkonnen and the Guild stay unallied.</p>
<div className={styles.columns}>
<div className={styles.controls}>
<label htmlFor="learn-alliance-scenario">Emperor’s practice response</label>
<select id="learn-alliance-scenario" value={state.allianceScenario}
onChange={e => reset({ allianceScenario: e.target.value as IntroductionState['allianceScenario'] })}>
<option value="invited">Offers you an alliance first</option>
<option value="accepts">Accepts your offer</option>
<option value="waits">Stays unallied</option>
</select>
<label htmlFor="learn-alliance-holdings">Emperor’s strongholds</label>
<select id="learn-alliance-holdings" value={state.allianceHoldings}
onChange={e => reset({ allianceHoldings: Number(e.target.value) as 1 | 2 })}>
<option value="2">Sietch Tabr and Habbanya Sietch · 2</option>
<option value="1">Sietch Tabr only · 1</option>
</select>
<p className="fine">These fixed responses demonstrate consent, not an AI’s strategy. Changing an option starts a new example.</p>
<button disabled={!practice.canOffer} onClick={() => update({ allianceActions: ['offer'] })}>
{practice.incoming ? 'Accept Emperor’s offer' : 'Offer alliance to Emperor'}
</button>
<button disabled={!practice.canLeave} onClick={() => update({ allianceActions: [...state.allianceActions, 'leave'] })}>
{practice.allied ? 'Break practice alliance' : practice.outgoing ? 'Withdraw your offer' : 'Remain unallied'}
</button>
<button disabled={state.allianceClosed} onClick={() => update({ allianceClosed: true })}>Finish practice Nexus</button>
<button disabled={!state.allianceClosed || state.allianceChecked} onClick={() => update({ allianceChecked: true })}>Check the same board at Mentat Pause</button>
<button onClick={() => reset()}>Restart alliance example</button>
</div>
<div className={styles.result} aria-live="polite">
<h3>{state.allianceChecked ? practice.winner.length ? 'You win together' : 'No stronghold victory yet'
: state.allianceClosed ? 'Nexus finished' : practice.allied ? 'Your alliance is public'
: state.allianceActions.includes('leave') ? 'You remain unallied' : 'Choose whether to ally'}</h3>
<p>{practice.allied ? 'You and the Emperor are allied. The ordinary alliance limit is two players.'
: practice.outgoing ? `${state.allianceClosed ? 'Your offer was not accepted.' : 'Your offer is pending.'} You and the Emperor still count separately.`
: practice.incoming && !state.allianceActions.length && !state.allianceClosed ? 'The Emperor has offered. You decide whether to accept.' : 'You are unallied.'}</p>
{state.allianceActions.includes('leave') && !state.allianceClosed && <p>In a live Nexus you may continue negotiating. Restart this example to try another choice.</p>}
<h3>Your stronghold progress</h3>
<p><strong>{practice.progress.strongholds.length} / {practice.progress.target} strongholds</strong>
{practice.allied ? ' held by your alliance' : ' held by you'}.</p>
<ul>{practice.progress.strongholds.map(id => <li key={id}>{TERRITORIES.find(t => t.id === id)!.name}</li>)}</ul>
<p>You occupy Arrakeen and Carthag. The Emperor occupies {state.allianceHoldings === 2 ? 'Sietch Tabr and Habbanya Sietch' : 'Sietch Tabr'}.
Harkonnen holds Tuek’s Sietch; the Guild has a force in the Polar Sink.</p>
{!state.allianceChecked && <p>This is prospective progress. Forming an alliance does not end the game during the Nexus.</p>}
{state.allianceClosed && <p>Ordinary alliances cannot now change until another Nexus. For this exercise, the later phases leave every force in place; check this unchanged position at the Mentat Pause.</p>}
{state.allianceChecked && <p>{practice.winner.length
? 'Atreides and the Emperor jointly hold four strongholds at the Mentat Pause and share the victory.'
: `You need ${practice.progress.target - practice.progress.strongholds.length} more stronghold${practice.progress.target - practice.progress.strongholds.length === 1 ? '' : 's'} for an ordinary ${practice.allied ? 'alliance' : 'solo'} victory. The game continues.`}</p>}
<h3>Nexus history</h3>
{practice.transcript.length ? <ol>{practice.transcript.map((entry, i) => <li key={i}>{entry}</li>)}</ol>
: <p>No offers yet. You may offer an alliance or stay unallied.</p>}
</div>
</div>
<p>Both players must agree to form an alliance. Either partner may break it during a Nexus.
In this ordinary four-player game, a solo player needs three strongholds and an alliance needs four at the Mentat Pause.</p>
<p>An alliance shares the victory and grants the faction’s listed alliance power, not every faction advantage.
Forces and cards remain owned by their players. Funding, movement restrictions, special victories and expansion exceptions are separate rules in the reference.</p>
</>;
}
+12 -10
View File
@@ -7,6 +7,7 @@ import { CardInspector, CardRules } from './card-inspector';
import { PHASES } from '@/game/catalog';
import { MovementLesson, TraitorLesson } from './introduction-practice';
import { BiddingLesson } from './introduction-bidding';
import { AllianceLesson } from './introduction-alliance';
import {
INTRODUCTION_CARDS, INTRODUCTION_LEADERS, INTRODUCTION_STEPS, INTRODUCTION_STORAGE_KEY,
introductionBattle, introductionCollection, introductionShipment, newIntroduction, restoreIntroduction,
@@ -25,10 +26,10 @@ export function Introduction() {
<Link href="/">← Return to Arrakis</Link>
<p className="eyebrow">LEARN TO PLAY · BASIC GAME</p>
<h1>A first turn on Arrakis</h1>
<p>Practice bidding, shipping, movement, battle plans, Traitor calls and collection, then take a seat at a real table. These are separate examples; they do not change any saved game.</p>
<p>Practice alliances, bidding, shipping, movement, battle plans, Traitor calls and collection, then take a seat at a real table. These are separate examples; they do not change any saved game.</p>
</header>
{/* Remount on schema changes so a development hot update also migrates old state. */}
{hydrated ? <IntroductionLessons key="v3" /> : <output>Restoring your lesson…</output>}
{hydrated ? <IntroductionLessons key="v4" /> : <output>Restoring your lesson…</output>}
</main>;
}
@@ -76,8 +77,9 @@ function IntroductionLessons() {
<ol className={styles.phases}>{PHASES.map(phase => <li key={phase}>{phase}</li>)}</ol>
<p>The table tells you who must act. Your decisions can wait while another player seals a plan or answers a power. Automatic effects show in the chronicle.</p>
</>}
{state.step === 1 && <BiddingLesson state={state} update={update} />}
{state.step === 2 && <>
{state.step === 1 && <AllianceLesson state={state} update={update} />}
{state.step === 2 && <BiddingLesson state={state} update={update} />}
{state.step === 3 && <>
<p>You have <strong>8 spice</strong> and six available reserve forces. Ship from reserves before moving on the board. This example has no discount, storm, occupancy restriction or reacting faction power.</p>
<div className={styles.columns}>
<div className={styles.controls}>
@@ -101,8 +103,8 @@ function IntroductionLessons() {
</div>
<p className="fine">Ordinary movement reaches an adjacent territory. Ornithopters and faction powers can extend its range. Inspect the live destination guide before committing.</p>
</>}
{state.step === 3 && <MovementLesson state={state} update={update} />}
{state.step === 4 && <>
{state.step === 4 && <MovementLesson state={state} update={update} />}
{state.step === 5 && <>
<p>A separate battle: you are the aggressor in Wind Pass with six ordinary forces and {INTRODUCTION_LEADERS.you.name}, strength {INTRODUCTION_LEADERS.you.strength}. Your opponent also has six forces. Neither side calls a Traitor or uses a faction power in this example.</p>
<div className={styles.columns}>
<div className={styles.controls}>
@@ -130,8 +132,8 @@ function IntroductionLessons() {
</div>
</div>
</>}
{state.step === 5 && <TraitorLesson state={state} update={update} />}
{state.step === 6 && <>
{state.step === 6 && <TraitorLesson state={state} update={update} />}
{state.step === 7 && <>
<p>A fresh position has 8 spice in Wind Pass, sector 14. You have surviving forces in that same sector, outside the storm. Collection uses the forces still on the board after battles.</p>
<div className={styles.columns}>
<div className={styles.controls}>
@@ -147,8 +149,8 @@ function IntroductionLessons() {
</div>
</div>
</>}
{state.step === 7 && <>
<p>You can revisit any example. This introduction covers bidding, core resources, movement and battle choices; it does not teach every faction, card, alliance, storm exception or expansion.</p>
{state.step === 8 && <>
<p>You can revisit any example. This introduction covers ordinary alliances, bidding, core resources, movement and battle choices; it does not teach every faction, card, alliance power, storm exception or expansion.</p>
<ul>
<li>Create a private room, choose a faction, and add friends or AI players. Share the room invitation.</li>
<li>Mark yourself ready after reviewing the selected rules. The host begins once all seats are ready.</li>
+16 -16
View File
@@ -1,27 +1,27 @@
# Current development status
Updated 20 September 2026. **The full twelve-faction goal is unfinished. Basic
Updated 21 September 2026. **The full twelve-faction goal is unfinished. Basic
is playable; public Advanced and unfinished expansion/module starts remain
gated.** Prototypes do not open completion or publication gates.
## Current checkpoint and work
Latest delivered checkpoint: `b9718ac`, [lobby AI configuration](LOBBY_AI_CONFIGURATION.md).
Types, lint, **4,916 offline tests**, production build and **46 HTTP tests** passed.
All 1,262 baseline rooms and 2,644 seats stayed unchanged; the new preservation
baseline contains 1,337 rooms and 2,805 seats. No restart was needed.
The preceding `b21b2bc` connects [one-use AI permission](SEAT_AI_PERMISSION.md).
The `8f7a62c` policy checkpoint removed the
usage cutoff and deferred AI refinement until feature completion, with three
adjacent 75% win-rate targets. Earlier evidence remains in the
[implementation history](IMPLEMENTATION_STATUS.md).
Latest delivered checkpoint: `b7ec940`, [interactive bidding](INTERACTIVE_INTRODUCTION.md).
Types, lint, **4,925 offline tests** and production build passed. All 1,337 rooms,
2,805 seats and existing recovery/entry/handover/delegation rows stayed unchanged.
The preceding `b9718ac` connects [lobby AI configuration](LOBBY_AI_CONFIGURATION.md)
and `b21b2bc` connects [one-use AI permission](SEAT_AI_PERMISSION.md).
The `8f7a62c` policy checkpoint removed the usage cutoff and deferred AI refinement
until feature completion, with three adjacent 75% win-rate targets. Earlier
evidence remains in the [implementation history](IMPLEMENTATION_STATUS.md).
Current work: [interactive bidding](INTERACTIVE_INTRODUCTION.md) connects the
eighth lesson to shared ordinary-auction calculations, private purchase inspection
and versioned saved practice. No AI strategy is changed. Independent review and
final browser/check/build/preservation evidence belong to the source-bound private
report and Git message. Alliances, faction/Advanced/expansion teaching and broader
disconnected-seat acceptance remain unfinished.
Current work: [interactive alliance practice](INTERACTIVE_INTRODUCTION.md) adds
the ninth lesson, ordinary Nexus consent/break choices and later shared stronghold
victory through production quotes. Versioned practice migrates all earlier lesson
positions and choices. No AI strategy is changed. Final independent review,
browser/check/build/preservation and Git evidence belong to its private report.
Alliance powers, faction/Advanced/expansion teaching and broader disconnected-seat
acceptance remain unfinished.
A bounded remaining-effect audit found that the reviewed faction/Nexus candidates
still cross recorded pending rulings. Continue independent features rather than
+13
View File
@@ -1,5 +1,18 @@
# Dune implementation status
## 21 September 2026 — Interactive Nexus alliance practice
The [introduction](INTERACTIVE_INTRODUCTION.md) now connects nine lessons and seven
Basic examples. A four-player Nexus teaches mutual consent, unanswered offers,
withdrawal and unilateral alliance breaks. An explicit later Mentat check contrasts
three shared strongholds with the ordinary four-stronghold joint victory. Shared
production pairing, progress and victory quotes preserve physical force ownership.
The live engine retains phase/expansion checks and card forfeiture. Version 4
practice saves preserve all prior lesson identities and committed choices.
Focused engine comparisons, migration and rejection checks accompany independent
review and final source-bound browser/check/build/preservation/Git evidence in the
private checkpoint. Broader teaching and all mode/publication gates remain open work.
## 20 September 2026 — Interactive ordinary bidding practice
The [introduction](INTERACTIVE_INTRODUCTION.md) now connects eight lessons and six
+39 -9
View File
@@ -1,12 +1,12 @@
# Interactive introduction
20 September 2026. **Prototyped; partial teaching coverage.** `/learn` is linked
21 September 2026. **Prototyped; partial teaching coverage.** `/learn` is linked
from the lobby and rules reference. It adds the previously missing guided
introduction alongside the reference's existing standalone practice wheel.
Eight navigable lessons cover public/private table information and the phase
sequence, bidding, shipment budgets, movement, sealed battle plans, Traitor calls, spice
collection and joining or recovering a saved table. Six independent Basic practice positions let a player
Nine navigable lessons cover public/private table information and the phase
sequence, ordinary Nexus alliances, bidding, shipment budgets, movement, sealed battle plans, Traitor calls, spice
collection and joining or recovering a saved table. Seven independent Basic practice positions let a player
change inputs, commit, inspect results and retry. These positions are deliberately
separate examples rather than a complete game or an authoritative live room.
@@ -40,6 +40,36 @@ that a prior passer can bid again. Direct PDF retrieval returned 403, so no new
binary/visual source inspection is claimed. Links remain developer provenance;
the player lesson links only to internal rules.
The Nexus position uses four base factions and two or one Emperor strongholds.
Players accept an incoming offer, make an offer to a fixed accepting/waiting
partner, stay unallied, withdraw an unanswered offer or break a formed alliance.
Closing the Nexus locks these decisions. A separate explicit teaching step
checks the unchanged board at a later Mentat Pause; it does not simulate the
intervening phases or award a victory at the moment of alliance formation.
Three shared strongholds fall short; four qualify for both allies.
`quoteNexusAlliance` extracts existing ordinary offer/pairing behavior into a
shared pure quote. The live engine retains phase authorization, Homeworld
restrictions, alliance dates, Nexus Card forfeiture, readiness and history.
`strongholdProgress` and `quoteVictory` supply the teaching result. Physical
forces stay with their original owner. The response scripts are local teaching
scenarios, not changes to live AI. Funding, alliance powers, movement restrictions,
special victory and expansion exceptions are not simulated in this example.
The base rulebook's Alliances (Nexus) and Mentat Pause sections remain the rule
authority; fresh publisher-indexed rulebook/quick-start results confirm the
ordinary two-member/four-stronghold contract. No new ambiguous ruling is inferred.
The version 4 save whitelists alliance choices and validates the bounded
opening/offer/withdraw/break sequence and phase order. Prior lesson identities,
auction history and every other accepted practice choice remain intact.
`tests/introduction-alliance.test.ts` compares all 24 offered scenario/holding/
action combinations with actual engine alliance transitions, checks later victory,
JSON continuation, closed-Nexus rejection and every version 3 lesson migration.
`tests/nexus-alliance.test.ts` checks pure immutability and live-engine integration;
existing Nexus Card forfeiture tests cover the retained expansion side effects.
Final independent review, browser, source-bound checks and saved-game preservation
are recorded in the private checkpoint and Git message.
The movement position starts with five ordinary forces in Red Chasm. It compares
range one with range three from a separate Arrakeen occupant, exact sectors,
storm exclusion and a stronghold already occupied by two other factions. Accepted
@@ -51,10 +81,10 @@ actual authoritative `applyAction` transitions; no live movement rule changed.
The existing wheel, keyboard controls and card inspectors remain available.
Lesson changes focus the heading; responsive columns stack on small screens.
Browser storage contains only a versioned, validated set of lesson choices under
`dune-introduction-v1`. Version 3 payloads explicitly migrate all five version 1
and seven version 2 lesson positions to the same lesson after insertion,
preserving prior choices and committed outcomes. New bidding fields start at
their defaults; version 1 also receives movement/Traitor defaults. Hot updates
`dune-introduction-v1`. Version 4 payloads explicitly migrate all five version 1,
seven version 2 and eight version 3 lesson positions to the same lesson after insertion,
preserving prior choices and committed outcomes. New alliance fields start at their defaults for every older version.
Versions 1 and 2 receive bidding defaults; version 1 also receives movement/Traitor defaults. Hot updates
remount the lesson state to apply this migration. Only the player's bid/pass
history is saved; deterministic replay validates the complete auction and rejects
underbids, overspending, full-hand actions and actions after the auction ends.
@@ -87,7 +117,7 @@ re-entry, finite termination and integer/funding limits. Final source-bound
browser/check/build and preservation evidence for bidding belongs to its private
checkpoint and Git message.
This does not replace a full interactive curriculum. Alliances, broader movement/arrival powers, faction lessons, Advanced
This does not replace a full interactive curriculum. Alliance powers, broader movement/arrival powers, faction lessons, Advanced
support and expansion lessons remain future work. The expanded lessons retain
Partial/Prototyped coverage. Final browser/check/build, preservation and Git
evidence for this follow-up belong to its source-bound private checkpoint. No rules mode, expansion or publication gate is opened.
+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'] },
],
},
{
+104
View File
@@ -0,0 +1,104 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { introductionAlliance } from '../game/introduction-alliance';
import { INTRODUCTION_STEPS, newIntroduction, restoreIntroduction } from '../game/introduction';
import { applyAction, createGame, newPlayer } from '../game/engine';
import { quoteVictory } from '../game/victory-quote';
void test('alliance practice requires agreement, preserves separate ownership and checks victory only later', () => {
const state = newIntroduction();
const invited = introductionAlliance(state);
assert.equal(invited.incoming, true);
assert.equal(invited.allied, false);
assert.equal(invited.progress.target, 3);
const offered = introductionAlliance({ ...state, allianceScenario: 'waits', allianceActions: ['offer'] });
assert.equal(offered.outgoing, true);
assert.equal(offered.allied, false);
assert.equal(offered.progress.strongholds.length, 2);
const accepted = introductionAlliance({ ...state, allianceActions: ['offer'] });
assert.equal(accepted.allied, true);
assert.equal(accepted.progress.strongholds.length, 4);
assert.equal(accepted.progress.target, 4);
assert.equal(accepted.progress.qualifies, true);
assert.deepEqual(accepted.winner, []);
assert.deepEqual(accepted.players.map(p => p.forces), invited.players.map(p => p.forces));
const finished = { ...state, allianceActions: ['offer' as const], allianceClosed: true, allianceChecked: true };
assert.deepEqual(introductionAlliance(finished).winner, ['you', 'emperor']);
const short = introductionAlliance({ ...finished, allianceHoldings: 1 });
assert.equal(short.progress.strongholds.length, 3);
assert.deepEqual(short.winner, []);
const broken = introductionAlliance({ ...finished, allianceActions: ['offer', 'leave'] });
assert.equal(broken.allied, false);
assert.equal(broken.progress.target, 3);
assert.deepEqual(broken.winner, []);
});
void test('every offered alliance sequence matches engine pairing and keeps formation, withdrawal and break saved', () => {
let samples = 0;
for (const allianceScenario of ['invited', 'accepts', 'waits'] as const)
for (const allianceHoldings of [1, 2] as const)
for (const allianceActions of [[], ['offer'], ['leave'], ['offer', 'leave']] as Array<Array<'offer' | 'leave'>>) {
const state = { ...newIntroduction(), allianceScenario, allianceHoldings, allianceActions };
const expected = introductionAlliance(state);
const initial = introductionAlliance({ ...state, allianceScenario: 'waits', allianceActions: [] });
let game = createGame('LEARNALLY', newPlayer('you', 'You', 'atreides'));
game.players = initial.players.map(seat => ({ ...newPlayer(seat.id, seat.id, seat.faction),
forces: seat.forces, reserves: 20 - Object.values(seat.forces).reduce((a, b) => a + b, 0) }));
Object.assign(game, { status: 'playing', phase: 1, turn: 2, nexus: true,
order: game.players.map(p => p.id), phaseOpening: null, storm: 18 });
for (const action of expected.actions) {
const before = structuredClone(game);
const next = applyAction(game, action.actor,
{ type: 'alliance', ...(action.target ? { target: action.target } : {}) });
assert.deepEqual(game, before);
game = JSON.parse(JSON.stringify(next));
assert.equal(game.status, 'playing');
assert.deepEqual(game.winner, []);
}
assert.deepEqual(game.players.map(p => ({ id: p.id, ally: p.ally })), expected.players.map(p => ({ id: p.id, ally: p.ally })));
assert.deepEqual(game.allianceOffers, expected.offers);
assert.deepEqual(game.players.map(p => p.forces), initial.players.map(p => p.forces));
const closed = { ...state, allianceClosed: true };
assert.equal(introductionAlliance(closed).canOffer, false);
assert.equal(introductionAlliance(closed).canLeave, false);
const checked = { ...closed, allianceChecked: true };
const restored = restoreIntroduction(JSON.stringify(checked));
assert.deepEqual(restored, checked);
assert.deepEqual(introductionAlliance(restored!).winner, quoteVictory({ ...game, phase: 8 }).winner);
game.nexus = false;
const before = structuredClone(game);
assert.throws(() => applyAction(game, 'you', { type: 'alliance' }), /Nexus/);
assert.deepEqual(game, before);
samples++;
}
assert.equal(samples, 24);
});
void test('v3 migration preserves all eight lesson identities, completed auction and old decisions', () => {
const titles = ['Your place at the table', 'Bid for a hidden card', 'Ship within your budget',
'Move across the board', 'Seal a battle plan', 'Reveal a Traitor', 'Collect the spice', 'Join a table'];
for (const [step, title] of titles.entries()) {
const old = { ...newIntroduction(), version: 3, step, auctionActions: [null, 4], auctionBid: 4,
shipped: true, moved: true, moveCity: true, revealed: true, collected: true,
traitorStage: 'resolved', traitorCall: true, allianceActions: ['invalid'], allianceChecked: true, token: 'discarded' };
const restored = restoreIntroduction(JSON.stringify(old))!;
assert.equal(restored.version, 4);
assert.equal(INTRODUCTION_STEPS[restored.step].title, title);
assert.deepEqual(restored.auctionActions, [null, 4]);
assert.ok(restored.shipped && restored.moved && restored.revealed && restored.collected && restored.traitorCall);
assert.deepEqual(restored.allianceActions, []);
assert.equal(restored.allianceChecked, false);
assert.equal(Object.hasOwn(restored, 'token'), false);
}
assert.equal(restoreIntroduction(JSON.stringify({ ...newIntroduction(), version: 3, step: 8 })), null);
});
void test('alliance saves reject invented stages and impossible action sequences', () => {
for (const patch of [
{ allianceScenario: 'secret' }, { allianceHoldings: 3 }, { allianceHoldings: '2' },
{ allianceActions: null }, { allianceActions: ['leave', 'offer'] },
{ allianceActions: ['offer', 'offer'] }, { allianceActions: ['leave', 'leave'] },
{ allianceActions: ['offer', 'leave', 'offer'] }, { allianceActions: ['attack'] },
{ allianceClosed: 1 }, { allianceChecked: 'yes' }, { allianceChecked: true },
]) assert.equal(restoreIntroduction(JSON.stringify({ ...newIntroduction(), ...patch })), null);
});
+1 -1
View File
@@ -214,7 +214,7 @@ void test('all seven v2 lessons keep their identity and prior decisions when bid
unrelated: 'removed',
};
const result = restoreIntroduction(JSON.stringify(old))!;
assert.equal(result.version, 3);
assert.equal(result.version, 4);
assert.equal(INTRODUCTION_STEPS[result.step].title, title);
assert.equal(result.shipped, true);
assert.equal(result.revealed, true);
+2 -2
View File
@@ -69,7 +69,7 @@ void test('all five v1 lessons migrate by identity and retain prior committed ch
for (const [step, title] of titles.entries()) {
const result = restoreIntroduction(JSON.stringify({ ...old, step, token: 'excluded', moved: true, traitorCall: true }))!;
assert.equal(INTRODUCTION_STEPS[result.step].title, title);
assert.equal(result.version, 3); assert.equal(result.shipped, true); assert.equal(result.revealed, true);
assert.equal(result.version, 4); assert.equal(result.shipped, true); assert.equal(result.revealed, true);
assert.equal(result.dial, 3); assert.equal(result.collected, true); assert.equal(result.collectors, 2);
assert.equal(result.moved, false); assert.equal(result.traitorCall, false); assert.equal('token' in result, false);
assert.deepEqual(restoreIntroduction(JSON.stringify(result)), result);
@@ -86,7 +86,7 @@ void test('new practice choices survive all stages and reject impossible saved c
}
for (const patch of [{ moveDestination: 'invented:7' }, { moveForces: 0 }, { moveCity: 'yes' },
{ moved: true, moveStorm: true }, { moved: true, moveDestination: 'imperial_basin:9' },
{ traitorStage: 'later' }, { traitorCall: true }, { opponentTraitor: 1 }, { version: 4 }]) {
{ traitorStage: 'later' }, { traitorCall: true }, { opponentTraitor: 1 }, { version: 5 }]) {
assert.equal(restoreIntroduction(JSON.stringify({ ...newIntroduction(), ...patch })), null);
}
});
+2 -2
View File
@@ -44,8 +44,8 @@ void test('saved practice restores sealed choices, rejects corrupt versions and
const saved = { ...newIntroduction(), step: 3, dial: 3, revealed: true, shipped: true };
assert.deepEqual(restoreIntroduction(JSON.stringify(saved)), saved);
assert.deepEqual(restoreIntroduction(JSON.stringify({ ...saved, token: 'not-a-real-token', hand: ['private'] })), saved);
for (const raw of [null, '', '{', 'null', '[]', JSON.stringify({ ...saved, version: 4 }),
JSON.stringify({ ...saved, step: -1 }), JSON.stringify({ ...saved, step: 8 }),
for (const raw of [null, '', '{', 'null', '[]', JSON.stringify({ ...saved, version: 5 }),
JSON.stringify({ ...saved, step: -1 }), JSON.stringify({ ...saved, step: 9 }),
JSON.stringify({ ...saved, defense: 'lasgun' }), JSON.stringify({ ...saved, dial: 2.5 }),
JSON.stringify({ ...saved, collectors: 99 }), JSON.stringify({ ...saved, revealed: 'true' }),
JSON.stringify({ ...saved, shipment: 6, destination: 'sand', shipped: true })]) {
+150
View File
@@ -0,0 +1,150 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction } from '../game/engine';
import {
quoteNexusAlliance,
type NexusAllianceSeat,
} from '../game/nexus-alliance';
import {
nexusPlayer,
nexusReady,
nexusTurnTwo,
orderNexusSpice,
} from './fixture-nexus-cards';
const players = (): NexusAllianceSeat[] => [
{ id: 'a', ally: null },
{ id: 'b', ally: null },
{ id: 'c', ally: null },
{ id: 'd', ally: null },
];
void test('a unilateral offer preserves every alliance and unrelated offer without mutating input', () => {
const seats = players();
const offers = { c: 'd' };
const before = structuredClone({ seats, offers });
Object.freeze(seats);
Object.freeze(offers);
for (const seat of seats) Object.freeze(seat);
const quote = quoteNexusAlliance({
players: seats,
offers,
actor: 'a',
target: 'b',
});
assert.deepEqual(quote, {
allies: { a: null, b: null, c: null, d: null },
offers: { c: 'd', a: 'b' },
formed: null,
});
assert.deepEqual({ seats, offers }, before);
});
void test('mutual offers form a reciprocal pair and remove only that pair own offers', () => {
const quote = quoteNexusAlliance({
players: players(),
offers: { b: 'a', c: 'a', d: 'c' },
actor: 'a',
target: 'b',
});
assert.deepEqual(quote, {
allies: { a: 'b', b: 'a', c: null, d: null },
offers: { c: 'a', d: 'c' },
formed: ['a', 'b'],
});
});
void test('withdrawing breaks the reciprocal pair and removes only the actor offer', () => {
const quote = quoteNexusAlliance({
players: [
{ id: 'a', ally: 'b' },
{ id: 'b', ally: 'a' },
{ id: 'c', ally: null },
],
offers: { a: 'c', b: 'c', c: 'a' },
actor: 'a',
target: null,
});
assert.deepEqual(quote, {
allies: { a: null, b: null, c: null },
offers: { b: 'c', c: 'a' },
formed: null,
});
});
void test('invalid actors, targets and occupied alliances reject without mutation', () => {
const seats = players();
const offers = { c: 'd' };
const before = structuredClone({ seats, offers });
for (const [actor, target, pattern] of [
['missing', 'a', /seated player/],
['a', 'a', /another player/],
['a', 'missing', /another seated player/],
] as const)
assert.throws(
() => quoteNexusAlliance({ players: seats, offers, actor, target }),
pattern,
);
seats[0].ally = 'c';
seats[2].ally = 'a';
assert.throws(
() =>
quoteNexusAlliance({
players: seats,
offers,
actor: 'a',
target: 'b',
}),
/Break existing alliances/,
);
assert.deepEqual(
{ seats, offers },
{
seats: [
{ id: 'a', ally: 'c' },
before.seats[1],
{ id: 'c', ally: 'a' },
before.seats[3],
],
offers: before.offers,
},
);
});
function negotiations() {
let game = nexusTurnTwo();
orderNexusSpice(game, ['worm', 'land', 'land']);
game = nexusReady(game);
assert.ok(game.spiceWindow);
game = nexusReady(game);
assert.equal(game.nexus, true);
assert.equal(game.spiceWindow, null);
return game;
}
void test('the actual engine preserves ordinary offer, formation and withdrawal side effects', () => {
let game = negotiations();
game.ready = ['h'];
game = applyAction(game, 'f', { type: 'alliance', target: 'a' });
assert.deepEqual(game.allianceOffers, { f: 'a' });
assert.equal(nexusPlayer(game, 'f').ally, null);
assert.deepEqual(game.ready, []);
game.ready = ['h'];
game = applyAction(game, 'a', { type: 'alliance', target: 'f' });
assert.equal(nexusPlayer(game, 'f').ally, 'a');
assert.equal(nexusPlayer(game, 'a').ally, 'f');
assert.equal(nexusPlayer(game, 'f').allySinceTurn, game.turn);
assert.equal(nexusPlayer(game, 'a').allySinceTurn, game.turn);
assert.deepEqual(game.allianceOffers, {});
assert.deepEqual(game.ready, []);
assert.match(game.log.at(-1)!.text, /formed an alliance/);
game.ready = ['h'];
game = applyAction(game, 'f', { type: 'alliance', target: null });
assert.equal(nexusPlayer(game, 'f').ally, null);
assert.equal(nexusPlayer(game, 'a').ally, null);
assert.deepEqual(game.ready, []);
assert.match(game.log.at(-1)!.text, /unallied/);
});