Seal exact native Homeworld Reinforcements costs and plan sources
This commit is contained in:
1 parent
05c4c8c7fb
commit
c64c08af89
32 files changed
+1484
-119
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { richeseCards } from './richese-cards';
|
||||
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
|
||||
import type { Plan } from './engine';
|
||||
import { treacheryDeck, type Card } from './cards';
|
||||
import type { Card } from './cards';
|
||||
import { CHEAP_HERO_TRAITOR } from './traitors';
|
||||
|
||||
export type PlanClaim =
|
||||
@@ -67,7 +67,7 @@ export function parsePlanClaim(
|
||||
if (
|
||||
v.name !== null &&
|
||||
(typeof v.name !== 'string' ||
|
||||
![...treacheryDeck(['ix']), ...richeseCards()].some((c) => c.name === v.name))
|
||||
!TRUTH_CARD_NAMES.includes(v.name))
|
||||
)
|
||||
return fail();
|
||||
return { kind: v.kind, name: v.name };
|
||||
|
||||
@@ -81,7 +81,8 @@ export type ResolutionParticipant = {
|
||||
noFieldAtTerritory?: boolean;
|
||||
};
|
||||
export type ResolutionCombatant = ResolutionParticipant & {
|
||||
/** Own physical reserve counters available before battle casualties. */
|
||||
/** Own typed reserves before card costs. The caller excludes an on-World fee
|
||||
* from the native combat pool/rules while retaining original combat traits. */
|
||||
reinforcementsReserves?: { normal: number; elite: number };
|
||||
harassWithdraw?: HarassWithdrawContext;
|
||||
harassSelection?: HarassWithdrawSelection;
|
||||
@@ -410,8 +411,8 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
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),
|
||||
'Reinforcements cannot combine with Homeworld or leader skills.');
|
||||
requireQuote(!(side.leaderSkills?.length || (side === a ? d : a).leaderSkills?.length),
|
||||
'Reinforcements cannot combine with leader skills.');
|
||||
const reserves = side.reinforcementsReserves!;
|
||||
requireQuote(!!reserves && typeof reserves === 'object' && !Array.isArray(reserves),
|
||||
'Reinforcements needs own typed reserves.');
|
||||
|
||||
+36
-13
@@ -112,6 +112,8 @@ import { botNexusHarkonnenBetrayalActions } from './bot-nexus-harkonnen-betrayal
|
||||
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
|
||||
import { fighterCount, isAdvisor } from './advisors';
|
||||
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
|
||||
import { reinforcementsBattleForces } from './reinforcements-homeworld';
|
||||
import type { NativeReserveSelections } from './homeworld-native-reserves';
|
||||
import { stoneBurnerPlanBlock, stoneBurnerComparison, stoneBurnerWithdrawalContext } from './stone-burner';
|
||||
import {
|
||||
applyAction,
|
||||
@@ -586,7 +588,7 @@ function ornithopterMoves(g: GameView): Action[] {
|
||||
return result;
|
||||
}
|
||||
function candidatePlan(a: Action): Plan {
|
||||
return {
|
||||
const plan: Plan = {
|
||||
...(Number(a.bankerSpice ?? 0)
|
||||
? { bankerSpice: Number(a.bankerSpice) }
|
||||
: {}),
|
||||
@@ -597,6 +599,9 @@ function candidatePlan(a: Action): Plan {
|
||||
defense: typeof a.defense === 'string' ? a.defense : null,
|
||||
kwisatz: a.kwisatz === true,
|
||||
};
|
||||
if (a.reinforcementsSources !== undefined)
|
||||
plan.reinforcementsSources = a.reinforcementsSources as NativeReserveSelections;
|
||||
return plan;
|
||||
}
|
||||
function plans(g: GameView): Action[] {
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
@@ -630,18 +635,16 @@ function plans(g: GameView): Action[] {
|
||||
const ownStrength = occupyProfile?.maxDial ?? (typedForces
|
||||
? maxCombatDial(forces)
|
||||
: own);
|
||||
const supportCache = new Map<
|
||||
number,
|
||||
{ support: number; cost: number } | null
|
||||
>();
|
||||
const supporting = (dial: number) => {
|
||||
if (supportCache.has(dial)) return supportCache.get(dial)!;
|
||||
type SupportChoice = { support: number; cost: number };
|
||||
const supportCache = new Map<number, SupportChoice | null>();
|
||||
const supporting = (dial: number, pool = forces, cache = supportCache) => {
|
||||
if (cache.has(dial)) return cache.get(dial)!;
|
||||
const choices = Array.from(
|
||||
{
|
||||
length:
|
||||
(g.advanced
|
||||
? Math.min(
|
||||
maxCombatSupport(forces),
|
||||
maxCombatSupport(pool),
|
||||
(me.spice ?? 0) +
|
||||
g.aid.available +
|
||||
(b.strongholdEffects[me.id] === 'arrakeen' ? 2 : 0),
|
||||
@@ -652,7 +655,7 @@ function plans(g: GameView): Action[] {
|
||||
(occupyProfile
|
||||
? occupyDials.some((option) => option.dial === dial && option.support === support)
|
||||
? quoteEcazOccupyDial(occupyProfile, dial, support).options : []
|
||||
: casualtyOptions(forces, dial, support)).map((loss) => ({
|
||||
: casualtyOptions(pool, dial, support)).map((loss) => ({
|
||||
support,
|
||||
cost:
|
||||
Math.max(
|
||||
@@ -667,9 +670,14 @@ function plans(g: GameView): Action[] {
|
||||
.flat()
|
||||
.sort((a, b) => a.cost - b.cost);
|
||||
const result = choices[0] ?? null;
|
||||
supportCache.set(dial, result);
|
||||
cache.set(dial, result);
|
||||
return result;
|
||||
};
|
||||
const reservePools = b.reinforcements?.homeworldSources?.map(sources => {
|
||||
const pool = b.native === me.id
|
||||
? reinforcementsBattleForces(forces, sources[b.territory]) : forces;
|
||||
return { sources, pool, cache: pool === forces ? supportCache : new Map<number, SupportChoice | null>() };
|
||||
});
|
||||
// Include affordable bank-supported dials even when the estimated winning
|
||||
// dial exceeds the budget; otherwise a large army can miss its free support.
|
||||
const bankSupportedDials =
|
||||
@@ -920,9 +928,24 @@ function plans(g: GameView): Action[] {
|
||||
);
|
||||
if (committedDial) action.dial = committedDial.value;
|
||||
if (!fixed('dial', Number(action.dial))) continue;
|
||||
const commitment = typedForces
|
||||
? supporting(Number(action.dial))
|
||||
: null;
|
||||
let commitment = typedForces && !(reinforcements && g.homeworlds)
|
||||
? supporting(Number(action.dial)) : null;
|
||||
if (reinforcements && g.homeworlds) {
|
||||
if (!reservePools?.length) continue;
|
||||
let selectedSources: NativeReserveSelections | undefined;
|
||||
let selectedCommitment: SupportChoice | null = null;
|
||||
for (const variant of reservePools) {
|
||||
if (!typedForces && Number(action.dial) > variant.pool.normal + variant.pool.elite) continue;
|
||||
const supported = typedForces ? supporting(Number(action.dial), variant.pool, variant.cache) : null;
|
||||
if (typedForces && !supported) continue;
|
||||
if (selectedSources && (selectedCommitment?.cost ?? 0) <= (supported?.cost ?? 0)) continue;
|
||||
selectedSources = variant.sources;
|
||||
selectedCommitment = supported;
|
||||
}
|
||||
if (!selectedSources) continue;
|
||||
action.reinforcementsSources = selectedSources;
|
||||
commitment = selectedCommitment;
|
||||
}
|
||||
if (typedForces && !commitment) continue;
|
||||
if (commitment) action.support = commitment.support;
|
||||
const supportCost = Math.max(
|
||||
|
||||
@@ -121,7 +121,7 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
|
||||
? 'Recruits has development controls during Revival, including clean classic Basic/Advanced Homeworlds. Double the current native free rate, including low-population bonuses, within the seven-force total; separate requests recheck thresholds. Native typed deposits and special-force caps stay. Recorded prior paid returns keep payment under the provisional no-refund policy; pending transactions, unknown ledgers, later Fremen grants and same-turn replay remain guarded.'
|
||||
: ecaz.card.effect === 'harassWithdraw'
|
||||
? 'Harass & Withdraw has controls in either battle-card slot, with physical allocation after reveal. Classic Homeworlds returns actual typed counters into native reserve custody: Advanced Emperor normal to Kaitain/Sardaukar to Salusa; Basic preserves its original primary World. The card is forbidden on any own Homeworld. Original sealed commitment/support remains. An own concealed No-Field separately follows provisional materialization first without resetting history. Stone compares post-withdrawal counters provisionally. Other profile/module limits remain guarded.'
|
||||
: 'Reinforcements has controls in either battle-card slot, including the explicit Advanced Occupy card variant. It spends three own reserves and adds +2 normal score, not support or physical tokens. Own No-Field reveal must leave three original reserves before this cost; later Harass returns cannot fund it. Original/copy Stone ignores the score bonus but still charges reserves. All-outcome payment and native cost-before-return order are provisional. Other profile/module limits remain guarded.',
|
||||
: 'Reinforcements works in either slot, including clean classic Basic/Advanced Homeworlds and the selected Advanced Occupy variant. Spend three original own reserves normal-first for +2 normal score, not support or physical tokens. Native World sources seal explicitly when ambiguous; a fee here cannot also fund your own-World dial. Original face/support stays fixed. No-Field must leave three before this cost; future Harass returns cannot fund it. Original/copied Stone ignores +2 but still charges reserves. Cost/order interpretations are provisional; other profiles/modules remain guarded.',
|
||||
topics: RULE_TOPICS.filter((topic) => topic.id === (ecaz.card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : ecaz.card.effect === 'reinforcements' ? 'card-reinforcements' : 'card-recruits')),
|
||||
};
|
||||
const topicIds =
|
||||
|
||||
+86
-24
@@ -600,7 +600,9 @@ import {
|
||||
type SpiceCard,
|
||||
} from './cards';
|
||||
import { ecazTreacheryCards, ecazTreacheryDefinition } from './ecaz-cards';
|
||||
import { isReinforcements, quoteReinforcements, ReinforcementsError } from './reinforcements';
|
||||
import { REINFORCEMENTS_CARD, isReinforcements, quoteReinforcements, ReinforcementsError } from './reinforcements';
|
||||
import { quoteReinforcementsHomeworldCost, reinforcementsHomeworldSources,
|
||||
reinforcementsBattleForces } from './reinforcements-homeworld';
|
||||
import {
|
||||
RECRUITS_CARD_ID,
|
||||
type RecruitsPreview,
|
||||
@@ -686,10 +688,12 @@ export type Plan = {
|
||||
support: number;
|
||||
allyPayment?: number;
|
||||
kwisatz?: boolean;
|
||||
/** Exact native reserve fee sources; only a Homeworld Reinforcements plan. */
|
||||
reinforcementsSources?: NativeReserveSelections;
|
||||
};
|
||||
export type PlanField = Exclude<
|
||||
keyof Plan,
|
||||
'support' | 'kwisatz' | 'allyPayment' | 'bankerSpice'
|
||||
'support' | 'kwisatz' | 'allyPayment' | 'bankerSpice' | 'reinforcementsSources'
|
||||
>;
|
||||
export type DiplomatDefenseReceipt = DiplomatDefenseQuote & {
|
||||
event: string;
|
||||
@@ -2200,6 +2204,8 @@ export type Game = {
|
||||
kind: 'winner' | 'explosion';
|
||||
pool: HomeworldForces;
|
||||
options: HomeworldForces[];
|
||||
/** Original printed face; reserve-card fees cannot reprice this explosion. */
|
||||
faceAtResolution?: 'high' | 'low';
|
||||
commitment?: { forces: CombatForces; dial: number; support: number; fullyCommitted?: true };
|
||||
} | null;
|
||||
lastBattleContext?: {
|
||||
@@ -8184,12 +8190,16 @@ function homeworldBattleLossIntegrity(g: Game) {
|
||||
requireRule(pending.pool && Object.keys(pending.pool).length === 2 &&
|
||||
pool.normal === pending.pool.normal && pool.elite === pending.pool.elite,
|
||||
'The Homeworld casualty receipt does not match its exact physical pool.');
|
||||
requireRule(pending.faceAtResolution === undefined ||
|
||||
(pending.kind === 'explosion' && ['high', 'low'].includes(pending.faceAtResolution)),
|
||||
'The native explosion receipt needs its original printed face.');
|
||||
let options: HomeworldForces[];
|
||||
if (pending.kind === 'explosion') {
|
||||
const rules = currentHomeworldBattleRules(g, pending.territory)!;
|
||||
requireRule(rules.native === pending.player && !pending.commitment,
|
||||
'Native explosion casualties belong to this Homeworld’s native faction.');
|
||||
options = quoteHomeworldBattleRules(pending.territory, g.players, rules).explosion.options;
|
||||
options = quoteHomeworldBattleRules(pending.territory, g.players,
|
||||
pending.faceAtResolution ? { ...rules, side: pending.faceAtResolution } : rules).explosion.options;
|
||||
} else {
|
||||
const commitment = pending.commitment;
|
||||
requireRule(commitment && commitment.forces.normal === pool.normal && commitment.forces.elite === pool.elite &&
|
||||
@@ -8346,10 +8356,18 @@ function ecazBattleProfile(g: Game): EcazOccupyBattleProfile | null {
|
||||
lead, canceled: occupy.canceled,
|
||||
}));
|
||||
}
|
||||
function planCombatForces(g: Game, p: Player, opponent: Player): CombatForces {
|
||||
function planCombatForces(g: Game, p: Player, opponent: Player,
|
||||
reserveCost?: ReinforcementsCost): CombatForces {
|
||||
const profile = ecazBattleProfile(g);
|
||||
return profile?.lead === p.id ? profile.forces :
|
||||
const forces = profile?.lead === p.id ? profile.forces :
|
||||
combatForces(g, p, g.battle!.territory, profile?.lead === opponent.id ? getPlayer(g, profile.forceOwner) : opponent);
|
||||
if (!g.homeworlds || !g.battle!.territory.startsWith('homeworld:')) return forces;
|
||||
const home = homeworldBattleLocation(g, g.battle!.territory);
|
||||
if (!home || home.native !== p.id) return forces;
|
||||
const plan = g.battle!.plans[p.id];
|
||||
const cost = reserveCost ?? (plan && (plan.weapon === REINFORCEMENTS_CARD || plan.defense === REINFORCEMENTS_CARD)
|
||||
? currentReinforcementsCost(g, p, plan.reinforcementsSources) : undefined);
|
||||
return reinforcementsBattleForces(forces, cost?.sources?.[home.id]);
|
||||
}
|
||||
function takeBattleLosses(g: Game, p: Player, t: string, losses: Casualties) {
|
||||
if (t.startsWith('homeworld:')) {
|
||||
@@ -13697,8 +13715,11 @@ function ecazTreacheryIntegrity(g: Game) {
|
||||
[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);
|
||||
currentReinforcementsCost(g, owner, plan.reinforcementsSources);
|
||||
}
|
||||
requireRule(plan.reinforcementsSources === undefined ||
|
||||
(g.homeworlds && (plan.weapon === REINFORCEMENTS_CARD || plan.defense === REINFORCEMENTS_CARD)),
|
||||
'Native reserve fee sources require the physical Reinforcements card in a Homeworld plan.');
|
||||
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.');
|
||||
@@ -13758,19 +13779,31 @@ function combinedCardBattleBlock(g: Game): string | null {
|
||||
}
|
||||
function reinforcementsModeSupported(g: Game) {
|
||||
const pairedEcaz = g.expansions.length === 1 && g.expansions[0] === 'ecaz';
|
||||
return !!g.ecazTreachery && !g.homeworlds && recruitsModeSupported(g) && !g.sandtrout &&
|
||||
return !!g.ecazTreachery && recruitsModeSupported(g) && !g.sandtrout &&
|
||||
((g.ecazOccupyPreview === true && ecazOccupyCompositionSupported(g)) ||
|
||||
((g.expansions.length === 0 || pairedEcaz) && g.players.every(player => {
|
||||
const expansion = faction(player.faction).expansion;
|
||||
return expansion === 'base' || (pairedEcaz && expansion === 'ecaz');
|
||||
})));
|
||||
}
|
||||
function currentReinforcementsCost(g: Game, p: Player) {
|
||||
type ReinforcementsCost = ReturnType<typeof quoteReinforcements> & {
|
||||
sources?: NativeReserveSelections;
|
||||
native?: ReturnType<typeof quoteNativeReserveWithdrawal>;
|
||||
};
|
||||
function currentReinforcementsCost(g: Game, p: Player,
|
||||
selections?: NativeReserveSelections): ReinforcementsCost {
|
||||
requireRule(reinforcementsModeSupported(g),
|
||||
'Reinforcements requires the independent Ecaz card variant in a supported native faction composition.');
|
||||
const combinedBlock = combinedCardBattleBlock(g);
|
||||
requireRule(!combinedBlock, combinedBlock ?? 'This combined card battle is unavailable.');
|
||||
requireRule(selections === undefined || g.homeworlds,
|
||||
'Native Reinforcements sources require the original Homeworld module.');
|
||||
try {
|
||||
if (g.homeworlds) {
|
||||
requireRule(g.homeworlds.custody, 'Reinforcements needs its original native reserve custody.');
|
||||
return homeworldRule(() => quoteReinforcementsHomeworldCost(
|
||||
homeworldContext(g), g.homeworlds!.custody!, p.id, selections));
|
||||
}
|
||||
const materializes = prospectiveNoFieldForces(p, g.battle?.territory ?? '');
|
||||
return quoteReinforcements(p.reserves - materializes - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0);
|
||||
} catch (error) {
|
||||
@@ -13799,6 +13832,8 @@ function reinforcementsPreview(g: Game, p: Player) {
|
||||
? '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,
|
||||
...(!blocked && g.homeworlds?.custody ? { homeworldSources: homeworldRule(() =>
|
||||
reinforcementsHomeworldSources(homeworldContext(g), g.homeworlds!.custody!, p.id)) } : {}),
|
||||
...(p.noField?.deployed?.location.territory === battle.territory
|
||||
? { noField: { materializes, reservesAfterReveal } } : {}) };
|
||||
}
|
||||
@@ -21172,7 +21207,13 @@ function validatePlan(
|
||||
): Plan {
|
||||
const b = g.battle!;
|
||||
const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
|
||||
const forces = planCombatForces(g, p, opponent);
|
||||
const selectedReinforcements = input.weapon === REINFORCEMENTS_CARD || input.defense === REINFORCEMENTS_CARD;
|
||||
requireRule(input.reinforcementsSources === undefined || selectedReinforcements,
|
||||
'Native reserve fee sources require the physical Reinforcements card.');
|
||||
const reserveCost = selectedReinforcements
|
||||
? currentReinforcementsCost(g, p, input.reinforcementsSources as NativeReserveSelections | undefined)
|
||||
: undefined;
|
||||
const forces = planCombatForces(g, p, opponent, reserveCost);
|
||||
const profile = ecazBattleProfile(g);
|
||||
const occupy = profile?.lead === p.id ? profile : null;
|
||||
const typedForces = g.advanced || p.faction === 'ixians' || !!occupy;
|
||||
@@ -21201,6 +21242,7 @@ function validatePlan(
|
||||
const plan: Plan = {
|
||||
...(bankerSpice ? { bankerSpice } : {}),
|
||||
...(allyPayment ? { allyPayment } : {}),
|
||||
...(reserveCost?.sources ? { reinforcementsSources: structuredClone(reserveCost.sources) } : {}),
|
||||
dial,
|
||||
leader:
|
||||
typeof input.leader === 'string' && input.leader ? input.leader : null,
|
||||
@@ -21291,7 +21333,6 @@ function validatePlan(
|
||||
validBattleSlotPair(w, d, planetologistWeapon),
|
||||
'Chemistry as a weapon needs another defense; Weirding Way as a defense needs another weapon.',
|
||||
);
|
||||
if ([w, d].some(isReinforcements)) currentReinforcementsCost(g, p);
|
||||
if ([w, d].some(isHarassWithdraw)) currentHarassWithdrawQuote(g, p, plan);
|
||||
if (isMirrorWeapon(w)) {
|
||||
const blocked = mirrorWeaponModeBlock(g) ?? stonePlanBlock(g, p, plan);
|
||||
@@ -21771,6 +21812,7 @@ function findLegalBattlePlan(
|
||||
const fixedDial = profile?.lead === p.id ? profile.fixedEcazDial : 0;
|
||||
const typed = g.advanced || p.faction === 'ixians';
|
||||
const maxDial = fixedDial + (typed ? maxCombatDial(forces) : forces.normal + forces.elite);
|
||||
const nativeSources = g.homeworlds ? reinforcementsPreview(g, p)?.homeworldSources : undefined;
|
||||
const numerical: { dial: number; support: number }[] = [];
|
||||
for (let dial = fixedDial; dial <= maxDial; dial += typed ? 0.5 : 1)
|
||||
for (
|
||||
@@ -21826,13 +21868,18 @@ function findLegalBattlePlan(
|
||||
for (const kwisatz of [false, true]) {
|
||||
const partial = { leader, weapon, defense, kwisatz };
|
||||
if (!accepts(partial)) continue;
|
||||
for (const n of numerical) {
|
||||
const candidate = { ...partial, ...n };
|
||||
if (!accepts(candidate)) continue;
|
||||
try {
|
||||
return validatePlan(g, p, candidate, promises);
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
const nativeFee = !!g.homeworlds &&
|
||||
(weapon === REINFORCEMENTS_CARD || defense === REINFORCEMENTS_CARD);
|
||||
for (let source = 0; source < (nativeFee ? nativeSources?.length ?? 0 : 1); source++) {
|
||||
for (const n of numerical) {
|
||||
const candidate: Partial<Plan> = { ...partial, ...n };
|
||||
if (nativeFee) candidate.reinforcementsSources = nativeSources![source];
|
||||
if (!accepts(candidate)) continue;
|
||||
try {
|
||||
return validatePlan(g, p, candidate, promises);
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22920,16 +22967,24 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
};
|
||||
};
|
||||
try {
|
||||
const attacker = combatant(b.attacker, b.defender);
|
||||
const defender = combatant(b.defender, b.attacker);
|
||||
const homeworld = currentHomeworldBattleRules(g, b.territory);
|
||||
if (homeworld) {
|
||||
const native = homeworld.native === attacker.id ? attacker :
|
||||
homeworld.native === defender.id ? defender : null;
|
||||
if (native) homeworld.nativeForces = { normal: native.forces.normal, elite: native.forces.elite };
|
||||
}
|
||||
const quote = quoteBattleResolution({
|
||||
advanced: g.advanced,
|
||||
...(b.ecazOccupy ? { ecazOccupy: ecazBattleProfile(g)! } : {}),
|
||||
aggressor: battleAggressor(g),
|
||||
typedCasualties: !!g.homeworlds,
|
||||
...(homeworldBattleLocation(g, b.territory) ? { homeworld: currentHomeworldBattleRules(g, b.territory)! } : {}),
|
||||
...(homeworld ? { homeworld } : {}),
|
||||
turn: g.turn,
|
||||
territory: b.territory,
|
||||
attacker: combatant(b.attacker, b.defender),
|
||||
defender: combatant(b.defender, b.attacker),
|
||||
attacker,
|
||||
defender,
|
||||
voters: traitorVoters(g, b).map((id) => ({
|
||||
id,
|
||||
beneficiary: traitorBeneficiary(g, b, id)!,
|
||||
@@ -23003,7 +23058,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
validateBattleForceLoss(getPlayer(g, loss.owner), b.territory, loss.normal + loss.elite);
|
||||
if (quote.homeworldExplosion)
|
||||
for (const losses of quote.homeworldExplosion.options) quoteHomeworldLoss(g, quote.homeworldExplosion.player, b.territory, losses);
|
||||
return { ...quote, smuggler, smugglerAllocation, sandmasterSelection };
|
||||
return { ...quote, homeworldFace: homeworld?.side, smuggler, smugglerAllocation, sandmasterSelection };
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof BattleResolutionQuoteError ||
|
||||
@@ -23239,17 +23294,23 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
|
||||
}
|
||||
for (const cost of quote.reinforcements ?? []) {
|
||||
const player = getPlayer(g, cost.player);
|
||||
const current = currentReinforcementsCost(g, player);
|
||||
const current = currentReinforcementsCost(g, player, b.plans[player.id].reinforcementsSources);
|
||||
requireRule(cost.normal === current.normal && cost.elite === current.elite,
|
||||
'Reinforcements lost its original physical reserve cost.');
|
||||
player.reserves -= cost.normal + cost.elite;
|
||||
if (current.native) {
|
||||
const owner = current.native.players.find(seat => seat.id === player.id)!;
|
||||
player.reserves = owner.reserves;
|
||||
if (player.elites) player.elites.reserves = owner.eliteReserves;
|
||||
g.homeworlds!.custody = current.native.state;
|
||||
} else 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;
|
||||
if (!current.native) player.elites.reserves -= cost.elite;
|
||||
player.elites.tanks += cost.elite;
|
||||
}
|
||||
observeOccupation(g);
|
||||
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' });
|
||||
}
|
||||
@@ -23598,6 +23659,7 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
|
||||
g.homeworldBattleLoss = {
|
||||
event: g.lastBattleContext.event, territory: b.territory, player: owner,
|
||||
kind: quote.homeworldExplosion ? 'explosion' : 'winner',
|
||||
...(quote.homeworldExplosion ? { faceAtResolution: quote.homeworldFace! } : {}),
|
||||
pool: combatArmy(g, owner, b.territory),
|
||||
options: (quote.homeworldExplosion?.options ?? casualtyCommitment!.options).map(({normal, elite}) => ({normal, elite})),
|
||||
...(casualtyCommitment ? { commitment: { forces: {...casualtyCommitment.forces}, dial: casualtyCommitment.dial, support: casualtyCommitment.support,
|
||||
|
||||
+12
-7
@@ -2710,6 +2710,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'Declare Truthtrance and choose Verified cards, traitors or spice. Select Number of a named card, choose its exact name, select a comparison and enter a whole count of zero or more. Review the public question before asking.',
|
||||
'Only physical cards currently in that player’s hand count. Separate cache cards, cards in discard and cards still in a deck are excluded. Shield and Shield Snooper are different names. A card retained in hand after battle still counts.',
|
||||
'The shared canonical name list includes the three independent Ecaz cards as well as ordinary/Ix and separate Richese cache identities. Naming one does not select its family, expose its physical ID or change its battle role.',
|
||||
'The server establishes the truthful Yes or No privately for the target. Their public answer reveals only the requested comparison, not card identities or the rest of their hand. An exact-count question answered Yes naturally confirms that count.',
|
||||
'Combine a card count with another count, named-card possession, traitor or spice fact using AND or OR. Only the combined answer is published. The answer describes current custody; it does not require the player to keep those cards afterward.',
|
||||
'All four AI levels answer the structured comparison. A definite answer discards Truthtrance once and resumes the interrupted table decision. This question type does not complete arbitrary facts or nonbattle future promises.',
|
||||
@@ -2917,6 +2918,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Freeform questions also accept public Yes, No, or I don’t know answers. Their truthfulness relies on the players. AI answers structured facts and battle-plan questions; it cannot yet interpret freeform questions.',
|
||||
'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises are enforced for the active unused shipment in base Basic and no-Guild Advanced games; see the shipment promise guide.',
|
||||
'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.',
|
||||
'Named conditions include Reinforcements and Harass slot occupancy without making either a weapon/defense category. In clean classic Homeworlds, a source-aware legal completion can choose the native fee needed for a promised dial; review and filling retain that exact allocation. Active preview, answers/history and server records use the actual combat location name, including native Worlds, rather than treating every battle as an Arrakis territory.',
|
||||
'If a completed opposing power makes a battle answer impossible, it is publicly released. A pending Voice response resolves before that release. A player cannot voluntarily spend a promised battle card or give away necessary support spice to evade the answer. Feasibility includes available Ghola cards, eligible leader/Kwisatz or force revivals, and possible revival income. Each actual card can be spent only once. Other future card/power sequences and the full timing audit remain unfinished.',
|
||||
'Holding both copies lets you declare one or both. Each question resolves separately, and each card is discarded only after its definite answer. Full timing and card-face audits remain pending.',
|
||||
],
|
||||
@@ -2929,6 +2931,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'truthtrance-hand-inventory',
|
||||
'truthtrance-shipment',
|
||||
'richese-no-field',
|
||||
'card-reinforcements',
|
||||
],
|
||||
},
|
||||
...FACTIONS.map(
|
||||
@@ -4058,7 +4061,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Homeworlds are an optional module from Ecaz & Moritani, available under the official rules with Basic or Advanced play. They are separate locations outside Arrakis; they do not receive ordinary storm movement or count as ordinary strongholds.',
|
||||
'Fresh private Homeworld prototypes retain all fourteen Skills, Discovery7/8 or both in classic or supported E1/E2 and standalone Ecaz OR Moritani tables. Original Nexus entries additionally support single or paired one-family E1/E2, natives from BOTH selected Ix+CHOAM families with union47, and standalone OR paired Ecaz/Moritani/ecaz33 with classics, with or without Skills. Basic/Advanced2–6, Tech3+ and Advanced Strongholds2+ retain original setup, native placement, phase-end industry and source-local held cards. Advanced Moritani/paired E3 excludes Harkonnen; otherwise legal standalone no-Skills Ecaz/Harkonnen does not lift its separate Duke Cunning exclusion. Winning typed native Suk saves stay at that home; external Emperor returns allocate physical normal/elite counters to original homes as the existing interpretation. Normal revival separately deposits Sardaukar at Salusa. Nested battles receive no Homeworld bonus. Mixed E3/three-family Nexus, pending effects, public starts and played-save conversion remain separate.',
|
||||
'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.',
|
||||
'A fresh classic Basic/Advanced independent36-card/Homeworld prototype connects Harass on Arrakis or a foreign World, never an own World, with exact typed native returns and fixed original commitment/support. Recruits doubles current native rates, including low-population bonuses, within the seven-force total; separate requests provisionally recheck thresholds and recorded free use. Native deposits and special-force caps remain. Reinforcements, expansion rosters and other overlays keep their separate guards.',
|
||||
'Fresh classic Basic/Advanced independent36-card/Homeworld setup connects all three bounded effects: Harass returns actual typed undialed forces through native custody, never on an own World; Recruits doubles current native rates with provisional separate-request thresholds; Reinforcements seals exact native reserve fee sources and excludes an on-World fee from physical dial/casualties while preserving original face/support. Other rosters/overlays, whole-mode acceptance and public starts remain guarded.',
|
||||
'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.',
|
||||
@@ -4977,7 +4980,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['revival', 'homeworlds', 'ecaz-modules', 'expansion-faction-games', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Independent physical play/discard and public turn rates use shared revival quotes, now including clean classic Homeworlds and native typed deposits. Separate requests provisionally recompute current thresholds; prior paid returns retain payment and recorded free use. Unknown ledgers, pending repricing and later Fremen grants remain guarded; Reinforcements retains its own World gate.', evidence: ['game/ecaz-cards.ts', 'game/recruits.ts', 'game/revival.ts', 'game/engine.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Independent physical play/discard and public turn rates use shared revival quotes, including clean classic Homeworlds and native typed deposits. Separate requests provisionally recompute current thresholds; prior paid returns retain payment and recorded free use. Unknown ledgers, pending repricing and later Fremen grants remain guarded; Reinforcements has its separate exact native-source fee composition.', 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 guide records examples, limits, independent-cap inference and the visibly provisional late-paid policy; source alternatives and late-rate questions remain explicit.', evidence: ['docs/RECRUITS_RULES.md', 'docs/RECRUITS_RUNTIME.md'] },
|
||||
@@ -4997,14 +5000,16 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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 interpretations, not publisher rulings. Same-plan Harass pays the original three-reserve cost before returns; returned reserves cannot fund or change it. In native Advanced Occupy, original or copied Stone ignores +2 normal score but still charges the reserve cost. Basic/unprofiled co-side and other profile/module 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.',
|
||||
'Clean classic2–6 Basic/Advanced Homeworlds use exact native normal-first fee sources, sealed in the plan. Ambiguous omissions reject; unique sources may derive. An own-World fee cannot also fund physical dial/casualties, but original printed face/support remains. Original high-face explosion losses survive a fee crossing low. Native-only Traitors, typed Tanks and cost-before-Harass remain; this is explicit 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.',
|
||||
],
|
||||
related: ['battle-cards', 'battle', 'card-recruits', 'card-harass-withdraw', 'implementation-checklist'],
|
||||
related: ['battle-cards', 'battle', 'homeworlds', 'card-recruits', 'card-harass-withdraw', 'card-truthtrance', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical card, both slots, own typed reserve cost, score-only bonus and mandatory discard connect in supported profiles. Same-plan Harass preserves the original reserve quote before return; selected lead cards/payment and variable ally casualties stay separate.', 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: 'Implementation', status: 'Partial', detail: 'Physical slots, typed reserve cost, score-only bonus and discard connect. Clean classic World plans seal exact native sources, excluding own-World fees from the physical pool while keeping original traits/printed face. Shared legality and named questions retain source-bearing completions. Other profile/module gates remain.', evidence: ['game/reinforcements.ts', 'game/reinforcements-homeworld.ts', 'game/homeworld-native-reserves.ts', 'game/battle-resolution-quote.ts', 'game/battle-promises.ts', 'game/truthtrance-card-names.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Owner-only cost/source choices, own-World physical bounds, wrapped exact allocation, source-aware legal-plan fill and earned private reviews connect; category answers remain None. Native named-question preview/answer/history uses canonical combat labels.', evidence: ['components/battle-preparation.tsx', 'components/reinforcements-sources.tsx', 'components/private-battle-plan.tsx', 'components/battle-promises.tsx', 'components/truthtrance.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: 'Original typed cost, Stone/Mirror and same-plan Harass proof now includes actual No-Field first-plan funding rejection, legal fallback, zero/funded costs, Traitor cancellation and actual Occupy owners. Mobile and four-profile continuations are bounded; complete modes/calibration/final assurance remain open.', evidence: ['tests/no-field-reinforcements.test.ts', 'tests/fixture-no-field-reinforcements.ts', 'tests/stone-reinforcements.test.ts', 'tests/harass-reinforcements.test.ts', 'tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Native actual setup/shipment/movement covers both slots, normal/mixed/star fees, deficient funding, each source allocation, own-World disjointness, frozen face/explosion, native Traitors, Harass and named promises. Six actual mobile journeys reach Collection;32 four-policy programmes/114 continuations and1682 engine-validated R candidates are separate bounded proof, not complete modes or calibration.', evidence: ['tests/reinforcements-homeworld.test.ts', 'tests/fixture-reinforcements-homeworld.ts', 'tests/reinforcements-homeworld-arrakis.test.ts', 'tests/fixture-reinforcements-homeworld-arrakis.ts', 'tests/no-field-reinforcements.test.ts', 'tests/fixture-no-field-reinforcements.ts', 'tests/stone-reinforcements.test.ts', 'tests/harass-reinforcements.test.ts', 'tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -5023,7 +5028,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'In explicitly selected Advanced Occupy, only the actual card user’s undialed forces return. An Ecaz user preserves its original mandatory fixed count at free full strength; an allied user uses its native variable commitment, while canceled Occupy uses its selected lead’s ordinary own pool. Returned reserves remain outside losses and Face Dance. Basic/unprofiled co-side, public starts and full acceptance remain gated.',
|
||||
'In supported same-plan Reinforcements, the provisional order pays three original own reserves before Harass returns undialed counters. Future returns cannot fund or change that reserve quote; neither the +2 score bonus nor that cost adds to the physical commitment. Both cards discard after use. An opposing successful Traitor cancels Harass but not the provisional Reinforcements cost.',
|
||||
'With an own concealed No-Field, this prototype provisionally uses the original materialization-first order. Before sealing, privately forecast its actual ordinary counters at the original marker sector; do not move them early. Both plans seal before native revelation, then any return allocation uses actual counters. Marker0 remains legal with a leader/cards, reserve shortage caps materialization, and withdrawal does not return/reset the token. Opposing successful Traitor cancels Harass, not that native reveal.',
|
||||
'Clean classic2–6 Basic/Advanced Homeworlds may be selected before independent36-card setup. Harass works on Arrakis or a foreign Homeworld, never any own World. Return actual undialed board/visitor counters through native custody, not Tanks: Basic primary; Advanced Emperor normal to Kaitain/Sardaukar to Salusa. Original dial/support and committed counters stay fixed across threshold changes. Native-only Traitors, late defense/explosion and mandatory disposal remain. Recruits has a separate native current-rate composition; Reinforcements and other overlays retain guards.',
|
||||
'Clean classic2–6 Basic/Advanced Homeworlds may be selected before independent36-card setup. Harass works on Arrakis or a foreign Homeworld, never any own World. Return actual undialed board/visitor counters through native custody, not Tanks: Basic primary; Advanced Emperor normal to Kaitain/Sardaukar to Salusa. Original dial/support and committed counters remain fixed across thresholds. Native-only Traitors, late defense/explosion and disposal stay. Recruits has native current rates; Reinforcements seals exact original native fees before these returns. Other overlays/rosters remain guarded.',
|
||||
],
|
||||
related: ['battle-cards', 'battle', 'card-recruits', 'homeworlds', 'ecaz-modules', 'implementation-checklist'],
|
||||
checklist: [
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { CombatForces } from './combat';
|
||||
import {
|
||||
homeworldForceGroups, type HomeworldCustody, type HomeworldCustodyContext,
|
||||
type HomeworldForces,
|
||||
} from './homeworld-custody';
|
||||
import {
|
||||
NativeReserveError, quoteNativeReserveWithdrawal, type NativeReserveSelections,
|
||||
} from './homeworld-native-reserves';
|
||||
import { quoteReinforcements, ReinforcementsError } from './reinforcements';
|
||||
|
||||
type Cost = ReturnType<typeof quoteReinforcements>;
|
||||
function nativeCost(context: HomeworldCustodyContext, actor: string): 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);
|
||||
}
|
||||
function sourceOptions(context: HomeworldCustodyContext, custody: HomeworldCustody,
|
||||
actor: string, cost: Cost): NativeReserveSelections[] {
|
||||
const homes = homeworldForceGroups(context, custody).filter(home => home.native === actor);
|
||||
if (homes.length === 1) return [{ [homes[0].id]: { normal: cost.normal, elite: cost.elite } }];
|
||||
if (homes.length !== 2) throw new NativeReserveError('Choose the actual native Homeworld reserve sources.');
|
||||
const [first, second] = homes;
|
||||
const firstPool = first.forces[actor], secondPool = second.forces[actor];
|
||||
const options: NativeReserveSelections[] = [];
|
||||
// At most six allocations for a three-counter typed cost. Both native World
|
||||
// entries remain explicit, including zero, so clients never guess a split.
|
||||
for (let normal = Math.max(0, cost.normal - secondPool.normal);
|
||||
normal <= Math.min(cost.normal, firstPool.normal); normal++) {
|
||||
for (let elite = Math.max(0, cost.elite - secondPool.elite);
|
||||
elite <= Math.min(cost.elite, firstPool.elite); elite++) {
|
||||
options.push({
|
||||
[first.id]: { normal, elite },
|
||||
[second.id]: { normal: cost.normal - normal, elite: cost.elite - elite },
|
||||
});
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/** Public physical alternatives for the existing normal-first fee. 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));
|
||||
}
|
||||
|
||||
/** 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);
|
||||
let sources = selections;
|
||||
if (sources === undefined) {
|
||||
const options = sourceOptions(context, custody, actor, cost);
|
||||
if (options.length !== 1)
|
||||
throw new NativeReserveError('Choose the Homeworld sources for Reinforcements; more than one allocation is possible.');
|
||||
sources = options[0];
|
||||
}
|
||||
const native = quoteNativeReserveWithdrawal(context, custody, actor,
|
||||
{ normal: cost.normal, elite: cost.elite }, sources);
|
||||
return { ...cost, sources, native };
|
||||
}
|
||||
|
||||
/** On an own World the reserve fee and combat army share physical counters.
|
||||
* Reserve the fee first, but retain the original sealed support/strength traits.
|
||||
* Foreign-World and Arrakis callers pass no on-location cost. */
|
||||
export function reinforcementsBattleForces(forces: CombatForces,
|
||||
onLocationCost?: HomeworldForces): CombatForces {
|
||||
if (!onLocationCost) return forces;
|
||||
const { normal, elite } = onLocationCost;
|
||||
if (!Number.isSafeInteger(normal) || normal < 0 || !Number.isSafeInteger(elite) || elite < 0 ||
|
||||
normal > forces.normal || elite > forces.elite)
|
||||
throw new ReinforcementsError('The reserve fee cannot also fund the physical Homeworld dial.');
|
||||
if (normal + elite === 0) return forces;
|
||||
return { ...forces, normal: forces.normal - normal, elite: forces.elite - elite };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { treacheryDeck } from './cards';
|
||||
import { richeseCards } from './richese-cards';
|
||||
import { ecazTreacheryCards } from './ecaz-cards';
|
||||
|
||||
/** Known physical names, not permission to deal/play an unselected family.
|
||||
* Current hand facts and named battle-slot promises share this registry. */
|
||||
export const TRUTH_CARD_NAMES = [
|
||||
...new Set([...treacheryDeck(['ix']), ...richeseCards(), ...ecazTreacheryCards()].map(card => card.name)),
|
||||
].sort();
|
||||
+6
-8
@@ -18,7 +18,6 @@ import {
|
||||
cardCountFactText,
|
||||
type CardCountFact,
|
||||
} from './truthtrance-card-count';
|
||||
import { richeseCards } from './richese-cards';
|
||||
import {
|
||||
parseHandInventoryFact,
|
||||
handInventoryFactMatches,
|
||||
@@ -30,10 +29,11 @@ import {
|
||||
planClaimText,
|
||||
type PlanClaim,
|
||||
} from './battle-promises';
|
||||
import { gameTerritories, territory } from './board';
|
||||
import { gameTerritories } from './board';
|
||||
import { combatLocationName } from './combat-location';
|
||||
import { parseForceCountFact, forceCountFactMatches, forceCountFactText, forceCountCounterError, type ForceCountFact, type ForceCountPlayer } from './truthtrance-force-count';
|
||||
import type { Action, Game, Player } from './engine';
|
||||
import { treacheryDeck } from './cards';
|
||||
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
|
||||
import { CHEAP_HERO_TRAITOR } from './traitors';
|
||||
import { DUKE_VIDAL_ID } from './duke-vidal';
|
||||
import { canUseAsTruthtranceRole } from './shrine';
|
||||
@@ -85,9 +85,6 @@ export class TruthError extends Error {}
|
||||
function check(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new TruthError(message);
|
||||
}
|
||||
export const TRUTH_CARD_NAMES = [
|
||||
...new Set([...treacheryDeck(['ix']), ...richeseCards()].map((c) => c.name)),
|
||||
].sort();
|
||||
const record = (value: unknown): Record<string, unknown> => {
|
||||
check(
|
||||
value && typeof value === 'object' && !Array.isArray(value),
|
||||
@@ -400,12 +397,13 @@ export function truthShipmentClaim(
|
||||
export function truthQuestionText(
|
||||
q: TruthQuestion,
|
||||
leaderName: (id: string) => string,
|
||||
locationName: (id: string) => string,
|
||||
): string {
|
||||
if (q.kind === 'freeform') return q.text;
|
||||
if (q.kind === 'shipment')
|
||||
return `Will you ${shipmentClaimText(q)} this turn?`;
|
||||
if (q.kind === 'battlePlan')
|
||||
return `In this battle in ${territory(q.territory).name}, will it be true that ${planClaimText(q.claim, leaderName)}?`;
|
||||
return `In this battle in ${locationName(q.territory)}, will it be true that ${planClaimText(q.claim, leaderName)}?`;
|
||||
const clause = (f: TruthFact): string => {
|
||||
if (isKnowledgeFact(f)) return knowledgeFactText(f);
|
||||
if (f.kind === 'noFieldValue') return noFieldValueFactText(f);
|
||||
@@ -564,7 +562,7 @@ export function resolveTruthAction(
|
||||
)!;
|
||||
effects.log(
|
||||
g,
|
||||
`${p.name} asks ${target.name}: ${truthQuestionText(w.question, (id) => (id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : battleLeaderIdentities(g).find((leader) => leader.id === id)!.name))}`,
|
||||
`${p.name} asks ${target.name}: ${truthQuestionText(w.question, (id) => (id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : battleLeaderIdentities(g).find((leader) => leader.id === id)!.name), id => combatLocationName(g, id))}`,
|
||||
);
|
||||
} else if (a.type === 'truthSave') {
|
||||
check(
|
||||
|
||||
Reference in new issue
Block a user