Implement revised Advanced Family Atomics rules

This commit is contained in:
Quintine committed 2026-10-27 11:24:41 +11:00
1 parent dff0c77a5b
commit e1596dbcee
16 files changed
+683 -29

No files matched your search

+114
View File
@@ -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);
}
});
+92
View File
@@ -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/);
});
+142
View File
@@ -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);
+43
View File
@@ -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);
});