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

+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',