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

+4
View File
@@ -61,6 +61,10 @@ uses one unused movement entitlement and retains Hajr's separate move.
The single physical Hajr can be played again after legitimate
[paid Box recovery](docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#recovered-hajr-replay--18-october-2026).
Advanced still requires a fresh post-movement opportunity.
Advanced Family Atomics is available after turn1, after storm distance is known
and before movement, from physical fighters on/adjacent to the Wall with no
intervening storm. Its used card appears in **Removed from game**. Basic keeps
its current Storm/discard procedure. See [source and scope](docs/FAMILY_ATOMICS.md).
After Guild Cunning, either physical Ornithopter mode may provisionally replace
an earned unused Hajr movement. Cunning itself adds no ordinary move; abandoned
card groups grant no extra credit. See the [combination contract](docs/NEXUS_GUILD_CUNNING_RUNTIME.md#physical-ornithopter-composition--18-october-2026).
+2
View File
@@ -18,6 +18,7 @@ import { ForceInspector } from './force-inspector';
import { FactionInspector } from './faction-inspector';
import { EcazLoyaltyCard } from './ecaz-loyalty';
import { DrawPiles } from './draw-piles';
import { RemovedTreachery } from './removed-treachery';
import { HandBrowser } from './hand-browser';
import { LobbyBotControls } from './lobby-bot-controls';
import { AdvancedPreviewNotice, RulesetControls } from './ruleset-controls';
@@ -1692,6 +1693,7 @@ export function GameTable({
<section className="board-column">
<SpiceBankerIncome game={g} />
<DrawPiles piles={g.drawPiles} />
<RemovedTreachery cards={g.removedTreachery ?? []} />
<div className="board-toolbar">
<span>
<Wind size={15} />
+26
View File
@@ -0,0 +1,26 @@
import type { Card } from '@/game/cards';
import { CardInspector } from './card-inspector';
/** Only receives the public faces of cards already removed from the game. */
export function RemovedTreachery({ cards }: { cards: readonly Card[] }) {
if (cards.length === 0) return null;
return (
<details className="rounded-lg border border-[#454b3c] p-3" aria-label="Removed Treachery cards">
<summary className="cursor-pointer font-semibold">
Removed from game ({cards.length} {cards.length === 1 ? 'card' : 'cards'})
</summary>
<p className="mt-3 text-sm">
These used cards cannot be drawn, reshuffled or recovered from discard.
</p>
<ul className="mt-3 grid list-none gap-3 p-0 sm:grid-cols-2">
{cards.map((card) => (
<li className="flex flex-col gap-2 rounded-md border border-[#454b3c] p-3" key={card.id}>
<strong>{card.name}</strong>
<CardInspector card={card} />
</li>
))}
</ul>
</details>
);
}
+37 -6
View File
@@ -20,6 +20,34 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
**Advanced Family Atomics — 27 October. Prototyped / Partial.**
Play after turn1 and calculated distance, before movement, from physical
fighters on/adjacent to the Wall with no strictly intervening storm. Endpoints
may be stormed; advisors cannot qualify. Native Wall casualties remain. Advanced
moves the used physical card into public `removedTreachery`, outside discard,
recycling, Semuta and Box. The persistent public pile supplies normal inspection.
Basic keeps its current timing/discard procedure; completed old plays are not
converted. [Source, geometry and remaining scope](FAMILY_ATOMICS.md).
Frozen **10-file union 82/82 plus one changed disposal case**, types/lint/build
and 5 original-game programmes pass: Advanced Wall, adjacent, Weather0,
intervening-storm rejection and Basic pre-distance. Actual original setup,
dials0+0/9+0, paid shipment2 for4 and the complete first turn earn Storm2.
Runtime records 14 GameTable SSR, 4 removed-pile renders and 4 actual
configured HandBrowser card-render Play callbacks. Hand selection/navigation
and browser hydration were not operated. Source/further recorded arrays total
192/19 across programmes; no played hand/force/purse/phase/Storm/removal
is installed. Other Weather/Storm packets are applied directly. Nineteen new
helper/native/display cases and the revised typed-Wall removal case pass.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-family-atomics-proof-v2/`;
HTML/journeys: adjacent `20261027-family-atomics-runtime/run-v2/`.
Passed union/disposal/types/lint carry from v1 under identical source bytes;
v2 repairs only the private hand-renderer capture. The existing server returned
HTTP200 without restart. No saved-game/database operation. Concealed No-Field
qualification, wider interactions and final full-mode/recovery/privacy/browser/
deployed acceptance remain.
**CHOAM total support income — 27 October. Prototyped / Partial.**
Advanced income now adds eligible support before rounding down once:3+5 yields4
and3+3 yields3. Original `support - allyPayment` eligibility retains Arrakeen's
@@ -95,12 +123,15 @@ Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-ix-starting-pool-p
journeys/HTML: adjacent `20261027-ix-starting-pool-runtime/run/`.
[Source contract](IX_PROTOTYPE.md#advanced-starting-draft-source-cutover--27-october-2026).
**Next source-identified function:** Family Atomics' full timing/sector/removal
contract (supplied physical39). The revision's stricter requirements remain
Advanced-only after bounded Basic comparison. Charity Sapho composition remains
for the interaction pass. These findings are concrete divergences, not a complete
rules audit. The bounded six-classic-faction pass found connected independent
functions; optional modules, interactions and final acceptance remain open.
**Next scope:** individual printed optional card/token effects, using their
canonical helper/control evidence, before declaring independent rule delivery
finished. A bounded supplied physical19–24 comparison found Discovery blow
casualties, occupied-Homeworld immediate sharing, Nexus replacement/redraw/
recycling and Tech accrual connected; it found no new standalone gap and does
not certify complete modules. Source-recorded provisional rulings stay pending.
Charity Sapho and Atomics No-Field qualification remain known interaction work.
The earlier bounded six-classic-faction pass likewise found connected independent
functions; complete rules and final acceptance are still unproven.
**Native E3 trained capture — 27 October. Prototyped / Partial.**
Fresh bare Advanced E3/all14 setups now connect Harkonnen with actual Ecaz
+78
View File
@@ -0,0 +1,78 @@
# Family Atomics source cutover
27 October 2026. **Prototyped / Partial.** The supplied Advanced authority,
physical39/printed37, gives the revised card's complete timing, sector test and
removal. It also expressly excludes advisors. The source is bound by the
[authorized amendment](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026).
```sh
pdftotext -f 39 -l 39 -layout UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf -
```
## Connected Advanced behavior
- Play after the first game turn, after movement distance is calculated and
before the storm actually moves. Weather Control zero is calculated distance.
Native Advanced Storm2 reveals its card immediately at turn opening; the
prototype does not invent an earlier quiet/null-distance turn2 boundary.
- Physical fighters must be on the Wall or in an adjacent territory with no
storm strictly between their sector and the touching Wall sector. A storm
in the originating sector or on the Wall is permitted. Basin11 reaches Wall9
through sector10; a storm10 blocks it. Pasty Mesa/Minor Erg approach Wall8.
The helper uses that direct interval, without a circular detour.
- Destroy all original Wall forces with the existing typed casualty path, clear
earlier confirmations and retain the permanent destroyed-wall state. The
three protected territories lose protection; rock/entry and ordinary storm
movement remain. Destruction does not retroactively sweep an unchanged sector.
- Move the physical card from hand to public `removedTreachery`, outside draw,
discard, recycling, Semuta offers and Box candidates. The persistent Removed
from game pile supplies its name and normal inspector. No discard receipt or
separate collection/confirmation is fabricated. Already completed older plays
retain their recorded outcome; no game is rewritten.
`game/family-atomics.ts` owns legality through shared ordinary-card availability.
The native action, printed-window witnesses and actual hand control use it.
The engine owns force destruction and the optional additive removed-card array;
legacy games project an empty array. Removed cards retain normal card-name
normalization. Detached printed-card guards do not invent discard knowledge for
the removed card. Minimal AI retains legal Storm confirmation; optional strategic
card use and calibration remain deferred.
## Basic boundary and remaining scope
Basic retains its existing territory-level qualification, before/after-distance
Storm window and ordinary discard. Earlier bounded primary comparison verified
storm endpoints and optional waiting for distance, but did not locate a primary
Basic card face imposing all four revision requirements. This cutover therefore
does not silently import the supplied Advanced revision into Basic.
Physical-fighter qualification is connected. Concealed No-Field qualification
and broader optional-module/card combinations remain interaction scope; the
prototype does not certify full modes. No database migration/reset, conversion
of completed plays, recovery/privacy campaign or deployed acceptance is claimed.
## Focused evidence
Frozen **10-file union 82/82 plus one changed disposal case**, types/lint/build
and five original-game programmes pass. Actual public Basic/explicit Advanced
preview starts, original unopened deal/Spice choices and scalar lotteries,
real dials0+0/9+0, paid shipment2 for4 and a full first turn earn actual Storm2.
Wall, clear adjacent, actual Weather0, blocked interval and Basic pre-distance
sources install no played state. Advanced reveals its original1 immediately;
Basic supplies its real null-distance window.
Runtime records 14 GameTable SSR, 4 public removed-pile renders and 4
actual GameTable-configured HandBrowser card-render Play callbacks. The hand
navigation/selection UI and browser hydration were not operated. Source/further
action arrays total 192/19 across programmes. Weather/remaining Storm packets
use the native engine directly. Nineteen new focused cases cover legality,
actual original forces/cards, public removal, Basic and four minimal policies.
The former Advanced discard case now verifies immediate removal and all typed
Wall casualties; Basic's existing continuation remains separate.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-family-atomics-proof-v2/`;
HTML/journeys: adjacent `20261027-family-atomics-runtime/run-v2/`.
Tests/disposal/types/lint carry from v1 under the identical source snapshot;
only the private HandBrowser-render capture changed. The existing server returned
HTTP200 without restart. Concealed No-Field qualification, broader interactions,
complete modes and final assurance/deployed acceptance remain open.
+29
View File
@@ -1,5 +1,34 @@
# Dune implementation status
## 27 October 2026 — Advanced Family Atomics timing and removal
Prototyped / Partial. Play after turn1 and calculated distance, before movement, from physical
fighters on/adjacent to the Wall with no strictly intervening storm. Endpoints
may be stormed; advisors cannot qualify. Native Wall casualties remain. Advanced
moves the used physical card into public `removedTreachery`, outside discard,
recycling, Semuta and Box. The persistent public pile supplies normal inspection.
Basic keeps its current timing/discard procedure; completed old plays are not
converted. [Source, geometry and remaining scope](FAMILY_ATOMICS.md).
Frozen **10-file union 82/82 plus one changed disposal case**, types/lint/build
and 5 original-game programmes pass: Advanced Wall, adjacent, Weather0,
intervening-storm rejection and Basic pre-distance. Actual original setup,
dials0+0/9+0, paid shipment2 for4 and the complete first turn earn Storm2.
Runtime records 14 GameTable SSR, 4 removed-pile renders and 4 actual
configured HandBrowser card-render Play callbacks. Hand selection/navigation
and browser hydration were not operated. Source/further recorded arrays total
192/19 across programmes; no played hand/force/purse/phase/Storm/removal
is installed. Other Weather/Storm packets are applied directly. Nineteen new
helper/native/display cases and the revised typed-Wall removal case pass.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-family-atomics-proof-v2/`;
HTML/journeys: adjacent `20261027-family-atomics-runtime/run-v2/`.
Passed union/disposal/types/lint carry from v1 under identical source bytes;
v2 repairs only the private hand-renderer capture. The existing server returned
HTTP200 without restart. No saved-game/database operation. Concealed No-Field
qualification, wider interactions and final full-mode/recovery/privacy/browser/
deployed acceptance remain.
## 27 October 2026 — CHOAM aggregate support rounding
Prototyped / Partial. Advanced income now adds eligible support before rounding down once:3+5 yields4
+11
View File
@@ -1,5 +1,16 @@
# Rule decision index
## Advanced Family Atomics — 27 October 2026
Supplied physical39/printed37 requires play after turn1, after distance
calculation/before movement, with no intervening storm between qualifying
physical fighters and the Wall. Endpoints may be stormed; advisors cannot
qualify. Advanced use removes the physical card outside discard. Basic retains
its current procedure after the earlier bounded primary comparison found no
verified Basic card face imposing the full revision. Direct touching-sector
intervals implement the geometry; concealed No-Field composition remains open.
No already-completed play is converted. [Source and scope](FAMILY_ATOMICS.md).
## CHOAM total support rounding — 27 October 2026
Supplied Advanced physical29/printed27 F calls for half the total eligible
+8 -12
View File
@@ -1,5 +1,4 @@
import { fighterCount } from './advisors';
import { TERRITORIES } from './board';
import { familyAtomicsBlock } from './family-atomics';
import { harvesterAvailable, type Card } from './cards';
import type { FactionId } from './catalog';
import { choamSaleGholaTiming } from './choam-market-ghola';
@@ -27,6 +26,7 @@ export type OrdinaryCardPlayer = {
export type OrdinaryCardState = {
status: 'lobby' | 'setup' | 'playing' | 'finished';
phase: number;
advanced?: boolean;
turn?: number;
revivalPrevention?: { player: string; turn: number } | null;
active: string | null;
@@ -39,6 +39,8 @@ export type OrdinaryCardState = {
choamMarket?: { owner: string } | null;
shieldWallDestroyed: boolean;
stormCardWindowOpen?: boolean;
storm?: number;
stormPending?: number | null;
stormResolution?: unknown;
spiceWindow?: {
harvested: boolean;
@@ -142,16 +144,10 @@ export function ordinaryCardAvailability(
return blocked('Storm movement must be an integer from 0 to 10.');
break;
case 'atomics':
if (state.shieldWallDestroyed || state.phase !== 0 || state.stormCardWindowOpen === false || state.stormResolution)
return blocked('Play Family Atomics before storm movement.');
if (
!TERRITORIES.some(
(t) =>
(t.id === 'shield_wall' || t.neighbors.includes('shield_wall')) &&
fighterCount(own, t.id) > 0,
)
)
return blocked('You need forces on or adjacent to the Shield Wall.');
{
const reason = familyAtomicsBlock(state, own);
if (reason) return blocked(reason);
}
break;
}
return { available: true };
+23 -3
View File
@@ -2503,6 +2503,8 @@ export type Game = {
ready: string[];
deck: Card[];
discard: Card[];
/** Public used cards explicitly removed from play, outside discard/recycling. */
removedTreachery?: Card[];
spiceDeck: SpiceCard[];
spicePeekKnown: boolean;
spiceDiscard: SpiceCard[][];
@@ -5641,6 +5643,16 @@ function discard(g: Game, p: Player, id: string, role?: Omit<EcazPoisonDiscard,
p.hand = p.hand.filter((c) => c.id !== id);
return c;
}
function removeUsedAtomics(g: Game, p: Player, id: string) {
requireRule(!g.pendingTreacheryDiscard && !g.pendingNullentropy,
'Finish the pending card disposal before removing Family Atomics.');
requireRule(!giftReserved(g, p.id, id), 'This card is reserved for the pending Richese gift.');
const card = p.hand.find(candidate => candidate.id === id);
requireRule(g.advanced && card?.effect === 'atomics', 'Remove the played Advanced Family Atomics card.');
p.hand = p.hand.filter(candidate => candidate.id !== id);
(g.removedTreachery ??= []).push(card);
return card;
}
function battleDiscardRoles(g: Game, battle: Battle) {
const players = [getPlayer(g, battle.attacker), getPlayer(g, battle.defender)];
const selected = players.map((p) => ({ weapon: cardOf(p, battle.plans[p.id].weapon),
@@ -19327,6 +19339,7 @@ function* nativeWindowCardActions(g: Game, actor: string, effect: WindowCardEffe
for (const card of player.hand.filter(card => card.effect === windowCardNativeEffect(effect) && !transferCardBlock(g, player, card)))
for (const amount of effect === 'weather' ? Array.from({ length: 11 }, (_, value) => value) : [undefined]) {
const availability = ordinaryCardAvailability({ status: g.status, phase: g.phase, turn: g.turn,
advanced: g.advanced, storm: g.storm, stormPending: g.stormPending,
active: g.active, me: actor, shieldWallDestroyed: g.shieldWallDestroyed,
stormCardWindowOpen: g.stormCardWindowOpen, stormResolution: g.stormResolution,
spiceWindow: g.spiceWindow, truthtrance: g.truthtrance, response: g.response,
@@ -19493,7 +19506,8 @@ function guardTurnActionPrintedCardCost(g: Game, player: Player, used: Card) {
// The Ghola effect/cost is complete; forecast beyond its owned interruption
// without executing or borrowing the original market's pending income.
if (trial.pendingChoamMarketGhola?.player === player.id) trial.pendingChoamMarketGhola = null;
rememberMovementDiscards(trial, [{ card: used, discardedBy: player.id, publicFace: true }]);
if (!g.removedTreachery?.some(card => card.id === used.id))
rememberMovementDiscards(trial, [{ card: used, discardedBy: player.id, publicFace: true }]);
requireRule(!!turnActionCompletion(trial, player.id),
'This printed card play would remove the last legal completion of your whole Truthtrance answer.');
}
@@ -38277,6 +38291,7 @@ function normalizeCardNames(g: Game) {
normalizeLegacyCardNames([
...g.deck,
...g.discard,
...(g.removedTreachery ?? []),
...(g.ornithopter ? [g.ornithopter.card] : []),
...g.players.flatMap((p) => p.hand),
...(g.auction?.cards ?? []),
@@ -42773,6 +42788,9 @@ function applyActionInner(
status: g.status,
phase: g.phase,
turn: g.turn,
advanced: g.advanced,
storm: g.storm,
stormPending: g.stormPending,
revivalPrevention: g.revivalPrevention,
active: g.active,
me: id,
@@ -42850,7 +42868,8 @@ function applyActionInner(
throw new RuleError(
'This card’s timing and effect are still being implemented.',
);
const used = discard(g, p, c.id);
const removed = g.advanced && c.effect === 'atomics';
const used = removed ? removeUsedAtomics(g, p, c.id) : discard(g, p, c.id);
if (execution === 'live' && (c.effect === 'hajr' || c.effect === 'ghola' || isWindowCardEffect(c.effect)))
guardTurnActionPrintedCardCost(g, p, used);
log(
@@ -42862,7 +42881,7 @@ function applyActionInner(
);
// Shipment/movement feasibility executes deterministic preparations on private clones.
// Its internal mode never comes from the submitted action or player state.
if (execution === 'live') stageOrdinaryCardDiscard(g, p, used);
if (execution === 'live' && !removed) stageOrdinaryCardDiscard(g, p, used);
return g;
}
if (t === 'ready') {
@@ -43607,6 +43626,7 @@ export function viewGame(state: Game, id: string, now?: number) {
spice: g.spice,
// Public physical draw piles only; auction pools and private holdings stay separate.
drawPiles: g.status === 'lobby' ? null : { treachery: g.deck.length, spice: g.spiceDeck.length },
removedTreachery: g.removedTreachery ?? [],
log: g.log,
nexus: g.nexus,
summonedWorm: g.summonedWorm
+52
View File
@@ -0,0 +1,52 @@
import { fighterCount, isAdvisor } from './advisors';
import { splitLocation, TERRITORIES } from './board';
import type { OrdinaryCardPlayer } from './card-availability';
export type FamilyAtomicsState = {
advanced?: boolean;
turn?: number;
phase: number;
shieldWallDestroyed: boolean;
storm?: number;
stormPending?: number | null;
stormCardWindowOpen?: boolean;
stormResolution?: unknown;
};
export type FamilyAtomicsPlayer = Pick<OrdinaryCardPlayer, 'id' | 'faction' | 'forces' | 'advisors'>;
const wall = TERRITORIES.find(territory => territory.id === 'shield_wall')!;
const nearby = TERRITORIES.filter(territory => territory.id === wall.id || territory.neighbors.includes(wall.id));
/** Supplied Advanced physical39/printed37 changes timing and the sector test.
* Basic keeps the original territory-level fighter quote. No card custody,
* modal priority or force destruction is decided by this helper.
*/
export function familyAtomicsBlock(state: FamilyAtomicsState, player: FamilyAtomicsPlayer): string | null {
if (state.shieldWallDestroyed || state.phase !== 0 || state.stormCardWindowOpen === false || state.stormResolution)
return 'Play Family Atomics before storm movement.';
if (!state.advanced)
return nearby.some(territory => fighterCount(player, territory.id) > 0)
? null : 'You need forces on or adjacent to the Shield Wall.';
if (!Number.isSafeInteger(state.turn) || (state.turn ?? 0) <= 1)
return 'Play Family Atomics after the first game turn.';
if (!Number.isSafeInteger(state.stormPending) || (state.stormPending ?? -1) < 0 || (state.stormPending ?? 41) > 40)
return 'Wait until storm movement is calculated before playing Family Atomics.';
// Zero is tolerated as an inactive/legacy position, not a physical board sector.
if (!Number.isSafeInteger(state.storm) || (state.storm ?? -1) < 0 || (state.storm ?? 19) > 18)
return 'Family Atomics needs the current storm position.';
const storm = state.storm!;
const qualified = Object.entries(player.forces).some(([key, amount]) => {
if (!Number.isSafeInteger(amount) || amount <= 0) return false;
const source = splitLocation(key);
const territory = nearby.find(candidate => candidate.id === source.territory);
if (!territory || !territory.sectors.includes(source.sector) ||
key !== `${source.territory}:${source.sector}` || isAdvisor(player, territory.id)) return false;
// Adjacent regions touch the Wall in their shared sector. Use the direct
// interval inside that region, never a circular detour around the storm.
return wall.sectors.filter(sector => territory.sectors.includes(sector)).some(touching => {
const first = Math.min(source.sector, touching), last = Math.max(source.sector, touching);
return !(storm > first && storm < last);
});
});
return qualified ? null : 'You need physical fighters on or adjacent to the Shield Wall with no intervening storm.';
}
+13 -3
View File
@@ -2821,15 +2821,25 @@ export const RULE_TOPICS: RuleTopic[] = [
title: 'Family Atomics',
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary:
'Destroy the Shield Wall while you have forces on or adjacent to it.',
steps: [
'Play before storm movement. The window remains open after the storm dials are revealed.',
'Forces on the wall are destroyed. Arrakeen, Carthag and Imperial Basin lose the wall’s storm protection.',
'Storm adjacency and card-interaction edge cases remain under audit.',
'Basic keeps its current before-movement Storm window, including before or after distance revelation, and ordinary discard after use.',
'Advanced permits play only after the first game turn, once storm movement is calculated and before it moves. You need physical fighters on the Shield Wall or an adjacent territory, with no storm strictly between their sector and the touching Wall sector. Your own sector or the Wall may be in storm. Advisors do not qualify.',
'All forces on the Wall are destroyed. Arrakeen, Carthag and Imperial Basin lose its storm protection for the rest of the game. The destroyed Wall remains rock and may still be entered. Ordinary storm casualties follow actual movement, without a retroactive sweep of an unchanged sector.',
'Advanced removes the used card from the game. Inspect its public face in Removed from game; it cannot be reshuffled or recovered from discard. Basic retains ordinary discard. Already completed older plays keep their recorded outcome.',
'Concealed No-Field qualification and broader card/module interactions remain under audit; this prototype does not certify complete Advanced.',
'A saved play preserves the destroyed wall and its casualties. Recovery does not require your already-destroyed qualifying forces to remain on the wall.',
],
related: ['storm', 'card-weather'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Advanced timing, direct intervening sectors, physical fighter qualification, Wall casualties and a separate removed-card zone are connected; wider combinations remain.', evidence: ['game/family-atomics.ts', 'game/card-availability.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Shared unavailable reasons and public removed-card inspection retain native card controls.', evidence: ['components/game-table.tsx', 'components/removed-treachery.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Existing minimal Storm confirmation remains legal; required printed-window guides share legality. Optional card strategy remains deferred.', evidence: ['game/bots.ts', 'game/engine.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Adopted Advanced text, Basic boundary, sector examples and removal are explicit.', evidence: ['docs/FAMILY_ATOMICS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, real turn-two engine and removed-pile sources; full modes, combinations and deployed acceptance remain.', evidence: ['tests/family-atomics.test.ts', 'tests/family-atomics-engine.test.ts', 'tests/removed-treachery.test.tsx'] },
],
},
{
id: 'card-harvester',
+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);
});