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

+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);