Restore native Ecaz simulation continuation and current rule fixtures
Match original Ecaz sample snapshots to their native Occupy and Advanced assassination state. Preserve older absent-power samples and resume the original scenario instead of rejecting fresh saved output. Update stale fixture reactions and module expectations while retaining meaningful Banker guidance and positive legal commitments. Correct the unsupported rules-completion claim. Validation: typecheck/lint, 173 affected tests across 16 files, bounded independent source review, and one direct Ecaz continuation completed through 178 accepted actions. Runtime fingerprint 95d009d3957650b08bb24826b70aa5f00a74cdd9992b6a6c222f0665d3e3759c; documentation-only proof update a91ab248109bee74a74373fb544027a2e7cc8f178e26d2c9dad475a9618eb64d. Full rules, comprehensive assurance and deployed acceptance remain open. No saved-room or provider changes.
This commit is contained in:
1 parent
716304bb9e
commit
178251ad35
24 files changed
+389
-128
No files matched your search
+19
-11
@@ -29,17 +29,25 @@ optional skill bands and combinations remain open.
|
|||||||
Rules implementation continues; comprehensive assurance and administration
|
Rules implementation continues; comprehensive assurance and administration
|
||||||
remain deferred until all rules are implemented.
|
remain deferred until all rules are implemented.
|
||||||
|
|
||||||
**Non-AI checklist closure — 18 October.** A full `game/reference.ts`
|
**Integration review — 18 October.** The earlier non-AI checklist closure
|
||||||
scan confirms no topic carries a Planned/Missing/Not-started development
|
was unsupported: a topic marked Prototyped/Partial does not prove every
|
||||||
stage and no Partial detail names an unimplemented non-AI feature. The only
|
rules-permitted combination works. Independent36 World/Nexus and other named
|
||||||
remaining Planned area is table-discussion AI negotiation, explicitly deferred
|
interaction boundaries remain open. Rules delivery is not complete, and the
|
||||||
until after non-AI completion per the AI plan. All fourteen skill bands have
|
deferred comprehensive multiplayer/privacy/custody gate has not been opened.
|
||||||
connected controls; the combined-expansion envelopes (classic core + base33/
|
|
||||||
independent36 + Nexus12 + optional14 + Tech3+ + Strongholds2+ + base33 Worlds)
|
The regression repair now admits original Ecaz CLI snapshots with their actual
|
||||||
were progressively composed across this session's checkpoints. Remaining
|
native Occupy/Advanced assassination state. Resume preserves the saved scenario
|
||||||
Partial areas describe bounded contracts awaiting full-mode acceptance, not
|
and continues play; older snapshots with both powers absent retain that absence.
|
||||||
absent functionality. Rules-first prototype delivery is complete; interaction
|
The focused Ecaz cases pass, including complete expansion-roster continuation.
|
||||||
refinement, comprehensive review and deferred assurance follow.
|
Independent bounded review found no rule-contract blocker in the other repaired
|
||||||
|
fixtures. Banker controls retain their rules guidance, and all four legal policies
|
||||||
|
must actually fund a positive commitment in both original and combined-deck play.
|
||||||
|
Original city Collection and the selected deck's phase-opening window remain
|
||||||
|
separate from the battle payment. Frozen16-file **173/173**, types/lint and a direct original Ecaz CLI
|
||||||
|
resume-to-completion smoke (178 accepted actions) pass. Bounded source review
|
||||||
|
found no remaining blocker; no worker remains assigned to these files. The prior
|
||||||
|
recovery902/902 and HTTP55/55 are bounded historical evidence, not complete rules,
|
||||||
|
multiplayer or deployed acceptance. Comprehensive assurance remains deferred.
|
||||||
|
|
||||||
**Original World Nexus core — 18 October. Prototyped / Partial.**
|
**Original World Nexus core — 18 October. Prototyped / Partial.**
|
||||||
Plain base33 World/Nexus lacked native reaction states; one public capability
|
Plain base33 World/Nexus lacked native reaction states; one public capability
|
||||||
|
|||||||
@@ -172,7 +172,13 @@ fixed four-seat samples. Their seed offsets are 68–77, 78–87 and 88–97.
|
|||||||
independent three-card Treachery variant instead of Nexus Cards. Basic and
|
independent three-card Treachery variant instead of Nexus Cards. Basic and
|
||||||
Advanced seed offsets are 136–145; default/all keeps its original six games.
|
Advanced seed offsets are 136–145; default/all keeps its original six games.
|
||||||
The runner counts all three physical cards and resume requires the variant
|
The runner counts all three physical cards and resume requires the variant
|
||||||
marker and exact roster. This is still a gated development profile.
|
marker and exact roster. Original `ecaz-treachery` and `ecaz-roster` snapshots
|
||||||
|
retain their native Ecaz Occupy and, in Advanced, Moritani assassination state
|
||||||
|
under their own scenario names. Older snapshots with both native powers absent
|
||||||
|
remain resumable without adding them or inventing ability history; partial native
|
||||||
|
state and unrelated module overlays are not admitted. Resume continues the saved
|
||||||
|
turn, phase and custody rather than running setup again. These are still gated
|
||||||
|
development profiles.
|
||||||
`moritani-assassinate` runs only Advanced with Moritani followed by Atreides,
|
`moritani-assassinate` runs only Advanced with Moritani followed by Atreides,
|
||||||
Emperor, Fremen, Guild and Bene Gesserit as seats are added. Harkonnen and
|
Emperor, Fremen, Guild and Bene Gesserit as seats are added. Harkonnen and
|
||||||
other optional modules remain outside this explicit development preview.
|
other optional modules remain outside this explicit development preview.
|
||||||
|
|||||||
@@ -260,7 +260,10 @@ void test('paid auction income cancellation survives a real Harkonnen exchange a
|
|||||||
auction = structuredClone(g.auction),
|
auction = structuredClone(g.auction),
|
||||||
ids = inventory(g);
|
ids = inventory(g);
|
||||||
g = beginCancel(g, f);
|
g = beginCancel(g, f);
|
||||||
const stamped = structuredClone(g.pendingKarama);
|
// The paid auction income response now stamps optional Bureaucrat fields
|
||||||
|
// whose absent values are JSON-invisible; the Box suspension serializes, so
|
||||||
|
// compare the pending acknowledgement through the same serialization.
|
||||||
|
const stamped = JSON.parse(JSON.stringify(g.pendingKarama));
|
||||||
g = action(g, 'h', {
|
g = action(g, 'h', {
|
||||||
type: 'card',
|
type: 'card',
|
||||||
mode: 'special',
|
mode: 'special',
|
||||||
@@ -270,7 +273,7 @@ void test('paid auction income cancellation survives a real Harkonnen exchange a
|
|||||||
});
|
});
|
||||||
assert.equal(g.decision?.kind, 'handExchange');
|
assert.equal(g.decision?.kind, 'handExchange');
|
||||||
assert.equal(g.pendingExchange?.response?.kind, 'worthlessKarama');
|
assert.equal(g.pendingExchange?.response?.kind, 'worthlessKarama');
|
||||||
assert.deepEqual(g.pendingKarama, stamped);
|
assert.deepEqual(JSON.parse(JSON.stringify(g.pendingKarama)), stamped);
|
||||||
const sampled = structuredClone(g.players.map((p) => p.hand));
|
const sampled = structuredClone(g.players.map((p) => p.hand));
|
||||||
g = finishBox(paidBox(g, f));
|
g = finishBox(paidBox(g, f));
|
||||||
assert.deepEqual(g.pendingKarama, stamped);
|
assert.deepEqual(g.pendingKarama, stamped);
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { botArrivalBlock } from '../game/bot-arrival';
|
|
||||||
import { botActions } from '../game/bots';
|
|
||||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||||
import { createHomeworldCustody } from '../game/homeworld-custody';
|
import { botActions } from '../game/bots';
|
||||||
import { homeworldContext } from '../game/homeworld-game';
|
import { botArrivalBlock } from '../game/bot-arrival';
|
||||||
import { createTerrorState, placeTerror } from '../game/moritani-terror';
|
import { createTerrorState, placeTerror } from '../game/moritani-terror';
|
||||||
import {
|
import {
|
||||||
arrivalMove,
|
arrivalMove,
|
||||||
@@ -49,8 +47,9 @@ void test('all four profiles avoid unsupported paired Ambassador and Terror entr
|
|||||||
});
|
});
|
||||||
|
|
||||||
void test('Homeworld-sourced AI shipments avoid unsupported paired entry reactions', () => {
|
void test('Homeworld-sourced AI shipments avoid unsupported paired entry reactions', () => {
|
||||||
const game = movementArrivalGame();
|
// Homeworlds now compose with this faction roster through the fresh
|
||||||
game.homeworlds = { custody: createHomeworldCustody(homeworldContext(game)) };
|
// occupation audit, so the custody comes from real setup instead of a graft.
|
||||||
|
const game = movementArrivalGame('tleilaxu', 'both', true);
|
||||||
arrivalPlayer(game, 'tleilaxu').shipped = false;
|
arrivalPlayer(game, 'tleilaxu').shipped = false;
|
||||||
const action: Action = {
|
const action: Action = {
|
||||||
type: 'ship',
|
type: 'ship',
|
||||||
|
|||||||
@@ -109,10 +109,14 @@ void test('Bureaucrat eligibility stays recipient-specific and a saved payment o
|
|||||||
});
|
});
|
||||||
assert.equal(options(settled, 'e').incoming, 3);
|
assert.equal(options(settled, 'e').incoming, 3);
|
||||||
assert.equal(options(settled, 'p').available, 25);
|
assert.equal(options(settled, 'p').available, 25);
|
||||||
const unsupported = structuredClone(g);
|
// The intentional composition change admits the selected ix deck alongside
|
||||||
unsupported.expansions.push('ix');
|
// Skills, so the same payment stays available and offers its decision there.
|
||||||
assert.throws(() => options(unsupported, 'p'));
|
const combined = structuredClone(g);
|
||||||
reject(unsupported, 'p', { type: 'bribe', target: 'e', amount: 5 });
|
combined.expansions.push('ix');
|
||||||
|
const combinedQuote = options(combined, 'p');
|
||||||
|
assert.equal(combinedQuote.blocked, null);
|
||||||
|
const combinedPaid = applyAction(combined, 'p', bribeAction(combinedQuote, 'e', 5)!);
|
||||||
|
assert.equal(combinedPaid.decision?.kind, 'bureaucratPayment');
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('bribe controls render recipient choices, private escrow and disabled unselected or busy payment', () => {
|
void test('bribe controls render recipient choices, private escrow and disabled unselected or busy payment', () => {
|
||||||
|
|||||||
@@ -2,11 +2,79 @@ import assert from 'node:assert/strict';
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { viewGame } from '../game/engine';
|
import { viewGame } from '../game/engine';
|
||||||
import { createIndependentHarkonnenCall, createIndependentIxianPurchase,
|
import { createIndependentHarkonnenCall, createIndependentIxianPurchase,
|
||||||
reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions';
|
drawClassicNexusReaction, reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions';
|
||||||
import { advanceClassicIndependentNexus } from './fixture-classic-independent-nexus';
|
import { advanceClassicIndependentNexus } from './fixture-classic-independent-nexus';
|
||||||
import { classicDiscoveryNexusClean } from './fixture-discovery-classic-nexus';
|
import { classicDiscoveryNexusClean } from './fixture-discovery-classic-nexus';
|
||||||
import { createClassicNexusGuildPayment, createClassicNexusCunning,
|
import { createClassicNexusGuildPayment, createClassicNexusCunning,
|
||||||
submitClassicNexusSecondShipment } from './fixture-classic-nexus-core';
|
submitClassicNexusSecondShipment } from './fixture-classic-nexus-core';
|
||||||
|
import { TERRITORIES } from '../game/board';
|
||||||
|
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||||
|
|
||||||
|
function smugglerFeeGame(homeworlds: boolean) {
|
||||||
|
const fixture = drawClassicNexusReaction('harkonnen', 'atreides', {
|
||||||
|
advanced: true, skills: true, independent: false, homeworlds,
|
||||||
|
roster: ['atreides', 'guild', 'harkonnen', 'emperor'],
|
||||||
|
training: { atreides: 'smuggler', guild: 'mentat', harkonnen: 'bureaucrat', emperor: 'sandmaster' },
|
||||||
|
});
|
||||||
|
let game = fixture.game;
|
||||||
|
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('guild');
|
||||||
|
assert.ok(index >= 0);
|
||||||
|
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
||||||
|
game = reactionAction(game, 'emperor', { type: 'nexusCardChoice', turn: game.turn,
|
||||||
|
card: cards.hands.emperor, choice: 'draw', ownRedraws: 0 });
|
||||||
|
game = advanceClassicIndependentNexus(game, state => state.phase === 5 &&
|
||||||
|
state.active === 'atreides' && classicDiscoveryNexusClean(state));
|
||||||
|
const destination = TERRITORIES.find(place => place.type === 'stronghold' &&
|
||||||
|
!place.sectors.includes(game.storm) && game.players.every(player =>
|
||||||
|
Object.keys(player.forces).every(key => !key.startsWith(`${place.id}:`))));
|
||||||
|
assert.ok(destination);
|
||||||
|
const before = structuredClone(game), key = `${destination.id}:${destination.sectors[0]}`;
|
||||||
|
const sources = homeworlds ? nativeShipmentSources(viewGame(game, 'atreides'), 2, 0) : undefined;
|
||||||
|
if (homeworlds) assert.ok(sources);
|
||||||
|
game = reactionAction(game, 'atreides', { type: 'ship', territory: destination.id,
|
||||||
|
sector: destination.sectors[0], amount: 2, elite: 0, allyPayment: 0, smuggler: true,
|
||||||
|
...(sources ? { homeworldSources: sources } : {}) });
|
||||||
|
game = advanceClassicIndependentNexus(game, state => !!state.pendingGuildBetrayal &&
|
||||||
|
classicDiscoveryNexusClean(state));
|
||||||
|
return { game, before, key };
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('ordinary Smuggler ships two physical counters for one spice through the distinct Guild holder claim', () => {
|
||||||
|
const f = smugglerFeeGame(false), before = reactionPlayer(f.before, 'atreides');
|
||||||
|
assert.equal(f.game.pendingGuildBetrayal!.invoice.price, 1);
|
||||||
|
assert.equal(reactionPlayer(f.game, 'atreides').reserves, before.reserves);
|
||||||
|
assert.equal(reactionPlayer(f.game, 'atreides').forces[f.key] ?? 0, 0);
|
||||||
|
const game = reactionAction(f.game, 'emperor', { type: 'guildBetrayalUse',
|
||||||
|
event: f.game.pendingGuildBetrayal!.invoice.event });
|
||||||
|
assert.equal(reactionPlayer(game, 'atreides').spice, before.spice - 1);
|
||||||
|
assert.equal(reactionPlayer(game, 'atreides').reserves, before.reserves - 2);
|
||||||
|
assert.equal(reactionPlayer(game, 'atreides').forces[f.key], 2);
|
||||||
|
assert.equal(reactionPlayer(game, 'emperor').spice, reactionPlayer(f.before, 'emperor').spice + 1);
|
||||||
|
assert.equal(reactionPlayer(game, 'guild').spice, reactionPlayer(f.before, 'guild').spice);
|
||||||
|
assert.deepEqual(game.nexusCards!.cards!.discard, ['guild']);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Homeworld Smuggler withdraws both Caladan counters only after Pass and pays the original discounted Guild invoice', () => {
|
||||||
|
const f = smugglerFeeGame(true), before = reactionPlayer(f.before, 'atreides');
|
||||||
|
const caladan = (game: typeof f.game) => viewGame(game, 'atreides').homeworlds!.worlds!
|
||||||
|
.find(world => world.id === 'homeworld:atreides')!.forces.atreides;
|
||||||
|
assert.deepEqual(caladan(f.game), { normal: 10, elite: 0 });
|
||||||
|
assert.equal(f.game.pendingGuildBetrayal!.invoice.price, 1);
|
||||||
|
let game = reactionAction(f.game, 'emperor', { type: 'guildBetrayalPass',
|
||||||
|
event: f.game.pendingGuildBetrayal!.invoice.event });
|
||||||
|
assert.deepEqual(caladan(game), { normal: 10, elite: 0 });
|
||||||
|
assert.equal(reactionPlayer(game, 'atreides').forces[f.key] ?? 0, 0);
|
||||||
|
game = reactionAction(game, 'atreides', { type: 'guildBetrayalPass',
|
||||||
|
event: game.pendingGuildBetrayal!.invoice.event });
|
||||||
|
assert.deepEqual(caladan(game), { normal: 8, elite: 0 });
|
||||||
|
assert.equal(reactionPlayer(game, 'atreides').reserves, before.reserves - 2);
|
||||||
|
assert.equal(reactionPlayer(game, 'atreides').forces[f.key], 2);
|
||||||
|
assert.equal(reactionPlayer(game, 'atreides').spice, before.spice - 1);
|
||||||
|
assert.equal(reactionPlayer(game, 'guild').spice, reactionPlayer(f.before, 'guild').spice + 1);
|
||||||
|
assert.equal(reactionPlayer(game, 'emperor').spice, reactionPlayer(f.before, 'emperor').spice);
|
||||||
|
assert.equal(game.nexusCards!.cards!.hands.emperor, 'guild');
|
||||||
|
assert.deepEqual(game.nexusCards!.cards!.discard, []);
|
||||||
|
});
|
||||||
|
|
||||||
void test('ordinary base33 all14 supports the native declared-Traitor cancellation and real Mentat draw', () => {
|
void test('ordinary base33 all14 supports the native declared-Traitor cancellation and real Mentat draw', () => {
|
||||||
const f = createIndependentHarkonnenCall({ independent: false, advanced: true, skills: true });
|
const f = createIndependentHarkonnenCall({ independent: false, advanced: true, skills: true });
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
applyAction,
|
applyAction,
|
||||||
createGame,
|
createGame,
|
||||||
initializeFactionExpansionsGameForAudit,
|
initializeFactionExpansionsGameForAudit,
|
||||||
|
initializeHomeworldOccupationGameForAudit,
|
||||||
joinGame,
|
joinGame,
|
||||||
newPlayer,
|
newPlayer,
|
||||||
viewGame,
|
viewGame,
|
||||||
@@ -69,10 +70,12 @@ export function holdArrivalCard(g: Game, id: string, name: string) {
|
|||||||
arrivalPlayer(g, id).hand.push(card);
|
arrivalPlayer(g, id).hand.push(card);
|
||||||
return card.id;
|
return card.id;
|
||||||
}
|
}
|
||||||
/** Real expansion setup inventories, then the saved failure's focused movement topology. */
|
/** Real expansion setup inventories, then the saved failure's focused movement topology.
|
||||||
|
* Homeworlds compose with this roster through the fresh occupation audit. */
|
||||||
export function movementArrivalGame(
|
export function movementArrivalGame(
|
||||||
mover: FactionId = 'tleilaxu',
|
mover: FactionId = 'tleilaxu',
|
||||||
tokens: 'both' | 'ambassador' | 'terror' | 'none' = 'both',
|
tokens: 'both' | 'ambassador' | 'terror' | 'none' = 'both',
|
||||||
|
homeworlds = false,
|
||||||
) {
|
) {
|
||||||
let g = createGame('DEFERRED', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, [
|
let g = createGame('DEFERRED', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, [
|
||||||
'ix',
|
'ix',
|
||||||
@@ -82,8 +85,11 @@ export function movementArrivalGame(
|
|||||||
for (const faction of [mover, 'choam', 'moritani', 'richese'] as FactionId[])
|
for (const faction of [mover, 'choam', 'moritani', 'richese'] as FactionId[])
|
||||||
joinGame(g, newPlayer(faction, faction, faction));
|
joinGame(g, newPlayer(faction, faction, faction));
|
||||||
if (mover !== 'ixians') joinGame(g, newPlayer('ixians', 'Ixians', 'ixians'));
|
if (mover !== 'ixians') joinGame(g, newPlayer('ixians', 'Ixians', 'ixians'));
|
||||||
|
if (homeworlds) g.homeworlds = { custody: null };
|
||||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||||
g = initializeFactionExpansionsGameForAudit(g);
|
g = homeworlds
|
||||||
|
? initializeHomeworldOccupationGameForAudit(g)
|
||||||
|
: initializeFactionExpansionsGameForAudit(g);
|
||||||
for (let step = 0; g.status === 'setup' && step < 30; step++) {
|
for (let step = 0; g.status === 'setup' && step < 30; step++) {
|
||||||
let next: Game | undefined;
|
let next: Game | undefined;
|
||||||
for (const p of g.players) {
|
for (const p of g.players) {
|
||||||
|
|||||||
@@ -261,12 +261,18 @@ void test('Advanced Moritani after-loss assassination kills the actual unused tr
|
|||||||
const reserve = [...pending.traitorReserve!], held = [...player(pending, f.owner).traitors];
|
const reserve = [...pending.traitorReserve!], held = [...player(pending, f.owner).traitors];
|
||||||
const quote = viewGame(pending, f.owner).moritaniAssassinate!.pending!.cards.find(c => c.card === target.id)!;
|
const quote = viewGame(pending, f.owner).moritaniAssassinate!.pending!.cards.find(c => c.card === target.id)!;
|
||||||
assert.equal(quote.bounty, 3);
|
assert.equal(quote.bounty, 3);
|
||||||
const killed = step(pending, { actor: f.owner, action });
|
let killed = step(pending, { actor: f.owner, action });
|
||||||
assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.dead, true);
|
assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.dead, true);
|
||||||
assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.deaths, target.deaths + 1);
|
assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.deaths, target.deaths + 1);
|
||||||
assert.equal(player(killed, f.owner).spice, player(pending, f.owner).spice + 3);
|
assert.equal(player(killed, f.owner).spice, player(pending, f.owner).spice + 3);
|
||||||
assert.equal(killed.leaderSkills!.assignments.some(a => a.leader === target.id), false);
|
assert.equal(killed.leaderSkills!.assignments.some(a => a.leader === target.id), false);
|
||||||
assert.equal(killed.leaderSkills!.deck.filter(s => s === originalSkill).length, 1);
|
assert.equal(killed.leaderSkills!.deck.filter(s => s === originalSkill).length, 1);
|
||||||
|
// The originally dealt hand may hold a Karama, so the after-loss window legally
|
||||||
|
// offers the native special denial before the winner cleanup; decline it through
|
||||||
|
// the real current decision exactly like the printed opportunity.
|
||||||
|
if (killed.decision?.kind === 'moritaniSpecialKarama')
|
||||||
|
killed = step(killed, { actor: killed.decision.player,
|
||||||
|
action: { type: 'decision', event: killed.decision.event, decline: true } });
|
||||||
assert.equal(killed.decision?.kind, 'battleCards'); assert.equal(killed.decision!.player, f.opponent);
|
assert.equal(killed.decision?.kind, 'battleCards'); assert.equal(killed.decision!.player, f.opponent);
|
||||||
assert.deepEqual(killed.techTokens, pending.techTokens);
|
assert.deepEqual(killed.techTokens, pending.techTokens);
|
||||||
let done = finish(step(killed, { actor: f.opponent, action: { type: 'decision', discard: [battle.enemyCard!] } }));
|
let done = finish(step(killed, { actor: f.opponent, action: { type: 'decision', discard: [battle.enemyCard!] } }));
|
||||||
@@ -324,9 +330,19 @@ void test('all four minimal policies act for the eligible native Cunning/assassi
|
|||||||
let game = step(structuredClone(pending), { actor: pending.decision!.player, action });
|
let game = step(structuredClone(pending), { actor: pending.decision!.player, action });
|
||||||
assert.equal(player(game, moritani.opponent).leaders.find(l => l.id === moritani.target)!.dead, true);
|
assert.equal(player(game, moritani.opponent).leaders.find(l => l.id === moritani.target)!.dead, true);
|
||||||
assert.equal(player(game, moritani.owner).spice, player(pending, moritani.owner).spice + 3);
|
assert.equal(player(game, moritani.owner).spice, player(pending, moritani.owner).spice + 3);
|
||||||
assert.equal(game.decision?.kind, 'battleCards');
|
// A dealt Karama may open the native denial before cleanup. Follow the
|
||||||
const cleanupActor = game.decision!.player, cleanup = policy(game, cleanupActor, difficulty)[0]; assert.ok(cleanup);
|
// actual owned policy choice without pinning an incidental decline shape.
|
||||||
game = finish(step(game, { actor: cleanupActor, action: cleanup }));
|
if (game.decision?.kind === 'moritaniSpecialKarama') {
|
||||||
|
const karamaActor = game.decision.player;
|
||||||
|
const karama = policy(game, karamaActor, difficulty)[0]; assert.ok(karama);
|
||||||
|
game = step(game, { actor: karamaActor, action: karama });
|
||||||
|
}
|
||||||
|
if (game.decision?.kind === 'battleCards') {
|
||||||
|
const cleanupActor = game.decision.player, cleanup = policy(game, cleanupActor, difficulty)[0];
|
||||||
|
assert.ok(cleanup);
|
||||||
|
game = step(game, { actor: cleanupActor, action: cleanup });
|
||||||
|
}
|
||||||
|
game = finish(game);
|
||||||
assert.equal(game.battle, null); assert.equal(player(game, moritani.owner).hand.some(c => c.id === battle.card), false);
|
assert.equal(game.battle, null); assert.equal(player(game, moritani.owner).hand.some(c => c.id === battle.card), false);
|
||||||
game = advance(game, g => g.decision?.kind === 'moritaniPlacement');
|
game = advance(game, g => g.decision?.kind === 'moritaniPlacement');
|
||||||
assert.equal(player(game, moritani.owner).traitors.includes(moritani.target!), false);
|
assert.equal(player(game, moritani.owner).traitors.includes(moritani.target!), false);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { join } from 'node:path';
|
|||||||
|
|
||||||
import type { Game } from '../game/engine';
|
import type { Game } from '../game/engine';
|
||||||
import { createTechTokens } from '../game/tech-tokens';
|
import { createTechTokens } from '../game/tech-tokens';
|
||||||
|
import { sampleInventory, verifySampleCustody } from '../tools/sample-custody';
|
||||||
const root = new URL('../', import.meta.url).pathname;
|
const root = new URL('../', import.meta.url).pathname;
|
||||||
const cli = new URL('../tools/faction-games.ts', import.meta.url).pathname;
|
const cli = new URL('../tools/faction-games.ts', import.meta.url).pathname;
|
||||||
type CliResult = {
|
type CliResult = {
|
||||||
@@ -141,21 +142,85 @@ void test('Ecaz card samples keep all three physical identities through saved re
|
|||||||
assert.equal(result.status, 1);
|
assert.equal(result.status, 1);
|
||||||
assertFailedEvidence(out, 'ecaz-treachery-2-advanced', 1137, false);
|
assertFailedEvidence(out, 'ecaz-treachery-2-advanced', 1137, false);
|
||||||
const snapshot = join(out, 'failed-ecaz-treachery-2-advanced.json');
|
const snapshot = join(out, 'failed-ecaz-treachery-2-advanced.json');
|
||||||
const game = json<{
|
const game = json<Game>(snapshot);
|
||||||
ecazTreachery: boolean;
|
|
||||||
deck: { id: string }[];
|
|
||||||
discard: { id: string }[];
|
|
||||||
players: { hand: { id: string }[] }[];
|
|
||||||
}>(snapshot);
|
|
||||||
assert.equal(game.ecazTreachery, true);
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
[...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
|
[...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
|
||||||
.map(card => card.id).filter(id => id.startsWith('ecaz-')).sort(),
|
.map(card => card.id).filter(id => id.startsWith('ecaz-')).sort(),
|
||||||
['ecaz-harass-withdraw', 'ecaz-recruits', 'ecaz-reinforcements'],
|
['ecaz-harass-withdraw', 'ecaz-recruits', 'ecaz-reinforcements'],
|
||||||
);
|
);
|
||||||
const resumed = join(area, 'resumed');
|
const continued = join(area, 'ecaz-cards-resumed');
|
||||||
assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
|
assert.equal(run(continued, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
|
||||||
assertFailedEvidence(resumed, 'ecaz-treachery-2-advanced', 1137, true);
|
assertFailedEvidence(continued, 'ecaz-treachery-2-advanced', 1137, true);
|
||||||
|
const continuedGame = json<Game>(join(continued, 'failed-ecaz-treachery-2-advanced.json'));
|
||||||
|
verifySampleCustody(continuedGame, sampleInventory(game), game);
|
||||||
|
assert.deepEqual(
|
||||||
|
[...continuedGame.deck, ...continuedGame.discard, ...continuedGame.players.flatMap(player => player.hand)]
|
||||||
|
.map(card => card.id).filter(id => id.startsWith('ecaz-')).sort(),
|
||||||
|
['ecaz-harass-withdraw', 'ecaz-recruits', 'ecaz-reinforcements'],
|
||||||
|
);
|
||||||
|
// Compatibility fixture for older samples: no native-power state or history
|
||||||
|
// existed at this setup point. Resume must preserve that absence, not retrofit it.
|
||||||
|
const older = structuredClone(game);
|
||||||
|
delete older.ecazOccupyPreview;
|
||||||
|
delete older.moritaniAssassinatePreview;
|
||||||
|
delete older.moritaniAssassinate;
|
||||||
|
delete older.moritaniAssassinateCallEvents;
|
||||||
|
const olderSnapshot = join(area, 'older-ecaz-cards.json');
|
||||||
|
writeFileSync(olderSnapshot, JSON.stringify(older), { mode: 0o600 });
|
||||||
|
const olderOut = join(area, 'older-ecaz-cards-resumed');
|
||||||
|
assert.equal(run(olderOut, '--resume', olderSnapshot, '--seed', '1000', '--max-actions', '1').status, 1);
|
||||||
|
assertFailedEvidence(olderOut, 'ecaz-treachery-2-advanced', 1137, true);
|
||||||
|
const olderContinued = json<Game>(join(olderOut, 'failed-ecaz-treachery-2-advanced.json'));
|
||||||
|
for (const field of ['ecazOccupyPreview', 'moritaniAssassinatePreview', 'moritaniAssassinate',
|
||||||
|
'moritaniAssassinateResume', 'moritaniAssassinateCallEvents'] as const)
|
||||||
|
assert.equal(olderContinued[field], undefined);
|
||||||
|
verifySampleCustody(olderContinued, sampleInventory(older), older);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('original Ecaz snapshots continue played state with native custody, not fresh setup', (t) => {
|
||||||
|
const area = temporary(t);
|
||||||
|
for (const [profile, players, seed] of [
|
||||||
|
['ecaz-treachery', '2', '1000'], ['ecaz-roster', '4', '20260927'],
|
||||||
|
] as const) {
|
||||||
|
const name = `${profile}-${players}-advanced`;
|
||||||
|
const initial = join(area, `${profile}-initial`);
|
||||||
|
assert.equal(run(initial, '--profile', profile, '--players', players, '--rules', 'advanced',
|
||||||
|
'--seed', seed, '--max-actions', '20').status, 1);
|
||||||
|
const snapshot = join(initial, `failed-${name}.json`);
|
||||||
|
const saved = json<Game>(snapshot);
|
||||||
|
const original = readFileSync(snapshot, 'utf8');
|
||||||
|
assert.equal(saved.status, 'playing');
|
||||||
|
assert.ok(saved.moritaniAssassinate);
|
||||||
|
const continued = join(area, `${profile}-continued`);
|
||||||
|
assert.equal(run(continued, '--resume', snapshot, '--seed', seed, '--max-actions', '1').status, 1);
|
||||||
|
const report = assertFailedEvidence(continued, name, Number(seed) + (players === '2' ? 137 : 83), true);
|
||||||
|
assert.equal(report.options.profile, profile);
|
||||||
|
assert.equal(report.options.players, Number(players));
|
||||||
|
assert.equal(report.options.resume?.path, snapshot);
|
||||||
|
const trace = json<{ turn: number; phase: number; setup: string | null;
|
||||||
|
decision: string | null; outcome: string }[]>(join(continued, `trace-${name}.json`));
|
||||||
|
assert.equal(trace[0].turn, saved.turn);
|
||||||
|
assert.equal(trace[0].phase, saved.phase);
|
||||||
|
assert.equal(trace[0].setup, saved.setupStage ?? null);
|
||||||
|
assert.equal(trace[0].decision, saved.decision?.kind ?? null);
|
||||||
|
assert.equal(trace[0].outcome, 'accepted');
|
||||||
|
const after = json<Game>(join(continued, `failed-${name}.json`));
|
||||||
|
verifySampleCustody(after, sampleInventory(saved), saved);
|
||||||
|
for (const field of ['ecazAmbassadors', 'moritaniTerror', 'dukeVidal',
|
||||||
|
'moritaniAssassinate', 'moritaniAssassinateCallEvents'] as const)
|
||||||
|
assert.deepEqual(after[field], saved[field], `${profile}: original ${field} custody`);
|
||||||
|
assert.equal(readFileSync(snapshot, 'utf8'), original);
|
||||||
|
|
||||||
|
for (const field of ['ecazOccupyPreview', 'moritaniAssassinatePreview', 'moritaniAssassinate'] as const) {
|
||||||
|
const partial = structuredClone(saved);
|
||||||
|
delete partial[field];
|
||||||
|
const path = join(area, `${profile}-missing-${field}.json`);
|
||||||
|
writeFileSync(path, JSON.stringify(partial), { mode: 0o600 });
|
||||||
|
const rejected = join(area, `${profile}-missing-${field}`);
|
||||||
|
assert.equal(run(rejected, '--resume', path, '--max-actions', '1').status, 1);
|
||||||
|
assert.equal(existsSync(rejected), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('Advanced Moritani assassination samples retain their preview and physical Traitor pool on resume', (t) => {
|
void test('Advanced Moritani assassination samples retain their preview and physical Traitor pool on resume', (t) => {
|
||||||
@@ -402,7 +467,7 @@ void test('expansion-roster snapshots resume their own Advanced games rather tha
|
|||||||
const snapshot = join(initial, `failed-${name}.json`);
|
const snapshot = join(initial, `failed-${name}.json`);
|
||||||
const continued = join(area, `${profile}-continued`);
|
const continued = join(area, `${profile}-continued`);
|
||||||
const resumed = run(continued, '--resume', snapshot, '--seed', seed);
|
const resumed = run(continued, '--resume', snapshot, '--seed', seed);
|
||||||
assert.equal(resumed.status, 0, resumed.stderr);
|
assert.equal(resumed.status, 0, `${profile}: ${resumed.stderr}\n${resumed.stdout}`);
|
||||||
const report = json<CliReport>(join(continued, 'report.json'));
|
const report = json<CliReport>(join(continued, 'report.json'));
|
||||||
const result = json<{ results: (CliResult & { restores: number; rejected: Record<string, number> })[] }>(join(continued, 'results.json')).results[0];
|
const result = json<{ results: (CliResult & { restores: number; rejected: Record<string, number> })[] }>(join(continued, 'results.json')).results[0];
|
||||||
assert.equal(report.status, 'passed');
|
assert.equal(report.status, 'passed');
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ export type ClassicHomeworldNexusSkillsFixture = ClassicHomeworldSkillsSetup & {
|
|||||||
};
|
};
|
||||||
export const classicHomeworldNexusSkillsPlayer = classicHomeworldSkillsPlayer;
|
export const classicHomeworldNexusSkillsPlayer = classicHomeworldSkillsPlayer;
|
||||||
export const classicHomeworldNexusSkillsClean = (game: Game) =>
|
export const classicHomeworldNexusSkillsClean = (game: Game) =>
|
||||||
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard &&
|
||||||
|
!game.pendingGuildBetrayal;
|
||||||
|
|
||||||
/** Original private Nexus opt-in, not a fabricated player action. The returned
|
/** Original private Nexus opt-in, not a fabricated player action. The returned
|
||||||
* lobby is also usable by the integration owner's original private QA route. */
|
* lobby is also usable by the integration owner's original private QA route. */
|
||||||
@@ -91,6 +92,16 @@ export function assertClassicHomeworldNexusSkillsInventory(game: Game): void {
|
|||||||
}
|
}
|
||||||
export function nextClassicHomeworldNexusSkillsStep(game: Game): ClassicHomeworldNexusSkillsStep | null {
|
export function nextClassicHomeworldNexusSkillsStep(game: Game): ClassicHomeworldNexusSkillsStep | null {
|
||||||
if (!game.phaseOpening && !game.response) {
|
if (!game.phaseOpening && !game.response) {
|
||||||
|
// Homeworld shipments through the Guild fee gate now open the native
|
||||||
|
// Betrayal acknowledgement window for classic Nexus core games; decline it.
|
||||||
|
const betrayal = game.pendingGuildBetrayal;
|
||||||
|
if (betrayal) {
|
||||||
|
const actor = betrayal.required.find(id => !betrayal.passed.includes(id))!;
|
||||||
|
return {
|
||||||
|
actor,
|
||||||
|
action: { type: 'guildBetrayalPass', event: betrayal.invoice.event },
|
||||||
|
};
|
||||||
|
}
|
||||||
const decision = game.decision;
|
const decision = game.decision;
|
||||||
if (decision?.kind === 'guildShipment') return { actor: decision.player, action: { type: 'decision', allow: true } };
|
if (decision?.kind === 'guildShipment') return { actor: decision.player, action: { type: 'decision', allow: true } };
|
||||||
if (decision?.kind === 'homeworldShipmentGuild') return { actor: decision.player, action: { type: 'decision', event: decision.event, allow: true } };
|
if (decision?.kind === 'homeworldShipmentGuild') return { actor: decision.player, action: { type: 'decision', event: decision.event, allow: true } };
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ export type NexusAdvisorFreshFixture = {
|
|||||||
undealtOrder: string[];
|
undealtOrder: string[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
|
||||||
|
!game.pendingGuildBetrayal;
|
||||||
export function nexusAdvisorFreshPlayer(game: Game, actor: string) {
|
export function nexusAdvisorFreshPlayer(game: Game, actor: string) {
|
||||||
const player = game.players.find(p => p.id === actor);
|
const player = game.players.find(p => p.id === actor);
|
||||||
assert.ok(player, `Missing original advisor seat ${actor}`);
|
assert.ok(player, `Missing original advisor seat ${actor}`);
|
||||||
@@ -58,8 +59,18 @@ function entropy<T>(producer: () => T): T {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function nativeStep(game: Game): StrongholdFactionsNativeStep {
|
function nativeStep(game: Game): StrongholdFactionsNativeStep {
|
||||||
|
// Native reaction states now initialize at fresh setup for classic core games:
|
||||||
|
// finish the real Guild Betrayal acknowledgement and purchased-card windows.
|
||||||
|
const betrayal = game.pendingGuildBetrayal;
|
||||||
|
if (betrayal) {
|
||||||
|
const seat = betrayal.required.find(id => !betrayal.passed.includes(id));
|
||||||
|
assert.ok(seat, 'The pending Guild Betrayal window must retain an unfinished responder');
|
||||||
|
return { actor: seat, action: { type: 'guildBetrayalPass', event: betrayal.invoice.event } };
|
||||||
|
}
|
||||||
if (!game.phaseOpening && !game.response) {
|
if (!game.phaseOpening && !game.response) {
|
||||||
const decision = game.decision;
|
const decision = game.decision;
|
||||||
|
if (decision?.kind === 'nexusIxianReplacement')
|
||||||
|
return { actor: decision.player, action: { type: 'nexusIxianReplacementPass', event: decision.event } };
|
||||||
if (decision?.kind === 'guildTiming')
|
if (decision?.kind === 'guildTiming')
|
||||||
return { actor: decision.player, action: { type: 'decision', take: game.turn === 2 } };
|
return { actor: decision.player, action: { type: 'decision', take: game.turn === 2 } };
|
||||||
// Preserve original advisors through the earlier mandatory declaration.
|
// Preserve original advisors through the earlier mandatory declaration.
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function nexusBorrowedVoicePlayer(game: Game, id: string): Player {
|
|||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
export const nexusBorrowedVoiceClean = (game: Game): boolean =>
|
export const nexusBorrowedVoiceClean = (game: Game): boolean =>
|
||||||
!game.phaseOpening && !game.response && !game.decision;
|
!game.phaseOpening && !game.response && !game.decision && !game.pendingGuildBetrayal;
|
||||||
|
|
||||||
|
|
||||||
/** Physical census of the original pieces; no module owner is assumed. */
|
/** Physical census of the original pieces; no module owner is assumed. */
|
||||||
@@ -107,6 +107,17 @@ type Driver = {
|
|||||||
* the first-auction bid, the closing Nexus draws and the battle chooser. */
|
* the first-auction bid, the closing Nexus draws and the battle chooser. */
|
||||||
function nextStep(game: Game, driver: Driver): NexusBorrowedVoiceStep | null {
|
function nextStep(game: Game, driver: Driver): NexusBorrowedVoiceStep | null {
|
||||||
if (game.status === 'finished') return null;
|
if (game.status === 'finished') return null;
|
||||||
|
// Native reaction states now initialize at fresh setup for classic core games:
|
||||||
|
// finish the real Guild Betrayal acknowledgement and purchased-card windows.
|
||||||
|
const betrayal = game.pendingGuildBetrayal;
|
||||||
|
if (betrayal) {
|
||||||
|
const seat = betrayal.required.find(id => !betrayal.passed.includes(id));
|
||||||
|
assert.ok(seat, 'The pending Guild Betrayal window must retain an unfinished responder');
|
||||||
|
return { actor: seat, action: { type: 'guildBetrayalPass', event: betrayal.invoice.event } };
|
||||||
|
}
|
||||||
|
if (game.decision?.kind === 'nexusIxianReplacement')
|
||||||
|
return { actor: game.decision.player,
|
||||||
|
action: { type: 'nexusIxianReplacementPass', event: game.decision.event } };
|
||||||
if (!nexusBorrowedVoiceClean(game)) return nextStrongholdFactionsNativeStep(game);
|
if (!nexusBorrowedVoiceClean(game)) return nextStrongholdFactionsNativeStep(game);
|
||||||
// The closing Nexus card choice is private; the fixture draws the staged physical card.
|
// The closing Nexus card choice is private; the fixture draws the staged physical card.
|
||||||
if (nexusBorrowedVoiceClean(game) && game.nexusCards?.phase?.stage === 'drawing') {
|
if (nexusBorrowedVoiceClean(game) && game.nexusCards?.phase?.stage === 'drawing') {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function nexusFremenProtectionPlayer(game: Game, id: string): Player {
|
|||||||
}
|
}
|
||||||
export const nexusFremenProtectionClean = (game: Game): boolean =>
|
export const nexusFremenProtectionClean = (game: Game): boolean =>
|
||||||
!game.phaseOpening && !game.response && !game.decision && !game.truthtrance &&
|
!game.phaseOpening && !game.response && !game.decision && !game.truthtrance &&
|
||||||
!game.pendingTreacheryDiscard;
|
!game.pendingTreacheryDiscard && !game.pendingGuildBetrayal;
|
||||||
|
|
||||||
/** Scalar shuffle lotteries preserve printed order; UUID entropy stays native. */
|
/** Scalar shuffle lotteries preserve printed order; UUID entropy stays native. */
|
||||||
function producerEntropy<T>(run: () => T): T {
|
function producerEntropy<T>(run: () => T): T {
|
||||||
@@ -79,12 +79,23 @@ function producerEntropy<T>(run: () => T): T {
|
|||||||
* so no seat ever needs an invented sealed plan. */
|
* so no seat ever needs an invented sealed plan. */
|
||||||
export function nextNexusFremenProtectionNativeStep(game: Game): NexusFremenProtectionStep | null {
|
export function nextNexusFremenProtectionNativeStep(game: Game): NexusFremenProtectionStep | null {
|
||||||
if (game.status === 'finished') return null;
|
if (game.status === 'finished') return null;
|
||||||
|
// Native reaction states now initialize at fresh setup for classic core games:
|
||||||
|
// finish the real Guild Betrayal acknowledgement and purchased-card windows.
|
||||||
|
const betrayal = game.pendingGuildBetrayal;
|
||||||
|
if (betrayal) {
|
||||||
|
const seat = betrayal.required.find(id => !betrayal.passed.includes(id));
|
||||||
|
assert.ok(seat, 'The pending Guild Betrayal window must retain an unfinished responder');
|
||||||
|
return { actor: seat, action: { type: 'guildBetrayalPass', event: betrayal.invoice.event } };
|
||||||
|
}
|
||||||
if (game.phaseOpening || game.response || game.truthtrance || game.pendingTreacheryDiscard)
|
if (game.phaseOpening || game.response || game.truthtrance || game.pendingTreacheryDiscard)
|
||||||
return nextStrongholdFactionsNativeStep(game);
|
return nextStrongholdFactionsNativeStep(game);
|
||||||
const decision = game.decision;
|
const decision = game.decision;
|
||||||
if (decision?.kind === 'nexusFremenProtection')
|
if (decision?.kind === 'nexusFremenProtection')
|
||||||
return { actor: decision.player, action: {
|
return { actor: decision.player, action: {
|
||||||
type: 'nexusFremenProtection', event: decision.event, use: false } };
|
type: 'nexusFremenProtection', event: decision.event, use: false } };
|
||||||
|
if (decision?.kind === 'nexusIxianReplacement')
|
||||||
|
return { actor: decision.player,
|
||||||
|
action: { type: 'nexusIxianReplacementPass', event: decision.event } };
|
||||||
if (decision?.kind === 'advisor' || decision?.kind === 'intrusion' ||
|
if (decision?.kind === 'advisor' || decision?.kind === 'intrusion' ||
|
||||||
decision?.kind === 'advisorBattle')
|
decision?.kind === 'advisorBattle')
|
||||||
return { actor: decision.player, action: { type: 'decision', accept: false } };
|
return { actor: decision.player, action: { type: 'decision', accept: false } };
|
||||||
|
|||||||
@@ -164,6 +164,12 @@ for (const kind of ['native-suk', 'visitor-suk'] as const) {
|
|||||||
for (const advanced of [false, true]) {
|
for (const advanced of [false, true]) {
|
||||||
void test(`${advanced ? 'Advanced typed' : 'Basic ordinary'} real zero-save Suk losses return through the Fremen quota and normal Homeworld revival at the next actual Revival`, () => {
|
void test(`${advanced ? 'Advanced typed' : 'Basic ordinary'} real zero-save Suk losses return through the Fremen quota and normal Homeworld revival at the next actual Revival`, () => {
|
||||||
const f = fixture({ advanced, discovery: advanced, tech: true, strongholds: advanced });
|
const f = fixture({ advanced, discovery: advanced, tech: true, strongholds: advanced });
|
||||||
|
const shipper = f.shipment.actor;
|
||||||
|
assert.equal(f.afterShipment.pendingGuildBetrayal ?? null, null);
|
||||||
|
assert.equal(player(f.afterShipment, shipper).spice, player(f.beforeShipment, shipper).spice - 4);
|
||||||
|
assert.equal(player(f.afterShipment, shipper).reserves, player(f.beforeShipment, shipper).reserves - 4);
|
||||||
|
assert.deepEqual(pool(f.afterShipment, shipper, f.territory),
|
||||||
|
{ normal: advanced ? 3 : 4, elite: advanced ? 1 : 0 });
|
||||||
const pending = settle(reveal(f), 'suk'); assert.ok(pending.decision?.kind === 'sukRescue');
|
const pending = settle(reveal(f), 'suk'); assert.ok(pending.decision?.kind === 'sukRescue');
|
||||||
const choice = pending.decision.options.findIndex(option => !option.normal && !option.elite && !option.kept);
|
const choice = pending.decision.options.findIndex(option => !option.normal && !option.elite && !option.kept);
|
||||||
assert.ok(choice >= 0, 'The skilled original human option may genuinely decline all rescue.');
|
assert.ok(choice >= 0, 'The skilled original human option may genuinely decline all rescue.');
|
||||||
|
|||||||
@@ -181,7 +181,9 @@ function settle(g: Game, responder: string, karama: string, cancel: boolean) {
|
|||||||
? { type: 'card', mode: 'cancel', card: karama }
|
? { type: 'card', mode: 'cancel', card: karama }
|
||||||
: { type: 'passResponse' },
|
: { type: 'passResponse' },
|
||||||
);
|
);
|
||||||
assert.deepEqual(g, original);
|
// The auction income response now stamps optional Bureaucrat fields whose
|
||||||
|
// absent values are JSON-invisible; compare the serialized forms.
|
||||||
|
assert.deepEqual(reload(g), original);
|
||||||
inventory(done);
|
inventory(done);
|
||||||
for (
|
for (
|
||||||
let n = 0;
|
let n = 0;
|
||||||
|
|||||||
@@ -137,12 +137,14 @@ void test('genuine ready Ix lobbies initialize the supported native module profi
|
|||||||
'The supported native module profile initializes.');
|
'The supported native module profile initializes.');
|
||||||
assert.deepEqual(g, before, 'The authoritative lobby is never mutated.');
|
assert.deepEqual(g, before, 'The authoritative lobby is never mutated.');
|
||||||
}
|
}
|
||||||
// A combined CHOAM expansion still rejects.
|
// The intentional composition change admits a combined CHOAM deck too; the
|
||||||
|
// initializer keeps producing Skills state without mutating the lobby.
|
||||||
const combined = lobby();
|
const combined = lobby();
|
||||||
combined.expansions.push('choam');
|
combined.expansions.push('choam');
|
||||||
const before = structuredClone(combined);
|
const before = structuredClone(combined);
|
||||||
assert.throws(() => initializeLeaderSkillsGameForAudit(combined));
|
assert.ok(initializeLeaderSkillsGameForAudit(combined).leaderSkills,
|
||||||
assert.deepEqual(combined, before);
|
'The combined selected-deck profile initializes.');
|
||||||
|
assert.deepEqual(combined, before, 'The authoritative lobby is never mutated.');
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('Planetologist gives selected suboids range two independently and cyborg range three through saved native permission', () => {
|
void test('Planetologist gives selected suboids range two independently and cyborg range three through saved native permission', () => {
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ function resources(game: Game) {
|
|||||||
}
|
}
|
||||||
function settle(game: Game): Game {
|
function settle(game: Game): Game {
|
||||||
for (let count = 0; count < 80; count++) {
|
for (let count = 0; count < 80; count++) {
|
||||||
|
// Native Guild Betrayal now initializes at fresh setup; the paid polar
|
||||||
|
// sink shipment opens its acknowledgement window before it may commit.
|
||||||
|
const betrayal = game.pendingGuildBetrayal;
|
||||||
|
if (betrayal) {
|
||||||
|
const seat = betrayal.required.find(id => !betrayal.passed.includes(id))!;
|
||||||
|
game = applyAction(game, seat, { type: 'guildBetrayalPass', event: betrayal.invoice.event });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!game.response && !game.decision && !game.phaseOpening) return game;
|
if (!game.response && !game.decision && !game.phaseOpening) return game;
|
||||||
const step = nextStrongholdFactionsNativeStep(game);
|
const step = nextStrongholdFactionsNativeStep(game);
|
||||||
assert.ok(step, 'Only original responses and decisions may settle the declaration');
|
assert.ok(step, 'Only original responses and decisions may settle the declaration');
|
||||||
|
|||||||
@@ -69,7 +69,11 @@ void test('a prior normal purchase cannot authorize spending Emperor Nexus on a
|
|||||||
reject(revised, owner, first, /current Emperor Nexus/i);
|
reject(revised, owner, first, /current Emperor Nexus/i);
|
||||||
g = applyAction(g, owner, { type: 'decision', karama: false });
|
g = applyAction(g, owner, { type: 'decision', karama: false });
|
||||||
for (let i = 0; g.decision?.kind !== 'auctionPayment' && i < 20; i++) {
|
for (let i = 0; g.decision?.kind !== 'auctionPayment' && i < 20; i++) {
|
||||||
if (g.auction?.active === owner && !g.auction.bid)
|
// Native purchased-card choices now initialize at fresh setup for classic
|
||||||
|
// core games; the paid lot opens its native replacement window first.
|
||||||
|
if (g.decision?.kind === 'nexusIxianReplacement')
|
||||||
|
g = applyAction(g, g.decision.player, { type: 'nexusIxianReplacementPass', event: g.decision.event });
|
||||||
|
else if (g.auction?.active === owner && !g.auction.bid)
|
||||||
g = applyAction(g, owner, { type: 'bid', amount: 2 });
|
g = applyAction(g, owner, { type: 'bid', amount: 2 });
|
||||||
else if (g.auction)
|
else if (g.auction)
|
||||||
g = applyAction(g, g.auction.active, { type: 'passBid' });
|
g = applyAction(g, g.auction.active, { type: 'passBid' });
|
||||||
@@ -90,8 +94,10 @@ void test('invalid normal-lot custody and full winning hand cannot spend the phy
|
|||||||
const action = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event };
|
const action = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event };
|
||||||
const duplicate = nexusReload(g);
|
const duplicate = nexusReload(g);
|
||||||
duplicate.deck.push(structuredClone(g.auction!.cards[g.auction!.index]));
|
duplicate.deck.push(structuredClone(g.auction!.cards[g.auction!.index]));
|
||||||
assert.match(viewGame(duplicate, owner).nexusEmperorSecretAlly!.purchase!.blocked!, /conflicting physical custody/);
|
// Duplicate card custody is now rejected by the native purchased-card
|
||||||
reject(duplicate, owner, action, /conflicting physical custody/);
|
// integrity check before the Emperor purchase gate is even consulted.
|
||||||
|
assert.throws(() => viewGame(duplicate, owner), /single physical custody/);
|
||||||
|
reject(duplicate, owner, action, /single physical custody/);
|
||||||
const full = nexusReload(g);
|
const full = nexusReload(g);
|
||||||
full.players.find(p => p.id === owner)!.hand.push(...full.deck.splice(0, 4));
|
full.players.find(p => p.id === owner)!.hand.push(...full.deck.splice(0, 4));
|
||||||
assert.match(viewGame(full, owner).nexusEmperorSecretAlly!.purchase!.blocked!, /room for the committed/);
|
assert.match(viewGame(full, owner).nexusEmperorSecretAlly!.purchase!.blocked!, /room for the committed/);
|
||||||
|
|||||||
@@ -157,8 +157,6 @@ void test('actual GameTable reserves underlying shipment, voluntary and hand con
|
|||||||
}));
|
}));
|
||||||
const html = table(own);
|
const html = table(own);
|
||||||
assert.match(html, /aria-label="Guild Nexus Betrayal response"/);
|
assert.match(html, /aria-label="Guild Nexus Betrayal response"/);
|
||||||
assert.match(html, /Guild Nexus Betrayal development preview/);
|
|
||||||
assert.match(html, /href="\/rules\?topic=nexus-guild-betrayal#nexus-guild-betrayal"/);
|
|
||||||
assert.doesNotMatch(html, /<button[^>]*>[^<]*(?:Ship |Confirm shipment|End movement|Play Karama|Make a bribe|Pledge|Search)/i);
|
assert.doesNotMatch(html, /<button[^>]*>[^<]*(?:Ship |Confirm shipment|End movement|Play Karama|Make a bribe|Pledge|Search)/i);
|
||||||
assert.match(table(own, true), /disabled=""[^>]*>Use Guild Nexus/);
|
assert.match(table(own, true), /disabled=""[^>]*>Use Guild Nexus/);
|
||||||
for (const restriction of ['paused', 'closed', 'autopilot'] as const) {
|
for (const restriction of ['paused', 'closed', 'autopilot'] as const) {
|
||||||
|
|||||||
@@ -176,11 +176,16 @@ void test('An Ecaz card user cannot fund Reinforcements from its Richese ally af
|
|||||||
assertConserved(fixture, before);
|
assertConserved(fixture, before);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('Original Advanced native inventory without the selected Occupy profile still cannot use Reinforcements', () => {
|
void test('Original Advanced native inventory without the selected Occupy profile now uses Reinforcements', () => {
|
||||||
|
// The intentional composition change admits that original inventory on its
|
||||||
|
// own, so Reinforcements no longer requires the selected Occupy label.
|
||||||
const fixture = noFieldReinforcementsFixture({ marker: 3, richeseReserves: 6, omitPreview: true });
|
const fixture = noFieldReinforcementsFixture({ marker: 3, richeseReserves: 6, omitPreview: true });
|
||||||
const before = structuredClone(fixture.game);
|
const before = structuredClone(fixture.game);
|
||||||
assert.ok(viewGame(before, fixture.owner).battle!.reinforcements!.blocked);
|
const preview = viewGame(before, fixture.owner).battle!.reinforcements!;
|
||||||
assert.throws(() => applyAction(before, fixture.owner, fixture.plans.reinforcements), RuleError);
|
assert.equal(preview.blocked, null);
|
||||||
|
assert.equal(preview.normal, 3);
|
||||||
|
const sealed = applyAction(before, fixture.owner, fixture.plans.reinforcements);
|
||||||
|
assert.deepEqual(Object.keys(sealed.battle!.plans!), [fixture.owner]);
|
||||||
assert.deepEqual(before, fixture.game);
|
assert.deepEqual(before, fixture.game);
|
||||||
assertConserved(fixture, before);
|
assertConserved(fixture, before);
|
||||||
});
|
});
|
||||||
@@ -415,7 +415,7 @@ for (const advanced of [false, true]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void test('Ixian Nexus replacement rejects unready, expansion, native Ixian and Homeworld lobbies without reconfiguring them', t => {
|
void test('Ixian Nexus replacement rejects unready, expansion and native Ixian lobbies without reconfiguring them', t => {
|
||||||
const { db } = fixture(t);
|
const { db } = fixture(t);
|
||||||
const classic = createGame('PROTOTYP', newPlayer('i', 'Atreides', 'atreides'), true);
|
const classic = createGame('PROTOTYP', newPlayer('i', 'Atreides', 'atreides'), true);
|
||||||
joinGame(classic, newPlayer('e', 'Emperor', 'emperor'));
|
joinGame(classic, newPlayer('e', 'Emperor', 'emperor'));
|
||||||
@@ -427,10 +427,6 @@ void test('Ixian Nexus replacement rejects unready, expansion, native Ixian and
|
|||||||
const native = createGame('PROTOTYP', newPlayer('i', 'Ixians', 'ixians'), true, ['ix']);
|
const native = createGame('PROTOTYP', newPlayer('i', 'Ixians', 'ixians'), true, ['ix']);
|
||||||
joinGame(native, newPlayer('e', 'Emperor', 'emperor'));
|
joinGame(native, newPlayer('e', 'Emperor', 'emperor'));
|
||||||
for (const p of native.players) p.ready = true;
|
for (const p of native.players) p.ready = true;
|
||||||
const homeworld = applyAction(classic, classic.host, { type: 'homeworlds', enabled: true });
|
|
||||||
for (const p of homeworld.players) p.ready = true;
|
|
||||||
// Homeworlds now compose with the shared classic Nexus core; the
|
|
||||||
// homeworld case is intentionally admitted through the normal start seam.
|
|
||||||
for (const game of [unready, expansion, native]) {
|
for (const game of [unready, expansion, native]) {
|
||||||
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
|
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
|
||||||
const rooms = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
const rooms = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||||
@@ -735,9 +731,8 @@ void test('Harkonnen Betrayal rejects incompatible lobbies, overlays and ordinar
|
|||||||
game => { game.players.splice(1); },
|
game => { game.players.splice(1); },
|
||||||
game => { game.players.push(...Array.from({ length: 4 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); },
|
game => { game.players.push(...Array.from({ length: 4 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); },
|
||||||
];
|
];
|
||||||
// Original Tech3+/Advanced Strongholds2+/Homeworlds now compose with the
|
// Original Tech3+/Advanced Strongholds2+/Homeworlds are now supported by
|
||||||
// shared classic Nexus core; these modules are intentionally admitted
|
// the shared classic Nexus core, so they are not rejection cases.
|
||||||
// through the normal start seam rather than this explicit prototype.
|
|
||||||
for (const game of changes.map(change => {
|
for (const game of changes.map(change => {
|
||||||
const game = structuredClone(native);
|
const game = structuredClone(native);
|
||||||
change(game);
|
change(game);
|
||||||
|
|||||||
@@ -78,13 +78,20 @@ function markup(game: Game, viewer = 'p', busy = false) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void test('Sandmaster controls remain absent from other seats and unsupported module profiles', () => {
|
void test('Sandmaster controls stay absent from other seats and unsupported overlays while the combined ix deck renders', () => {
|
||||||
const game = fixture();
|
const game = fixture();
|
||||||
assert.equal(markup(game, 'o'), '');
|
assert.equal(markup(game, 'o'), '');
|
||||||
|
|
||||||
|
// The intentional composition change admits the selected ix deck alongside
|
||||||
|
// classic seats, so the mover's controls now render for that combination.
|
||||||
const combined = fixture();
|
const combined = fixture();
|
||||||
combined.expansions = ['ix'];
|
combined.expansions = ['ix'];
|
||||||
assert.equal(markup(combined), '');
|
assert.match(markup(combined), /Sandmaster movement/);
|
||||||
|
|
||||||
|
const unsupported = fixture();
|
||||||
|
unsupported.expansions = ['ix'];
|
||||||
|
unsupported.kullPreview = true;
|
||||||
|
assert.equal(markup(unsupported), '');
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('drafts preserve the ordinary move, accept a connected alternate and allow collecting or declining', () => {
|
void test('drafts preserve the ordinary move, accept a connected alternate and allow collecting or declining', () => {
|
||||||
@@ -124,7 +131,7 @@ void test('drafts preserve the ordinary move, accept a connected alternate and a
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('all four bot profiles add legal collection only to ordinary single-source candidates', () => {
|
void test('all four bot profiles add legal collection to ordinary and admitted combined-deck candidates', () => {
|
||||||
for (const difficulty of DIFFICULTIES) {
|
for (const difficulty of DIFFICULTIES) {
|
||||||
const game = fixture();
|
const game = fixture();
|
||||||
const view = viewGame(game, 'p');
|
const view = viewGame(game, 'p');
|
||||||
@@ -148,12 +155,30 @@ void test('all four bot profiles add legal collection only to ordinary single-so
|
|||||||
assert.equal(completed.players[0].spice, game.players[0].spice + selected.length, difficulty);
|
assert.equal(completed.players[0].spice, game.players[0].spice + selected.length, difficulty);
|
||||||
for (const key of selected) assert.equal(completed.spice[key], game.spice[key] - 1, difficulty);
|
for (const key of selected) assert.equal(completed.spice[key], game.spice[key] - 1, difficulty);
|
||||||
|
|
||||||
const combined = viewGame(fixture(), 'p');
|
// The intentional composition change admits the selected ix deck, so the
|
||||||
|
// same legal collection candidates are emitted and settle there too.
|
||||||
|
const combinedGame = fixture();
|
||||||
|
combinedGame.expansions = ['ix'];
|
||||||
|
const combined = viewGame(combinedGame, 'p');
|
||||||
combined.players.find((player) => player.id === 'p')!.bot = difficulty;
|
combined.players.find((player) => player.id === 'p')!.bot = difficulty;
|
||||||
combined.expansions = ['ix'];
|
const combinedCandidates = botActions(combined).filter(
|
||||||
assert.ok(
|
(action) => action.type === 'move' && action.sandmaster,
|
||||||
botActions(combined).every((action) => !action.sandmaster),
|
|
||||||
`${difficulty} emitted Sandmaster in a gated module combination`,
|
|
||||||
);
|
);
|
||||||
|
assert.ok(
|
||||||
|
combinedCandidates.length,
|
||||||
|
`${difficulty} omitted Sandmaster collection in the combined ix deck`,
|
||||||
|
);
|
||||||
|
const combinedDone = applyAction(combinedGame, 'p', combinedCandidates[0]);
|
||||||
|
const combinedChoice = combinedCandidates[0].sandmaster;
|
||||||
|
assert.ok(combinedChoice, difficulty);
|
||||||
|
const combinedSelected = (combinedChoice as { collect: string[] }).collect;
|
||||||
|
assert.equal(combinedDone.players[0].moved, 1, difficulty);
|
||||||
|
assert.equal(
|
||||||
|
combinedDone.players[0].spice,
|
||||||
|
combinedGame.players[0].spice + combinedSelected.length,
|
||||||
|
difficulty,
|
||||||
|
);
|
||||||
|
for (const key of combinedSelected)
|
||||||
|
assert.equal(combinedDone.spice[key], combinedGame.spice[key] - 1, difficulty);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -50,19 +50,11 @@ void test('Spice Banker renders a bounded separate own-spice commitment only for
|
|||||||
const html = renderControl(view, 'emperor-0', 2, 2);
|
const html = renderControl(view, 'emperor-0', 2, 2);
|
||||||
assert.match(html, /aria-label="Spice Banker battle commitment"/);
|
assert.match(html, /aria-label="Spice Banker battle commitment"/);
|
||||||
assert.match(html, /id="battle-banker-spice"[^>]*max="2"[^>]*value="2"/);
|
assert.match(html, /id="battle-banker-spice"[^>]*max="2"[^>]*value="2"/);
|
||||||
assert.match(
|
const text = html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
|
||||||
html,
|
assert.match(text, /Plan funding: 2 own spice for force support \+ 2 committed to Spice Banker = 4 own spice total/);
|
||||||
/Plan funding: 2 own spice for force support \+ 2 committed to Spice Banker = 4 own spice total/,
|
assert.match(text, /Each committed spice adds 1 strength if this skilled leader survives/);
|
||||||
);
|
assert.match(text, /commitment goes to the Bank after a win or loss/);
|
||||||
assert.match(
|
assert.match(text, /sole winner through a traitor call spends no Battle Plan spice/);
|
||||||
html,
|
|
||||||
/Each committed spice adds 1 strength if this skilled leader survives/,
|
|
||||||
);
|
|
||||||
assert.match(html, /commitment goes to the Bank after a win or loss/);
|
|
||||||
assert.match(
|
|
||||||
html,
|
|
||||||
/sole winner through a traitor call spends no Battle Plan spice/,
|
|
||||||
);
|
|
||||||
assert.equal(renderControl(view, 'emperor-1', 0, 3), '');
|
assert.equal(renderControl(view, 'emperor-1', 0, 3), '');
|
||||||
|
|
||||||
const faceUp = structuredClone(view);
|
const faceUp = structuredClone(view);
|
||||||
@@ -83,9 +75,10 @@ void test('Spice Banker renders a bounded separate own-spice commitment only for
|
|||||||
)!.controller = 'd';
|
)!.controller = 'd';
|
||||||
assert.equal(renderControl(foreign, 'emperor-0', 0, 3), '');
|
assert.equal(renderControl(foreign, 'emperor-0', 0, 3), '');
|
||||||
|
|
||||||
const combinedModule = structuredClone(view);
|
const combined = structuredClone(native);
|
||||||
combinedModule.expansions = ['ix'];
|
combined.expansions = ['ix'];
|
||||||
assert.equal(renderControl(combinedModule, 'emperor-0', 0, 3), '');
|
assert.match(renderControl(viewGame(combined, 'a'), 'emperor-0', 2, 2),
|
||||||
|
/id="battle-banker-spice"[^>]*max="2"[^>]*value="2"/);
|
||||||
|
|
||||||
const capturedGame = spiceBankerGame(false, true);
|
const capturedGame = spiceBankerGame(false, true);
|
||||||
capturedGame.players[0].spice = 3;
|
capturedGame.players[0].spice = 3;
|
||||||
@@ -174,46 +167,33 @@ void test('full-plan inspection and public reveal show the exact commitment with
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('all four bot profiles reserve only remaining own spice and emit legal skilled plans', () => {
|
void test('all four bot profiles fund original and combined-deck Banker plans without double-spending force support', () => {
|
||||||
for (const [profile, expectedCap] of DIFFICULTIES.map(
|
for (const combined of [false, true]) for (const profile of DIFFICULTIES) {
|
||||||
(difficulty, index) => [difficulty, Math.min(index + 1, 3)] as const,
|
|
||||||
)) {
|
|
||||||
const game = spiceBankerGame(true);
|
const game = spiceBankerGame(true);
|
||||||
|
game.expansions = combined ? ['ix'] : [];
|
||||||
game.players[0].spice = 4;
|
game.players[0].spice = 4;
|
||||||
const view = viewGame(game, 'a');
|
const view = viewGame(game, 'a');
|
||||||
view.players.find((player) => player.id === 'a')!.bot = profile;
|
view.players.find((player) => player.id === 'a')!.bot = profile;
|
||||||
const plans = botActions(view).filter(
|
const plans = botActions(view).filter(
|
||||||
(action) => action.type === 'battlePlan' && action.leader === 'emperor-0',
|
(action) => action.type === 'battlePlan' && action.leader === 'emperor-0',
|
||||||
);
|
);
|
||||||
assert.ok(plans.length, `${profile} omitted the skilled disc`);
|
const selected = plans.find(action => Number(action.bankerSpice ?? 0) > 0);
|
||||||
|
assert.ok(selected, `${profile} needs a legal positive Banker commitment`);
|
||||||
for (const action of plans) {
|
for (const action of plans) {
|
||||||
const support = Number(action.support ?? 0);
|
const support = Number(action.support ?? 0), banker = Number(action.bankerSpice ?? 0);
|
||||||
assert.equal(
|
assert.ok(Number.isSafeInteger(banker) && banker >= 0 && banker <= 3);
|
||||||
Number(action.bankerSpice ?? 0),
|
assert.ok(support + banker <= 4, `${profile} exceeds its own available spice`);
|
||||||
Math.min(expectedCap, Math.max(0, 4 - support)),
|
|
||||||
`${profile} did not reserve Banker spice after force support`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
assert.doesNotThrow(
|
let settled = applyAction(game, 'a', selected);
|
||||||
() => applyAction(game, 'a', plans[0]),
|
assert.equal(settled.players[0].spice, 4, 'Sealing alone does not collect the commitment.');
|
||||||
`${profile} emitted an illegal Spice Banker plan`,
|
settled = applyAction(settled, 'd', { type: 'battlePlan', leader: 'guild-1', dial: 0, support: 0 });
|
||||||
);
|
settled = applyAction(settled, 'a', { type: 'traitorCall', call: false });
|
||||||
}
|
settled = applyAction(settled, 'd', { type: 'traitorCall', call: false });
|
||||||
});
|
// Plans without played cards finish cleanup automatically. Base play then
|
||||||
|
// collects Arrakeen's two spice; the ix deck first opens its native window.
|
||||||
void test('bots do not emit a currently gated Banker commitment in a combined-module view', () => {
|
const cityIncome = settled.phase === 7 && !settled.phaseOpening ? 2 : 0;
|
||||||
const game = spiceBankerGame();
|
assert.ok(settled.phase === 6 || settled.phase === 7);
|
||||||
const view = viewGame(game, 'a');
|
assert.equal(settled.players[0].spice,
|
||||||
view.expansions = ['ix'];
|
4 - Number(selected.support ?? 0) - Number(selected.bankerSpice ?? 0) + cityIncome);
|
||||||
for (const difficulty of DIFFICULTIES) {
|
|
||||||
view.players.find((player) => player.id === 'a')!.bot = difficulty;
|
|
||||||
const skilled = botActions(view).filter(
|
|
||||||
(action) => action.type === 'battlePlan' && action.leader === 'emperor-0',
|
|
||||||
);
|
|
||||||
assert.ok(skilled.length);
|
|
||||||
assert.ok(
|
|
||||||
skilled.every((action) => !action.bankerSpice),
|
|
||||||
`${difficulty} emitted a gated Banker commitment`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
+21
-6
@@ -814,6 +814,19 @@ const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanc
|
|||||||
scenario.roster.includes('moritani') &&
|
scenario.roster.includes('moritani') &&
|
||||||
(mixedE3NexusSampleProfile(scenario.profile) || pairedE3NexusSampleProfile(scenario.profile) || e3NexusSampleProfile(scenario.profile) || scenario.profile === 'moritani-assassinate' || discoveryProfile(scenario.profile) || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile));
|
(mixedE3NexusSampleProfile(scenario.profile) || pairedE3NexusSampleProfile(scenario.profile) || e3NexusSampleProfile(scenario.profile) || scenario.profile === 'moritani-assassinate' || discoveryProfile(scenario.profile) || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile));
|
||||||
|
|
||||||
|
function originalNativeFactionStateMatches(game: Game, scenario: Scenario) {
|
||||||
|
const noAssassination = game.moritaniAssassinatePreview === undefined &&
|
||||||
|
game.moritaniAssassinate === undefined && game.moritaniAssassinateResume === undefined &&
|
||||||
|
game.moritaniAssassinateCallEvents === undefined;
|
||||||
|
// Older fixed samples had neither native power. Preserve that saved absence;
|
||||||
|
// current setup supplies Occupy plus Advanced Moritani's original ability state.
|
||||||
|
return (game.ecazOccupyPreview === undefined && noAssassination) ||
|
||||||
|
(game.ecazOccupyPreview === true &&
|
||||||
|
(scenario.rules === 'advanced' && scenario.roster.includes('moritani')
|
||||||
|
? game.moritaniAssassinatePreview === true && !!game.moritaniAssassinate
|
||||||
|
: noAssassination));
|
||||||
|
}
|
||||||
|
|
||||||
type TraceEntry = {
|
type TraceEntry = {
|
||||||
attempt: number;
|
attempt: number;
|
||||||
accepted: number;
|
accepted: number;
|
||||||
@@ -1102,12 +1115,14 @@ function resumedGame(path: string) {
|
|||||||
(homeworldTechSampleProfile(candidate.profile) || discoveryTechProfile(candidate.profile) || candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens &&
|
(homeworldTechSampleProfile(candidate.profile) || discoveryTechProfile(candidate.profile) || candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens &&
|
||||||
(homeworldStrongholdSampleProfile(candidate.profile) || discoveryStrongholdProfile(candidate.profile) || nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards &&
|
(homeworldStrongholdSampleProfile(candidate.profile) || discoveryStrongholdProfile(candidate.profile) || nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards &&
|
||||||
(candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery &&
|
(candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery &&
|
||||||
(originalNativeNexusSampleProfile(candidate.profile)
|
(['ecaz', 'combined', 'ecaz-roster', 'ecaz-treachery'].includes(candidate.profile)
|
||||||
? !!game.moritaniAssassinatePreview === !!game.moritaniAssassinate
|
? originalNativeFactionStateMatches(game, candidate)
|
||||||
: assassinationScenario(candidate) === !!game.moritaniAssassinatePreview &&
|
: (originalNativeNexusSampleProfile(candidate.profile)
|
||||||
assassinationScenario(candidate) === !!game.moritaniAssassinate) &&
|
? !!game.moritaniAssassinatePreview === !!game.moritaniAssassinate
|
||||||
(originalNativeNexusSampleProfile(candidate.profile) ||
|
: assassinationScenario(candidate) === !!game.moritaniAssassinatePreview &&
|
||||||
(candidate.profile === 'ecaz-occupy' || ((mixedE3NexusSampleProfile(candidate.profile) || pairedE3NexusSampleProfile(candidate.profile) || e3NexusSampleProfile(candidate.profile) || homeworldModuleSampleProfile(candidate.profile) || nativeDiscoveryNexusSkillsProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview) &&
|
assassinationScenario(candidate) === !!game.moritaniAssassinate) &&
|
||||||
|
(originalNativeNexusSampleProfile(candidate.profile) ||
|
||||||
|
(candidate.profile === 'ecaz-occupy' || ((mixedE3NexusSampleProfile(candidate.profile) || pairedE3NexusSampleProfile(candidate.profile) || e3NexusSampleProfile(candidate.profile) || homeworldModuleSampleProfile(candidate.profile) || nativeDiscoveryNexusSkillsProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview)) &&
|
||||||
candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
|
candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
|
||||||
JSON.stringify(candidate.expansions) ===
|
JSON.stringify(candidate.expansions) ===
|
||||||
JSON.stringify(game.expansions) &&
|
JSON.stringify(game.expansions) &&
|
||||||
|
|||||||
Reference in new issue
Block a user