Allow chosen Reinforcements reserve costs
Verify and publish checkpoint container / container (push) Successful in 11m11s
Verify and publish checkpoint container / container (push) Successful in 11m11s
This commit is contained in:
1 parent
9dd386ff7a
commit
1cf90f1328
16 files changed
+419
-44
No files matched your search
@@ -515,6 +515,12 @@ export function GameTable({
|
||||
selectedDefenseId === 'ecaz-reinforcements';
|
||||
const harassSelected = (selectedWeaponId === 'ecaz-harass-withdraw' && !planetologistWeapon) ||
|
||||
selectedDefenseId === 'ecaz-harass-withdraw';
|
||||
const [reinforcementsEliteDraft, setReinforcementsEliteDraft] = useState<{ key: string; elite: number } | null>(null);
|
||||
const reinforcementsEliteKey = `${g.code}:${me.id}:${g.battle?.event ?? ''}`;
|
||||
const reinforcementsEliteOptions = g.battle?.reinforcements?.eliteOptions ?? [];
|
||||
const chosenReinforcementsElite = reinforcementsEliteDraft?.key === reinforcementsEliteKey &&
|
||||
reinforcementsEliteOptions.includes(reinforcementsEliteDraft.elite)
|
||||
? reinforcementsEliteDraft.elite : reinforcementsEliteOptions[0];
|
||||
const [reinforcementsSourceDraft, setReinforcementsSourceDraft] = useState<{
|
||||
key: string; index: number;
|
||||
} | null>(null);
|
||||
@@ -597,6 +603,8 @@ export function GameTable({
|
||||
enabled: plan.planetologistSpecial === true,
|
||||
});
|
||||
setKwisatz(!!plan.kwisatz);
|
||||
setReinforcementsEliteDraft(plan.reinforcementsElite === undefined ? null
|
||||
: { key: reinforcementsEliteKey, elite: plan.reinforcementsElite });
|
||||
if (plan.reinforcementsSources) {
|
||||
const chosen = JSON.stringify(plan.reinforcementsSources);
|
||||
const index = reinforcementsSourceChoices?.findIndex(choice => JSON.stringify(choice) === chosen);
|
||||
@@ -5779,12 +5787,23 @@ export function GameTable({
|
||||
Reinforcements may occupy either card slot but is neither a weapon nor a defense.
|
||||
It adds 2 to battle strength without changing the physical dial or spice support.
|
||||
The cost is three reserve forces sent to the Tanks on any revealed outcome.
|
||||
Available for that quote: {g.battle.reinforcements.normal} normal,{' '}
|
||||
Default fee: {g.battle.reinforcements.normal} normal,{' '}
|
||||
{g.battle.reinforcements.elite} elite.
|
||||
The card is discarded even after a traitor call or explosion.
|
||||
{g.battle.reinforcements.blocked && ` ${g.battle.reinforcements.blocked}`}
|
||||
</p>
|
||||
)}
|
||||
{reinforcementsSelected && !g.homeworlds && reinforcementsEliteOptions.length > 0 && (
|
||||
<label className="block space-y-1">
|
||||
<span>Reinforcements reserve fee</span>
|
||||
<select value={chosenReinforcementsElite} disabled={busy}
|
||||
onChange={event => setReinforcementsEliteDraft({ key: reinforcementsEliteKey, elite: Number(event.target.value) })}>
|
||||
{reinforcementsEliteOptions.map(elite => <option key={elite} value={elite}>
|
||||
{3 - elite} normal + {elite} special
|
||||
</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{reinforcementsSelected && reinforcementsSourceChoices && (
|
||||
<ReinforcementsSources game={g} choices={reinforcementsSourceChoices}
|
||||
selected={reinforcementsSourceIndex} busy={busy}
|
||||
@@ -5903,7 +5922,10 @@ export function GameTable({
|
||||
weapon,
|
||||
defense,
|
||||
...(reinforcementsSelected && selectedReserveSource
|
||||
? { reinforcementsSources: selectedReserveSource } : {}),
|
||||
? { reinforcementsSources: selectedReserveSource,
|
||||
reinforcementsElite: Object.values(selectedReserveSource).reduce((sum, forces) => sum + forces.elite, 0) }
|
||||
: reinforcementsSelected && chosenReinforcementsElite !== undefined
|
||||
? { reinforcementsElite: chosenReinforcementsElite } : {}),
|
||||
}),
|
||||
!!occupyControl?.blocked || !!stonePlanReason || !!harassControl.blocked || !!smugglerPlanReason || !!reinforcementsPlanReason || !battlePairValid,
|
||||
)}
|
||||
@@ -5922,6 +5944,9 @@ export function GameTable({
|
||||
{g.battle.plans[me.id]?.planetologistSpecial && (
|
||||
<p className="fine">Planetologist alternate Special: +2 if the leader survives; no normal card effect.</p>
|
||||
)}
|
||||
{g.battle.plans[me.id]?.reinforcementsElite !== undefined && !g.battle.plans[me.id]?.reinforcementsSources && (
|
||||
<p className="fine">Sealed reserve fee: {3 - g.battle.plans[me.id].reinforcementsElite!} normal + {g.battle.plans[me.id].reinforcementsElite} special.</p>
|
||||
)}
|
||||
{g.battle.plans[me.id]?.reinforcementsSources && (
|
||||
<p className="fine">Sealed reserve fee: {reinforcementsSourceLabel(g, g.battle.plans[me.id].reinforcementsSources!)}</p>
|
||||
)}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function ReinforcementsSources({ game, choices, selected, onChange, busy
|
||||
if (!choices.length) return null;
|
||||
return <fieldset disabled={busy} className="space-y-3 rounded-lg border border-[#a88b60]/50 p-3">
|
||||
<legend>Reinforcements reserve sources</legend>
|
||||
<p className="fine">Three original native reserves go to the Tanks, normal first. These are not the invading army or future Harass returns.</p>
|
||||
<p className="fine">Choose the three original native reserves sent to the Tanks. These are not the invading army or future Harass returns.</p>
|
||||
{choices.length > 1 ? <label className="block space-y-1">
|
||||
<span>Native reserve fee allocation</span>
|
||||
<select value={selected} onChange={event => onChange(Number(event.target.value))}>
|
||||
|
||||
@@ -17,6 +17,17 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Reinforcements reserve choice — 29 October. Prototyped / Partial.**
|
||||
The card holder can choose any available mix of three normal and special
|
||||
reserve counters when sealing a plan, including native Homeworld sources.
|
||||
The selected physical cost settles once; older plans without a choice retain
|
||||
normal-first payment. One hundred eleven related cases, two Homeworld bot
|
||||
cases, quick type/lint checks and the production build pass. The older No-Field
|
||||
fixture now checks the final-battle opportunity
|
||||
before settlement; completed-battle payment assertions remain. The existing
|
||||
all-outcome interpretation and full card acceptance remain open.
|
||||
[Scope](REINFORCEMENTS_RUNTIME.md#chosen-reserve-mix--29-october-2026).
|
||||
|
||||
**Freeform hand size and combined facts — 29 October. Prototyped / Partial.**
|
||||
New fact-scoped freeform questions recognize total hand-size comparisons and
|
||||
one AND/OR combining two named-card or spice facts. The table sees the
|
||||
|
||||
@@ -16,6 +16,25 @@ fixes the exact card. Harass & Withdraw may share the plan under the
|
||||
Original or copied Stone Burner composes in clean selected Ix/CHOAM families
|
||||
and the existing native Advanced Occupy envelope. Other profile boundaries remain.
|
||||
|
||||
## Chosen reserve mix — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** When sealing Reinforcements, its owner can select any
|
||||
available combination of three ordinary and special reserves. The chosen mix is
|
||||
private with the plan and is paid exactly once when the battle settles. On
|
||||
Homeworlds the owner also selects valid native source allocations for that mix;
|
||||
those counters cannot also fund an own-World dial. Existing sealed plans without
|
||||
a mix keep the earlier normal-first payment. The game enforces availability,
|
||||
physical source and original No-Field materialization constraints.
|
||||
|
||||
The printed card requires three reserve forces, while the source review found
|
||||
no printed normal-first restriction; this player choice is a rules-based
|
||||
interpretation, not a new publisher ruling. The existing provisional all-outcome
|
||||
payment and timing interpretations remain unchanged. The [normal battle](../tests/reinforcements-typed-choice-runtime.test.ts),
|
||||
[Homeworld](../tests/reinforcements-homeworld-choice.test.ts) and
|
||||
[controls](../tests/reinforcements-typed-choice-controls.test.tsx) cases cover
|
||||
actual actions, physical pools, private plan and minimal legal bot paths. Full
|
||||
card interactions and deployed acceptance remain open.
|
||||
|
||||
## Independent Discovery composition — 18 October 2026
|
||||
|
||||
Original selected36/38/50 cards now compose with Discovery7/8 and optional
|
||||
@@ -77,8 +96,8 @@ is added. Original Tech/Advanced Strongholds separately use the phase-module
|
||||
contract above. Classic, paired E3 and selected Advanced Occupy remain separate;
|
||||
public starts and played-save conversion stay gated.
|
||||
|
||||
Reuse the existing explicitly provisional normal-first, all-outcome fee and
|
||||
fee-before-Harass order, not a new publisher ruling. Native casualty origins
|
||||
At this checkpoint the provisional fee used normal-first, all-outcome payment
|
||||
and fee-before-Harass order. The later [reserve-choice update](#chosen-reserve-mix--29-october-2026) replaces normal-first for newly chosen plans. Native casualty origins
|
||||
alone feed Ixian substitution; do not substitute a second copy of the fee.
|
||||
Tleilaxu Face Dance and Advanced Auditor/CHOAM phase opening remain native.
|
||||
Richese first materializes its own No-Field, then pays three original remaining
|
||||
@@ -113,9 +132,9 @@ The [combined-army follow-up](ECAZ_OCCUPY_RULES.md#independent-cards-with-combin
|
||||
retains own-holder reserve cost and selected lead card/payment ownership.
|
||||
It does not revise the provisional outcome accounting below.
|
||||
|
||||
The holder must have three own reserve counters when sealing the plan. The owner-only battle offer displays availability and the exact provisional cost. The current prototype deterministically takes ordinary reserves first, then elite reserves if needed, transferring precisely three physical counters to the corresponding Tanks subpools when the battle settles. There is no spice payment or on-board arrival. The +2 changes the calculated normal-outcome battle score, **not** the committed physical dial, support payment, casualty count or leader survival. The card is discarded after any revealed result, including an own, opposing or mutual Traitor call and Lasgun/Shield explosion. The cost is likewise paid on each of those outcomes. A successful Traitor call still supersedes the normal score; the modifier never overrides it. Public history records the transfer; the sealed card and owner reserve mix stay private until the reveal. JSON-restored decisions and transactional room updates cannot charge the same cost twice.
|
||||
The holder must have three own reserve counters when sealing the plan. The owner-only battle offer displays availability and the exact provisional cost. The holder selects an available ordinary/elite mix, transferring precisely three physical counters to the corresponding Tanks subpools when the battle settles. Earlier plans without a choice retain normal-first payment. There is no spice payment or on-board arrival. The +2 changes the calculated normal-outcome battle score, **not** the committed physical dial, support payment, casualty count or leader survival. The card is discarded after any revealed result, including an own, opposing or mutual Traitor call and Lasgun/Shield explosion. The cost is likewise paid on each of those outcomes. A successful Traitor call still supersedes the normal score; the modifier never overrides it. Public history records the transfer; the sealed card and owner reserve mix stay private until the reveal. JSON-restored decisions and transactional room updates cannot charge the same cost twice.
|
||||
|
||||
**Provisional interpretations, not publisher rulings:** The card says “increase the dialed number by 2” and transfer three reserves, but no retrieved combined ruling answers whether a sole successful Traitor caller instead keeps their reserves, how the transfer orders against an explosion, whether +2 adds Advanced support or physical losses, which reserve types the holder may select, or whether the three spent reserves count as battle losses for Atreides. For this bounded prototype, payment is at battle settlement before on-board casualties on every outcome; the engine counts the three as battle losses, chooses normal-first rather than offering a type selection, and keeps the score bonus separate from physical dial. [The source review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) records the competing reading and pending user question. These choices are visible so they can be revised without changing prior hands or inventing hidden information.
|
||||
**Provisional interpretations, not publisher rulings:** The card says “increase the dialed number by 2” and transfer three reserves, but no retrieved combined ruling answers whether a sole successful Traitor caller instead keeps their reserves, how the transfer orders against an explosion, whether +2 adds Advanced support or physical losses, which reserve types the holder may select, or whether the three spent reserves count as battle losses for Atreides. For this bounded prototype, payment is at battle settlement before on-board casualties on every outcome; the engine counts the three as battle losses, permits an available typed reserve choice, and keeps the score bonus separate from physical dial. [The source review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) records the competing reading and pending user question. These choices are visible so they can be revised without changing prior hands or inventing hidden information.
|
||||
|
||||
The owner receives a card-slot explanation and disabled reason when fewer than three reserve counters remain or the bounded mode is unsupported. All four AI profiles generate legal candidates in the classic and Ecaz/Moritani profiles and can select either slot, including an Advanced plan. The current scoring is a minimal legal participation heuristic, not difficulty calibration. The prototype does not certify the whole three-card variant, other expansion combinations, optional modules, historical-save compatibility or public mode starts.
|
||||
|
||||
@@ -128,7 +147,7 @@ order for the two physical specials in one plan: quote and debit three
|
||||
**original own reserve** counters before Harass returns any undialed counters.
|
||||
This is an implementation interpretation, not a newly retrieved publisher FAQ
|
||||
or an answer to the earlier reserve-payment question. The source audit's printed
|
||||
effects and original provisional all-outcome/normal-first cost are unchanged.
|
||||
effects and original provisional all-outcome cost were unchanged at that checkpoint; the later typed reserve choice is described above.
|
||||
|
||||
Three original own reserves must remain funded at commitment after projecting
|
||||
any own No-Field revelation. A future return cannot fund a deficient plan or
|
||||
@@ -262,7 +281,7 @@ other rosters/overlays, public starts and save conversion remain gated.
|
||||
|
||||
### Sources, physical pool and original quote
|
||||
|
||||
The existing provisional normal-first/all-outcome fee still consumes exactly3
|
||||
The then-provisional normal-first/all-outcome fee consumed exactly3
|
||||
own native reserve counters into matching normal/special Tanks subpools before
|
||||
Harass returns and battle casualties. Visitors, an ally and future returns
|
||||
cannot pay. Canonical native withdrawal updates World custody and aggregate
|
||||
|
||||
@@ -80,6 +80,7 @@ export class StoneBurnerAllocationRequiredError extends BattleResolutionQuoteErr
|
||||
}
|
||||
}
|
||||
export type ResolutionPlan = {
|
||||
reinforcementsElite?: number;
|
||||
bankerSpice?: number;
|
||||
planetologistSpecial?: true;
|
||||
dial: number;
|
||||
@@ -473,7 +474,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
const reserves = side.reinforcementsReserves!;
|
||||
requireQuote(!!reserves && typeof reserves === 'object' && !Array.isArray(reserves),
|
||||
'Reinforcements needs own typed reserves.');
|
||||
const cost = quoteReinforcements(reserves.normal, reserves.elite);
|
||||
const cost = quoteReinforcements(reserves.normal, reserves.elite, side.plan.reinforcementsElite);
|
||||
reinforcements.push({ player: side.id, card: REINFORCEMENTS_CARD, normal: cost.normal, elite: cost.elite });
|
||||
}
|
||||
const harass = slots.some((selectedCard, slot) =>
|
||||
|
||||
@@ -632,6 +632,7 @@ function candidatePlan(a: Action): Plan {
|
||||
};
|
||||
if (a.reinforcementsSources !== undefined)
|
||||
plan.reinforcementsSources = a.reinforcementsSources as NativeReserveSelections;
|
||||
if (typeof a.reinforcementsElite === 'number') plan.reinforcementsElite = a.reinforcementsElite;
|
||||
return plan;
|
||||
}
|
||||
function plans(g: GameView): Action[] {
|
||||
@@ -987,6 +988,7 @@ function plans(g: GameView): Action[] {
|
||||
}
|
||||
if (!selectedSources) continue;
|
||||
action.reinforcementsSources = selectedSources;
|
||||
action.reinforcementsElite = Object.values(selectedSources).reduce((sum, forces) => sum + forces.elite, 0);
|
||||
commitment = selectedCommitment;
|
||||
}
|
||||
if (typedForces && !commitment) continue;
|
||||
|
||||
+24
-13
@@ -776,7 +776,7 @@ import {
|
||||
type SpiceCard,
|
||||
} from './cards';
|
||||
import { ecazTreacheryCards, ecazTreacheryDefinition } from './ecaz-cards';
|
||||
import { REINFORCEMENTS_CARD, isReinforcements, quoteReinforcements, ReinforcementsError } from './reinforcements';
|
||||
import { REINFORCEMENTS_CARD, isReinforcements, quoteReinforcements, reinforcementsEliteOptions, ReinforcementsError } from './reinforcements';
|
||||
import { quoteReinforcementsHomeworldCost, reinforcementsHomeworldSources,
|
||||
reinforcementsBattleForces } from './reinforcements-homeworld';
|
||||
import {
|
||||
@@ -873,12 +873,14 @@ export type Plan = {
|
||||
support: number;
|
||||
allyPayment?: number;
|
||||
kwisatz?: boolean;
|
||||
/** Explicit number of special reserve counters in the three-counter fee. */
|
||||
reinforcementsElite?: number;
|
||||
/** Exact native reserve fee sources; only a Homeworld Reinforcements plan. */
|
||||
reinforcementsSources?: NativeReserveSelections;
|
||||
};
|
||||
export type PlanField = Exclude<
|
||||
keyof Plan,
|
||||
'support' | 'kwisatz' | 'allyPayment' | 'bankerSpice' | 'reinforcementsSources' | 'planetologistSpecial'
|
||||
'support' | 'kwisatz' | 'allyPayment' | 'bankerSpice' | 'reinforcementsSources' | 'reinforcementsElite' | 'planetologistSpecial'
|
||||
>;
|
||||
export type DiplomatDefenseReceipt = DiplomatDefenseQuote & {
|
||||
event: string;
|
||||
@@ -10814,7 +10816,7 @@ function planCombatForces(g: Game, p: Player, opponent: Player,
|
||||
if (!home || home.native !== p.id) return forces;
|
||||
const plan = g.battle!.plans[p.id];
|
||||
const cost = reserveCost ?? (plan && usesNativeBattleSpecial(plan, REINFORCEMENTS_CARD)
|
||||
? currentReinforcementsCost(g, p, plan.reinforcementsSources) : undefined);
|
||||
? currentReinforcementsCost(g, p, plan.reinforcementsSources, plan.reinforcementsElite) : undefined);
|
||||
return reinforcementsBattleForces(forces, cost?.sources?.[home.id]);
|
||||
}
|
||||
function takeBattleLosses(g: Game, p: Player, t: string, losses: Casualties) {
|
||||
@@ -18717,8 +18719,10 @@ function ecazTreacheryIntegrity(g: Game) {
|
||||
[plan.weapon, plan.defense].every(id => id === null || !!cardOf(owner, id)) &&
|
||||
validBattleSlotPair(cardOf(owner, plan.weapon), cardOf(owner, plan.defense), true),
|
||||
'The sealed Reinforcements plan lost its physical card or legal slot.');
|
||||
currentReinforcementsCost(g, owner, plan.reinforcementsSources);
|
||||
currentReinforcementsCost(g, owner, plan.reinforcementsSources, plan.reinforcementsElite);
|
||||
}
|
||||
requireRule(plan.reinforcementsElite === undefined || usesNativeBattleSpecial(plan, REINFORCEMENTS_CARD),
|
||||
'A typed reserve fee requires the physical Reinforcements card.');
|
||||
requireRule(plan.reinforcementsSources === undefined ||
|
||||
(g.homeworlds && usesNativeBattleSpecial(plan, REINFORCEMENTS_CARD)),
|
||||
'Native reserve fee sources require the physical Reinforcements card in a Homeworld plan.');
|
||||
@@ -18799,7 +18803,7 @@ type ReinforcementsCost = ReturnType<typeof quoteReinforcements> & {
|
||||
native?: ReturnType<typeof quoteNativeReserveWithdrawal>;
|
||||
};
|
||||
function currentReinforcementsCost(g: Game, p: Player,
|
||||
selections?: NativeReserveSelections): ReinforcementsCost {
|
||||
selections?: NativeReserveSelections, selectedElite?: number): ReinforcementsCost {
|
||||
requireRule(reinforcementsModeSupported(g),
|
||||
'Reinforcements requires the independent Ecaz card variant in a supported native faction composition.');
|
||||
const combinedBlock = combinedCardBattleBlock(g);
|
||||
@@ -18812,11 +18816,11 @@ function currentReinforcementsCost(g: Game, p: Player,
|
||||
requireRule(g.homeworlds.custody, 'Reinforcements needs its original native reserve custody.');
|
||||
// Richese materializes ordinary primary-World reserves before this fee.
|
||||
if (materializes)
|
||||
quoteReinforcements(p.reserves - materializes - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0);
|
||||
quoteReinforcements(p.reserves - materializes - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0, selectedElite);
|
||||
return homeworldRule(() => quoteReinforcementsHomeworldCost(
|
||||
homeworldContext(g), g.homeworlds!.custody!, p.id, selections));
|
||||
homeworldContext(g), g.homeworlds!.custody!, p.id, selections, selectedElite));
|
||||
}
|
||||
return quoteReinforcements(p.reserves - materializes - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0);
|
||||
return quoteReinforcements(p.reserves - materializes - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0, selectedElite);
|
||||
} catch (error) {
|
||||
if (error instanceof ReinforcementsError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
@@ -18842,9 +18846,11 @@ function reinforcementsPreview(g: Game, p: Player) {
|
||||
blocked = materializes
|
||||
? 'Your No-Field reveals before Reinforcements; three own reserves must remain after materialization.'
|
||||
: 'Reinforcements needs three physical forces in your reserves.';
|
||||
return { blocked, normal, elite,
|
||||
const eliteOptions = blocked ? [] : reinforcementsEliteOptions(
|
||||
reservesAfterReveal - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0);
|
||||
return { blocked, normal, elite, eliteOptions,
|
||||
...(!blocked && g.homeworlds?.custody ? { homeworldSources: homeworldRule(() =>
|
||||
reinforcementsHomeworldSources(homeworldContext(g), g.homeworlds!.custody!, p.id)) } : {}),
|
||||
eliteOptions.flatMap(choice => reinforcementsHomeworldSources(homeworldContext(g), g.homeworlds!.custody!, p.id, choice))) } : {}),
|
||||
...(noFieldAtCombatLocation(p,battle.territory)
|
||||
? { noField: { materializes, reservesAfterReveal } } : {}) };
|
||||
}
|
||||
@@ -31181,8 +31187,12 @@ function validatePlan(
|
||||
const selectedReinforcements = usesNativeBattleSpecial(input, REINFORCEMENTS_CARD);
|
||||
requireRule(input.reinforcementsSources === undefined || selectedReinforcements,
|
||||
'Native reserve fee sources require the physical Reinforcements card.');
|
||||
requireRule(input.reinforcementsElite === undefined || selectedReinforcements &&
|
||||
typeof input.reinforcementsElite === 'number',
|
||||
'A typed reserve fee requires the physical Reinforcements card.');
|
||||
const reserveCost = selectedReinforcements
|
||||
? currentReinforcementsCost(g, p, input.reinforcementsSources as NativeReserveSelections | undefined)
|
||||
? currentReinforcementsCost(g, p, input.reinforcementsSources as NativeReserveSelections | undefined,
|
||||
input.reinforcementsElite as number | undefined)
|
||||
: undefined;
|
||||
const forces = planCombatForces(g, p, opponent, reserveCost);
|
||||
const profile = ecazBattleProfile(g);
|
||||
@@ -31215,6 +31225,7 @@ function validatePlan(
|
||||
...(input.planetologistSpecial === true ? { planetologistSpecial: true as const } : {}),
|
||||
...(allyPayment ? { allyPayment } : {}),
|
||||
...(reserveCost?.sources ? { reinforcementsSources: structuredClone(reserveCost.sources) } : {}),
|
||||
...(input.reinforcementsElite !== undefined ? { reinforcementsElite: input.reinforcementsElite as number } : {}),
|
||||
dial,
|
||||
leader:
|
||||
typeof input.leader === 'string' && input.leader ? input.leader : null,
|
||||
@@ -33951,7 +33962,7 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
|
||||
const reduced = casualtyCommitment.forces.temporaryElite !== sardaukar.receipt.count;
|
||||
const plan = b.plans[winner.id];
|
||||
const fee = reduced && usesNativeBattleSpecial(plan,REINFORCEMENTS_CARD)
|
||||
? currentReinforcementsCost(g,winner,plan.reinforcementsSources).sources?.[b.territory] : undefined;
|
||||
? currentReinforcementsCost(g,winner,plan.reinforcementsSources,plan.reinforcementsElite).sources?.[b.territory] : undefined;
|
||||
requireRule(!reduced || (fee && g.homeworlds && homeworldBattleLocation(g,b.territory)?.native === winner.id),
|
||||
'Reduced temporary Sardaukar require the original native Reinforcements fee.');
|
||||
sardaukar.casualties = {...structuredClone(casualtyCommitment),
|
||||
@@ -33979,7 +33990,7 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
|
||||
}
|
||||
for (const cost of quote.reinforcements ?? []) {
|
||||
const player = getPlayer(g, cost.player);
|
||||
const current = currentReinforcementsCost(g, player, b.plans[player.id].reinforcementsSources);
|
||||
const current = currentReinforcementsCost(g, player, b.plans[player.id].reinforcementsSources, b.plans[player.id].reinforcementsElite);
|
||||
requireRule(cost.normal === current.normal && cost.elite === current.elite,
|
||||
'Reinforcements lost its original physical reserve cost.');
|
||||
if (current.native) {
|
||||
|
||||
+4
-4
@@ -1656,7 +1656,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A native Richese ally may supply ordinary counters, a concealed No-Field or both under the selected Basic/Advanced provisional additive pool. Even a zero marker retains coalition presence. The selected actor gets its private variable pool/mixed notice; rivals do not. Both plans seal before one reserve-capped materialization, retaining ordinary counters and Richese-only variable losses. Ordinary Prescience follows the physical force owner; distinct Advanced special Karama keeps its private plan-read interpretation. Prevented Ecaz uses only its own pool; neither reading nor prevention reveals the marker early.',
|
||||
'Stone Burner compares physical undialed counters, not total dial or paid strength: active Occupy contributes floor(Ecaz forces / 2) plus the ally’s native undialed tokens. Canceled Occupy compares only the selected lead’s own pool. Physical Mirror copies use the same comparison and retain their original cleanup. Hidden opposing No-Field preflight uses public possible pools, not its actual denomination; genuinely winner-changing typed allocations remain blocked.',
|
||||
'With the independent variant, Harass returns only the actual card user’s undialed forces. An Ecaz user keeps the original mandatory ceil contribution committed at free full strength while its floor count returns; ally support is separate. An allied user subtracts fixed strength before choosing its native typed commitment. Canceled Occupy uses its selected lead’s own pool. Returned reserves are outside later casualties/explosion/Face Dance.',
|
||||
'Reinforcements consumes three card-user reserves normal-first and adds +2 normal score, never support or physical arrivals. Under the provisional native order, an own No-Field materializes before that original reserve fee and successful Harass returns; three reserves must remain after reveal before this plan may seal. Original/copied Stone ignores +2 score but not the fee. Existing all-revealed-outcome cost/disposal remains provisional. Unsupported co-side profiles stay guarded.',
|
||||
'Reinforcements consumes three chosen card-user reserves and adds +2 normal score, never support or physical arrivals. Under the provisional native order, an own No-Field materializes before that original reserve fee and successful Harass returns; three reserves must remain after reveal before this plan may seal. Original/copied Stone ignores +2 score but not the fee. Existing all-revealed-outcome cost/disposal remains provisional. Unsupported co-side profiles stay guarded.',
|
||||
'Advanced numbers come from the user-authorized revised PDF, physical pages 32 and 19, not a new publisher erratum. Basic follows the main printed paragraph provisionally. Original faction/deck prerequisites, separate ten-card Richese cache and independent three-card variant selection stay. Ordinary-plus-marker Basic/Advanced Occupy extends the provisional additive interpretation, not the unresolved FAQ cap. Fresh plain/card Ecaz and standalone Ecaz/classic Skills retain ordinary Arrakis Occupy, including independent cards without other modules. Bare Basic native mixed markers are connected; unsupported overlays and full-mode acceptance remain separate.',
|
||||
],
|
||||
checklist: [
|
||||
@@ -5514,7 +5514,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Existing admitted World starts with Nexus and/or Discoveries also initialize original occupation benefits through fresh setup, optionally with Skills and original phase modules. Occupied Collection, ordinary deposits, nested entry, earned Nexus/Stronghold cards and deferred Banker retain their separate events. Original start/role limits remain; existing occupation history is never replaced and played games are not converted.',
|
||||
'Each faction uses its own card and matching world token. In Basic play the Emperor uses Kaitain alone. Advanced play adds Salusa Secundus for its Sardaukar; movement can transfer both force types between the two Emperor worlds.',
|
||||
'Provisional physical Ornithopter composition permits independently legal Imperial transfers as first or second groups, mixed with ground movement or another distinct typed World group. Original normal/starred pools prevent counter reuse after merging; fresh World-aware snapshots and the recorded older-flight inference retain the card event. Range3 grants no new World route. Only completed two-group use earns the extra movement credit; one card is discarded, and actual later Hajr is independent.',
|
||||
'Registered selected-family World all14 composes independent36/38/50-card setup and original Tech3+/Advanced Strongholds2+, including mixed E3. Clean bare World cards now use the registered selected-family profile with optional phase modules and original occupation state. Recruits uses current native rates/typed limits and recorded paid/free use; Banker and phase-end Axlotl remain separate. Reinforcements seals exact normal-first sources and excludes an own-World fee from dial/casualties while preserving original face/support. Suk rescues committed losses, not fees. Harass returns actual visitors through native custody and is forbidden on every own World. Original Nexus/Discovery/Semuta and role/effect guards remain.',
|
||||
'Registered selected-family World all14 composes independent36/38/50-card setup and original Tech3+/Advanced Strongholds2+, including mixed E3. Clean bare World cards now use the registered selected-family profile with optional phase modules and original occupation state. Recruits uses current native rates/typed limits and recorded paid/free use; Banker and phase-end Axlotl remain separate. Reinforcements seals exact chosen typed sources and excludes an own-World fee from dial/casualties while preserving original face/support. Suk rescues committed losses, not fees. Harass returns actual visitors through native custody and is forbidden on every own World. Original Nexus/Discovery/Semuta and role/effect guards remain.',
|
||||
'Native reserves determine high or low population. Salusa counts Sardaukar, while the other cards count the faction’s reserves there. Reaching the high face’s minimum restores high population. Occupation also applies the low penalty.',
|
||||
'Low population normally grants one additional free force revival and one additional bank spice when collecting CHOAM Charity. Salusa grants neither bonus; CHOAM’s Tupile reverses the usual pattern by granting an advantage while low and a penalty while high.',
|
||||
'A qualifying charity claim separates its ordinary payer from the extra bank spice. Inflation doubles both portions or prevents the whole collection. Canceling Bene Gesserit charity prevents both portions of that claim.',
|
||||
@@ -6653,9 +6653,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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. Classic/paired Ecaz/Moritani Basic/Advanced, clean selected Ix/CHOAM native/base rosters and explicitly selected native Advanced Occupy prototypes spend ordinary reserves first and then elite reserves, moving exactly three to corresponding Tanks subpools at resolution. These are the actual card holder’s reserves, not the variable ally’s pool; no extra forces arrive.',
|
||||
'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.',
|
||||
'All-outcome payment, normal-first selection and reserve transfer counting as battle losses remain provisional, not publisher rulings. Same-plan Harass pays the original three-reserve fee before returns; returned reserves cannot fund it. In native Advanced Occupy, original/copied Stone ignores +2 normal score but still charges the fee. Basic mixed-marker co-sides, unprofiled armies and other card-specific limits remain guarded.',
|
||||
'All-outcome payment and reserve transfer counting as battle losses remain provisional, not publisher rulings. The holder chooses an available ordinary/special reserve mix; older sealed plans without a choice retain normal-first payment. Same-plan Harass pays the original three-reserve fee before returns; returned reserves cannot fund it. In native Advanced Occupy, original/copied Stone ignores +2 normal score but still charges the fee. Basic mixed-marker co-sides, unprofiled armies and other card-specific limits remain guarded.',
|
||||
'With your own concealed No-Field, the provisional native order materializes its reserve-limited counters before this cost. At least three own reserves must remain after that reveal, before any future Harass return; otherwise Reinforcements is unavailable before the first plan seals. This checks only the actual card user, never its ally’s marker or reserves.',
|
||||
'Classic and selected Ix/CHOAM Basic/Advanced Worlds retain exact native normal-first sources, sealed when ambiguous; unique sources may derive. Own-World fees cannot also fund dial/casualties; original face/support stays, including explosion thresholds. Original phase modules preserve their own prerequisites and earned custody. Native-only World Traitors, typed Tanks and fee-before-Harass stay provisional composition, not a new card FAQ.',
|
||||
'Classic and selected Ix/CHOAM Basic/Advanced Worlds retain exact native sources for the chosen reserve mix, sealed when ambiguous; unique sources may derive. Own-World fees cannot also fund dial/casualties; original face/support stays, including explosion thresholds. Original phase modules preserve their own prerequisites and earned custody. Native-only World Traitors, typed Tanks and fee-before-Harass stay provisional composition, not a new card FAQ.',
|
||||
'One canonical physical-name registry now includes the independent cards for current facts and named battle conditions. Naming Reinforcements in a weapon slot does not make it a weapon category. Native questions use actual combat-location labels. Source-aware legal completion, private full-plan/read-only review and the fill button retain exact native sources.',
|
||||
'Selected Ix/CHOAM Worlds retain original38/50 setup and native stock, optionally with original Tech3+ and Advanced Strongholds2+. Cyborg substitution and Face Dance use their actual physical pools, never a duplicate fee origin. Native Richese must retain three World reserves AFTER its own projected No-Field reveal before R seals; rejection moves no counter or marker. Original phase rewards and earned held-card support stay; admitted Stone compositions retain their separate guards.',
|
||||
'Supported all14/card World/phase profiles keep the same physical fee and score. An own-World fee cannot fund the same physical dial/casualty; pre-fee native traits/face remain original. Suk rescues actual committed losses, never fee counters, keeping native saves at that home or using original typed external destinations. Earned Arrakeen never subsidizes a World or reserve fee. A Richese marker must leave three after materialization; future Harass cannot fund a refused seal. Native E3 card Worlds and unrelated guards remain.',
|
||||
|
||||
@@ -9,10 +9,10 @@ import {
|
||||
import { quoteReinforcements, ReinforcementsError } from './reinforcements';
|
||||
|
||||
type Cost = ReturnType<typeof quoteReinforcements>;
|
||||
function nativeCost(context: HomeworldCustodyContext, actor: string): Cost {
|
||||
function nativeCost(context: HomeworldCustodyContext, actor: string, selectedElite?: number): Cost {
|
||||
const seat = context.players.find(player => player.id === actor);
|
||||
if (!seat) throw new NativeReserveError('Reinforcements needs its seated native reserve owner.');
|
||||
return quoteReinforcements(seat.reserves - seat.eliteReserves, seat.eliteReserves);
|
||||
return quoteReinforcements(seat.reserves - seat.eliteReserves, seat.eliteReserves, selectedElite);
|
||||
}
|
||||
function sourceOptions(context: HomeworldCustodyContext, custody: HomeworldCustody,
|
||||
actor: string, cost: Cost): NativeReserveSelections[] {
|
||||
@@ -37,18 +37,19 @@ function sourceOptions(context: HomeworldCustodyContext, custody: HomeworldCusto
|
||||
return options;
|
||||
}
|
||||
|
||||
/** Public physical alternatives for the existing normal-first fee. Enumeration
|
||||
/** Public physical alternatives for the chosen typed fee, normal-first by default. Enumeration
|
||||
* does not select a source, move counters or grant permission to play a card. */
|
||||
export function reinforcementsHomeworldSources(context: HomeworldCustodyContext,
|
||||
custody: HomeworldCustody, actor: string): NativeReserveSelections[] {
|
||||
return sourceOptions(context, custody, actor, nativeCost(context, actor));
|
||||
custody: HomeworldCustody, actor: string, selectedElite?: number): NativeReserveSelections[] {
|
||||
return sourceOptions(context, custody, actor, nativeCost(context, actor, selectedElite));
|
||||
}
|
||||
|
||||
/** Exact native withdrawal; the caller commits matching Tanks and observes
|
||||
* population only after this whole fee. Ambiguous sources must be declared. */
|
||||
export function quoteReinforcementsHomeworldCost(context: HomeworldCustodyContext,
|
||||
custody: HomeworldCustody, actor: string, selections?: NativeReserveSelections) {
|
||||
const cost = nativeCost(context, actor);
|
||||
custody: HomeworldCustody, actor: string, selections?: NativeReserveSelections,
|
||||
selectedElite?: number) {
|
||||
const cost = nativeCost(context, actor, selectedElite);
|
||||
let sources = selections;
|
||||
if (sources === undefined) {
|
||||
const options = sourceOptions(context, custody, actor, cost);
|
||||
|
||||
+17
-6
@@ -17,15 +17,26 @@ export function isReinforcements(card: Card | undefined): boolean {
|
||||
ecazTreacheryDefinition(card)?.card.effect === 'reinforcements';
|
||||
}
|
||||
|
||||
/** Spend exactly three own reserve counters, ordinary before elite; never dial them. */
|
||||
/** Legal elite counts for the three-counter fee, in ordinary-first order. */
|
||||
export function reinforcementsEliteOptions(normalReserves: number, eliteReserves: number): number[] {
|
||||
if (![normalReserves, eliteReserves].every(n => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0))
|
||||
throw new ReinforcementsError('Reinforcements needs valid nonnegative reserve counts.');
|
||||
const minimum = Math.max(0, 3 - normalReserves);
|
||||
const maximum = Math.min(3, eliteReserves);
|
||||
return minimum > maximum ? [] : Array.from({ length: maximum - minimum + 1 }, (_, i) => minimum + i);
|
||||
}
|
||||
|
||||
/** Spend exactly three own reserve counters; omitted choice retains the original ordinary-first rule. */
|
||||
export function quoteReinforcements(
|
||||
normalReserves: number,
|
||||
eliteReserves: number,
|
||||
selectedElite?: 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)
|
||||
const options = reinforcementsEliteOptions(normalReserves, eliteReserves);
|
||||
if (!options.length)
|
||||
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 };
|
||||
const elite = selectedElite === undefined ? options[0] : selectedElite;
|
||||
if (!Number.isSafeInteger(elite) || !options.includes(elite))
|
||||
throw new ReinforcementsError('Choose an available mix of three ordinary and elite reserve forces.');
|
||||
return { normal: 3 - elite, elite, bonus: 2 };
|
||||
}
|
||||
@@ -104,8 +104,9 @@ for (const [reserves, marker] of [[6, 3], [8, 5], [3, 0]] as const)
|
||||
assert.equal(revealed.battle!.revealed, true);
|
||||
assertMarkerRevealed(fixture, revealed);
|
||||
const { afterVotes, game: done } = finishNoFieldReinforcements(fixture, revealed);
|
||||
assert.equal(occupyRicheseSeat(afterVotes, fixture.richese).reserves, reserves - 3 - fixture.variableDial,
|
||||
'The future undialed return neither funds nor changes the three-counter original reserve cost.');
|
||||
assert.ok(afterVotes.battle, 'The shared final battle opportunity precedes settlement.');
|
||||
assert.equal(occupyRicheseSeat(afterVotes, fixture.richese).reserves, reserves - marker,
|
||||
'The No-Field has materialized; the reserve fee and return have not settled yet.');
|
||||
assert.equal(done.lastBattleContext!.result, 'normal');
|
||||
assert.equal(done.lastBattleContext!.winner, fixture.owner);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, reserves - 3 - fixture.variableDial);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { NativeReserveError, type NativeReserveSelections } from '../game/homeworld-native-reserves';
|
||||
import { type HomeworldCustody, type HomeworldCustodyContext } from '../game/homeworld-custody';
|
||||
import {
|
||||
quoteReinforcementsHomeworldCost as quote,
|
||||
reinforcementsHomeworldSources as sources,
|
||||
} from '../game/reinforcements-homeworld';
|
||||
import { ReinforcementsError } from '../game/reinforcements';
|
||||
import { combatArmy } from '../game/combat-location';
|
||||
import {
|
||||
assertReinforcementsHomeworldCustody,
|
||||
createReinforcementsHomeworldFixture,
|
||||
finishReinforcementsHomeworldBattle,
|
||||
reinforcementsHomeworldPlayer as player,
|
||||
reinforcementsHomeworldPool as pool,
|
||||
sealReinforcementsHomeworldPlan,
|
||||
} from './fixture-reinforcements-homeworld';
|
||||
|
||||
const kaitain = 'homeworld:emperor';
|
||||
const salusa = 'homeworld:emperor:salusa';
|
||||
const ix = 'homeworld:ixians';
|
||||
const units = (normal: number, elite = 0) => ({ normal, elite });
|
||||
|
||||
function fixture(): { context: HomeworldCustodyContext; custody: HomeworldCustody } {
|
||||
return {
|
||||
context: { advanced: true, players: [
|
||||
{ id: 'e', faction: 'emperor', reserves: 10, eliteReserves: 3 },
|
||||
{ id: 'i', faction: 'ixians', reserves: 10, eliteReserves: 2 },
|
||||
] },
|
||||
custody: { salusa: units(3, 2), visitors: {
|
||||
[kaitain]: { i: units(2) },
|
||||
[ix]: { e: units(1, 1) },
|
||||
} },
|
||||
};
|
||||
}
|
||||
|
||||
void test('Homeworld options and costs honor the selected typed fee and preserve normal-first defaults', () => {
|
||||
const { context, custody } = fixture();
|
||||
const before = structuredClone({ context, custody });
|
||||
const defaults = sources(context, custody, 'e');
|
||||
assert.deepEqual(defaults, sources(context, custody, 'e', 0));
|
||||
assert.equal(defaults.length, 4);
|
||||
const mixed = sources(context, custody, 'e', 2);
|
||||
assert.equal(mixed.length, 4);
|
||||
for (const allocation of mixed) {
|
||||
assert.equal(allocation[kaitain].normal + allocation[salusa].normal, 1);
|
||||
assert.equal(allocation[kaitain].elite + allocation[salusa].elite, 2);
|
||||
const result = quote(context, custody, 'e', allocation, 2);
|
||||
assert.deepEqual({ normal: result.normal, elite: result.elite, bonus: result.bonus },
|
||||
{ normal: 1, elite: 2, bonus: 2 });
|
||||
assert.deepEqual(result.sources, allocation);
|
||||
assert.equal(result.native.players[0].reserves, 7);
|
||||
assert.equal(result.native.players[0].eliteReserves, 1);
|
||||
}
|
||||
assert.throws(() => quote(context, custody, 'e', undefined, 2), NativeReserveError);
|
||||
const defaultResult = quote(context, custody, 'e', defaults[0]);
|
||||
assert.deepEqual({ normal: defaultResult.normal, elite: defaultResult.elite }, { normal: 3, elite: 0 });
|
||||
assert.deepEqual({ context, custody }, before);
|
||||
});
|
||||
|
||||
void test('single native Homeworld can automatically pay an explicit elite choice', () => {
|
||||
const { context, custody } = fixture();
|
||||
assert.deepEqual(sources(context, custody, 'i', 2), [{ [ix]: units(1, 2) }]);
|
||||
const result = quote(context, custody, 'i', undefined, 2);
|
||||
assert.deepEqual(result.sources, { [ix]: units(1, 2) });
|
||||
assert.deepEqual({ normal: result.normal, elite: result.elite, bonus: result.bonus },
|
||||
{ normal: 1, elite: 2, bonus: 2 });
|
||||
assert.equal(result.native.players[1].eliteReserves, 0);
|
||||
});
|
||||
|
||||
void test('selected types and explicit native allocations are both validated', () => {
|
||||
const { context, custody } = fixture();
|
||||
const before = structuredClone({ context, custody });
|
||||
for (const choice of [-1, 4, 1.5, NaN, Infinity]) {
|
||||
assert.throws(() => sources(context, custody, 'e', choice), ReinforcementsError);
|
||||
assert.throws(() => quote(context, custody, 'e', undefined, choice), ReinforcementsError);
|
||||
}
|
||||
for (const allocation of [
|
||||
{ [kaitain]: units(3) }, // valid default fee, wrong for the chosen mix
|
||||
{ [ix]: units(1, 2) }, // visitor forces cannot pay the fee
|
||||
{ [kaitain]: units(1, 2) }, // Kaitain has only one elite
|
||||
{ [kaitain]: units(1, 1) }, // does not sum to the chosen fee
|
||||
{ [kaitain]: { normal: 1, elite: 2, extra: 0 } },
|
||||
]) {
|
||||
assert.throws(() => quote(context, custody, 'e', allocation as unknown as NativeReserveSelections, 2), NativeReserveError);
|
||||
}
|
||||
assert.deepEqual({ context, custody }, before);
|
||||
});
|
||||
|
||||
void test('Advanced Emperor seals and pays a mixed Homeworld fee from the selected Worlds', () => {
|
||||
const allocation = { [kaitain]: units(1), [salusa]: units(1, 1) };
|
||||
const f = createReinforcementsHomeworldFixture({ advanced: true, sources: allocation });
|
||||
const before = player(f.game, f.owner);
|
||||
assert.deepEqual(pool(f.game, f.owner, kaitain), units(10));
|
||||
assert.deepEqual(pool(f.game, f.owner, salusa), units(2, 1));
|
||||
assert.deepEqual(combatArmy(f.game, f.owner, f.location), units(3, 4));
|
||||
const action = { ...f.planActions[0].action, reinforcementsElite: 1 };
|
||||
let game = sealReinforcementsHomeworldPlan(f.game, { actor: f.owner, action });
|
||||
assert.deepEqual(game.homeworlds, f.game.homeworlds, 'Sealing does not pay the fee');
|
||||
assert.equal(player(game, f.owner).elites!.reserves, before.elites!.reserves);
|
||||
game = sealReinforcementsHomeworldPlan(JSON.parse(JSON.stringify(game)), f.planActions[1]);
|
||||
const done = finishReinforcementsHomeworldBattle(game);
|
||||
const after = player(done, f.owner);
|
||||
assert.equal(done.lastBattleContext!.winner, f.opponent);
|
||||
assert.deepEqual(pool(done, f.owner, kaitain), units(9));
|
||||
assert.deepEqual(pool(done, f.owner, salusa), units(1));
|
||||
assert.deepEqual(combatArmy(done, f.owner, f.location), units(0));
|
||||
assert.equal(after.reserves, before.reserves - 3);
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves - 1);
|
||||
assert.equal(after.tanks, before.tanks + 10, 'Three fee forces and seven defeated invaders');
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks + 5, 'One fee elite and four defeated elites');
|
||||
assert.equal(after.battleLosses, before.battleLosses + 10);
|
||||
assertReinforcementsHomeworldCustody(f.game);
|
||||
assertReinforcementsHomeworldCustody(done);
|
||||
});
|
||||
@@ -70,7 +70,10 @@ void test('actual Advanced native movement exposes all three legal fee allocatio
|
||||
for (const salusaNormal of [0, 1, 2]) {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced: true, sources: sources(salusaNormal) });
|
||||
const choices = viewGame(f.game, f.owner).battle!.reinforcements!.homeworldSources!;
|
||||
assert.deepEqual(choices.map(canonicalSources).sort(), [0, 1, 2].map(n => canonicalSources(sources(n))).sort());
|
||||
assert.deepEqual(choices.filter(choice => Object.values(choice).every(group => group.elite === 0))
|
||||
.map(canonicalSources).sort(), [0, 1, 2].map(n => canonicalSources(sources(n))).sort());
|
||||
assert.ok(choices.some(choice => Object.values(choice).some(group => group.elite > 0)),
|
||||
'The expanded preview also offers physically available special reserves.');
|
||||
let game = sealReinforcementsHomeworldPlan(f.game, f.planActions[0]);
|
||||
game = sealReinforcementsHomeworldPlan(JSON.parse(JSON.stringify(game)) as Game, f.planActions[1]);
|
||||
const done = finishReinforcementsHomeworldBattle(game);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { applyAction, viewGame, type Game, type GameView } from '../game/engine';
|
||||
import { harassWithdrawGame, takeHarassCard } from './fixture-harass-withdraw';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
||||
},
|
||||
});
|
||||
const { GameTable } = await import('../components/game-table');
|
||||
aliases.deregister();
|
||||
|
||||
const card = 'ecaz-reinforcements';
|
||||
function game(): Game {
|
||||
const result = harassWithdrawGame({ advanced: true, factions: ['emperor', 'atreides', 'harkonnen'] });
|
||||
takeHarassCard(result, 'a', card);
|
||||
return result;
|
||||
}
|
||||
function markup(view: GameView): string {
|
||||
return renderToStaticMarkup(createElement(GameTable, { game: view, send: async () => {}, onExit() {}, busy: false }));
|
||||
}
|
||||
|
||||
void test('owner can see every available Reinforcements reserve mix in the battle plan', () => {
|
||||
const state = game(), view = viewGame(state, 'a');
|
||||
assert.deepEqual(view.battle?.reinforcements?.eliteOptions, [0, 1, 2, 3]);
|
||||
// Staged projection forces the physical card into its slot, avoiding a
|
||||
// browser hook driver; the actual sealed-plan test below uses engine actions.
|
||||
view.battle!.ownCommitments = [{ source: 'native', beneficiary: 'd', target: 'a', field: 'weapon', value: card }];
|
||||
const html = markup(view);
|
||||
const select = html.match(/<label[^>]*>\s*<span>Reinforcements reserve fee<\/span>[\s\S]*?<\/label>/)?.[0];
|
||||
assert.ok(select);
|
||||
for (const elite of [0, 1, 2, 3]) {
|
||||
assert.match(select, new RegExp(`value="${elite}"`));
|
||||
assert.match(select, new RegExp(`${3 - elite} normal \\+ ${elite} special`));
|
||||
}
|
||||
const opponent = markup(viewGame(state, 'd'));
|
||||
assert.doesNotMatch(opponent, /Reinforcements reserve fee/);
|
||||
});
|
||||
|
||||
void test('sealed typed reserve choice is visible only to its owner', () => {
|
||||
const state = applyAction(game(), 'a', {
|
||||
type: 'battlePlan', dial: 1, support: 1, leader: 'emperor-0', weapon: card, defense: null,
|
||||
reinforcementsElite: 2,
|
||||
});
|
||||
const owner = viewGame(state, 'a'), opponent = viewGame(state, 'd');
|
||||
assert.equal(owner.battle?.plans.a.reinforcementsElite, 2);
|
||||
assert.deepEqual(opponent.battle?.plans, {});
|
||||
assert.match(markup(owner), /Sealed reserve fee:\s*1 normal \+ 2 special/);
|
||||
assert.doesNotMatch(markup(opponent), /Sealed reserve fee:/);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
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 { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { harassCustody, harassWithdrawGame, takeHarassCard } from './fixture-harass-withdraw';
|
||||
|
||||
const card = 'ecaz-reinforcements';
|
||||
|
||||
/** Genuine Advanced setup and native battle admission; the shared fixture
|
||||
* stages physical board forces and card custody, preserving every counter. */
|
||||
function fixture(): Game {
|
||||
const game = harassWithdrawGame({ advanced: true, factions: ['emperor','atreides','harkonnen'] });
|
||||
takeHarassCard(game, 'a', card);
|
||||
harassCustody(game);
|
||||
return game;
|
||||
}
|
||||
function plan(elite?: number, useCard = true): Action {
|
||||
return { type: 'battlePlan', dial: 1, support: 1, leader: 'emperor-0',
|
||||
weapon: useCard ? card : null, defense: null,
|
||||
...(elite === undefined ? {} : { reinforcementsElite: elite }),
|
||||
} as Action;
|
||||
}
|
||||
function second(game: Game): Game {
|
||||
return applyAction(game, 'd', { type:'battlePlan', dial:1, support:1,
|
||||
leader:'atreides-0', weapon:null, defense:null });
|
||||
}
|
||||
function finish(game: Game): Game {
|
||||
let next = applyAction(game, 'a', {type:'traitorCall',call:false});
|
||||
next = applyAction(next, 'd', {type:'traitorCall',call:false});
|
||||
if (next.decision?.kind === 'moritaniAssassinate')
|
||||
next = applyAction(next, next.decision.player,
|
||||
{type:'decision', event:next.decision.event, decline:true});
|
||||
harassCustody(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
for (const selected of [0,1,2,3] as const) {
|
||||
void test(`Reinforcements commits the selected ${selected} elite reserve cost`, () => {
|
||||
const start = fixture(), initial = structuredClone(start);
|
||||
const owner = start.players.find(row => row.id === 'a')!;
|
||||
assert.ok(owner.reserves - owner.elites!.reserves >= 3);
|
||||
assert.ok(owner.elites!.reserves >= 3);
|
||||
const one = applyAction(start, 'a', plan(selected));
|
||||
assert.deepEqual(start, initial);
|
||||
assert.equal(one.players[0].reserves, owner.reserves, 'Sealing does not spend the cost');
|
||||
assert.equal(one.players[0].elites!.reserves, owner.elites!.reserves);
|
||||
assert.deepEqual(viewGame(one, 'd').battle?.plans, {}, 'A sealed plan remains private');
|
||||
const resolved = finish(second(one));
|
||||
const after = resolved.players.find(row => row.id === 'a')!;
|
||||
assert.equal(after.reserves, owner.reserves - 3);
|
||||
assert.equal(after.elites!.reserves, owner.elites!.reserves - selected);
|
||||
assert.equal(after.elites!.tanks, owner.elites!.tanks + selected);
|
||||
assert.equal(resolved.discard.filter(row => row.id === card).length, 1);
|
||||
assert.equal(resolved.log.filter(row => row.text.includes('used Reinforcements: three reserve forces')).length, 1);
|
||||
});
|
||||
}
|
||||
|
||||
void test('legacy omitted selection preserves normal-first reserve costs', () => {
|
||||
const initial = fixture();
|
||||
const owner = initial.players[0];
|
||||
const done = finish(second(applyAction(initial, 'a', plan())));
|
||||
assert.equal(done.players[0].reserves, owner.reserves - 3);
|
||||
assert.equal(done.players[0].elites!.reserves, owner.elites!.reserves);
|
||||
});
|
||||
|
||||
void test('invalid or irrelevant elite-cost selections reject without mutating the game', () => {
|
||||
const game = fixture();
|
||||
for (const action of [plan(-1), plan(4), plan(1.5), plan(Number.NaN), plan(1, false)]) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, 'a', action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
const restricted = fixture();
|
||||
restricted.players[0].elites!.reserves = 2;
|
||||
restricted.players[0].elites!.tanks = 3;
|
||||
restricted.players[0].reserves = 3;
|
||||
restricted.players[0].tanks = 12;
|
||||
harassCustody(restricted);
|
||||
for (const invalid of [0,3]) {
|
||||
const before = structuredClone(restricted);
|
||||
assert.throws(() => applyAction(restricted, 'a', plan(invalid)));
|
||||
assert.deepEqual(restricted, before);
|
||||
}
|
||||
assert.doesNotThrow(() => applyAction(restricted, 'a', plan(2)));
|
||||
});
|
||||
|
||||
void test('all bot profiles retain a legal battle plan at an eligible typed-cost opportunity', () => {
|
||||
const game = fixture();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, 'a');
|
||||
view.players.find(row => row.id === 'a')!.bot = difficulty;
|
||||
const actions = botActions(view);
|
||||
const battle = actions.find(action => action.type === 'battlePlan');
|
||||
assert.ok(battle, `${difficulty} must have a legal battle plan`);
|
||||
assert.doesNotThrow(() => applyAction(game, 'a', battle));
|
||||
}
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { baseDeck, leaders, type Card } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { BattleResolutionQuoteError, quoteBattleResolution, type BattleResolutionInput } from '../game/battle-resolution-quote';
|
||||
import { isReinforcements, quoteReinforcements, ReinforcementsError, REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { isReinforcements, quoteReinforcements, reinforcementsEliteOptions, ReinforcementsError, REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
|
||||
const physicalCards = [...baseDeck(), ...richeseCards(), ...ecazTreacheryCards()];
|
||||
const reinforcement = ecazTreacheryCards().find(c => c.id === REINFORCEMENTS_CARD)!;
|
||||
@@ -42,6 +42,21 @@ function putCard(g: BattleResolutionInput, side: 'attacker' | 'defender', slot:
|
||||
g[side].plan[slot] = card.id;
|
||||
}
|
||||
|
||||
void test('Reinforcements permits each available typed mix and retains the legacy default', () => {
|
||||
assert.deepEqual(reinforcementsEliteOptions(4, 4), [0, 1, 2, 3]);
|
||||
assert.deepEqual(reinforcementsEliteOptions(2, 2), [1, 2]);
|
||||
assert.deepEqual(reinforcementsEliteOptions(0, 4), [3]);
|
||||
assert.deepEqual(reinforcementsEliteOptions(1, 1), []);
|
||||
for (const elite of [0, 1, 2, 3])
|
||||
assert.deepEqual(quoteReinforcements(4, 4, elite), { normal: 3 - elite, elite, bonus: 2 });
|
||||
assert.deepEqual(quoteReinforcements(2, 2), { normal: 2, elite: 1, bonus: 2 });
|
||||
assert.deepEqual(quoteReinforcements(2, 2, 2), { normal: 1, elite: 2, bonus: 2 });
|
||||
for (const elite of [-1, 0, 3, 4, 1.5, NaN, Infinity])
|
||||
assert.throws(() => quoteReinforcements(2, 2, elite), ReinforcementsError);
|
||||
assert.throws(() => quoteReinforcements(1, 1, 1), ReinforcementsError);
|
||||
assert.throws(() => reinforcementsEliteOptions(-1, 3), ReinforcementsError);
|
||||
});
|
||||
|
||||
void test('canonical identity and reserve cost boundaries preserve the physical counter types', () => {
|
||||
assert.equal(isReinforcements(reinforcement), true);
|
||||
assert.equal(isReinforcements({ ...reinforcement, name: 'Reinforcement' }), false);
|
||||
|
||||
Reference in new issue
Block a user