Connect staged Advanced classic group alliances
Verify and publish checkpoint container / container (push) Successful in 10m53s

This commit is contained in:
Quintine committed 2026-10-29 02:37:23 +11:00
1 parent ef92ddf2b9
commit 3ba9087405
22 files changed
+561 -44

No files matched your search

+11
View File
@@ -17,6 +17,17 @@ open mode gates.
## Current checkpoint and work
**Advanced classic group foundation — 29 October. Prototyped / Partial.**
Staged group alliances now connect genuine Advanced Nexus consent, ordinary
multi-donor bid/shipment funding, the ending player's immediate allied
separation, classic battle/stronghold quotes and selected Fremen and Emperor
alliance benefits. Twenty related files pass 143 cases. The public-setup
journeys stage the empty group ledger and selected card ordering; funding and
board/worm tests disclose additional staged wallets, counters or cards.
Typecheck, lint and build pass. Full
group play, expansion/module profiles and the lobby entry remain unfinished.
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#advanced-classic-group-foundation--29-october-2026).
**Three-member short alliance option — 29 October. Prototyped / Partial.**
A distinct staged Basic variant caps alliances at three and gives pairs or
triples a three-stronghold target; the older four-stronghold and unlimited
+27
View File
@@ -1,5 +1,32 @@
# Optional three-stronghold alliance victory
## Advanced classic group foundation — 29 October 2026
**Staged prototype, not a selectable game mode.** The same group negotiation
and donor-specific ordinary bid and shipment funding now admit Advanced games
with only classic factions and no optional modules. Advanced group separation
uses each actual ally and removes the ending player's shared fighters at that
player's own movement deadline, retaining Polar Sink and advisor exceptions.
Battle selection excludes every ally; group victory uses distinct strongholds
and the configured staged target. Selected group Voice/Prescience, Fremen
worm protection and per-recipient Fremen and Emperor revival use their existing
native opportunities. Existing unanimous group admission and larger-ally
power sharing remain provisional online interpretations.
The supplied revised rulebook, physical p. 42 (printed p. 40), lists the
larger-alliance options; physical p. 15 (printed p. 13) sets the Advanced
allied separation deadline and advisor exception. This does not certify full
Advanced group play, expansion factions, optional modules, all faction
interactions or complete games. No lobby enables a group ledger.
Twenty related files pass 143 cases. Two genuine public Advanced setups reach
the original Nexus and form groups after only the empty group ledger is staged;
selected unopened card ordering and lotteries are recorded by their fixtures.
The funding journeys use actual bids, shipments and ordered Emperor revival,
with labelled test wallets and a conserved Tank counter. Board separation and
the Fremen worm journey use staged physical positions and cards. These tests do
not constitute an unstaged full game or Advanced expansion/module acceptance.
## Three-member, three-stronghold variant — 29 October 2026
**Staged Basic prototype, not a selectable game mode.** A distinct saved
+7
View File
@@ -1,5 +1,12 @@
# Rule decision index
## Advanced classic group alliance foundation — 29 October 2026
The staged classic group profile applies the user-authorized Advanced
separation deadline to each ally, retaining Polar and advisor exceptions.
Group consent and power-sharing details remain provisional online procedures.
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#advanced-classic-group-foundation--29-october-2026).
## Three-member short alliance option — 29 October 2026
The authorized revised rulebook, physical p. 42 (printed p. 40), lists a
+4 -2
View File
@@ -3,6 +3,7 @@ import { ecazOccupancyRelation } from './ecaz-occupy';
import { presenceAt, type ForcePresence } from './force-presence';
import type { OccupancySeat } from './occupancy';
import { coalitionAllies, coalitionGroups, type CoalitionState } from './coalition-alliance';
import { FACTIONS } from './catalog';
/** Physical baseline distinguishes this turn's visitors from old co-occupation. */
export type BasicAlliedShipment = Readonly<{
@@ -40,8 +41,9 @@ function currentShipmentAllies(context: ShipmentContext, player: OccupancySeat):
}));
const groups = coalitionGroups(seats, context.coalitionState);
if (groups.some(group => group.members.length >= 3) &&
(context.advanced || context.players.some(seat => seat.faction === 'ecaz')))
throw new Error('Three-member shipment rules require Basic play without Ecaz.');
(context.players.some(seat => seat.faction === 'ecaz') || context.advanced &&
context.players.some(seat => !FACTIONS.some(faction => faction.id === seat.faction && faction.expansion === 'base'))))
throw new Error('Larger alliance shipment rules require a supported roster without Ecaz.');
return coalitionAllies(seats, context.coalitionState, player.id);
}
+8 -6
View File
@@ -1,4 +1,5 @@
import type { Player } from './engine';
import { FACTIONS } from './catalog';
import { advisorStanceAfterSettlement } from './advisor-automatic-flip';
import type { ForcePresence } from './force-presence';
import { presenceAt, presenceByLocation } from './force-presence';
@@ -29,7 +30,7 @@ export type BoardSeat = Pick<Player, 'id' | 'faction' | 'ally' | 'allySinceTurn'
ForcePresence;
export type BoardContext = MobileBoard & {
advanced: boolean;
/** Fresh Basic coalition protocol; existing played Basic tables are not retrofitted. */
/** Existing pair games retain their original protocol. */
ecazOccupyPreview?: boolean;
storm: number;
order: readonly string[];
@@ -37,13 +38,14 @@ export type BoardContext = MobileBoard & {
coalitionState?: CoalitionState;
};
/** Three-member board rules are provisionally restricted to Basic without Ecaz. */
/** Advanced larger groups are currently scoped to the classic roster. */
function validateBoardCoalitions(g: BoardContext) {
if (g.coalitionState === undefined) return;
try {
for (const group of coalitionGroups(g.players, g.coalitionState))
if (group.members.length >= 3 && (g.advanced || g.players.some(player => player.faction === 'ecaz')))
throw new Error('Three-member board rules require Basic play without Ecaz.');
if (group.members.length >= 3 && (g.players.some(player => player.faction === 'ecaz') ||
g.advanced && g.players.some(player => !FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'))))
throw new Error('Larger alliance board rules require a supported roster without Ecaz.');
} catch (error) {
throw new BoardResolutionError((error as Error).message);
}
@@ -175,8 +177,8 @@ export function quoteBattleBoard(g: BoardContext) {
return { released, battles };
}
/** Union the per-edge Basic separation obligations without inventing an age for
* an older pair when a third player joins. No forces are removed by this quote. */
/** Union per-edge separation obligations without inventing an age for an older
* pair when a third player joins. Advanced applies at each player's turn end. */
export function quoteCoalitionAlliedSeparation(
g: BoardContext & { turn: number; remaining: readonly string[] },
player: string,
+3 -3
View File
@@ -20,8 +20,8 @@ export type CoalitionBattlePreparationContext = {
const classicFactions = new Set(['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen']);
/** Select the owner or a single allied combatant for native Voice or
* Prescience. The unexposed group extension is limited to the same bare Basic
* classic profile as its other faction powers. Ambiguous group battles fail
* Prescience. The unexposed group extension supports bare Basic and Advanced
* classic profiles. Ambiguous group battles fail
* closed. `requireMutualPair` preserves the stricter legacy Truthtrance quote;
* normal battle preparation historically accepted the owner's scalar ally. */
export function coalitionBattleBeneficiary(
@@ -36,7 +36,7 @@ export function coalitionBattleBeneficiary(
if (!owner || !game.players.some(player => player.id === attacker) ||
!game.players.some(player => player.id === defender)) return null;
const combatants = [attacker, defender];
if (game.coalitionState && game.status === 'playing' && game.advanced === false &&
if (game.coalitionState && game.status === 'playing' && typeof game.advanced === 'boolean' &&
Array.isArray(game.expansions) && game.expansions.length === 0 &&
!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.ecazTreachery && !game.discoveryEnabled &&
+2 -2
View File
@@ -35,10 +35,10 @@ const classic = new Set(['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild
const validTerritory = (value: string) => TERRITORIES.some(row => row.id === value &&
(row.type === 'sand' || SPICE_CARDS.some(([territory]) => territory === value)));
/** Only the existing unexposed bare Basic classic group profile is supported.
/** Only the unexposed bare Basic/Advanced classic group profile is supported.
* Return null for all legacy pair games and other profiles. */
function threatened(g: Game, owner: string, territory: string): string[] | null {
if (!g.coalitionState || g.status !== 'playing' || g.phase !== 1 || g.advanced ||
if (!g.coalitionState || g.status !== 'playing' || g.phase !== 1 ||
g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens ||
g.strongholdCards || g.ecazTreachery || g.discoveryEnabled ||
!g.players.every(player => classic.has(player.faction))) return null;
+6 -5
View File
@@ -16200,7 +16200,7 @@ function unsettledPromises(records: readonly {fulfilled?: boolean; released?: bo
* 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 &&
return g.coalitionState !== undefined && g.status === 'playing' &&
g.phase === 1 && !!g.nexus && !g.spiceWindow && !g.spiceResolution &&
!g.response && !g.decision && !g.truthtrance && !g.phaseOpening &&
!g.pendingKarama && !g.pendingTreacheryDiscard && !g.pendingKull &&
@@ -16306,13 +16306,14 @@ function applyCoalitionTransition(g: Game, result: CoalitionTransition) {
}
syncCoalitionEscrow(g, before);
}
/** Staged group-funding scope: Basic physical shipments and ordinary bids;
/** Staged classic group-funding scope: physical shipments and ordinary bids;
* optional modules and special transactions remain separate. */
function coalitionFundingAvailable(g: Game, p: Player, committedBid = false): boolean {
if (!g.coalitionEscrow || !g.coalitionState || g.status !== 'playing' || g.advanced || ![3, 5].includes(g.phase) ||
if (!g.coalitionEscrow || !g.coalitionState || g.status !== 'playing' || ![3, 5].includes(g.phase) ||
g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens ||
g.strongholdCards || g.ecazTreachery || g.discoveryEnabled ||
g.spiceBankerIncomePreview || g.kullPreview || g.richeseAuction || g.ixAuction) return false;
g.spiceBankerIncomePreview || g.kullPreview || g.richeseAuction || g.ixAuction ||
!g.players.every(seat => ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction))) return false;
const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
if (!group || group.members.length < 3) return false;
const groupHasUnsettled = (records: readonly {player: string; fulfilled?: boolean; released?: boolean}[] | undefined) =>
@@ -16322,7 +16323,7 @@ function coalitionFundingAvailable(g: Game, p: Player, committedBid = false): bo
g.bribePromises, g.bribeExpressionPromises].some(groupHasUnsettled);
}
function coalitionEmperorGroup(g: Game, p: Player) {
if (!g.coalitionState || p.faction !== 'emperor' || g.status !== 'playing' || g.advanced ||
if (!g.coalitionState || p.faction !== 'emperor' || g.status !== 'playing' ||
g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens ||
g.strongholdCards || g.ecazTreachery || g.discoveryEnabled ||
!g.players.every(seat => ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction))) return null;
+2 -2
View File
@@ -25,10 +25,10 @@ const id = (value: unknown): value is string =>
const count = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
/** Match the unexposed Basic classic group profile; other profiles retain the
/** Match the unexposed Basic/Advanced classic group profile; other profiles retain the
* existing mutual-pair path until their interactions are connected. */
function groupRecipients(g: FremenRevivalGrantContext, owner: string): string[] | null {
if (!g.coalitionState || g.status !== 'playing' || g.advanced !== false ||
if (!g.coalitionState || g.status !== 'playing' || typeof g.advanced !== 'boolean' ||
!Array.isArray(g.expansions) || g.expansions.length || g.homeworlds || g.nexusCards ||
g.leaderSkills || g.techTokens || g.strongholdCards || g.ecazTreachery || g.discoveryEnabled ||
!g.players.every(player => ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(player.faction)))
+1 -1
View File
@@ -2700,7 +2700,7 @@ 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. 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: 'Implementation', status: 'Partial', detail: 'Existing ordinary reciprocal offers, withdrawal and break preserve readiness and configured alliance restrictions. Staged Basic and bare-classic Advanced 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'] },
+6 -5
View File
@@ -12,6 +12,7 @@ import { isDiscoveryLocationId, JACURUTU_SIETCH } from './discoveries';
import type { TwoPlayerControllers } from './two-player-controllers';
import { allianceVictoryTarget } from './alliance-victory-option';
import { coalitionGroups } from './coalition-alliance';
import { FACTIONS } from './catalog';
export type StrongholdProgress = {
player: string;
@@ -38,9 +39,8 @@ function requireProgress(
if (!condition) throw new VictoryProgressError(message);
}
/** Ordered side membership for the bounded coalition victory quote. Three-member
* Advanced and Ecaz combinations remain unavailable until their board rules
* and faction interactions are integrated. No game mode is enabled here. */
/** Ordered side membership for the bounded coalition victory quote. Advanced
* larger groups are scoped to classic rosters; no game mode is enabled here. */
export function victoryMembers(g: BoardContext, player: string): string[] {
const seat = g.players.find(candidate => candidate.id === player);
requireProgress(seat, 'Victory needs a seated player.');
@@ -48,8 +48,9 @@ export function victoryMembers(g: BoardContext, player: string): string[] {
return g.order.filter(member => member === player || member === seat.ally);
const groups = coalitionGroups(g.players, g.coalitionState);
requireProgress(!groups.some(group => group.members.length >= 3 &&
(g.advanced || g.players.some(candidate => candidate.faction === 'ecaz'))),
'Three-member victory rules require Basic play without Ecaz.');
(g.players.some(candidate => candidate.faction === 'ecaz') || g.advanced &&
g.players.some(candidate => !FACTIONS.some(faction => faction.id === candidate.faction && faction.expansion === 'base')))),
'Larger alliance victory rules require a supported roster without Ecaz.');
const group = groups.find(candidate => candidate.members.includes(player));
return g.order.filter(member => member === player || !!group?.members.includes(member));
}
+108
View File
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, newPlayer, viewGame, type Game } from '../game/engine';
import { answerCoalitionAdmission, beginCoalitionAdmission, coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { quoteBattleBoard, quoteCoalitionAlliedSeparation } from '../game/board-resolution-quote';
import { isAlliedShipmentVisit } from '../game/allied-separation';
import { strongholdProgress } from '../game/victory-progress';
import { quoteVictory } from '../game/victory-quote';
/** Controlled staged public board and coalition. The engine cannot yet start
* this preview or negotiate Advanced groups; no native game is claimed. */
function staged(): Game {
const game = createGame('ADVANCEDGROUPBOARD', newPlayer('a', 'Atreides', 'atreides'), true);
game.players.push(newPlayer('b', 'Emperor', 'emperor'), newPlayer('c', 'Bene Gesserit', 'beneGesserit'),
newPlayer('d', 'Harkonnen', 'harkonnen'));
Object.assign(game, { status: 'playing', phase: 5, turn: 2, storm: 18,
active: 'a', order: ['a', 'b', 'c', 'd'], movementRemaining: ['a', 'b', 'c', 'd'] });
for (const player of game.players) { player.forces = {}; player.ally = null; player.shipped = false; player.moved = 0; }
game.players[0].ally = 'b'; game.players[1].ally = 'a';
game.players[0].allySinceTurn = 2; game.players[1].allySinceTurn = 2;
const state = emptyCoalitionState('three-short');
const group = coalitionOf(game.players, state, 'a'); assert.ok(group);
const offer = beginCoalitionAdmission(game.players, state, { event: 'invite', nexus: 'nexus', turn: 2,
groupId: group.id, entrant: 'c', proposer: 'a', expectedRevision: 0 });
const approval = answerCoalitionAdmission(game.players, offer.state, { event: 'invite', nexus: 'nexus',
turn: 2, actor: 'b', accept: true, expectedRevision: offer.state.revision });
const joined = answerCoalitionAdmission(game.players, approval.state, { event: 'invite', nexus: 'nexus',
turn: 2, actor: 'c', accept: true, expectedRevision: approval.state.revision });
for (const player of game.players) if (Object.hasOwn(joined.allyUpdates, player.id)) player.ally = joined.allyUpdates[player.id];
game.coalitionState = joined.state;
return game;
}
const p = (game: Game, id: string) => game.players.find(player => player.id === id)!;
void test('Advanced group separation unions all allies immediately while exempting advisors and Polar Sink', () => {
const game = staged();
p(game, 'a').forces = { 'arrakeen:10': 1, 'carthag:11': 1, 'sietch_tabr:14': 1, 'polar_sink:0': 1 };
p(game, 'b').forces = { 'arrakeen:10': 1, 'carthag:11': 1, 'polar_sink:0': 1 };
p(game, 'c').forces = { 'arrakeen:10': 1, 'sietch_tabr:14': 1 };
p(game, 'c').advisors = { sietch_tabr: {} };
const before = structuredClone(game);
const losses = quoteCoalitionAlliedSeparation({ ...game, remaining: ['a', 'b', 'c', 'd'] }, 'a');
assert.deepEqual(new Set(losses), new Set(['arrakeen', 'carthag']));
assert.deepEqual(quoteCoalitionAlliedSeparation({ ...game, remaining: ['a', 'b', 'c', 'd'] }, 'c'), ['arrakeen']);
assert.deepEqual(game, before, 'A quote does not remove any counters.');
assert.equal(isAlliedShipmentVisit(game, p(game, 'a'), 'arrakeen'), true);
});
void test('Advanced endMovement actually removes only the ending group member’s shared fighters', () => {
const game = staged();
const owner = p(game, 'a'), emperor = p(game, 'b'), bg = p(game, 'c');
owner.forces = { 'arrakeen:10': 2, 'carthag:11': 3, 'sietch_tabr:14': 1, 'polar_sink:0': 1 };
owner.reserves = 13;
emperor.forces = { 'arrakeen:10': 1, 'polar_sink:0': 1 }; emperor.reserves = 18;
bg.forces = { 'carthag:11': 1, 'sietch_tabr:14': 1 }; bg.reserves = 18;
bg.advisors = { sietch_tabr: {} };
const otherForces = structuredClone(game.players.filter(seat => seat.id !== 'a').map(seat => ({
id: seat.id, forces: seat.forces, tanks: seat.tanks,
})));
const warning = viewGame(game, 'a').advancedAllySeparation;
assert.ok(warning);
assert.deepEqual(new Set(warning.territories), new Set(['Arrakeen', 'Carthag']));
const after = applyAction(game, 'a', { type: 'endMovement' });
assert.equal(p(after, 'a').tanks, 5);
assert.equal(p(after, 'a').reserves, 13);
assert.equal(p(after, 'a').forces['arrakeen:10'] ?? 0, 0);
assert.equal(p(after, 'a').forces['carthag:11'] ?? 0, 0);
assert.equal(p(after, 'a').forces['sietch_tabr:14'], 1);
assert.equal(p(after, 'a').forces['polar_sink:0'], 1);
assert.deepEqual(after.players.filter(seat => seat.id !== 'a').map(seat => ({
id: seat.id, forces: seat.forces, tanks: seat.tanks,
})), otherForces);
assert.equal(after.active, 'b');
assert.deepEqual(after.movementRemaining, ['b', 'c', 'd']);
});
void test('Advanced group board generates enemy battles without ever pairing allies', () => {
const game = staged();
p(game, 'a').forces = { 'imperial_basin:9': 1 };
p(game, 'b').forces = { 'imperial_basin:9': 1 };
p(game, 'd').forces = { 'imperial_basin:9': 1 };
const battles = quoteBattleBoard(game).battles.filter(battle => battle.territory === 'imperial_basin');
assert.equal(battles.length, 2);
assert.deepEqual(new Set(battles.map(battle => [battle.attacker, battle.defender].sort().join('/'))),
new Set(['a/d', 'b/d']));
});
void test('Advanced three-short group wins on three distinct controlled strongholds', () => {
const game = staged(); game.phase = 8;
p(game, 'a').forces = { 'arrakeen:10': 1 };
p(game, 'b').forces = { 'carthag:11': 1 };
p(game, 'c').forces = { 'sietch_tabr:14': 1 };
const row = strongholdProgress(game).progress.find(item => item.player === 'a')!;
assert.deepEqual(row.members, ['a', 'b', 'c']);
assert.equal(row.target, 3);
assert.equal(row.qualifies, true);
assert.deepEqual(quoteVictory(game).winner, ['a', 'b', 'c']);
});
void test('Advanced groups with Ecaz or another expansion faction stay outside these board quotes', () => {
for (const faction of ['ecaz', 'ixians'] as const) {
const game = staged(); p(game, 'd').faction = faction;
assert.throws(() => quoteBattleBoard(game));
assert.throws(() => quoteCoalitionAlliedSeparation({ ...game, remaining: game.order }, 'a'));
assert.throws(() => strongholdProgress(game));
assert.throws(() => isAlliedShipmentVisit(game, p(game, 'a'), 'arrakeen'));
}
});
@@ -0,0 +1,168 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck, spiceDeck } from '../game/cards';
import { emptyCoalitionState, type CoalitionEdge } from '../game/coalition-alliance';
import { coalitionBattleBeneficiary } from '../game/coalition-battle-preparation';
import { createCoalitionWormProtection, currentCoalitionWormAlly,
decideCoalitionWormProtection, resolveCoalitionWormProtection,
protectedCoalitionWormAllies } from '../game/coalition-worm-protection';
import { projectFremenGroupRevivalGrant, quoteFremenRevivalGrant } from '../game/fremen-revival-grant';
import { createRevivalOrder } from '../game/revival-order';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
/** The unexposed Advanced group and phase are deliberately staged: no lobby
* offers this mode. A real ordered-Revival frame, native response actions and
* conservation of physical forces are exercised after the controlled state. */
function source(): Game {
const game = createGame('ADVANCEDGROUPPOWERS', newPlayer('f', 'Fremen', 'fremen'), true);
game.players.push(newPlayer('a', 'Atreides', 'atreides'), newPlayer('h', 'Harkonnen', 'harkonnen'),
newPlayer('g', 'Guild', 'guild'));
Object.assign(game, {status:'playing', turn:2, phase:4, order:['f','a','h','g'],
active:'f', ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null});
const members = ['a','f','h'];
const edges: CoalitionEdge[] = [
{members:['a','f'], formedTurn:1}, {members:['a','h'], formedTurn:1},
{members:['f','h'], formedTurn:1},
];
game.coalitionState = {...emptyCoalitionState('unlimited'),
groups:[{id:'staged-group', members, formedTurn:1, edges}]};
for (const player of game.players) {
player.ally = null; player.forces = {}; player.reserves = 20; player.tanks = 0;
player.spice = 10; player.hand = []; player.traitors = []; player.traitorChoices = [];
}
for (const id of ['a','h']) { seat(game, id).reserves = 16; seat(game, id).tanks = 4; }
game.revivalOrder = createRevivalOrder({turn:game.turn, order:game.order, ready:game.ready});
// This is an actual physical card from the staged original deck, used to
// keep the normal cancellation response open until players choose to pass.
const karama = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(karama >= 0);
seat(game, 'g').hand.push(game.deck.splice(karama, 1)[0]);
return game;
}
function pass(game: Game): Game {
for (let count = 0; game.response && count < 20; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id));
assert.ok(actor);
game = act(game, actor.id, {type:'passResponse'});
}
assert.equal(game.response, null);
return game;
}
void test('Advanced allied Voice and Prescience select the actual combatant and reject allied opposing sides', () => {
const game = source();
assert.equal(coalitionBattleBeneficiary(game, 'a', 'f', 'g'), 'f');
assert.equal(coalitionBattleBeneficiary(game, 'a', 'g', 'h', true), 'h');
assert.equal(coalitionBattleBeneficiary(game, 'a', 'a', 'g'), 'a');
assert.equal(coalitionBattleBeneficiary(game, 'a', 'f', 'h'), null);
});
void test('Advanced Fremen resolves each threatened group ally in storm order', () => {
const game = source();
game.phase = 1;
for (const id of ['a','h']) { seat(game, id).forces = {'red_chasm:7':2}; seat(game, id).reserves -= 2; }
let frame = createCoalitionWormProtection(game, 'f', 'red_chasm', 'advanced-worm');
assert.ok(frame);
assert.deepEqual(frame.order, ['a','h']);
assert.equal(currentCoalitionWormAlly(game, frame), 'a');
frame = decideCoalitionWormProtection(game, frame, 'f', frame.event, 'a', true);
frame = resolveCoalitionWormProtection(game, frame, 'f', frame.event, 'a', false);
assert.equal(currentCoalitionWormAlly(game, frame), 'h');
frame = decideCoalitionWormProtection(game, frame, 'f', frame.event, 'h', true);
frame = resolveCoalitionWormProtection(game, frame, 'f', frame.event, 'h', true);
assert.deepEqual(protectedCoalitionWormAllies(game, frame), ['a']);
assert.deepEqual(frame.outcomes.map(row => row.outcome), ['allowed','canceled']);
});
void test('Advanced worm draw applies allowed and canceled group protections before native casualties', () => {
// Controlled supplement to the staged group source: move the four original
// forces from reserves into the same exposed sector, and position original
// Spice Cards as the prior territory and the next worm. All decisions,
// Karama consumption, resolution and resulting casualties are engine actions.
let game = source();
Object.assign(game, {phase:1, turn:3, nexus:false, ready:[], active:null,
response:null, decision:null, phaseOpening:null, stormPending:null});
delete game.revivalOrder;
for (const player of game.players) {
player.forces = {'red_chasm:7':3}; player.reserves = 17; player.tanks = 0;
}
const additional = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(additional >= 0);
seat(game, 'g').hand.push(game.deck.splice(additional, 1)[0]);
const cards = spiceDeck();
const land = cards.find(card => 'territory' in card && card.territory === 'red_chasm');
const worm = cards.find(card => 'worm' in card); assert.ok(land && worm);
game.spiceDiscard = [[land], []];
game.spiceDeck = [worm, ...cards.filter(card => card !== land && card !== worm && 'territory' in card),
...cards.filter(card => card !== worm && 'worm' in card)];
game.spice = {'red_chasm:7':8};
for (const player of game.players) game = act(game, player.id, {type:'ready'});
assert.equal(game.decision?.kind, 'wormProtection');
assert.deepEqual(game.coalitionWormProtection?.order, ['a','h']);
assert.equal(game.decision.ally, 'a');
game = act(game, 'f', {type:'decision', accept:true, event:game.decision.event, ally:'a'});
assert.equal(game.response?.kind, 'wormAllyProtection');
game = pass(game);
assert.equal(game.decision?.kind, 'wormProtection');
assert.equal(game.decision.ally, 'h');
game = act(game, 'f', {type:'decision', accept:true, event:game.decision.event, ally:'h'});
assert.equal(game.response?.kind, 'wormAllyProtection');
const counter = seat(game, 'g').hand.find(card => card.effect === 'karama'); assert.ok(counter);
game = act(game, 'g', {type:'card', mode:'cancel', card:counter.id});
assert.equal(game.response?.kind, 'wormSurvival');
assert.deepEqual(game.coalitionWormProtection?.outcomes,
[{ally:'a', outcome:'allowed'}, {ally:'h', outcome:'canceled'}]);
game = pass(game);
for (const id of ['f','a']) assert.equal(seat(game, id).forces['red_chasm:7'], 3);
for (const id of ['h','g']) {
assert.equal(seat(game, id).forces['red_chasm:7'] ?? 0, 0);
assert.equal(seat(game, id).tanks, 3);
}
assert.equal(game.spice['red_chasm:7'], undefined);
assert.equal(game.discard.filter(card => card.id === counter.id).length, 1);
assert.equal(game.coalitionWormProtection, undefined);
for (const player of game.players)
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20);
});
void test('Advanced Fremen grants each group ally independently while native Revival remains ordered', () => {
let game = source();
const beforeOrder = structuredClone(game.revivalOrder);
assert.deepEqual(projectFremenGroupRevivalGrant(game, 'f')!.map(row => row.recipient), ['a','h']);
for (const target of ['a','h']) {
assert.deepEqual(quoteFremenRevivalGrant(game, 'f', target), {turn:2, owner:'f', recipient:target});
const pending = act(game, 'f', {type:'grantRevival', target});
assert.equal(pending.response?.kind, 'fremenRevivalGrant');
assert.equal(pending.response?.recipient, target);
game = pass(pending);
assert.ok(game.freeRevival.includes(target));
assert.deepEqual(game.revivalOrder, beforeOrder);
}
game = act(game, 'f', {type:'ready'});
assert.equal(game.revivalOrder?.remaining[0], 'a');
for (const target of ['a','h']) {
assert.equal(game.revivalOrder?.remaining[0], target);
assert.equal(viewGame(game, target).revival.freeRemaining, 3);
const original = seat(game, target).spice;
game = pass(act(game, target, {type:'revive', amount:3, elite:0}));
assert.equal(seat(game, target).spice, original);
assert.equal(seat(game, target).tanks, 1);
assert.equal(seat(game, target).reserves, 19);
game = act(game, target, {type:'ready'});
}
});
void test('Advanced group faction extensions retain the existing expansion and module fences', () => {
const sourceGame = source();
for (const patch of [{expansions:['ix']}, {homeworlds:{}}, {nexusCards:{}}, {leaderSkills:{}},
{techTokens:{}}, {strongholdCards:{}}, {ecazTreachery:true}, {discoveryEnabled:true}]) {
const game = {...structuredClone(sourceGame), ...patch} as Game;
assert.equal(coalitionBattleBeneficiary(game, 'a', 'f', 'g'), null);
assert.equal(projectFremenGroupRevivalGrant(game, 'f'), null);
game.phase = 1;
seat(game, 'a').forces = {'red_chasm:7':1};
assert.equal(createCoalitionWormProtection(game, 'f', 'red_chasm', 'worm'), null);
}
});
+120
View File
@@ -0,0 +1,120 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { advanceTruthAlliance, createTruthAllianceFixture, truthAllianceClean } from './fixture-truth-alliance';
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
const remaining = (game: Game, donor: string, recipient: string) =>
game.coalitionEscrow!.pledges.find(row => row.donor === donor && row.recipient === recipient)?.amount ?? 0;
const allSpice = (game: Game) => game.players.reduce((sum, p) => sum + p.spice, 0) +
(game.coalitionEscrow?.pledges.reduce((sum, row) => sum + row.amount, 0) ?? 0);
/** Genuine Advanced preview and normal Nexus. Only an empty group ledger is
* added there; the base fixture selects unopened deal/lot/Spice positions and
* scalar lotteries. Pair, invitation, consent and later phases use native
* actions. Tests explicitly stage wallets or one conserved Tank force. */
function group() {
const source = createTruthAllianceFixture({advanced:true, consent:'none'});
let game = structuredClone(source.game);
game.coalitionState = emptyCoalitionState('three-short');
game = act(game, source.asker, {type:'alliance', target:source.opponent});
game = act(game, source.opponent, {type:'alliance', target:source.asker});
const pair = coalitionOf(game.players, game.coalitionState, source.asker); assert.ok(pair);
game = act(game, source.asker, {type:'coalitionPropose', groupId:pair.id, entrant:source.target,
expectedRevision:game.coalitionState!.revision});
for (const id of [source.opponent, source.target]) {
const offer = game.coalitionState!.proposal; assert.ok(offer);
game = act(game, id, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus, turn:offer.turn,
accept:true, expectedRevision:game.coalitionState!.revision});
}
assert.equal(coalitionOf(game.players, game.coalitionState, source.asker)!.members.length, 3);
assert.ok(game.coalitionEscrow);
return {game, source};
}
function stageWallets(game: Game, payer: string, donor1: string, donor2: string) {
seat(game, payer).spice = 2;
seat(game, donor1).spice = 10;
seat(game, donor2).spice = 10;
}
function pledge(game: Game, payer: string, donor1: string, donor2: string): Game {
game = act(game, donor1, {type:'coalitionPledge', recipient:payer, amount:4});
return act(game, donor2, {type:'coalitionPledge', recipient:payer, amount:3});
}
function responses(game: Game): Game {
for (let i = 0; game.response && i < 30; i++) {
const player = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(player);
game = act(game, player.id, {type:'passResponse'});
}
assert.equal(game.response, null);
return game;
}
void test('Advanced group bid settles both donors once and pays the Emperor', () => {
const {game: initial, source} = group();
const payer = source.asker, donor1 = source.opponent, donor2 = source.target;
let game = advanceTruthAlliance(initial, state => state.phase === 3 && !!state.auction && truthAllianceClean(state));
for (let count = 0; game.auction && game.auction.active !== payer && count < game.players.length; count++)
game = act(game, game.auction.active, {type:'passBid'});
assert.equal(game.auction?.active, payer);
stageWallets(game, payer, donor1, donor2);
const beforeSpice = allSpice(game), hand = seat(game, payer).hand.length;
game = pledge(game, payer, donor1, donor2);
assert.equal(viewGame(game, payer).coalitionFunding?.incoming.length, 2);
game = act(game, payer, {type:'bid', amount:6, allyPayment:0,
coalitionContributions:[{donor:donor1, amount:3}, {donor:donor2, amount:2}]});
for (let count = 0; !game.response && !game.decision && game.auction?.bidder === payer && count < 10; count++)
game = act(game, game.auction.active, {type:'passBid'});
if (game.decision?.kind === 'auctionPayment') game = act(game, payer, {type:'decision', karama:false});
if (game.response) {
assert.equal(game.response.kind, 'emperorIncome');
game = responses(game);
}
assert.ok(seat(game, payer).hand.length > hand);
assert.equal(seat(game, payer).spice, 1);
assert.equal(seat(game, donor1).spice, 6);
assert.equal(seat(game, donor2).spice, 13, 'Emperor receives the six-spice winning price.');
assert.equal(remaining(game, donor1, payer), 1);
assert.equal(remaining(game, donor2, payer), 1);
assert.equal(allSpice(game), beforeSpice);
});
void test('Advanced group shipment spends the actual donor pledges and bank invoice', () => {
const {game: initial, source} = group();
const payer = source.asker, donor1 = source.opponent, donor2 = source.target;
let game = advanceTruthAlliance(initial, state => state.phase === 5 && state.active === payer && truthAllianceClean(state));
stageWallets(game, payer, donor1, donor2);
// Select a safe sector without moving any force or changing its inventory.
game.storm = 18;
const beforeSpice = allSpice(game), reserves = seat(game, payer).reserves;
game = pledge(game, payer, donor1, donor2);
game = act(game, payer, {type:'ship', territory:'imperial_basin', sector:9, amount:3, elite:0, allyPayment:0,
coalitionContributions:[{donor:donor1, amount:3}, {donor:donor2, amount:2}]});
game = responses(game);
assert.equal(seat(game, payer).forces['imperial_basin:9'], 3);
assert.equal(seat(game, payer).reserves, reserves - 3);
assert.equal(seat(game, payer).spice, 1);
assert.equal(remaining(game, donor1, payer), 1);
assert.equal(remaining(game, donor2, payer), 1);
assert.equal(allSpice(game), beforeSpice - 6, 'There is no Guild seat; the invoice goes to the bank.');
});
void test('Advanced Emperor pays a group ally for one extra revival on that ally’s actual Revival turn', () => {
const {game: initial, source} = group();
let game = advanceTruthAlliance(initial, state => state.phase === 4 && truthAllianceClean(state) &&
!!state.revivalOrder?.remaining[0] && state.revivalOrder.remaining[0] !== source.target);
const target = game.revivalOrder!.remaining[0], recipient = seat(game, target), emperor = seat(game, source.target);
// Move one original ordinary reserve to Tanks and supply a test-bank wallet.
assert.ok(recipient.reserves > 0);
recipient.reserves--; recipient.tanks++;
emperor.spice = 10;
const reserves = recipient.reserves, tanks = recipient.tanks;
game = act(game, source.target, {type:'emperorRevival', target, amount:1, elite:0});
if (game.response) assert.equal(game.response.kind, 'emperorRevival');
game = responses(game);
assert.equal(seat(game, source.target).spice, 8);
assert.equal(seat(game, target).reserves, reserves + 1);
assert.equal(seat(game, target).tanks, tanks - 1);
assert.equal(game.emperorExtra[target], 1);
});
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { CoalitionNexus, coalitionNexusOptions } from '../components/coalition-nexus';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { applyAction, viewGame, type Game } from '../game/engine';
import { createTruthAllianceFixture } from './fixture-truth-alliance';
/** Public Advanced Harkonnen/Atreides/Emperor setup, genuine first turn and
* ordinary worm Nexus. The fixture selects only unopened original deals,
* auction and Spice positions and scalar lotteries. The optional group's
* empty unexposed ledger is inserted at the real Nexus, before any offer. */
function pair(variant: 'three-short' | 'unlimited') {
const source = createTruthAllianceFixture({advanced:true, consent:'none'});
let game = structuredClone(source.game);
assert.equal(game.advanced, true);
assert.equal(game.nexus, true);
assert.ok(game.players.every(player => !player.ally));
game.coalitionState = emptyCoalitionState(variant);
game = applyAction(game, source.asker, {type:'alliance', target:source.opponent});
game = applyAction(game, source.opponent, {type:'alliance', target:source.asker});
return {game, source};
}
function answer(game: Game, player: string): Game {
const proposal = game.coalitionState!.proposal; assert.ok(proposal);
return applyAction(game, player, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus,
turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision});
}
for (const variant of ['three-short', 'unlimited'] as const) {
void test(`Advanced ${variant} genuine Nexus forms a three-player group with legal bot and human consent`, () => {
const {game, source} = pair(variant);
const group = coalitionOf(game.players, game.coalitionState, source.asker); assert.ok(group);
assert.deepEqual(new Set(group.members), new Set([source.asker, source.opponent]));
const controls = viewGame(game, source.target).coalitionNexus; assert.ok(controls);
assert.equal(controls.variant, variant);
assert.deepEqual(coalitionNexusOptions(controls).invitations.map(row => row.entrant), [source.target]);
const html = renderToStaticMarkup(createElement(CoalitionNexus, {view:controls, onIntent(){}}));
assert.match(html, /Ask to join/);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, source.target);
view.players.find(seat => seat.id === source.target)!.bot = difficulty;
const propose = botActions(view).find(action => action.type === 'coalitionPropose');
assert.ok(propose, difficulty);
let pending = applyAction(game, source.target, propose);
assert.deepEqual(pending.coalitionState!.proposal!.approvals, [source.target]);
const projected = viewGame(pending, source.opponent).coalitionNexus; assert.ok(projected);
assert.equal(projected.proposal!.entrant, source.target);
assert.equal(coalitionNexusOptions(projected).mayAccept, true);
assert.match(renderToStaticMarkup(createElement(CoalitionNexus, {view:projected, onIntent(){}})), /Accept invitation/);
pending = answer(pending, source.asker);
assert.deepEqual(new Set(coalitionOf(pending.players, pending.coalitionState, source.asker)!.members),
new Set([source.asker, source.opponent]));
const approvalView = viewGame(pending, source.opponent);
approvalView.players.find(seat => seat.id === source.opponent)!.bot = difficulty;
const approval = botActions(approvalView).find(action => action.type === 'coalitionAnswer');
assert.ok(approval, difficulty);
assert.equal(approval.accept, true);
pending = applyAction(pending, source.opponent, approval);
assert.equal(pending.coalitionState!.proposal, null);
assert.deepEqual(new Set(coalitionOf(pending.players, pending.coalitionState, source.asker)!.members),
new Set([source.asker, source.opponent, source.target]));
assert.equal(viewGame(pending, source.target).coalitionNexus!.variant, variant);
}
});
}
+2 -2
View File
@@ -91,7 +91,7 @@ void test('shipment eligibility keeps turn and destination guards and rejects in
assert.equal(isAlliedShipmentVisit(context, p, 'red_chasm'), false);
assert.throws(() => isAlliedShipmentVisit({ ...context, coalitionState: null as unknown as CoalitionState }, p, 'red_chasm'), /ledger cannot be null/);
p.moved = 0;
assert.throws(() => isAlliedShipmentVisit({ ...context, advanced: true }, p, 'red_chasm'), /Basic play without Ecaz/);
assert.equal(isAlliedShipmentVisit({ ...context, advanced: true }, p, 'red_chasm'), true);
g.players[3].faction = 'ecaz';
assert.throws(() => isAlliedShipmentVisit(context, p, 'red_chasm'), /Basic play without Ecaz/);
assert.throws(() => isAlliedShipmentVisit(context, p, 'red_chasm'), /supported roster without Ecaz/);
});
@@ -71,7 +71,7 @@ void test('each group ally supplies the beneficiary for the corresponding native
void test('unsupported group profiles and ambiguous battles cannot borrow an ally power', () => {
const source = groupGame();
for (const patch of [{advanced:true}, {expansions:['ix']}, {homeworlds:{}},
for (const patch of [{expansions:['ix']}, {homeworlds:{}},
{nexusCards:{}}, {leaderSkills:{}}, {techTokens:{}}, {strongholdCards:{}},
{ecazTreachery:true}, {discoveryEnabled:true}]) {
const unsupported = {...source, ...patch};
+1 -1
View File
@@ -77,7 +77,7 @@ void test('legacy pair timing remains unchanged and unsupported coalition combin
assert.equal(territoryEntryBlock(g.players, 'a', 'red_chasm'),
'You cannot enter a territory occupied by your ally.');
g.players[0].ally = null; g.players[1].ally = null;
assert.throws(() => quoteBattleBoard({ ...g, advanced: true, coalitionState }), /Basic play without Ecaz/);
assert.doesNotThrow(() => quoteBattleBoard({ ...g, advanced: true, coalitionState }));
g.players[2] = newPlayer('c', 'Ecaz', 'ecaz');
assert.throws(() => territoryEntryBlock(g.players, 'a', 'red_chasm', false, false, coalitionState), /do not yet support Ecaz/);
});
+4 -4
View File
@@ -197,12 +197,12 @@ void test('a two-player alliance retains the original implicit Emperor recipient
conserved(game);
});
void test('the staged three-member Emperor benefit is unavailable in Advanced play', () => {
void test('the staged Advanced Emperor benefit identifies both group allies', () => {
const {game} = fixture();
game.advanced = true;
assert.equal(viewGame(game, 'e').coalitionEmperor, null);
reject(game, 'e', {type:'emperorGift', target:'a', amount:1});
reject(game, 'e', {type:'emperorRevival', target:'h', amount:1, elite:0});
assert.deepEqual(viewGame(game, 'e').coalitionEmperor?.allies, ['a', 'h']);
const gifted = passResponses(send(game, 'e', {type:'emperorGift', target:'a', amount:1}));
assert.equal(player(gifted, 'a').spice, 11);
});
void test('group Emperor aid projects and respects spice committed to its own leading bid', () => {
+2 -3
View File
@@ -93,12 +93,11 @@ void test('group grant rejects missing, non-allied and self targets as well as w
reject(game, 'f', {type:'grantRevival', target:'a'});
});
void test('group grant rejects unsupported Advanced play and cannot retarget a pending source', () => {
void test('group grant projects Advanced recipients and cannot retarget a pending source', () => {
const {game} = fixture();
const advanced = structuredClone(game);
advanced.advanced = true;
assert.equal(viewGame(advanced, 'f').fremenGroupRevivalGrant, null);
reject(advanced, 'f', {type:'grantRevival', target:'a'});
assert.ok(viewGame(advanced, 'f').fremenGroupRevivalGrant?.some(row => row.recipient === 'a'));
const pending = send(game, 'f', {type:'grantRevival', target:'a'});
pending.response!.recipient = 'h';
reject(pending, 'g', {type:'passResponse'});
+3 -3
View File
@@ -67,7 +67,7 @@ void test('Fremen can independently select each of five allied revival recipient
}
assert.throws(() => quoteFremenRevivalGrant(game, 'f', 'f'));
game.advanced = true;
assert.equal(projectFremenGroupRevivalGrant(game, 'f'), null);
assert.equal(projectFremenGroupRevivalGrant(game, 'f')?.length, 5);
});
void test('group battle preparation selects any single member and rejects two allied sides', () => {
@@ -77,6 +77,6 @@ void test('group battle preparation selects any single member and rejects two al
assert.equal(coalitionBattleBeneficiary(game, 'a', ally, 'g'), ally);
assert.equal(coalitionBattleBeneficiary(game, 'a', 'e', 'b'), null);
assert.equal(coalitionBattleBeneficiary(game, 'a', 'g', 'h'), null);
const unsupported = {...game, advanced:true};
assert.equal(coalitionBattleBeneficiary(unsupported, 'a', 'e', 'g'), null);
const advanced = {...game, advanced:true};
assert.equal(coalitionBattleBeneficiary(advanced, 'a', 'e', 'g'), 'e');
});
+4 -4
View File
@@ -95,11 +95,11 @@ void test('Fremen, Guild and no-Guild Fremen final branches award the full allia
assert.deepEqual(quoteVictory(noGuild).winner, ['p0', 'p1', 'p2']);
});
void test('unsupported triples and malformed coalition records fail closed across quotes', () => {
void test('Advanced classic triples quote normally while unsupported rosters and malformed records fail closed', () => {
const g = game(); g.advanced = true;
assert.throws(() => strongholdProgress(g), VictoryProgressError);
assert.throws(() => fremenSpecialVictory(g), VictoryProgressError);
assert.throws(() => quoteVictory(g), VictoryQuoteError);
assert.doesNotThrow(() => strongholdProgress(g));
assert.doesNotThrow(() => fremenSpecialVictory(g));
assert.doesNotThrow(() => quoteVictory(g));
g.advanced = false; g.players[3].faction = 'ecaz';
assert.throws(() => strongholdProgress(g), VictoryProgressError);
assert.throws(() => quoteVictory(g), VictoryQuoteError);