Resolve Family Atomics with concealed No-Field forces

This commit is contained in:
Quintine committed 2026-10-27 11:54:38 +11:00
1 parent 28df687bf9
commit 57a59c1663
13 files changed
+511 -24

No files matched your search

@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import type { Difficulty } from '../game/bot-profiles';
import { actFamilyAtomicsNoField as act, createFamilyAtomicsNoFieldFixture as fixture,
familyAtomicsNoFieldInventory as inventory, familyAtomicsNoFieldPlayer as p,
familyAtomicsNoFieldReload as reload, finishFamilyAtomicsNoFieldStorm as finishStorm } from './fixture-family-atomics-no-field';
function reject(game: Game, actor: string, action: Action) {
const before = reload(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function assertWallCasualties(f: ReturnType<typeof fixture>, game: Game) {
const before = p(f.game, f.richese), after = p(game, f.richese), amount = Math.min(f.value, before.reserves);
assert.equal(game.shieldWallDestroyed, true); assert.equal(after.noField!.deployed, null);
assert.notEqual(after.noFieldEvent, f.event); assert.deepEqual(after.noField!.tokens, f.marker!.tokens);
assert.equal(after.noField!.lastShipped, f.token);
assert.equal(after.reserves, before.reserves - amount); assert.equal(after.tanks, before.tanks + amount);
assert.equal(Object.entries(after.forces).some(([key, n]) => key.startsWith('shield_wall:') && n > 0), false);
assert.deepEqual(inventory(game), f.original);
}
void test('native concealed zero alone qualifies Advanced Atomics and reveals without inventing Wall casualties', () => {
const f = fixture({ value: 0 });
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.equal(f.sourceCounts.setup, f.setupActions.length); assert.equal(f.sourceCounts.firstDials, f.firstDials.length);
assert.equal(f.sourceCounts.shipment, f.shipment.steps.length); assert.equal(f.sourceCounts.source, f.sourceActions.length);
assert.ok(f.shipment.steps.some(step => step.actor === f.richese && step.action.type === 'ship' && step.action.noField === f.token));
assert.equal(p(f.shipment.before, f.richese).spice - p(f.shipment.after, f.richese).spice, 2);
assert.deepEqual(p(f.game, f.richese).forces, {}); assert.equal(f.game.stormPending, 1);
const game = act(reload(f.game), f.owner, f.action); assertWallCasualties(f, game);
assert.equal(p(game, f.richese).tanks, 0);
assert.deepEqual(game.removedTreachery?.map(card => card.id), [f.card]);
assert.equal(game.discard.some(card => card.id === f.card), false);
assert.equal(game.pendingTreacheryDiscard ?? null, null);
reject(game, f.owner, f.action);
});
void test('original concealed three and five materialize once before all real Wall forces enter Tanks', () => {
for (const value of [3, 5] as const) {
const f = fixture({ value }); let game = act(reload(f.game), f.owner, f.action);
assertWallCasualties(f, game);
assert.equal(p(game, f.richese).tanks, value);
assert.deepEqual(game.removedTreachery?.map(card => card.id), [f.card]);
game = finishStorm(game);
assert.equal(p(game, f.richese).tanks, value); assert.equal(p(game, f.richese).noField!.deployed, null);
assert.equal(game.removedTreachery?.filter(card => card.id === f.card).length, 1);
assert.deepEqual(inventory(game), f.original);
}
});
void test('adjacent concealed zero three and five qualify without revealing denomination or changing their event', () => {
for (const value of [0, 3, 5] as const) {
const f = fixture({ value, geometry: 'adjacent' }), before = p(f.game, f.richese);
const game = act(reload(f.game), f.owner, f.action), after = p(game, f.richese);
assert.equal(game.shieldWallDestroyed, true); assert.deepEqual(after.noField, f.marker);
assert.equal(after.noFieldEvent, f.event); assert.equal(after.reserves, before.reserves);
assert.equal(after.tanks, before.tanks); assert.deepEqual(after.forces, {});
const foreign = viewGame(game, 'atreides').players.find(player => player.id === f.richese)!;
assert.deepEqual(foreign.noField!.deployed, { controller: f.richese,
location: { territory: 'imperial_basin', sector: 11 }, effectiveForces: 1 });
assert.deepEqual(game.removedTreachery?.map(card => card.id), [f.card]);
assert.deepEqual(inventory(game), f.original);
}
});
void test('a real intervening Storm blocks adjacent concealed zero before any card cost or marker revelation', () => {
const f = fixture({ value: 0, geometry: 'blocked' });
assert.deepEqual(f.firstDials.map(step => step.action.amount), [9, 0]); assert.equal(f.game.storm, 10);
reject(f.game, f.owner, f.action);
assert.equal(f.game.shieldWallDestroyed, false); assert.deepEqual(p(f.game, f.richese).noField, f.marker);
assert.equal(p(f.game, f.richese).noFieldEvent, f.event); assert.equal(p(f.game, f.richese).tanks, 0);
assert.ok(p(f.game, f.owner).hand.some(card => card.id === f.card));
assert.deepEqual(inventory(f.game), f.original);
});
void test('Basic marker-only Atomics retains early Storm timing and ordinary physical discard for all printed values', () => {
for (const value of [0, 3, 5] as const) {
const f = fixture({ advanced: false, value }); assert.equal(f.game.stormPending, null);
assert.equal(f.game.semutaPreview, true, 'Retain the actual public Basic Richese normalization marker');
const game = act(reload(f.game), f.owner, f.action); assertWallCasualties(f, game);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(game.removedTreachery ?? [], []);
}
});
void test('an actual classic adjacent qualifier detonates another owner concealed Wall marker through native revelation', () => {
const f = fixture({ value: 5, foreignOwner: true }); assert.ok(f.physicalShipment);
assert.equal(f.owner, 'emperor'); assert.equal(p(f.game, f.owner).forces['imperial_basin:9'], 1);
assert.equal(p(f.physicalShipment.before, f.owner).spice - p(f.physicalShipment.after, f.owner).spice, 2);
const game = act(reload(f.game), f.owner, f.action); assertWallCasualties(f, game);
assert.equal(p(game, f.owner).forces['imperial_basin:9'], 1); assert.equal(p(game, f.owner).tanks, 0);
assert.equal(p(game, f.richese).tanks, 5);
assert.deepEqual(game.removedTreachery?.map(card => card.id), [f.card]);
assert.equal(game.discard.some(card => card.id === f.card), false);
});
void test('all four minimal profiles confirm and complete actual Storm while an adjacent zero remains concealed', () => {
const f = fixture({ value: 0, geometry: 'adjacent' });
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) {
const view = viewGame(f.game, f.owner); view.players.find(player => player.id === f.owner)!.bot = difficulty;
const choice = botActions(view).find(action => action.type === 'ready'); assert.ok(choice);
let game = act(reload(f.game), f.owner, choice); game = finishStorm(game);
assert.equal(game.phase, 1); assert.equal(game.shieldWallDestroyed, false);
assert.deepEqual(p(game, f.richese).noField, f.marker); assert.equal(p(game, f.richese).noFieldEvent, f.event);
assert.ok(p(game, f.owner).hand.some(card => card.id === f.card)); assert.deepEqual(inventory(game), f.original);
}
});
+48 -5
View File
@@ -13,6 +13,10 @@ function player(forces: Record<string, number> = { 'shield_wall:8': 1 },
extra: Partial<FamilyAtomicsPlayer> = {}): FamilyAtomicsPlayer {
return { id: 'p', faction: 'atreides', forces, ...extra };
}
function concealed(territory = 'shield_wall', sector = 8, controller = 'p') {
return { ...player({}, { faction: 'richese' }),
noField: { deployed: { controller, location: { territory, sector } } } };
}
void test('Basic keeps its original pre-movement territory gate without Advanced turn or calculation requirements', () => {
const basic = { phase: 0, shieldWallDestroyed: false };
@@ -61,14 +65,53 @@ void test('a strictly intervening storm blocks the direct sector route without a
assert.equal(blocked(state({ storm: 4 }), player({ 'pasty_mesa:5': 1 })), null);
});
void test('BG advisors and a concealed No-Field marker do not supply Advanced physical fighters', () => {
void test('BG advisors cannot qualify while ordinary BG fighters remain eligible', () => {
const advisors = player({ 'shield_wall:8': 2 }, { faction: 'beneGesserit', advisors: { shield_wall: {} } });
assert.match(blocked(state(), advisors)!, /physical fighters/);
assert.match(blocked(state(), advisors)!, /fighters/);
assert.match(blocked(state({ advanced: false }), advisors)!, /need forces/);
assert.equal(blocked(state(), player({ 'shield_wall:8': 2 }, { faction: 'beneGesserit' })), null);
const concealed = { ...player({}, { faction: 'richese' }),
});
void test('an owned concealed No-Field alone counts at the Wall or adjacent sector under the existing geometry', () => {
assert.equal(blocked(state({ storm: 8 }), concealed()), null);
assert.equal(blocked(state(), concealed('pasty_mesa', 5)), null);
assert.equal(blocked(state({ storm: 8 }), concealed('pasty_mesa', 5)), null);
assert.match(blocked(state({ storm: 6 }), concealed('pasty_mesa', 5))!, /intervening storm/);
assert.match(blocked(state({ storm: 10 }), concealed('imperial_basin', 11))!, /intervening storm/);
assert.equal(blocked({ phase: 0, shieldWallDestroyed: false }, concealed()), null);
assert.equal(blocked(state({ advanced: false, turn: 1, storm: 6, stormPending: null }),
concealed('pasty_mesa', 5)), null, 'Basic retains its original territory-level requirement');
});
void test('the public controlled marker supplies one presence without consulting hidden value or reserves', () => {
const publicOnly = concealed();
assert.equal(blocked(state(), publicOnly), null);
for (const [value, reserves] of [[0, 0], [3, 1], [5, 20]]) {
// Extra private payload is irrelevant to this public marker-as-one quote.
const supplemented = { ...publicOnly, reserves,
noField: { deployed: { ...publicOnly.noField.deployed, value } } };
const before = structuredClone(supplemented);
assert.equal(blocked(state(), supplemented), null);
assert.equal(blocked(state({ advanced: false }), supplemented), null);
assert.deepEqual(supplemented, before);
}
});
void test('an absent, invalid or foreign-controlled marker cannot qualify or hide ordinary eligible fighters', () => {
const missingController = { ...player({}, { faction: 'richese' }),
noField: { deployed: { location: { territory: 'shield_wall', sector: 8 } } } };
assert.match(blocked(state(), concealed)!, /physical fighters/);
const invalid: FamilyAtomicsPlayer[] = [
concealed('shield_wall', 8, 'foreign'), concealed('shield_wall', 8, ''),
concealed('shield_wall', 0), concealed('pasty_mesa', 9), concealed('arsunt', 9),
player({}), player({}, { noField: null }), player({}, { noField: { deployed: null } }),
missingController as unknown as FamilyAtomicsPlayer,
];
for (const own of invalid) {
assert.match(blocked(state(), own)!, /fighters/);
assert.match(blocked(state({ advanced: false }), own)!, /need forces/);
assert.equal(blocked(state(), { ...own, forces: { 'shield_wall:8': 1 } }), null);
assert.equal(blocked(state({ advanced: false }), { ...own, forces: { 'shield_wall:8': 1 } }), null);
}
});
void test('remote presence, invalid physical locations and nonpositive counters cannot establish the sector source', () => {
@@ -76,7 +119,7 @@ void test('remote presence, invalid physical locations and nonpositive counters
{ 'shield_wall:8:extra': 1 }, { 'shield_wall:8': 0 }, { 'shield_wall:8': -1 },
{ 'shield_wall:8': Number.NaN }];
for (const forces of invalidForces)
assert.match(blocked(state(), player(forces))!, /physical fighters/);
assert.match(blocked(state(), player(forces))!, /fighters/);
});
void test('one unblocked owned fighter source is enough and quoting never changes state or forces', () => {
+146
View File
@@ -0,0 +1,146 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { traitorDeck } from '../game/traitors';
import type { NoFieldValue } from '../game/richese-no-field';
import { actFamilyAtomics, familyAtomicsPlayer, familyAtomicsReload } from './fixture-family-atomics';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import type { StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type FamilyAtomicsNoFieldStep = StrongholdFactionsNativeStep;
export const actFamilyAtomicsNoField = actFamilyAtomics;
export const familyAtomicsNoFieldPlayer = familyAtomicsPlayer;
export const familyAtomicsNoFieldReload = familyAtomicsReload;
export const familyAtomicsNoFieldClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingShipment;
export const nextFamilyAtomicsNoFieldStep = nextRicheseStrongholdsNativeStep;
export function advanceFamilyAtomicsNoField(game: Game, until: (state: Game) => boolean,
actions: FamilyAtomicsNoFieldStep[] = []): Game {
for (let count = 0; count < 1500; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original marker chronology ended before its requested boundary');
const step = nextFamilyAtomicsNoFieldStep(game); assert.ok(step, 'Complete the actual native pending control');
game = actFamilyAtomicsNoField(game, step.actor, step.action, actions);
}
throw Error('The original Atomics/No-Field source did not reach its finite boundary');
}
export function finishFamilyAtomicsNoFieldStorm(game: Game, actions: FamilyAtomicsNoFieldStep[] = []): Game {
return advanceFamilyAtomicsNoField(game, state => state.phase !== 0 && familyAtomicsNoFieldClean(state), actions);
}
export function familyAtomicsNoFieldInventory(game: Game) {
const cards = [...game.deck, ...game.discard, ...(game.removedTreachery ?? []), ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []), ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
.map(card => card.id).sort();
assert.deepEqual(cards, [...treacheryDeck(game.expansions), ...richeseCards()].map(card => card.id).sort());
const traitors = [...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
[...player.traitors, ...player.traitorChoices])].sort();
assert.deepEqual(traitors, traitorDeck(game.players, false).sort());
const spice = [...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort();
for (const player of game.players)
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
return { cards, traitors, spice };
}
/** Fresh public Richese/classics with no optional modules. A real single-token
* shipment supplies the only Richese Arrakis presence. The hidden denomination
* is selected through the owned native packet, never installed into a marker.
*/
export function createFamilyAtomicsNoFieldFixture(options: {
advanced?: boolean; value?: NoFieldValue; geometry?: 'wall' | 'adjacent' | 'blocked'; foreignOwner?: boolean;
} = {}) {
const advanced = options.advanced ?? true, value = options.value ?? 0;
const geometry = options.geometry ?? 'wall', foreignOwner = options.foreignOwner ?? false;
assert.ok(!foreignOwner || geometry === 'wall', 'The bounded foreign-cost source destroys a Wall marker');
const richese = 'richese', owner = foreignOwner ? 'emperor' : richese;
let game = createGame('ATOMICSNOFIELD', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: FamilyAtomicsNoFieldStep[] = [];
const act = (actor: string, action: Action) => { game = actFamilyAtomicsNoField(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceFamilyAtomicsNoField(game, until, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => !player.hand.length));
const atomics = game.deck.find(card => card.effect === 'atomics');
const weather = game.deck.find(card => card.effect === 'weather');
const ordinary = game.deck.find(card => card.kind === 'projectile'); assert.ok(atomics && weather && ordinary);
const deal = foreignOwner ? [weather, ordinary, atomics] : [atomics, ordinary, weather];
const dealOrder = deal.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), setupActions = structuredClone(actions);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal.map(card => card.id));
assert.deepEqual(familyAtomicsNoFieldPlayer(game, richese).forces, {});
const original = familyAtomicsNoFieldInventory(game);
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
});
const firstDialsFrom = actions.length;
for (const [index, actor] of game.stormDialers.entries())
act(actor, { type: 'stormDial', amount: geometry === 'blocked' && index === 0 ? 9 : 0 });
const firstDials = structuredClone(actions.slice(firstDialsFrom));
reach(state => state.turn === 1 && state.phase === 5 && familyAtomicsNoFieldClean(state));
const destination = geometry === 'wall' ? { territory: 'shield_wall', sector: 9 }
: { territory: 'imperial_basin', sector: 11 };
let shipment: { before: Game; steps: FamilyAtomicsNoFieldStep[]; after: Game } | null = null;
let physicalShipment: { before: Game; steps: FamilyAtomicsNoFieldStep[]; after: Game } | null = null;
let token = '', shipmentEvent = '';
for (let count = 0; game.phase === 5 && count < 80; count++) {
if (familyAtomicsNoFieldClean(game) && game.active === richese && !familyAtomicsNoFieldPlayer(game, richese).shipped) {
const own = familyAtomicsNoFieldPlayer(game, richese), selected = own.noField!.tokens.find(token => token.value === value);
assert.ok(selected && own.noFieldEvent); token = selected.id; shipmentEvent = own.noFieldEvent;
const before = structuredClone(game), from = actions.length;
act(richese, { type: 'ship', noField: token, event: shipmentEvent, ...destination }); reach(familyAtomicsNoFieldClean);
shipment = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
assert.equal(familyAtomicsNoFieldPlayer(before, richese).spice - familyAtomicsNoFieldPlayer(game, richese).spice, 2);
assert.equal(familyAtomicsNoFieldPlayer(before, richese).reserves, familyAtomicsNoFieldPlayer(game, richese).reserves);
continue;
}
if (foreignOwner && familyAtomicsNoFieldClean(game) && game.active === owner && !familyAtomicsNoFieldPlayer(game, owner).shipped) {
const before = structuredClone(game), from = actions.length;
act(owner, { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 1, elite: 0, allyPayment: 0 });
reach(familyAtomicsNoFieldClean);
physicalShipment = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
assert.equal(familyAtomicsNoFieldPlayer(before, owner).spice - familyAtomicsNoFieldPlayer(game, owner).spice, 2);
continue;
}
const step = nextFamilyAtomicsNoFieldStep(game); assert.ok(step); act(step.actor, step.action);
}
assert.ok(shipment); assert.ok(!foreignOwner || physicalShipment);
reach(state => state.turn === 2 && state.phase === 0 && familyAtomicsNoFieldClean(state));
assert.equal(game.storm, geometry === 'blocked' ? 10 : 1);
assert.equal(game.stormPending, advanced ? 1 : null);
const own = familyAtomicsNoFieldPlayer(game, richese);
assert.deepEqual(own.forces, {}); assert.equal(own.tanks, 0);
assert.equal(own.noField!.deployed!.tokenId, token);
assert.deepEqual(own.noField!.deployed!.location, destination);
const deployedEvent = familyAtomicsNoFieldPlayer(shipment.after, richese).noFieldEvent;
assert.notEqual(deployedEvent, shipmentEvent, 'The accepted native shipment rotates its consumed offer event');
assert.equal(own.noFieldEvent, deployedEvent, 'Native intervening phases retain the accepted concealed deployment event');
assert.ok(familyAtomicsNoFieldPlayer(game, owner).hand.some(card => card.id === atomics.id));
assert.deepEqual(familyAtomicsNoFieldInventory(game), original);
return { game, sourceGame: structuredClone(game), initial, lobby, setup, afterSetup, advanced, value, geometry,
foreignOwner, owner, richese, card: atomics.id, weather: weather.id, token, event: own.noFieldEvent,
destination, marker: structuredClone(own.noField), action: { type: 'card', card: atomics.id } satisfies Action,
shipment, physicalShipment, original, dealOrder, spiceOrder, firstDials, setupActions, actions,
sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { setup: setupActions.length, firstDials: firstDials.length, shipment: shipment.steps.length,
physicalShipment: physicalShipment?.steps.length ?? 0, source: actions.length },
sourceDisclosure: `Actual public ${advanced ? 'Advanced preview' : 'Basic'} Richese/Atreides/Emperor +CHOAM-deck setup, original private Traitors and original three No-Field tokens. ` +
'No Skills, Homeworlds, Nexus Cards or other optional modules. Only original unopened starting-card/land-Spice positions and scalar lotteries selected. ' +
`Richese actually ships its owned ${value} marker for two spice to ${destination.territory}:${destination.sector}; all physical reserves stay in supply while concealed. ` +
(foreignOwner ? 'Emperor separately ships an actual one-counter adjacent Basin9 qualifier for two spice and holds the originally dealt Atomics. ' : 'Richese itself holds the originally dealt Atomics; the marker is its only Arrakis presence. ') +
`Native first dials ${geometry === 'blocked' ? '9+0 earn Storm10' : '0+0 retain Storm1'}, then actual auctions/turns/phases reach original Storm2. ` +
(advanced ? 'Its calculated card1 is automatically revealed by the actual turn opening. ' : 'The original Basic second dial window remains uncalculated. ') +
'Every count derives from recorded actions. No held hand, purse, physical force, phase, queue, token deployment, marker event, wall state or casualty is installed.' };
}
export type FamilyAtomicsNoFieldFixture = ReturnType<typeof createFamilyAtomicsNoFieldFixture>;