Implement Ix High paid Cyborg revival bonus
This commit is contained in:
1 parent
86608f7ea9
commit
052737d0f9
18 files changed
+929
-12
No files matched your search
@@ -189,7 +189,8 @@ export function quoteChoamWorthlessCancellation(
|
||||
p.target !== p.owner &&
|
||||
g.pendingRevival.kind === 'forces' &&
|
||||
whole(g.pendingRevival.free) &&
|
||||
g.pendingRevival.free > 0 &&
|
||||
(g.pendingRevival.free > 0 ||
|
||||
(whole(g.pendingRevival.ixSuboidBonus) && g.pendingRevival.ixSuboidBonus > 0)) &&
|
||||
!g.pendingRevival.emperorExtra,
|
||||
'Reactive La La La needs the original opposing free force revival.',
|
||||
);
|
||||
|
||||
+13
-4
@@ -569,6 +569,7 @@ import {
|
||||
} from './truthtrance';
|
||||
import type { MixedShipmentExpression } from './mixed-shipment-question';
|
||||
import { controlsLeader, nativeAvailable } from './leader-control';
|
||||
import { ixHomeworldRevivalEnabled, quoteIxSuboidBonus } from './ix-homeworld-revival';
|
||||
import type { EcazRevivalCohort } from './ecaz-revival-cohort';
|
||||
import {
|
||||
newRevivalRules,
|
||||
@@ -28079,8 +28080,9 @@ function currentRevivalResumeQuote(g: Game, stage: 'stop' | 'finish') {
|
||||
/** Shared physical accounting after the authoritative revival quote. */
|
||||
function commitRevivalForcePieces(g: Game, p: Player, revival: PendingRevival) {
|
||||
const n = revival.amount!, elite = revival.elite ?? 0;
|
||||
addNativeReserves(g, p, n, elite);
|
||||
p.tanks -= n;
|
||||
const physical = n + (revival.ixSuboidBonus ?? 0);
|
||||
addNativeReserves(g, p, physical, elite);
|
||||
p.tanks -= physical;
|
||||
if (revival.emperorExtra)
|
||||
g.emperorExtra[p.id] = (g.emperorExtra[p.id] ?? 0) + n;
|
||||
else if (!revival.nexusEmperorEvent) {
|
||||
@@ -28166,7 +28168,7 @@ function finishRevival(
|
||||
`${rule.name} accrued ${token.spice} spice for ${getPlayer(g, income.owner).name}, payable at phase end.`,
|
||||
);
|
||||
}
|
||||
log(g, `${p.name} revived ${n} forces.${g.homeworlds ? ` ${revival.free} were free; ${n - revival.free} were paid. ${payer.name} paid ${revival.cost} spice. Future revival requests use the resulting Homeworld population.` : ''}`,
|
||||
log(g, `${p.name} revived ${n} forces.${g.homeworlds ? ` ${revival.free} were free; ${n - revival.free} were paid. ${payer.name} paid ${revival.cost} spice. Future revival requests use the resulting Homeworld population.` : ''}${revival.ixSuboidBonus ? ` Ix HIGH also returned ${revival.ixSuboidBonus} physical Suboid${revival.ixSuboidBonus === 1 ? '' : 's'} for free, without using normal revival allowance.` : ''}`,
|
||||
{ faction: p.faction, name: 'Revival' });
|
||||
stageHomeworldRevivalReturn(g, p, source, group, deploymentGrant);
|
||||
} else {
|
||||
@@ -28229,7 +28231,7 @@ function beginRevival(g: Game, revival: PendingRevival) {
|
||||
if (
|
||||
choam &&
|
||||
choam.id !== revival.player &&
|
||||
revival.free > 0 &&
|
||||
(revival.free > 0 || (revival.ixSuboidBonus ?? 0) > 0) &&
|
||||
!revival.emperorExtra && !revival.nexusEmperorEvent
|
||||
) {
|
||||
g.decision = {
|
||||
@@ -36487,6 +36489,11 @@ function applyActionInner(
|
||||
: 0;
|
||||
const quote = forceRevivalQuote(g, p, n, elite, freeElite);
|
||||
requireRule(quote.cost <= p.spice, 'Not enough spice for revival.');
|
||||
const ixSuboidBonus = p.faction === 'ixians' ? quoteIxSuboidBonus({
|
||||
enabled: ixHomeworldRevivalEnabled(g, id) && !g.revivalRules?.freeBlocked?.includes(id), amount: n, elite,
|
||||
free: quote.free, freeElite,
|
||||
normalTanks: p.tanks - (p.elites?.tanks ?? 0),
|
||||
}) : 0;
|
||||
const checks: PendingRevival['checks'] = [];
|
||||
const choamBenefit = p.faction === 'choam' && !g.revivalRules?.choamBlocked;
|
||||
const uncancelableLimit = Math.max(
|
||||
@@ -36504,6 +36511,7 @@ function applyActionInner(
|
||||
amount: n,
|
||||
elite,
|
||||
...quote,
|
||||
...(ixSuboidBonus ? { ixSuboidBonus } : {}),
|
||||
checks,
|
||||
});
|
||||
return g;
|
||||
@@ -38343,6 +38351,7 @@ export function viewGame(state: Game, id: string) {
|
||||
choamBlocked: !!g.revivalRules?.choamBlocked,
|
||||
freeRemaining: freeRevivalRemaining(g, me),
|
||||
homeworldBonus: homeworldLowBonus(g, me.id),
|
||||
ixSuboidBonusEnabled: ixHomeworldRevivalEnabled(g, me.id),
|
||||
tleilaxuHomeworldIncomeBlocked: homeworldRule(() =>
|
||||
tleilaxuHomeworldFreeIncomeBlocked(g),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Game } from './engine';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import { homeworldPopulations } from './homeworld-population';
|
||||
|
||||
type IxHomeworldContext = Pick<Game, 'advanced' | 'players' | 'homeworlds'>;
|
||||
export type IxSuboidBonusInput = {
|
||||
enabled: boolean;
|
||||
amount: number;
|
||||
elite: number;
|
||||
free: number;
|
||||
freeElite: number;
|
||||
normalTanks: number;
|
||||
};
|
||||
|
||||
export class IxHomeworldRevivalError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'IxHomeworldRevivalError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Printed native High benefit in either rules band. Actual revival timing,
|
||||
* cost and eligibility belong to the original revival quote. */
|
||||
export function ixHomeworldRevivalEnabled(context: IxHomeworldContext, playerId: string): boolean {
|
||||
if (!context.homeworlds?.custody ||
|
||||
!context.players.some(player => player.id === playerId && player.faction === 'ixians')) return false;
|
||||
return homeworldPopulations(homeworldContext(context), context.homeworlds.custody)
|
||||
.some(home => home.native === playerId && home.card === 'ix' && home.side === 'high');
|
||||
}
|
||||
|
||||
const count = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
|
||||
/** Additional physical Suboids for paid Cyborgs, after reserving every ordinary
|
||||
* Suboid in the declared group. A shortage reduces only this bonus. */
|
||||
export function quoteIxSuboidBonus(input: IxSuboidBonusInput): number {
|
||||
const { enabled, amount, elite, free, freeElite, normalTanks } = input;
|
||||
if (typeof enabled !== 'boolean' ||
|
||||
![amount, elite, free, freeElite, normalTanks].every(count) ||
|
||||
amount < 1 || amount > 20 || elite > amount || free > amount ||
|
||||
freeElite > elite || freeElite > free || free - freeElite > amount - elite ||
|
||||
normalTanks < amount - elite)
|
||||
throw new IxHomeworldRevivalError('Choose valid physical revival counts, free counter types and available Suboids.');
|
||||
return enabled ? Math.min(2 * (elite - freeElite), normalTanks - (amount - elite)) : 0;
|
||||
}
|
||||
+9
-1
@@ -4602,6 +4602,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Caladan shares the admitted original Bidding inspection with the occupier, not its ally; native inspection can still be prevented independently. Ix gives the occupier the actual private drawn pool and selection, not unrelated setup or Technology. Richese sets its own method/direction before the occupier chooses only the physical cache card; seller, payment and other decisions stay native. Giedi gives one original purchase bonus to the occupier or its reciprocal ally with real receiver capacity, even when Harkonnen’s purchased card fills its hand.',
|
||||
'The separately marked fresh Tupile entry grants one hand slot to the proved occupier and reciprocal ally. Every server-derived hand limit includes it, so Harkonnen can hold nine without receiving a tenth bonus. After a proved loss, each old holder privately selects exactly its excess original eligible cards to return to the normal limit; committed cards remain protected. Original phase opening and remaining movement resume without another shipment or payment. Basic unknown lifecycle blocks rather than guesses cleanup. Advanced departure can restore CHOAM’s low intelligence without clearing its previous answers or used factions.',
|
||||
'The fresh native Ecaz high-card preview shows a separate prospective conjunction: native seven-plus population, at least one actual jointly held stronghold and proved current alliance holdings of two other native factions’ Homeworlds. This does not change the ordinary four-stronghold or three-joint Ecaz targets. Original Mentat decides the win before prediction/fallback; source uncertainty remains blocked. Duke authority and the Ecaz-only revival ruling are separate.',
|
||||
'Ix High adds up to two physical Suboids from Tanks per actually paid Cyborg in an ordinary revival. The persistent quote follows selected free types. Its additional allowance, partial shortage and free-event income are explicit provisional compositions; prior La La La suppresses new bonuses, while native cancellation and atomic request-stop outcomes remain. Recruits does not multiply the bonus. Old accepted requests are not retrofitted.',
|
||||
'The card collection below is available for reference. Homeworld starts are enabled in supported fresh preview envelopes; the remaining occupation variants and combinations stay under verification.',
|
||||
'Original classic World/Nexus uses base33 or selected independent36 with optional all14, Tech3+ and Advanced Strongholds2+ with real first-Storm token owners and end-Mentat card claims. Guild World shipments and Cunning visitor returns pass native permission/rate before the funded fee; borrowed Caladan fee1/fee5 settles to the distinct holder while typed custody waits. Temporary-Sardaukar own-Kaitain battles keep ordinary-only casualties and true Salusa custody; Giedi native-call cancellation follows original capture/execution/cleanup and one private replacement. Normal Smuggler companions may also supply the fee in core. On an own World, Reinforcements provisionally spends ordinary non-role counters first, reducing temporary Sardaukar roles only when converted counters pay. Kaitain7 paying local3 leaves four temporary roles on four normals; the original five-counter declaration and pre-fee traits remain. Other envelopes and complete-mode acceptance stay separate.',
|
||||
],
|
||||
@@ -4620,7 +4621,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
"All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.",
|
||||
"All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.",
|
||||
evidence: [
|
||||
'game/faction-module-profile.ts',
|
||||
'game/reinforcements-homeworld.ts',
|
||||
@@ -4628,6 +4629,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/nexus-world-independent-cards.test.ts',
|
||||
'tests/nexus-world-independent-battles.test.ts',
|
||||
'game/homeworld-cards.ts',
|
||||
'game/ix-homeworld-revival.ts',
|
||||
'components/ix-homeworld-revival.tsx',
|
||||
'tests/fixture-ix-homeworld-revival-bonus.ts',
|
||||
'tests/ix-homeworld-revival.test.ts',
|
||||
'tests/ix-homeworld-revival-bonus-runtime.test.ts',
|
||||
'tests/ix-homeworld-revival-continuation.test.ts',
|
||||
'tests/ix-homeworld-revival-controls.test.ts',
|
||||
'game/homeworld-game.ts',
|
||||
'game/homeworld-native-reserves.ts',
|
||||
'game/homeworld-revival-deployment.ts',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DUKE_VIDAL_ID } from './duke-vidal';
|
||||
import { resolveEcazDukeRevival } from './ecaz-duke-revival';
|
||||
import { isAuditorLeader } from './choam-auditor';
|
||||
import { controlsLeader } from './leader-control';
|
||||
import { ixHomeworldRevivalEnabled, quoteIxSuboidBonus } from './ix-homeworld-revival';
|
||||
import {
|
||||
eliteRevivalRemaining,
|
||||
normalForceRevivalLimit,
|
||||
@@ -73,15 +74,38 @@ export function continuationCustody(
|
||||
p: Player,
|
||||
pending: Pending,
|
||||
) {
|
||||
if (pending.ixSuboidBonus !== undefined) {
|
||||
requireRevival(pending.kind === 'forces' && !pending.emperorExtra &&
|
||||
pending.nexusEmperorEvent === undefined && pending.payer === undefined &&
|
||||
p.faction === 'ixians' && nonnegative(pending.ixSuboidBonus) &&
|
||||
pending.ixSuboidBonus > 0 && pending.ixSuboidBonus <= 13 &&
|
||||
nonnegative(pending.amount) && nonnegative(pending.elite) &&
|
||||
nonnegative(pending.free) && nonnegative(pending.normalCost) &&
|
||||
pending.cost > 0,
|
||||
'The Ix bonus needs its original paid native Cyborg request.');
|
||||
const freeElite = (pending.amount! - pending.free) * 2 +
|
||||
pending.elite! - pending.normalCost;
|
||||
let expected = -1;
|
||||
try {
|
||||
expected = quoteIxSuboidBonus({
|
||||
enabled: ixHomeworldRevivalEnabled(g, p.id),
|
||||
amount: pending.amount!, elite: pending.elite!, free: pending.free,
|
||||
freeElite, normalTanks: p.tanks - (p.elites?.tanks ?? 0),
|
||||
});
|
||||
} catch { /* Convert malformed physical quotes into the native rule error. */ }
|
||||
requireRevival(expected === pending.ixSuboidBonus,
|
||||
'The pending Ix bonus no longer matches its paid Cyborgs and physical Suboids.');
|
||||
}
|
||||
if (pending.kind === 'forces') {
|
||||
const n = pending.amount!,
|
||||
elite = pending.elite ?? 0;
|
||||
elite = pending.elite ?? 0,
|
||||
physical = n + (pending.ixSuboidBonus ?? 0);
|
||||
requireRevival(
|
||||
nonnegative(p.tanks) && nonnegative(p.reserves) && n <= p.tanks,
|
||||
nonnegative(p.tanks) && nonnegative(p.reserves) && physical <= p.tanks,
|
||||
'The pending revival forces are no longer in the tanks.',
|
||||
);
|
||||
requireRevival(
|
||||
nonnegative(p.reserves + n) &&
|
||||
nonnegative(p.reserves + physical) &&
|
||||
nonnegative(p.revived) &&
|
||||
(pending.nexusEmperorEvent !== undefined ||
|
||||
(pending.emperorExtra
|
||||
@@ -107,7 +131,7 @@ export function continuationCustody(
|
||||
nonnegative(eliteTanks) &&
|
||||
eliteTanks <= p.tanks &&
|
||||
elite <= eliteTanks &&
|
||||
n - elite <= p.tanks - eliteTanks &&
|
||||
physical - elite <= p.tanks - eliteTanks &&
|
||||
elite <= eliteRevivalRemaining(p, g.advanced),
|
||||
'The pending ordinary and elite revival groups no longer match their tanks.',
|
||||
);
|
||||
|
||||
@@ -426,7 +426,7 @@ export function quoteRevivalResume(
|
||||
};
|
||||
if (
|
||||
pending.kind === 'forces' &&
|
||||
pending.free > 0 &&
|
||||
(pending.free > 0 || (pending.ixSuboidBonus ?? 0) > 0) &&
|
||||
g.techTokens &&
|
||||
p.faction !== 'tleilaxu'
|
||||
) {
|
||||
@@ -464,7 +464,9 @@ export function quoteRevivalResume(
|
||||
lastFree === undefined || count(lastFree),
|
||||
'The revival free-income receipt is invalid.',
|
||||
);
|
||||
const free = pending.free > 0;
|
||||
// The physical Homeworld grant is one free-return event within the same
|
||||
// invoice, without spending the ordinary free-rate ledger.
|
||||
const free = pending.free > 0 || (pending.ixSuboidBonus ?? 0) > 0;
|
||||
const reward =
|
||||
free &&
|
||||
lastFree !== g.turn &&
|
||||
|
||||
@@ -316,6 +316,9 @@ export type PendingRevival = {
|
||||
emperorExtra?: boolean;
|
||||
/** Independent fixed-three source; never native Emperor funding or ordinary usage. */
|
||||
nexusEmperorEvent?: string;
|
||||
/** Frozen physical Ix HIGH grant, additional to ordinary allowance. Absent
|
||||
* on requests accepted before this effect was implemented. */
|
||||
ixSuboidBonus?: number;
|
||||
amount?: number;
|
||||
elite?: number;
|
||||
leader?: string;
|
||||
|
||||
Reference in new issue
Block a user