Connect staged Advanced classic group alliances
Verify and publish checkpoint container / container (push) Successful in 10m53s
Verify and publish checkpoint container / container (push) Successful in 10m53s
This commit is contained in:
1 parent
ef92ddf2b9
commit
3ba9087405
22 files changed
+561
-44
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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'] },
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user