Connect bounded Reinforcements Battle Plan
This commit is contained in:
1 parent
763749f25e
commit
23ce406872
19 files changed
+745
-35
No files matched your search
@@ -33,6 +33,7 @@ import { HARASS_WITHDRAW_CARD, isHarassWithdraw, quoteHarassWithdraw, type Haras
|
||||
import { auditCount } from './choam-auditor';
|
||||
import { sukGraduateSkill, type SukGraduateSkill } from './suk-graduate';
|
||||
import { validateSpiceBankerSpend } from './spice-banker';
|
||||
import { isReinforcements, quoteReinforcements, REINFORCEMENTS_CARD } from './reinforcements';
|
||||
import { rihaniVictorySkill, type RihaniSkill } from './rihani-decipherer';
|
||||
import { copiedDiplomatDefense, quoteDiplomatDefense, type DiplomatDefenseQuote } from './diplomat-defense';
|
||||
import {
|
||||
@@ -75,6 +76,8 @@ export type ResolutionParticipant = {
|
||||
noFieldAtTerritory?: boolean;
|
||||
};
|
||||
export type ResolutionCombatant = ResolutionParticipant & {
|
||||
/** Own physical reserve counters available before battle casualties. */
|
||||
reinforcementsReserves?: { normal: number; elite: number };
|
||||
harassWithdraw?: HarassWithdrawContext;
|
||||
harassSelection?: HarassWithdrawSelection;
|
||||
diplomatDefense?: Pick<DiplomatDefenseQuote, 'leader' | 'source' | 'kind'> & { card: string };
|
||||
@@ -133,6 +136,8 @@ export type BattleSupportPayment = {
|
||||
freeByTraitor: boolean;
|
||||
};
|
||||
export type BattleResolutionQuote = {
|
||||
/** Reserve-to-Tanks costs apply to every revealed outcome, including traitors and explosions. */
|
||||
reinforcements?: { player: string; card: typeof REINFORCEMENTS_CARD; normal: number; elite: number }[];
|
||||
harassWithdraw?: (HarassWithdrawQuote & { player: string; card: typeof HARASS_WITHDRAW_CARD })[];
|
||||
result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion';
|
||||
winner: string | null;
|
||||
@@ -372,10 +377,30 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
};
|
||||
const effectiveAd = effectiveDefense(a, d, ad), effectiveDd = effectiveDefense(d, a, dd);
|
||||
const withdrawals = new Map<string, HarassWithdrawQuote>();
|
||||
const reinforcements: NonNullable<BattleResolutionQuote['reinforcements']> = [];
|
||||
for (const side of [a, d]) {
|
||||
const slots = [card(side, side.plan.weapon), card(side, side.plan.defense)];
|
||||
requireQuote(!slots.some(c => c?.id === 'ecaz-reinforcements'),
|
||||
'Reinforcements battle effects are still being implemented.');
|
||||
const selectedIds = [side.plan.weapon, side.plan.defense, side.plan.leader, side.lateDefense];
|
||||
requireQuote(selectedIds.every((selectedId) =>
|
||||
selectedId !== REINFORCEMENTS_CARD || slots.some(c => c?.id === selectedId && isReinforcements(c))),
|
||||
'Reinforcements must be a canonical physical card in exactly one battle-card slot.');
|
||||
const selected = slots.filter(isReinforcements);
|
||||
requireQuote(selected.length <= 1 && (selected.length === 0) === (side.reinforcementsReserves === undefined),
|
||||
'Reinforcements requires its own reserve counts exactly when selected.');
|
||||
if (selected.length) {
|
||||
requireQuote(validBattleSlotPair(slots[0], slots[1]) &&
|
||||
!(side === d && reinforcements.length),
|
||||
'Reinforcements needs a unique physical card and valid weapon and defense slot pair.');
|
||||
requireQuote(!input.homeworld && !(side.leaderSkills?.length || (side === a ? d : a).leaderSkills?.length) &&
|
||||
!slots.some(isHarassWithdraw) &&
|
||||
!isStoneBurner(aw) && !isStoneBurner(dw),
|
||||
'Reinforcements cannot combine with Homeworld, leader skills, own Harass & Withdraw or Stone Burner.');
|
||||
const reserves = side.reinforcementsReserves!;
|
||||
requireQuote(!!reserves && typeof reserves === 'object' && !Array.isArray(reserves),
|
||||
'Reinforcements needs own typed reserves.');
|
||||
const cost = quoteReinforcements(reserves.normal, reserves.elite);
|
||||
reinforcements.push({ player: side.id, card: REINFORCEMENTS_CARD, normal: cost.normal, elite: cost.elite });
|
||||
}
|
||||
const harass = slots.some(isHarassWithdraw);
|
||||
requireQuote((!side.harassWithdraw && side.harassSelection === undefined) || harass, 'The withdrawal context requires the physical Harass & Withdraw card.');
|
||||
if (harass) {
|
||||
@@ -608,6 +633,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
scores = {
|
||||
attacker:
|
||||
a.plan.dial - attackerPenalty +
|
||||
(reinforcements[0]?.player === a.id ? 2 : 0) +
|
||||
(a.id === homeworld?.native ? homeworld.strength : 0) +
|
||||
(deaths.attacker || effects.stunned
|
||||
? 0
|
||||
@@ -616,6 +642,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
(a.plan.kwisatz ? 2 : 0)),
|
||||
defender:
|
||||
d.plan.dial - defenderPenalty +
|
||||
(reinforcements[0]?.player === d.id ? 2 : 0) +
|
||||
(d.id === homeworld?.native ? homeworld.strength : 0) +
|
||||
(deaths.defender || effects.stunned
|
||||
? 0
|
||||
@@ -755,7 +782,8 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
// The explicit after-use instruction also applies to a successful own traitor
|
||||
// call, using the same specificity inference as Planetologist's discard.
|
||||
const mandatorySkillDiscard = (side: ResolutionCombatant, selected: string) =>
|
||||
mandatoryPlanetologistDiscard(side, selected) || side.diplomatDefense?.card === selected || isHarassWithdraw(card(side, selected));
|
||||
mandatoryPlanetologistDiscard(side, selected) || side.diplomatDefense?.card === selected ||
|
||||
isHarassWithdraw(card(side, selected)) || isReinforcements(card(side, selected));
|
||||
const moritani = input.participants.find((p) => p.faction === 'moritani');
|
||||
if (winner && (moritani || input.nexusMoritani)) {
|
||||
const loser = winner === a ? d : a,
|
||||
@@ -837,6 +865,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
: [];
|
||||
return {
|
||||
...(harassWithdraw.length ? { harassWithdraw } : {}),
|
||||
...(reinforcements.length ? { reinforcements } : {}),
|
||||
result,
|
||||
winner: winner?.id ?? null,
|
||||
attackerTraitor: ac,
|
||||
|
||||
+8
-2
@@ -699,7 +699,8 @@ function plans(g: GameView): Action[] {
|
||||
const bankerPrototypeAvailable = spiceBankerModeSupported(g);
|
||||
const specialForLeader = (card: Card | undefined, leader: string | null) =>
|
||||
canUsePlanetologistBattleSpecial({assignments:battleSkills, selectedLeader:leader, card});
|
||||
const prototypeCardAllowed = (card: Card) => card.id !== 'ecaz-reinforcements' &&
|
||||
const prototypeCardAllowed = (card: Card) =>
|
||||
(card.id !== 'ecaz-reinforcements' || (!!b.reinforcements && !b.reinforcements.blocked)) &&
|
||||
(card.id !== 'ecaz-harass-withdraw' || (!!b.harassWithdraw && !b.harassWithdraw.blocked));
|
||||
const weapons = [
|
||||
null,
|
||||
@@ -795,6 +796,9 @@ function plans(g: GameView): Action[] {
|
||||
isStoneBurner(w) || (inspected && isStoneBurner(enemyWeapon));
|
||||
if ((!leader && (weapon || defense)) || !validBattleSlotPair(w, d, specialForLeader(w,leader)))
|
||||
continue;
|
||||
const reinforcements = weapon === 'ecaz-reinforcements' || defense === 'ecaz-reinforcements';
|
||||
if (reinforcements && (weapon === 'ecaz-harass-withdraw' ||
|
||||
defense === 'ecaz-harass-withdraw' || isStoneBurner(w))) continue;
|
||||
if (voice) {
|
||||
const used =
|
||||
playedVoiceMatch(
|
||||
@@ -967,6 +971,8 @@ function plans(g: GameView): Action[] {
|
||||
? battleWeaponsExplode(w, d, enemyWeapon, enemyDefense)
|
||||
: (isShield(revealedDefense) && w?.kind === 'lasgun') ||
|
||||
(revealedWeapon?.kind === 'lasgun' && isShield(d));
|
||||
const reinforcementBonus = reinforcements && !stoneBattle &&
|
||||
!selfExplosion && !explosionRisk ? 2 : 0;
|
||||
const undialed = stoneBattle
|
||||
? Math.min(
|
||||
...casualtyOptions(
|
||||
@@ -1006,7 +1012,7 @@ function plans(g: GameView): Action[] {
|
||||
(effectiveDefense ? 2 : 0)
|
||||
: level === 0
|
||||
? variation(g, `${leader}-${weapon}-${defense}-${dial}`) * 100
|
||||
: (ownSurvivingStrength +
|
||||
: (ownSurvivingStrength + reinforcementBonus +
|
||||
(!effects.attackerDead && !effects.stunned
|
||||
? bankerSpice
|
||||
: 0)) * 2 +
|
||||
|
||||
+71
-6
@@ -541,6 +541,7 @@ import {
|
||||
type SpiceCard,
|
||||
} from './cards';
|
||||
import { ecazTreacheryCards, ecazTreacheryDefinition } from './ecaz-cards';
|
||||
import { isReinforcements, quoteReinforcements, ReinforcementsError } from './reinforcements';
|
||||
import {
|
||||
RECRUITS_CARD_ID,
|
||||
type RecruitsPreview,
|
||||
@@ -8972,8 +8973,17 @@ function ecazTreacheryIntegrity(g: Game) {
|
||||
'The Ecaz Treachery Cards inventory contains an unknown physical card.',
|
||||
);
|
||||
for (const [player, plan] of Object.entries(g.battle?.plans ?? {})) {
|
||||
requireRule(![plan.weapon, plan.defense].includes('ecaz-reinforcements'),
|
||||
'Reinforcements battle effects are still being implemented.');
|
||||
if ([plan.weapon, plan.defense].includes('ecaz-reinforcements')) {
|
||||
const owner = getPlayer(g, player);
|
||||
requireRule(plan.leader !== 'ecaz-reinforcements' &&
|
||||
g.battle?.lateDefense?.[player] !== 'ecaz-reinforcements' &&
|
||||
plan.weapon !== plan.defense &&
|
||||
owner.hand.some(isReinforcements) &&
|
||||
[plan.weapon, plan.defense].every(id => id === null || !!cardOf(owner, id)) &&
|
||||
validBattleSlotPair(cardOf(owner, plan.weapon), cardOf(owner, plan.defense)),
|
||||
'The sealed Reinforcements plan lost its physical card or legal slot.');
|
||||
currentReinforcementsCost(g, owner);
|
||||
}
|
||||
requireRule(plan.leader !== HARASS_WITHDRAW_CARD && g.battle?.lateDefense?.[player] !== HARASS_WITHDRAW_CARD &&
|
||||
!(plan.weapon === HARASS_WITHDRAW_CARD && plan.defense === HARASS_WITHDRAW_CARD),
|
||||
'Harass & Withdraw occupies exactly one ordinary Battle Plan card slot.');
|
||||
@@ -9018,6 +9028,37 @@ function recruitsModeSupported(g: Game) {
|
||||
!g.discoveryEnabled
|
||||
);
|
||||
}
|
||||
function reinforcementsModeSupported(g: Game) {
|
||||
return !!g.ecazTreachery && recruitsModeSupported(g) && !g.sandtrout &&
|
||||
g.expansions.length === 0 &&
|
||||
g.players.every(player => faction(player.faction).expansion === 'base');
|
||||
}
|
||||
function currentReinforcementsCost(g: Game, p: Player) {
|
||||
requireRule(reinforcementsModeSupported(g),
|
||||
'Reinforcements currently requires the standalone Ecaz card variant with classic factions.');
|
||||
try {
|
||||
return quoteReinforcements(p.reserves - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0);
|
||||
} catch (error) {
|
||||
if (error instanceof ReinforcementsError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function reinforcementsPreview(g: Game, p: Player) {
|
||||
const battle = g.battle;
|
||||
if (!battle || ![battle.attacker, battle.defender].includes(p.id) ||
|
||||
!p.hand.some(isReinforcements)) return null;
|
||||
const normal = Math.min(3, Math.max(0, p.reserves - (p.elites?.reserves ?? 0)));
|
||||
const elite = Math.min(3 - normal, Math.max(0, p.elites?.reserves ?? 0));
|
||||
let blocked: string | null = null;
|
||||
if (!reinforcementsModeSupported(g))
|
||||
blocked = 'Reinforcements currently requires the standalone Ecaz card variant with classic factions.';
|
||||
else if (g.status !== 'playing' || g.phase !== 6 || battle.revealed || battle.plans[p.id])
|
||||
blocked = 'Choose Reinforcements before sealing your Battle Plan.';
|
||||
else if (normal + elite < 3)
|
||||
blocked = 'Reinforcements needs three physical forces in your reserves.';
|
||||
return { blocked, normal, elite };
|
||||
}
|
||||
|
||||
function recruitsInteractionPending(g: Game) {
|
||||
return !!(
|
||||
g.response ||
|
||||
@@ -14996,11 +15037,11 @@ function validatePlan(
|
||||
);
|
||||
requireRule(
|
||||
!plan.weapon || (w && battleCardSlotEligible('weapon', w, { planetologistWeapon })),
|
||||
'Choose a weapon, worthless card, eligible Planetologist Special, or Harass & Withdraw.',
|
||||
'Choose a weapon, Worthless card, eligible Planetologist Special, Harass & Withdraw, or Reinforcements.',
|
||||
);
|
||||
requireRule(
|
||||
!plan.defense || (d && battleCardSlotEligible('defense', d)),
|
||||
'Choose a defense, worthless card, or Harass & Withdraw.',
|
||||
'Choose a defense, Worthless card, Harass & Withdraw, or Reinforcements.',
|
||||
);
|
||||
requireRule(
|
||||
!plan.weapon || plan.weapon !== plan.defense,
|
||||
@@ -15014,8 +15055,11 @@ function validatePlan(
|
||||
validBattleSlotPair(w, d, planetologistWeapon),
|
||||
'Chemistry as a weapon needs another defense; Weirding Way as a defense needs another weapon.',
|
||||
);
|
||||
requireRule(![w, d].some(card => card?.id === 'ecaz-reinforcements'),
|
||||
'Reinforcements battle effects are still being implemented.');
|
||||
if ([w, d].some(isReinforcements)) {
|
||||
requireRule(![w, d].some(card => isHarassWithdraw(card) || isStoneBurner(card)),
|
||||
'Reinforcements cannot share this bounded plan with Harass & Withdraw or Stone Burner.');
|
||||
currentReinforcementsCost(g, p);
|
||||
}
|
||||
if ([w, d].some(isHarassWithdraw)) currentHarassWithdrawQuote(g, p, plan);
|
||||
if (isStoneBurner(w)) {
|
||||
const blocked = stonePlanBlock(g, p, dial, support);
|
||||
@@ -16479,6 +16523,10 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
leaderSkills: battleLeaderSkills(g, p),
|
||||
occupiedStrongholds: leaderSkillStrongholdCount(g, p),
|
||||
forces: combatForces(g, p, b.territory, opponent),
|
||||
...([cardOf(p, plan.weapon), cardOf(p, plan.defense)].some(isReinforcements)
|
||||
? { reinforcementsReserves: {
|
||||
normal: p.reserves - (p.elites?.reserves ?? 0), elite: p.elites?.reserves ?? 0,
|
||||
} } : {}),
|
||||
...([cardOf(p, plan.weapon), cardOf(p, plan.defense)].some(isHarassWithdraw)
|
||||
? { harassWithdraw: harassWithdrawContext(g, p),
|
||||
...(b.harassAllocation?.player === p.id && b.harassAllocation.stage === 'selected'
|
||||
@@ -16739,6 +16787,22 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
|
||||
: `${player.name} paid ${payment.amount} committed Spice Banker spice to the bank, separate from force support. The payment is spent win or lose; only a surviving skilled leader receives its strength bonus.`,
|
||||
{ faction: player.faction, name: 'Spice Banker payment' });
|
||||
}
|
||||
for (const cost of quote.reinforcements ?? []) {
|
||||
const player = getPlayer(g, cost.player);
|
||||
const current = currentReinforcementsCost(g, player);
|
||||
requireRule(cost.normal === current.normal && cost.elite === current.elite,
|
||||
'Reinforcements lost its original physical reserve cost.');
|
||||
player.reserves -= cost.normal + cost.elite;
|
||||
player.tanks += cost.normal + cost.elite;
|
||||
player.battleLosses += cost.normal + cost.elite;
|
||||
if (cost.elite) {
|
||||
requireRule(player.elites, 'Reinforcements lost its elite reserve custody.');
|
||||
player.elites.reserves -= cost.elite;
|
||||
player.elites.tanks += cost.elite;
|
||||
}
|
||||
log(g, `${player.name} used Reinforcements: three reserve forces (${cost.elite} elite) went to the Tanks, and the dialed battle strength gained 2 without adding on-board forces or spice support.`,
|
||||
{ faction: player.faction, name: 'Reinforcements' });
|
||||
}
|
||||
if (quote.choamIncome) g.pendingChoamBattleIncome = { ...quote.choamIncome };
|
||||
for (const [player, plan, canceled] of [[a, ap, dc], [d, dp, ac]] as const)
|
||||
if (canceled && [cardOf(player, plan.weapon), cardOf(player, plan.defense)].some(isHarassWithdraw))
|
||||
@@ -26973,6 +27037,7 @@ export function viewGame(state: Game, id: string) {
|
||||
} : null,
|
||||
nexusInsights: projectedNexusInsights(g, id),
|
||||
harassWithdraw: harassWithdrawPreview(g, me),
|
||||
reinforcements: reinforcementsPreview(g, me),
|
||||
ownCommitments: committedPlanElements(b, id),
|
||||
prescience: b.prescience
|
||||
? {
|
||||
|
||||
+26
-4
@@ -4195,21 +4195,43 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'During Revival, Recruits doubles every faction’s current free rate and raises the ordinary force limit to seven for this turn.',
|
||||
steps: [
|
||||
'The separate three-card variant includes Recruits, Reinforcements and Harass & Withdraw. It does not require the Ecaz faction. A development preview includes all three physical cards before setup; Recruits and bounded Harass & Withdraw controls are connected. Reinforcements remains unfinished.',
|
||||
'The separate three-card variant includes Recruits, Reinforcements and Harass & Withdraw. It does not require the Ecaz faction. A development preview includes all three physical cards before setup; Recruits, bounded Reinforcements and Harass & Withdraw controls are connected.',
|
||||
'The holder may play Recruits from its Revival panel. Discard the card once, then all players use the increased rates while this Revival phase remains open, including players who had already marked themselves ready. Completed returns still count toward their turn totals.',
|
||||
'Atreides rises from two free forces to four; Fremen from three to six; Guild from one to two. The ordinary force limit becomes seven. Native unlimited CHOAM and Tleilaxu allowances remain unlimited, and their free rates become zero and four. Paid prices and separate elite limits still apply.',
|
||||
'Free-revival prevention still prevents free returns. A Fremen ally grant completed before Recruits doubles from three to six. A later grant, play after a paid ordinary return, unresolved transactions and a second use in the same turn remain guarded pending their rules decisions.',
|
||||
'The preview supports Basic or Advanced faction games without additional optional modules. Public activation, Reinforcements and complete combined-game acceptance remain unfinished.',
|
||||
'The preview supports Basic or Advanced classic-faction games without additional optional modules. Public activation and complete combined-game acceptance remain unfinished.',
|
||||
],
|
||||
related: ['revival', 'ecaz-modules', 'expansion-faction-games', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Genuine independent three-card setup, physical Recruits play/discard, public turn effect, shared revival quotes, preserved use and guarded timing are connected. Harass has its own bounded battle prototype; Reinforcements remains missing.', evidence: ['game/ecaz-cards.ts', 'game/recruits.ts', 'game/revival.ts', 'game/engine.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Genuine independent three-card setup, physical Recruits play/discard, public turn effect, shared revival quotes, preserved use and guarded timing are connected. Harass and Reinforcements have separate bounded battle prototypes; complete variant interactions remain gated.', evidence: ['game/ecaz-cards.ts', 'game/recruits.ts', 'game/revival.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Implemented', detail: 'The owned physical card has full inspection, a Revival action and explicit unavailable reasons. All seats see the effective public rates after play.', evidence: ['components/recruits.tsx', 'components/game-table.tsx', 'game/card-presentation.ts'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four profiles use the same legal owned offer for useful own or allied revival, then existing authoritative revival choices. Complete card strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'The card guide records examples, current prototype limits and the independent-cap inference; late-paid and late-rate questions remain explicit.', evidence: ['docs/RECRUITS_RULES.md', 'docs/RECRUITS_RUNTIME.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, controls, AI and authenticated recovery checks cover the bounded preview. Sample and browser evidence is recorded separately; full variant acceptance remains open.', evidence: ['tests/recruits.test.ts', 'tests/recruits-engine.test.ts', 'tests/recruits-controls.test.tsx', 'tests/recruits-recovery.test.ts', 'tests/prototype-room.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'card-reinforcements',
|
||||
title: 'Reinforcements',
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Add two to battle strength while paying three own reserve counters into the Tanks when the physical card is revealed.',
|
||||
steps: [
|
||||
'In the independent Ecaz Treachery Cards variant, place your held Reinforcements card in either battle-card slot; it is neither a weapon nor a defense. A category Prescience answer is None, not the name of this card. One physical card cannot fill both slots.',
|
||||
'Have three own forces in reserves when sealing. The current Basic/Advanced classic-faction prototype spends ordinary reserves first and then elite reserves, moving exactly three to their corresponding Tanks subpools when the battle resolves. No extra forces arrive in the battle territory.',
|
||||
'The normal-outcome score gains 2 without adding to the physical dial, spice support or on-board casualties. The card and its reserve cost are consumed even on a Traitor victory, a mutual Traitor result or a Lasgun/Shield explosion; successful Traitor calls still decide the outcome directly.',
|
||||
'Payment on every outcome, deterministic normal-first selection and counting the reserve transfer as battle losses are documented provisional interpretations, not publisher rulings. Harass/Stone Burner combinations, other optional modules and expansion factions remain unavailable in this bounded prototype.',
|
||||
],
|
||||
related: ['battle-cards', 'battle', 'card-recruits', 'card-harass-withdraw', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical card custody, either battle slot, three typed reserve counters, normal score modifier and mandatory discard connect in classic-faction Basic/Advanced games only.', evidence: ['game/reinforcements.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Owner-only availability, exact cost guidance and both battle selectors support the card; private category answers remain None.', evidence: ['components/battle-preparation.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four profiles consider legal physical slot candidates and the normal score bonus; strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'The printed effect and unresolved Traitor, explosion, type-selection and accounting interpretations are distinguished from this temporary prototype.', evidence: ['docs/ECAZ_TREACHERY_RULES.md', 'docs/REINFORCEMENTS_RUNTIME.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Rule, engine, controls and saved concurrent recovery cases cover this bounded behavior; full variant and combined modes are not certified.', evidence: ['tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/reinforcements-controls.test.tsx', 'tests/reinforcements-recovery.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'card-harass-withdraw',
|
||||
title: 'Harass & Withdraw',
|
||||
@@ -4245,7 +4267,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Development setup supports two through six distinct seats from the base factions and selected expansion sets. Expansion selection and optional modules are separate; public expansion starts remain disabled.',
|
||||
'Ecaz places six starting forces in Imperial Basin, allocated among sectors 9, 10 and 11, and keeps fourteen in reserve. Finish Fremen placement first; Bene Gesserit then places its Advanced starting advisor. Moritani places its six forces in an unoccupied territory after all other setup is complete.',
|
||||
'Ixians keep their private starting-card selection and physical cyborg/suboid forces. Tleilaxu receives three private Face Dancers. Advanced CHOAM receives its Auditor. Richese has a separate ten-card cache and three private No-Fields. Ecaz and Moritani receive their Ambassador and Terror inventories and share one Duke Vidal disc.',
|
||||
'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: the separate three-card variant has Recruits and bounded Harass & Withdraw development controls; Reinforcements remains unfinished.',
|
||||
'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: the separate three-card variant has Recruits, bounded Harass & Withdraw and bounded Reinforcements development controls for classic-faction games.',
|
||||
'Advanced Ixians may explicitly decline Technology for one Richese special lot. Their once-per-round use remains available later. The special-lot exchange itself is unfinished pending replacement-card custody interpretation.',
|
||||
'Fresh unsupported movement arrivals reject before opening prevention choices or reserving a new movement card. A saved CHOAM allow choice can return an uncommitted unsupported move for another choice, preserving its existing reserved Ornithopter. Simultaneous Ambassador/Terror order remains unresolved.',
|
||||
'Ordinary shipment checks unfinished arrival combinations before saving the Guild interception choice. An already saved ordinary shipment may return unspent to its shipper when allowance encounters that arrival guard, so the shipper can choose another destination. Older No-Field, Smuggler, Nexus and Ambassador shipments are excluded from this recovery. Simultaneous arrival order remains unfinished.',
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Card } from './cards';
|
||||
import { ecazTreacheryDefinition } from './ecaz-cards';
|
||||
|
||||
export const REINFORCEMENTS_CARD = 'ecaz-reinforcements' as const;
|
||||
|
||||
export class ReinforcementsError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ReinforcementsError';
|
||||
}
|
||||
}
|
||||
|
||||
/** A matching id alone does not establish the physical variant card's identity. */
|
||||
export function isReinforcements(card: Card | undefined): boolean {
|
||||
return !!card && card.id === REINFORCEMENTS_CARD &&
|
||||
Object.keys(card).sort().join(',') === 'effect,id,kind,name' &&
|
||||
ecazTreacheryDefinition(card)?.card.effect === 'reinforcements';
|
||||
}
|
||||
|
||||
/** Spend exactly three own reserve counters, ordinary before elite; never dial them. */
|
||||
export function quoteReinforcements(
|
||||
normalReserves: number,
|
||||
eliteReserves: number,
|
||||
): { normal: number; elite: number; bonus: 2 } {
|
||||
if (![normalReserves, eliteReserves].every(n => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0))
|
||||
throw new ReinforcementsError('Reinforcements needs valid nonnegative reserve counts.');
|
||||
if (normalReserves < 3 && eliteReserves < 3 - normalReserves)
|
||||
throw new ReinforcementsError('Reinforcements needs at least three own forces in reserves.');
|
||||
const normal = Math.min(3, normalReserves);
|
||||
return { normal, elite: 3 - normal, bonus: 2 };
|
||||
}
|
||||
Reference in new issue
Block a user