Implement revised Advanced Family Atomics rules
This commit is contained in:
1 parent
dff0c77a5b
commit
e1596dbcee
16 files changed
+683
-29
No files matched your search
@@ -0,0 +1,114 @@
|
||||
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 { actFamilyAtomics as act, createFamilyAtomicsFixture as fixture,
|
||||
familyAtomicsInventory as inventory, familyAtomicsPlayer as p, familyAtomicsReload as reload,
|
||||
finishFamilyAtomicsStorm as finishStorm, type FamilyAtomicsStep } from './fixture-family-atomics';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = reload(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
void test('genuine Advanced Atomics source earns its Wall army and original automatically revealed second Storm', () => {
|
||||
const f = fixture();
|
||||
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.beforeSecondStorm, f.beforeSecondStormActions.length);
|
||||
assert.equal(f.sourceCounts.secondStormOpening, f.secondStormOpening.length);
|
||||
assert.equal(f.sourceCounts.source, f.actions.length);
|
||||
assert.ok(f.shipment.steps.some(step => step.actor === f.owner && step.action.type === 'ship'));
|
||||
assert.equal(p(f.shipment.before, f.owner).spice - p(f.shipment.after, f.owner).spice, 4);
|
||||
assert.equal(p(f.game, f.owner).forces['shield_wall:9'], 2);
|
||||
assert.equal(f.firstBeforeDistance.turn, 1); assert.equal(f.firstBeforeDistance.stormPending, null);
|
||||
assert.equal(f.firstAfterDistance.stormPending, 0);
|
||||
reject(reload(f.firstBeforeDistance), f.owner, f.action);
|
||||
reject(reload(f.firstAfterDistance), f.owner, f.action);
|
||||
assert.equal(f.beforeSecondStorm.phase, 8);
|
||||
reject(reload(f.beforeSecondStorm), f.owner, f.action);
|
||||
assert.equal(f.secondStormOpening.length, 1);
|
||||
assert.equal(f.secondStormOpening[0].action.type, 'ready');
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.stormPending, 1); assert.equal(f.game.storm, 1);
|
||||
assert.deepEqual(inventory(f.game), f.original);
|
||||
});
|
||||
|
||||
void test('Advanced Atomics destroys actual Wall counters and removes its physical card once without a discard', () => {
|
||||
const f = fixture(); let game = reload(f.game);
|
||||
game = act(game, f.owner, { type: 'ready' }); assert.ok(game.ready.includes(f.owner));
|
||||
const spice = p(game, f.owner).spice;
|
||||
game = act(game, f.owner, f.action);
|
||||
assert.equal(game.shieldWallDestroyed, true); assert.deepEqual(game.ready, []);
|
||||
assert.equal(p(game, f.owner).forces['shield_wall:9'] ?? 0, 0);
|
||||
assert.equal(p(game, f.owner).tanks, 2); assert.equal(p(game, f.owner).spice, spice);
|
||||
assert.deepEqual(game.removedTreachery?.map(card => card.id), [f.card]);
|
||||
assert.equal(p(game, f.owner).hand.some(card => card.id === f.card), false);
|
||||
assert.equal(game.discard.some(card => card.id === f.card), false);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
for (const player of game.players)
|
||||
assert.deepEqual(viewGame(game, player.id).removedTreachery.map(card => card.id), [f.card]);
|
||||
reject(game, f.owner, f.action);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
game = finishStorm(game);
|
||||
assert.equal(game.phase, 1); assert.equal(game.removedTreachery?.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(p(game, f.owner).tanks, 2); assert.equal(game.discard.some(card => card.id === f.card), false);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('Advanced Atomics accepts genuine adjacent fighters with a clear path and preserves them off the Wall', () => {
|
||||
const f = fixture({ geometry: 'adjacent' });
|
||||
const game = act(reload(f.game), f.owner, f.action);
|
||||
assert.equal(game.storm, 1); assert.equal(game.shieldWallDestroyed, true);
|
||||
assert.equal(p(game, f.owner).forces['imperial_basin:11'], 2); assert.equal(p(game, f.owner).tanks, 0);
|
||||
assert.deepEqual(game.removedTreachery?.map(card => card.id), [f.card]);
|
||||
assert.equal(game.discard.some(card => card.id === f.card), false);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('actual first dials can earn a Storm strictly between adjacent fighters and the touching Wall sector', () => {
|
||||
const f = fixture({ geometry: 'blocked' });
|
||||
assert.deepEqual(f.firstDials.map(step => step.action.type === 'stormDial' ? step.action.amount : null), [9, 0]);
|
||||
assert.equal(f.game.storm, 10); assert.equal(p(f.game, f.owner).forces['imperial_basin:11'], 2);
|
||||
reject(f.game, f.owner, f.action);
|
||||
assert.equal(f.game.shieldWallDestroyed, false); assert.equal(p(f.game, f.owner).tanks, 0);
|
||||
assert.ok(p(f.game, f.owner).hand.some(card => card.id === f.card));
|
||||
assert.deepEqual(f.game.removedTreachery ?? [], []); assert.deepEqual(inventory(f.game), f.original);
|
||||
});
|
||||
|
||||
void test('Basic retains its real pre-distance Storm window and ordinary Atomics discard', () => {
|
||||
const f = fixture({ advanced: false, window: 'beforeDistance' });
|
||||
assert.equal(f.game.stormPending, null);
|
||||
const game = act(reload(f.game), f.owner, f.action);
|
||||
assert.equal(game.shieldWallDestroyed, true); assert.equal(p(game, f.owner).tanks, 2);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(p(game, f.owner).hand.some(card => card.id === f.card), false);
|
||||
assert.deepEqual(game.removedTreachery ?? [], []);
|
||||
assert.deepEqual(viewGame(game, f.owner).removedTreachery, []);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('an actual Weather Control zero is a valid calculated Advanced distance before Atomics', () => {
|
||||
const f = fixture(); let game = act(reload(f.game), f.observer, { type: 'card', card: f.weather, amount: 0 });
|
||||
assert.equal(game.stormPending, 0); assert.equal(game.discard.filter(card => card.id === f.weather).length, 1);
|
||||
game = act(game, f.owner, f.action);
|
||||
assert.equal(game.shieldWallDestroyed, true); assert.equal(p(game, f.owner).tanks, 2);
|
||||
assert.deepEqual(game.removedTreachery?.map(card => card.id), [f.card]);
|
||||
assert.equal(game.discard.some(card => card.id === f.card), false);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('all four minimal Storm policies propose legal native confirmation without strategic Atomics requirements', () => {
|
||||
const f = fixture();
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) {
|
||||
let game = reload(f.game); const actions: FamilyAtomicsStep[] = [];
|
||||
const view = viewGame(game, f.owner); view.players.find(player => player.id === f.owner)!.bot = difficulty;
|
||||
const action = botActions(view).find(action => action.type === 'ready');
|
||||
assert.ok(action, `${difficulty} supplies the actual native Storm confirmation`);
|
||||
game = act(game, f.owner, action, actions);
|
||||
assert.ok(game.ready.includes(f.owner)); assert.equal(actions.length, 1);
|
||||
assert.ok(p(game, f.owner).hand.some(card => card.id === f.card));
|
||||
assert.deepEqual(game.removedTreachery ?? [], []);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
familyAtomicsBlock as blocked,
|
||||
type FamilyAtomicsPlayer,
|
||||
type FamilyAtomicsState,
|
||||
} from '../game/family-atomics';
|
||||
|
||||
function state(extra: Partial<FamilyAtomicsState> = {}): FamilyAtomicsState {
|
||||
return { advanced: true, turn: 2, phase: 0, shieldWallDestroyed: false, storm: 18, stormPending: 3, ...extra };
|
||||
}
|
||||
function player(forces: Record<string, number> = { 'shield_wall:8': 1 },
|
||||
extra: Partial<FamilyAtomicsPlayer> = {}): FamilyAtomicsPlayer {
|
||||
return { id: 'p', faction: 'atreides', forces, ...extra };
|
||||
}
|
||||
|
||||
void test('Basic keeps its original pre-movement territory gate without Advanced turn or calculation requirements', () => {
|
||||
const basic = { phase: 0, shieldWallDestroyed: false };
|
||||
assert.equal(blocked(basic, player()), null);
|
||||
assert.equal(blocked({ ...basic, advanced: false, turn: 1, storm: 6, stormPending: null },
|
||||
player({ 'pasty_mesa:5': 1 })), null);
|
||||
assert.match(blocked(basic, player({ 'arsunt:9': 1 }))!, /on or adjacent/);
|
||||
});
|
||||
|
||||
void test('an intact Wall and the still-open original Storm card window are common requirements', () => {
|
||||
for (const extra of [{ shieldWallDestroyed: true }, { phase: 1 },
|
||||
{ stormCardWindowOpen: false }, { stormResolution: { distance: 3 } }]) {
|
||||
assert.match(blocked(state(extra), player())!, /before storm movement/);
|
||||
assert.match(blocked(state({ ...extra, advanced: false }), player())!, /before storm movement/);
|
||||
}
|
||||
assert.equal(blocked(state({ stormCardWindowOpen: true, stormResolution: null }), player()), null);
|
||||
});
|
||||
|
||||
void test('Advanced requires a later turn and calculated movement including zero before the storm moves', () => {
|
||||
assert.match(blocked(state({ turn: 1 }), player())!, /after the first/);
|
||||
assert.match(blocked(state({ turn: undefined }), player())!, /after the first/);
|
||||
for (const stormPending of [undefined, null, -1, 1.5, 41, Number.NaN])
|
||||
assert.match(blocked(state({ stormPending }), player())!, /movement is calculated/);
|
||||
assert.equal(blocked(state({ stormPending: 0 }), player()), null);
|
||||
assert.equal(blocked(state({ stormPending: 40 }), player()), null);
|
||||
for (const storm of [undefined, -1, 19, 1.5, Number.NaN])
|
||||
assert.match(blocked(state({ storm }), player())!, /storm position/);
|
||||
assert.equal(blocked(state({ storm: 0 }), player()), null, 'inactive/legacy position zero remains tolerated');
|
||||
});
|
||||
|
||||
void test('physical fighters in every actual touching region qualify and storm at either endpoint is allowed', () => {
|
||||
for (const key of ['shield_wall:8', 'shield_wall:9', 'gara_kulon:8', 'hole_in_the_rock:9',
|
||||
'imperial_basin:9', 'pasty_mesa:8', 'the_minor_erg:8', 'sihaya_ridge:9'])
|
||||
assert.equal(blocked(state(), player({ [key]: 1 })), null, key);
|
||||
assert.equal(blocked(state({ storm: 5 }), player({ 'pasty_mesa:5': 1 })), null);
|
||||
assert.equal(blocked(state({ storm: 8 }), player({ 'pasty_mesa:5': 1 })), null);
|
||||
assert.equal(blocked(state({ storm: 11 }), player({ 'imperial_basin:11': 1 })), null);
|
||||
assert.equal(blocked(state({ storm: 9 }), player({ 'imperial_basin:11': 1 })), null);
|
||||
});
|
||||
|
||||
void test('a strictly intervening storm blocks the direct sector route without a long circular detour', () => {
|
||||
assert.match(blocked(state({ storm: 6 }), player({ 'pasty_mesa:5': 1 }))!, /intervening storm/);
|
||||
assert.match(blocked(state({ storm: 7 }), player({ 'the_minor_erg:5': 1 }))!, /intervening storm/);
|
||||
assert.match(blocked(state({ storm: 10 }), player({ 'imperial_basin:11': 1 }))!, /intervening storm/);
|
||||
assert.equal(blocked(state({ storm: 12 }), player({ 'imperial_basin:11': 1 })), null);
|
||||
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', () => {
|
||||
const advisors = player({ 'shield_wall:8': 2 }, { faction: 'beneGesserit', advisors: { shield_wall: {} } });
|
||||
assert.match(blocked(state(), advisors)!, /physical 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' }),
|
||||
noField: { deployed: { location: { territory: 'shield_wall', sector: 8 } } } };
|
||||
assert.match(blocked(state(), concealed)!, /physical fighters/);
|
||||
});
|
||||
|
||||
void test('remote presence, invalid physical locations and nonpositive counters cannot establish the sector source', () => {
|
||||
const invalidForces: Record<string, number>[] = [{ 'arsunt:9': 1 }, { 'shield_wall:0': 1 }, { 'pasty_mesa:9': 1 },
|
||||
{ '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/);
|
||||
});
|
||||
|
||||
void test('one unblocked owned fighter source is enough and quoting never changes state or forces', () => {
|
||||
const current = state({ storm: 6 });
|
||||
const own = player({ 'pasty_mesa:5': 2, 'imperial_basin:9': 1 });
|
||||
const before = structuredClone({ current, own });
|
||||
Object.freeze(current);
|
||||
Object.freeze(own.forces);
|
||||
Object.freeze(own);
|
||||
assert.equal(blocked(current, own), null);
|
||||
assert.deepEqual({ current, own }, before);
|
||||
assert.match(blocked(current, player({ 'pasty_mesa:5': 2 }))!, /intervening storm/);
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { actChoamCharityFunding } from './fixture-choam-charity-funding';
|
||||
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type FamilyAtomicsStep = StrongholdFactionsNativeStep;
|
||||
export const actFamilyAtomics = actChoamCharityFunding;
|
||||
export const familyAtomicsReload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
|
||||
export const familyAtomicsPlayer = (game: Game, actor: string) => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const familyAtomicsClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||
!game.decision && !game.pendingTreacheryDiscard && !game.truthtrance;
|
||||
export const nextFamilyAtomicsStep = nextStrongholdFactionsNativeStep;
|
||||
export function advanceFamilyAtomics(game: Game, until: (state: Game) => boolean,
|
||||
actions: FamilyAtomicsStep[] = []): Game {
|
||||
for (let count = 0; count < 1200; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Atomics chronology ended before its requested boundary');
|
||||
const next = nextFamilyAtomicsStep(game); assert.ok(next, 'The quiet native source needs an actual control');
|
||||
game = actFamilyAtomics(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original Atomics chronology did not reach its finite boundary');
|
||||
}
|
||||
export function passFamilyAtomicsResponses(game: Game, actions: FamilyAtomicsStep[] = []): Game {
|
||||
while (game.response) {
|
||||
const next = nextFamilyAtomicsStep(game); assert.ok(next); assert.equal(next.action.type, 'passResponse');
|
||||
game = actFamilyAtomics(game, next.actor, next.action, actions);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
export function finishFamilyAtomicsStorm(game: Game, actions: FamilyAtomicsStep[] = []): Game {
|
||||
return advanceFamilyAtomics(game, state => state.phase !== 0 && familyAtomicsClean(state), actions);
|
||||
}
|
||||
export function familyAtomicsInventory(game: Game) {
|
||||
const cards = [...game.deck, ...game.discard, ...(game.removedTreachery ?? []),
|
||||
...game.players.flatMap(player => player.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])]
|
||||
.map(card => card.id).sort();
|
||||
assert.deepEqual(cards, treacheryDeck(game.expansions).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 };
|
||||
}
|
||||
|
||||
/** Original bare A/H/E setup and one paid two-counter shipment. Only original
|
||||
* unopened Treachery/Spice positions and scalar lotteries are selected. The
|
||||
* blocked route earns Storm10 through the actual FIRST dials (9 + 0), leaving
|
||||
* real Imperial Basin11 fighters beyond Storm10 from the Wall's touching9.
|
||||
*/
|
||||
export function createFamilyAtomicsFixture(options: {
|
||||
advanced?: boolean; geometry?: 'wall' | 'adjacent' | 'blocked'; window?: 'beforeDistance' | 'calculated';
|
||||
} = {}) {
|
||||
const advanced = options.advanced ?? true, geometry = options.geometry ?? 'wall';
|
||||
const window = options.window ?? 'calculated';
|
||||
assert.ok(!advanced || window === 'calculated', 'Advanced Storm2 reveals its distance immediately; use the original preceding Mentat snapshot');
|
||||
const owner = 'harkonnen', observer = 'emperor';
|
||||
let game = createGame('FAMILYATOMICS', newPlayer('atreides', 'Atreides', 'atreides'), advanced);
|
||||
joinGame(game, newPlayer(owner, 'Harkonnen', 'harkonnen'));
|
||||
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
|
||||
const initial = structuredClone(game), actions: FamilyAtomicsStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actFamilyAtomics(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceFamilyAtomics(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.filter(card => card.kind === 'projectile').slice(0, 2);
|
||||
assert.ok(atomics && weather); assert.equal(ordinary.length, 2);
|
||||
const prefix = [ordinary[0], atomics, ordinary[1], weather];
|
||||
const dealOrder = prefix.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)), prefix.map(card => card.id));
|
||||
const original = familyAtomicsInventory(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 firstBeforeDistance = structuredClone(game), firstDialsFrom = actions.length;
|
||||
assert.equal(game.stormPending, null);
|
||||
for (const [index, actor] of game.stormDialers.entries())
|
||||
act(actor, { type: 'stormDial', amount: geometry === 'blocked' && index === 0 ? 9 : 0 });
|
||||
const firstAfterDistance = structuredClone(game), firstDials = structuredClone(actions.slice(firstDialsFrom));
|
||||
assert.equal(game.stormPending, geometry === 'blocked' ? 9 : 0);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && familyAtomicsClean(state));
|
||||
const shipmentBefore = structuredClone(game), shipmentFrom = actions.length;
|
||||
const destination = geometry === 'wall' ? { territory: 'shield_wall', sector: 9 }
|
||||
: { territory: 'imperial_basin', sector: 11 };
|
||||
const shipmentAction: Action = { type: 'ship', ...destination, amount: 2, elite: 0, allyPayment: 0 };
|
||||
act(owner, shipmentAction); reach(familyAtomicsClean);
|
||||
const shipment = { before: shipmentBefore, steps: structuredClone(actions.slice(shipmentFrom)), after: structuredClone(game) };
|
||||
assert.equal(familyAtomicsPlayer(shipmentBefore, owner).spice - familyAtomicsPlayer(game, owner).spice, 4);
|
||||
const location = `${destination.territory}:${destination.sector}`;
|
||||
assert.equal(familyAtomicsPlayer(game, owner).forces[location], 2);
|
||||
act(owner, { type: 'endMovement' });
|
||||
reach(state => state.turn === 1 && state.phase === 8 && familyAtomicsClean(state) &&
|
||||
state.ready.length === state.players.length - 1);
|
||||
const beforeSecondStorm = structuredClone(game), beforeSecondStormActions = structuredClone(actions);
|
||||
const revealFrom = actions.length;
|
||||
reach(state => state.turn === 2 && state.phase === 0 && familyAtomicsClean(state));
|
||||
const afterSecondStormOpening = structuredClone(game), secondStormOpening = structuredClone(actions.slice(revealFrom));
|
||||
if (!advanced && window === 'calculated') reach(state => state.stormPending !== null && familyAtomicsClean(state));
|
||||
assert.equal(game.turn, 2); assert.equal(game.phase, 0); assert.equal(game.shieldWallDestroyed, false);
|
||||
assert.equal(game.storm, geometry === 'blocked' ? 10 : 1);
|
||||
if (advanced) assert.equal(game.stormPending, 1, 'The original Advanced card lottery is revealed immediately at turn start');
|
||||
else assert.equal(game.stormPending, window === 'beforeDistance' ? null : 2);
|
||||
assert.equal(familyAtomicsPlayer(game, owner).forces[location], 2);
|
||||
assert.equal(familyAtomicsPlayer(game, owner).tanks, 0);
|
||||
assert.ok(familyAtomicsPlayer(game, owner).hand.some(card => card.id === atomics.id));
|
||||
assert.deepEqual(familyAtomicsInventory(game), original);
|
||||
return { game, initial, lobby, setup, afterSetup, firstBeforeDistance, firstAfterDistance,
|
||||
firstDials, beforeSecondStorm, beforeSecondStormActions, afterSecondStormOpening, secondStormOpening,
|
||||
owner, observer, advanced, geometry, window, location, destination, card: atomics.id, weather: weather.id,
|
||||
action: { type: 'card', card: atomics.id } satisfies Action,
|
||||
shipmentAction, shipment, original, dealOrder, spiceOrder, actions, setupActions,
|
||||
sourceActions: actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { setup: setupActions.length, firstDials: firstDials.length, shipment: shipment.steps.length,
|
||||
beforeSecondStorm: beforeSecondStormActions.length, secondStormOpening: secondStormOpening.length, source: actions.length },
|
||||
sourceDisclosure: `Actual public ${advanced ? 'Advanced preview' : 'Basic'} Atreides/Harkonnen/Emperor base setup, native Traitors and starting armies. ` +
|
||||
'Select only original unopened Treachery/land-Spice positions and scalar lotteries. Harkonnen actually pays four spice to ship two original reserve counters; every intervening turn, auction and phase control is native. ' +
|
||||
(geometry === 'blocked' ? 'Actual first Storm dials9+0 earn Storm10; the surviving shipment is Imperial Basin11. '
|
||||
: `Actual first Storm dials0+0 retain Storm1; the surviving shipment is ${location}. `) +
|
||||
(advanced ? 'The final actual Mentat Ready automatically opens Storm2 and reveals its original one-sector card; no quiet Advanced turn2/null-distance state is claimed. '
|
||||
: 'Basic Storm2 retains its native dial window and is optionally actually calculated. ') +
|
||||
'All counts derive from recorded arrays. No held card, force, purse, phase, storm distance, wall, removal or discard is installed.' };
|
||||
}
|
||||
export type FamilyAtomicsFixture = ReturnType<typeof createFamilyAtomicsFixture>;
|
||||
@@ -115,6 +115,7 @@ function inventory(g: Game) {
|
||||
const ids = [
|
||||
...g.deck,
|
||||
...g.discard,
|
||||
...(g.removedTreachery ?? []),
|
||||
...g.players.flatMap((p) => p.hand),
|
||||
...(g.richeseCache ?? []),
|
||||
...(g.ornithopter ? [g.ornithopter.card] : []),
|
||||
@@ -281,7 +282,7 @@ void test('a Harvester in the storm records its completed blow without creating
|
||||
assert.equal(done.spiceWindow!.amount, 16);
|
||||
});
|
||||
|
||||
void test('Family Atomics can recover after killing its own qualifying Wall fighters and all typed opposing groups', () => {
|
||||
void test('Advanced Family Atomics removes its card instead of opening a discard after killing all typed Wall groups', () => {
|
||||
const g = fixture(0, true, ['emperor', 'fremen', 'atreides']),
|
||||
card = hold(g, 'p', 'atomics');
|
||||
player(g, 'p').forces = { 'shield_wall:8': 2, 'arrakeen:10': 1 };
|
||||
@@ -303,9 +304,13 @@ void test('Family Atomics can recover after killing its own qualifying Wall figh
|
||||
g.stormPending = 1;
|
||||
g.stormMovementSource = createStormSource(g.turn, 'card', g.stormPending);
|
||||
g.ready = ['t'];
|
||||
const { pending, done } = play(g, 'p', { type: 'card', card });
|
||||
assert.equal(pending.shieldWallDestroyed, true);
|
||||
assert.deepEqual(pending.ready, []);
|
||||
const original = inventory(g), done = applyAction(g, 'p', { type: 'card', card });
|
||||
assert.equal(done.shieldWallDestroyed, true);
|
||||
assert.deepEqual(done.ready, []);
|
||||
assert.equal(done.pendingTreacheryDiscard ?? null, null);
|
||||
assert.deepEqual(done.removedTreachery?.map(removed => removed.id), [card]);
|
||||
assert.equal(done.discard.some(discarded => discarded.id === card), false);
|
||||
assert.deepEqual(inventory(done), original);
|
||||
for (const [id, total, elite] of [
|
||||
['p', 2, 1],
|
||||
['q', 3, 2],
|
||||
@@ -315,7 +320,6 @@ void test('Family Atomics can recover after killing its own qualifying Wall figh
|
||||
assert.equal(player(done, id).elites!.tanks, elite);
|
||||
}
|
||||
assert.equal(player(done, 'p').forces['arrakeen:10'], 1);
|
||||
assert.deepEqual(done.players, pending.players);
|
||||
const after = ready(done);
|
||||
assert.equal(player(after, 'p').tanks, 2);
|
||||
assert.equal(player(after, 'q').tanks, 3);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { RemovedTreachery } from '../components/removed-treachery';
|
||||
import { baseDeck, type Card } from '../game/cards';
|
||||
|
||||
const atomics = baseDeck().find((card) => card.effect === 'atomics')!;
|
||||
const weather = baseDeck().find((card) => card.effect === 'weather')!;
|
||||
const markup = (cards: readonly Card[]) =>
|
||||
renderToStaticMarkup(createElement(RemovedTreachery, { cards }));
|
||||
|
||||
void test('an empty public removed pile renders no section or invented cards', () => {
|
||||
assert.equal(markup([]), '');
|
||||
});
|
||||
|
||||
void test('removed cards have a persistent counted summary and readable public names', () => {
|
||||
const single = markup([atomics]);
|
||||
assert.match(single, /<details[^>]*aria-label="Removed Treachery cards"/);
|
||||
assert.match(single, /<summary[^>]*>Removed from game \(1 card\)<\/summary>/);
|
||||
assert.match(single, /<strong>Family Atomics<\/strong>/);
|
||||
|
||||
const multiple = markup([atomics, weather]);
|
||||
assert.match(multiple, /Removed from game \(2 cards\)/);
|
||||
assert.match(multiple, /<strong>Weather Control<\/strong>/);
|
||||
assert.equal(multiple.match(/<li\b/g)?.length, 2);
|
||||
});
|
||||
|
||||
void test('each removed face uses the existing read-only card inspector', () => {
|
||||
const html = markup([atomics, weather]);
|
||||
assert.match(html, /aria-label="Inspect card: Family Atomics"/);
|
||||
assert.match(html, /aria-label="Inspect card: Weather Control"/);
|
||||
assert.equal(html.match(/aria-label="Inspect card:/g)?.length, 2);
|
||||
assert.doesNotMatch(html, /Play card|Confirm play|Recover card|name="card"/);
|
||||
});
|
||||
|
||||
void test('the removed pile explains its final destination without mutating supplied faces', () => {
|
||||
const cards = Object.freeze([Object.freeze({ ...atomics })]);
|
||||
const before = JSON.stringify(cards);
|
||||
const html = markup(cards);
|
||||
assert.match(html, /cannot be drawn, reshuffled or recovered from discard/);
|
||||
assert.equal(JSON.stringify(cards), before);
|
||||
});
|
||||
Reference in new issue
Block a user