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
@@ -165,6 +165,7 @@ import {
|
||||
import { IxTechnology } from './ix-technology';
|
||||
import { MobileStronghold } from './mobile-stronghold';
|
||||
import { IxSubstitution } from './ix-substitution';
|
||||
import { IxHomeworldRevivalBonus } from './ix-homeworld-revival';
|
||||
import { controlsLeader } from '@/game/leader-control';
|
||||
import { leaderStrengthLabel, harvesterAvailable } from '@/game/cards';
|
||||
import { RevivalCommerce } from './revival-commerce';
|
||||
@@ -4435,6 +4436,19 @@ export function GameTable({
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{me.faction === 'ixians' && (
|
||||
<IxHomeworldRevivalBonus
|
||||
enabled={g.revival.ixSuboidBonusEnabled}
|
||||
blocked={g.revival.freeBlocked}
|
||||
amount={amount}
|
||||
elite={eliteAmount}
|
||||
free={Math.min(amount, g.revival.freeRemaining)}
|
||||
freeElite={freeCyborgFirst
|
||||
? Math.min(amount, g.revival.freeRemaining, eliteAmount)
|
||||
: Math.max(0, Math.min(amount, g.revival.freeRemaining) - (amount - eliteAmount))}
|
||||
normalTanks={me.tanks - (me.elites?.tanks ?? 0)}
|
||||
/>
|
||||
)}
|
||||
{me.faction === 'emperor' &&
|
||||
me.ally &&
|
||||
(() => {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { quoteIxSuboidBonus } from '@/game/ix-homeworld-revival';
|
||||
|
||||
/** The ordinary revival controls own selection and payment. This persistent
|
||||
* quote explains the automatic physical grant without adding another choice. */
|
||||
export function IxHomeworldRevivalBonus(props: {
|
||||
enabled: boolean;
|
||||
blocked?: boolean;
|
||||
amount: number;
|
||||
elite: number;
|
||||
free: number;
|
||||
freeElite: number;
|
||||
normalTanks: number;
|
||||
}) {
|
||||
if (!props.enabled) return null;
|
||||
if (props.blocked) return <p className="notice" aria-live="polite">
|
||||
La La La prevents the free Ix HIGH Suboid bonus on new requests this phase.
|
||||
{' '}Paid Cyborg revival remains available.
|
||||
</p>;
|
||||
let bonus: number;
|
||||
try { bonus = quoteIxSuboidBonus(props); }
|
||||
catch {
|
||||
return <p className="fine" aria-live="polite">
|
||||
Ix HIGH adds up to two physical Suboids from Tanks per paid Cyborg.
|
||||
Select an available revival group to see its bonus.
|
||||
</p>;
|
||||
}
|
||||
return <p className="notice" aria-live="polite">
|
||||
Ix HIGH: this selection also returns {bonus} Suboid{bonus === 1 ? '' : 's'} for free.
|
||||
{' '}These additional physical forces do not use normal revival allowance
|
||||
or its free quota. Available Suboids in Tanks limit the bonus.
|
||||
</p>;
|
||||
}
|
||||
@@ -16,6 +16,37 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Ix High paid-Cyborg revival bonus — 25 October. Prototyped / Partial.**
|
||||
An ordinary paid Cyborg return now automatically returns up to two additional
|
||||
physical Suboids from Tanks. Selected free types, original invoice and native
|
||||
payment/stop responses remain. Bonus quantities are frozen before the group,
|
||||
spend no ordinary revival/free quota and never manufacture pieces. Quota,
|
||||
shortage, La La La and free-event income are explicitly provisional compositions;
|
||||
Recruits does not multiply the grant. Persistent selected-group text and existing
|
||||
minimal policies are connected, without strategic tuning.
|
||||
[Source, provisional policy and limits](HOMEWORLD_BENEFITS_RULES.md#provisional-ix-high-paid-revival-bonus--25-october-2026).
|
||||
|
||||
Disjoint helper/native-fixture work, bounded source/correctness review and root
|
||||
integration cover **12 files, 210 cases**. The original union passed 209; only a
|
||||
controlled Recruits fixture attempted execution without its required card
|
||||
module. Repairing it to an honestly labelled quote-only case passes its whole
|
||||
**6/6** file, types/lint/build; hashes confirm the other 2,793 source files unchanged.
|
||||
Four genuine native engine/selected-quote/GameTable SSR journeys pass: Basic 111
|
||||
and Advanced 118 source actions earn original 47-card setup/draft, paid 5 mixed
|
||||
invasion, real five-counter loss and turn-two revival; prior-free 112 retains
|
||||
actual earlier usage, and original-Ghola 111 leaves one actual bonus Suboid.
|
||||
Five physical returns consume three ordinary slots; partial stock, original
|
||||
negative branches and all four minimal profiles pass. Controlled continuation
|
||||
quotes cover discounted/unfunded invoices, once-only income and old requests.
|
||||
No full-mode, final-assurance, hydration/browser/mobile or deployed acceptance
|
||||
is claimed; no saved-room/D1/server/reset/restart/deployment operation occurred.
|
||||
|
||||
A bounded ordinary-faction and Treachery-card lookup found no further wholly
|
||||
absent native power; that is not complete source coverage.
|
||||
The remaining **High Richese No-Field plus reserve shipment** is a concrete
|
||||
Homeworld content gap, distinct from its existing Smuggler companion. Preserve
|
||||
the user's specific unfunded-Karama/Kull deferral while continuing other rules.
|
||||
|
||||
**Current Traitor-declaration Truthtrance — 25 October. Prototyped / Partial.**
|
||||
A structured revealed-battle question now binds an actual unsubmitted voter to
|
||||
Reveal or decline. Native matching/KH/voter/beneficiary rules supply the private
|
||||
|
||||
@@ -2,6 +2,73 @@
|
||||
|
||||
Source audit, 9 September 2026. This separates publisher wording, rule composition, and remaining decisions. It does not enable Homeworld games or replace the outstanding Ambassador/Duke revival rulings.
|
||||
|
||||
## Provisional Ix High paid revival bonus — 25 October 2026
|
||||
|
||||
**Prototyped / Partial.** The printed Ix High card now connects an ordinary
|
||||
paid Cyborg revival to its automatic physical Suboid return. The original
|
||||
`revive` controls still choose the base group and which Cyborgs use the ordinary
|
||||
free quota. A free Cyborg, paid Suboid, Ghola return, or another faction paying
|
||||
Emperor extra revival does not trigger this native paid-Cyborg grant.
|
||||
|
||||
The card establishes two free Suboids per paid Cyborg. The authorized revised
|
||||
PDF's physical page 22 gives the current-population Homeworld rule, but does
|
||||
not supply an Ix-specific quota/shortage ruling. Physical page 9 retains the
|
||||
ordinary revival cap. The following are explicit **provisional composition
|
||||
policies**, selected under the user's 14 October instruction to proceed with
|
||||
recommended interpretations; they are not publisher errata:
|
||||
|
||||
- Observe native Ix High before accepting the group. A return that first raises
|
||||
Ix from Low to High earns no retroactive bonus; a later independent paid
|
||||
Cyborg request observes its own current population.
|
||||
- Reserve the selected base group's normal Suboids first. Return up to two
|
||||
remaining physical Suboids from Tanks per paid Cyborg. A shortage reduces the
|
||||
grant; it never manufactures a counter, substitutes a Cyborg, blocks the
|
||||
original legal purchase or leaves a debt for a later return.
|
||||
- The bonus is additional to normal revival allowance and ordinary free quota.
|
||||
Recruits does not multiply it. A discounted positive Cyborg payment qualifies;
|
||||
canceling the price discount retains the grant only if the original request
|
||||
can still be funded. The original selected quantities and invoice stay intact.
|
||||
- Prior La La La prevents the free bonus on new requests while leaving paid
|
||||
revival available. The existing reactive opportunity also recognizes a
|
||||
bonus-only free event: successful La La La stops the whole atomic request
|
||||
without payment or returns; preventing La La La resumes the original frozen
|
||||
request and grant. No new Homeworld-only Karama response is generated.
|
||||
- A delivered bonus counts as one free-return event for existing Tleilaxu and
|
||||
Axlotl income. Combine it with any base free return rather than awarding
|
||||
twice. The paid invoice, once-per-turn receipts and Tleilax's phase-start Low
|
||||
suppression remain. This composes the revised PDF's general free-revival
|
||||
income at physical pages 21 and 38; neither passage specifically names Ix.
|
||||
|
||||
The accepted request stores only its positive `ixSuboidBonus` count. Settlement
|
||||
checks its paid Cyborg allocation and actual remaining Suboids, then deposits
|
||||
the entire physical return once. Ordinary `revived` and `freeForcesRevived`
|
||||
increase only for the selected base group. Old pending requests without that
|
||||
field retain their accepted behavior. The player view exposes current High
|
||||
eligibility; persistent selection text explains the automatic grant or La La La
|
||||
suppression. Existing minimal AI uses the same ordinary legal actions, with no
|
||||
strategic refinement.
|
||||
|
||||
Implementation and focused evidence: [physical quote](../game/ix-homeworld-revival.ts),
|
||||
[native chronology](../tests/fixture-ix-homeworld-revival-bonus.ts),
|
||||
[native tests](../tests/ix-homeworld-revival-bonus-runtime.test.ts),
|
||||
[quote tests](../tests/ix-homeworld-revival.test.ts),
|
||||
[continuation tests](../tests/ix-homeworld-revival-continuation.test.ts),
|
||||
[controls](../components/ix-homeworld-revival.tsx) and
|
||||
[control tests](../tests/ix-homeworld-revival-controls.test.ts).
|
||||
Frozen proof covers 12 related files, 210 cases: 209 originally passed, and the
|
||||
sole Recruits fixture failure was corrected to a quote-only composition test;
|
||||
its full 6/6 file, final types/lint/build pass with the other 2,793 source files
|
||||
unchanged. Four actual native runtime/SSR journeys pass (Basic 111, Advanced 118,
|
||||
prior-free 112 and original-Ghola 111 source actions). These earn original 47 cards,
|
||||
a real paid 5 mixed invasion and five actual Tanks casualties; no played state
|
||||
is installed. Separate controlled continuation/income/La La La/Recruits contexts
|
||||
are labelled. Proof is retained privately in
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261025-ix-homeworld-revival-bonus/`.
|
||||
Full Homeworld/module combinations, comprehensive assurance and final deployed
|
||||
acceptance remain open. This dated section supersedes the earlier audit's
|
||||
decision-pending implementation boundary below; it does not turn those gaps in
|
||||
printed authority into located rulings.
|
||||
|
||||
## Provisional high-threshold revival deployment — 9 October 2026
|
||||
|
||||
The previously blocked case "a supported revival whose own return first reaches
|
||||
|
||||
@@ -1,5 +1,49 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 25 October 2026 — Ix High adds physical Suboids to paid Cyborg revival
|
||||
|
||||
The original ordinary revival now snapshots the native High entitlement and
|
||||
selected paid/free Cyborg allocation, freezes its positive physical grant and
|
||||
returns the selected group plus remaining bonus Suboids in one native deposit.
|
||||
Provisional additional-quota/partial-shortage policy keeps ordinary revived/free
|
||||
usage on the base group only. No owed stock, substituted elite or extra choice.
|
||||
Original payment, discount cancellation, native request stops and one combined
|
||||
Tleilaxu/Axlotl free-event receipt remain; prior La La La suppresses new grants.
|
||||
Successful reactive La La La stops the entire return, while preventing that
|
||||
card effect resumes the frozen source. Recruits changes its rate, not the bonus.
|
||||
Old pending requests are not retrofitted. Persistent quote text uses the shared
|
||||
calculator; all four existing profiles retain legal ordinary actions.
|
||||
|
||||
Disjoint helper/native-fixture lanes, bounded source/correctness review and root
|
||||
integration cover **12 files, 210 cases**. The first union passed 209 with one
|
||||
controlled Recruits fixture rejected by the real module requirement. That test
|
||||
is now explicitly quote-only; its complete six-case file, final types/lint and
|
||||
build pass. Exact hashes retain the other 2,793 source files and all prior passing
|
||||
runtime/union behavior. Initial quick feedback also completed the Recruits
|
||||
fixture's typed source fields. No requirement was weakened to obtain a pass.
|
||||
|
||||
Four native engine/selected-quote/GameTable SSR journeys pass. Basic 111 and
|
||||
Advanced 118 source actions earn original 47-card private draft, native HMS,
|
||||
actual paid 5 typed invasion, a real five-counter battle loss, retained Ix High 9
|
||||
and the next Revival. Returning three selected counters adds two actual Suboids
|
||||
without spending extra normal slots. Prior-free112 preserves its completed
|
||||
return; original-Ghola 111 leaves a one-Suboid partial bonus. All original physical
|
||||
inventory stays in its actual zones. Separate labelled controlled cases cover
|
||||
Recruits, earlier La La La and pending invoice/income behavior. No played
|
||||
resources, phase, casualty or hand is installed in the native chronology.
|
||||
|
||||
Final proof/HTML outside Git:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261025-ix-homeworld-revival-bonus/`.
|
||||
`frozen-fixture-repair/report.json` binds tree
|
||||
`13ebdd86fcd44694ccf806ee7bbe71ba18c67e624c0839cd8c6464cc14a5237a`,
|
||||
2794 files, base `86608f7ea9aa4a1e9585d36bc3fd314b69363f2a`.
|
||||
The preceding frozen report retains the four runtime journeys and 209 passing
|
||||
cases; `fixture-repair-overlay.json` records the sole changed test file.
|
||||
[Policy/source contract](HOMEWORLD_BENEFITS_RULES.md#provisional-ix-high-paid-revival-bonus--25-october-2026).
|
||||
Full Homeworld/mode/module acceptance and final assurance remain open. No AI
|
||||
refinement, saved-room/D1/server/reset/restart/deployment operation. SSR is not
|
||||
browser hydration/mobile/deployed evidence.
|
||||
|
||||
## 25 October 2026 — Truthtrance binds the current native Traitor declaration
|
||||
|
||||
A separate revealed-battle question binds an actual unsubmitted native voter,
|
||||
|
||||
@@ -1252,6 +1252,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
|
||||
| Discoveries | Preserve seven sourced Spice Cards/eight tokens, faction peeks and Collection choices. Fresh classic/native E1/E2/standalone E3 may retain original Tech3..6; separate fresh classic all14 Skills/Discovery/Tech uses original training, entry, movement, stash, rescue/rewards and Bureaucrat invoices. Native setup, revealed-only No-Fields and Moritani source histories remain. Advanced Orgiz uses the supplied p24 territory-Collection trigger; Basic stays provisional per deposit. Only Jacurutu adds a stronghold. Contested/shared/mixed cases, other combinations and public starts remain unfinished. | [Component authority](DISCOVERY_COMPONENTS.md), [native composition](DISCOVERY_PROTOTYPE.md#native-setup-and-original-consumers--5-october-2026), [classic Skills/Advanced source cutover](DISCOVERY_PROTOTYPE.md#classic-leader-skills-and-advanced-orgiz--5-october-2026) |
|
||||
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
|
||||
| Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) |
|
||||
| Ix High paid-Cyborg bonus | Provisional composition: observe High before the accepted ordinary group; add up to two actual remaining Suboid Tanks per paid Cyborg outside normal allowance/free quota, without Recruits multiplication or future debt. Preserve the paid invoice and original cancellation/stop outcomes. Prior La La La suppresses new grants; a delivered bonus is one existing free-return income event, not a second award. The card establishes the grant; quota, shortage and event classification are application policy, not a located Ix FAQ. | [Current contract and source boundaries](HOMEWORLD_BENEFITS_RULES.md#provisional-ix-high-paid-revival-bonus--25-october-2026) |
|
||||
| Homeworld effects | Keep original payment/revival/placement identities and qualification observations; observations alone do not choose an occupied-benefit owner. | [Payments](HOMEWORLD_PAYMENT_INCOME_RULES.md), [card economy](HOMEWORLD_CARD_ECONOMY_RULES.md), [Collection](HOMEWORLD_COLLECTION_RULES.md), [revival deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md), [victory reinforcement](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), [replacement](HOMEWORLD_REPLACEMENT_RULES.md), [Tupile](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) |
|
||||
| Duke Vidal revival custody | The user selected living set-aside custody after Ecaz revives Duke; a separate Ambassador/Nexus acquisition grants battle control. Only Ecaz may revive him, including Ghola. Native repeated returns now use the separately labelled provisional identity-cohort policy, not a new publisher example or user number. Captured scope and broad combinations remain separately bounded. | [Selected custody](ECAZ_REVIVAL_RULES.md#selected-product-interpretation-28-september-2026), [current cohort policy](ECAZ_REVIVAL_RULES.md#identity-tracked-repeated-cohorts--14-october-2026) |
|
||||
| Mirror Weapon copied-card cleanup | The user selected ordinary winner retention for the physical Mirror even if its effective copy activates Tooth or Artillery. The original Tooth/Artillery cards retain their named mandatory-discard exceptions. This is a product interpretation, **not** a located publisher ruling. Independent copy-first Tooth/Stone choices and physical cleanup have a bounded CHOAM/Richese-deck runtime, including bare Basic native E3 and selected Ix with original fixed/typed coalition accounting. Basic E3 phase modules and other combined modules/full games remain gated. | [Printed Mirror face and source audit](MIRROR_WEAPON_ENGINE_AUDIT.md#user-selected-cleanup-interpretation-28-september-2026) |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { MOBILE_LOCATION } from '../game/board';
|
||||
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { homeworldPopulations } from '../game/homeworld-population';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { actTruthMovement } from './fixture-truth-movement';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
|
||||
export type IxHomeworldRevivalBonusStep = { actor: string; action: Action };
|
||||
export type IxHomeworldRevivalBonusTransition = { before: Game; steps: IxHomeworldRevivalBonusStep[]; after: Game };
|
||||
export const actIxHomeworldRevivalBonus = actTruthMovement;
|
||||
export const ixHomeworldRevivalBonusPlayer = (game: Game, actor = 'ixians') => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const ixHomeworldRevivalBonusClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
|
||||
export const ixHomeworldRevivalBonusCards = (game: Game) => {
|
||||
const cards = [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? []),
|
||||
...game.players.flatMap(player => player.hand)];
|
||||
const ids = cards.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'Every original Treachery Card retains one physical slot');
|
||||
return ids;
|
||||
};
|
||||
export function ixHomeworldRevivalBonusInventory(game: Game) {
|
||||
homeworldGameIntegrity(game);
|
||||
const sum = (counts: Record<string, number>) => Object.values(counts).reduce((total, count) => total + count, 0);
|
||||
for (const player of game.players) {
|
||||
const visitors = Object.values(game.homeworlds!.custody!.visitors)
|
||||
.map(groups => groups[player.id] ?? { normal: 0, elite: 0 });
|
||||
assert.equal(player.reserves + player.tanks + sum(player.forces) +
|
||||
visitors.reduce((total, group) => total + group.normal + group.elite, 0), 20);
|
||||
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks + sum(player.elites.forces) +
|
||||
visitors.reduce((total, group) => total + group.elite, 0), player.faction === 'ixians' ? 7 : 5);
|
||||
}
|
||||
return ixHomeworldRevivalBonusCards(game);
|
||||
}
|
||||
export const ixHomeworldRevivalBonusPopulation = (game: Game, owner = 'ixians') => {
|
||||
const home = homeworldPopulations(homeworldContext(game), game.homeworlds!.custody!)
|
||||
.find(home => home.native === owner && home.card === 'ix'); assert.ok(home); return home;
|
||||
};
|
||||
export const ixHomeworldRevivalBonusNativeForces = (game: Game, owner = 'ixians') => {
|
||||
const home = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
|
||||
.find(home => home.native === owner && !home.secondary); assert.ok(home); return home.forces[owner];
|
||||
};
|
||||
|
||||
/** Only original native controls. Private battle plans and revival declarations
|
||||
* remain explicit; the original Caladan winner may decline its extra force. */
|
||||
export function nextIxHomeworldRevivalBonus(game: Game): IxHomeworldRevivalBonusStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'mobileStronghold') return { actor: decision.player, action: decision.placement
|
||||
? { type: 'decision', location: 'rock_outcroppings:14' } : { type: 'decision', decline: true } };
|
||||
if (decision?.kind === 'ixAuction') {
|
||||
const card = game.ixAuction!.cards[0]; assert.ok(card);
|
||||
return { actor: decision.player, action: { type: 'decision', card: card.id, position: 'bottom' } };
|
||||
}
|
||||
if (decision?.kind === 'caladanReinforcement')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
|
||||
if (decision?.kind === 'fullPlanOffer' || decision?.kind === 'revivalStop')
|
||||
return { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
}
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export const nextIxHomeworldRevivalBonusNativeStep = nextIxHomeworldRevivalBonus;
|
||||
export function advanceIxHomeworldRevivalBonus(game: Game, until: (state: Game) => boolean,
|
||||
actions: IxHomeworldRevivalBonusStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Ixian chronology ended before its requested control');
|
||||
const next = nextIxHomeworldRevivalBonus(game);
|
||||
assert.ok(next, `Choose the original private plan or Truthtrance at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actIxHomeworldRevivalBonus(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The genuine Ixian Homeworld source did not reach its finite boundary');
|
||||
}
|
||||
export function passIxHomeworldRevivalBonus(game: Game, actions: IxHomeworldRevivalBonusStep[] = []): Game {
|
||||
for (let count = 0; game.response && count < 60; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actIxHomeworldRevivalBonus(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.ok(!game.response); return game;
|
||||
}
|
||||
export function finishIxHomeworldRevivalBonus(game: Game, actions: IxHomeworldRevivalBonusStep[] = []): Game {
|
||||
return advanceIxHomeworldRevivalBonus(game, state => state.phase === 5 && ixHomeworldRevivalBonusClean(state), actions);
|
||||
}
|
||||
|
||||
/** Actual public Basic/Advanced Ix/Atreides/Emperor with Homeworlds. Native
|
||||
* first-Storm HMS setup retains its six counters. A paid five-force invasion
|
||||
* then really loses one Cyborg and four Suboids while native Ix remains HIGH.
|
||||
* Stop at the actual next Revival, before the caller's paid-Cyborg request. */
|
||||
export function createIxHomeworldRevivalBonusFixture(options: {
|
||||
advanced?: boolean; ghola?: boolean; priorFree?: boolean;
|
||||
} = {}) {
|
||||
const advanced = options.advanced ?? false, owner = 'ixians', opponent = 'atreides', observer = 'emperor';
|
||||
let game = createGame('IXHOMEWORLDREVIVALBONUS', newPlayer(owner, 'Ixians', 'ixians'), advanced, ['ix']);
|
||||
joinGame(game, newPlayer(opponent, 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
|
||||
const initial = structuredClone(game), actions: IxHomeworldRevivalBonusStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actIxHomeworldRevivalBonus(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceIxHomeworldRevivalBonus(game, until, actions); };
|
||||
const transition = (run: () => void): IxHomeworldRevivalBonusTransition => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
const enableHomeworlds = transition(() => { act(game.host, { type: 'homeworlds', enabled: true }); });
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced);
|
||||
assert.deepEqual(game.expansions, ['ix']); assert.ok(game.homeworlds);
|
||||
assert.ok(!game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards &&
|
||||
!game.discoveryEnabled && !game.ecazTreachery);
|
||||
assert.ok(game.players.every(player => !player.hand.length));
|
||||
const originalInventory = ixHomeworldRevivalBonusCards(game); assert.equal(originalInventory.length, 47);
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 3);
|
||||
assert.equal(ordinary.length, 3);
|
||||
const gholaCard = options.ghola ? game.deck.find(card => card.effect === 'ghola') : null;
|
||||
assert.ok(!options.ghola || gholaCard);
|
||||
const selected = [gholaCard ?? ordinary[0], ordinary[1], ordinary[2]];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ixSetup');
|
||||
const beforeDraft = structuredClone(game);
|
||||
const startingDraft = transition(() => { act(owner, { type: 'decision', card: selected[0].id }); });
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).forces[MOBILE_LOCATION], 6);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).elites?.forces[MOBILE_LOCATION], 3);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).reserves, 14);
|
||||
assert.equal(ixHomeworldRevivalBonusPopulation(game, owner).side, 'high');
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1, 2, 3].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'mobileStronghold' && state.decision.placement);
|
||||
const hmsPlacement = transition(() => { act(owner, { type: 'decision', location: 'rock_outcroppings:14' }); });
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && ixHomeworldRevivalBonusClean(state));
|
||||
const beforeShipment = structuredClone(game), homeworldSources = nativeShipmentSources(viewGame(game, owner), 5, 1);
|
||||
assert.ok(homeworldSources);
|
||||
const shipmentStep: IxHomeworldRevivalBonusStep = { actor: owner, action: {
|
||||
type: 'ship', amount: 5, elite: 1, territory: 'arrakeen', sector: 10, allyPayment: 0, homeworldSources,
|
||||
} };
|
||||
const shipment = transition(() => { act(shipmentStep.actor, shipmentStep.action); reach(ixHomeworldRevivalBonusClean); });
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(beforeShipment, owner).spice - ixHomeworldRevivalBonusPlayer(game, owner).spice, 5);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).forces['arrakeen:10'], 5);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).elites?.forces['arrakeen:10'], 1);
|
||||
assert.equal(ixHomeworldRevivalBonusPopulation(game, owner).population, 9);
|
||||
reach(state => state.phase === 6 && ixHomeworldRevivalBonusClean(state) && !!state.active);
|
||||
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices
|
||||
.find(choice => choice.territory === 'arrakeen'); assert.ok(choice);
|
||||
const battleStep: IxHomeworldRevivalBonusStep = { actor: choice.chooser, action: {
|
||||
type: 'chooseBattle', territory: 'arrakeen',
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker,
|
||||
} };
|
||||
act(battleStep.actor, battleStep.action);
|
||||
const beforePreparation = structuredClone(game);
|
||||
reach(state => !!state.battle && ixHomeworldRevivalBonusClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false);
|
||||
const beforePlans = structuredClone(game);
|
||||
const plans: IxHomeworldRevivalBonusStep[] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', leader: 'ixians-4', dial: 0, support: 0, weapon: null, defense: null } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', leader: 'atreides-0', dial: 0, support: 0, weapon: null, defense: null } },
|
||||
];
|
||||
for (const step of plans) act(step.actor, step.action);
|
||||
const revealed = structuredClone(game);
|
||||
reach(state => !state.battle && ixHomeworldRevivalBonusClean(state));
|
||||
const afterBattle = structuredClone(game);
|
||||
assert.equal(game.lastBattleContext?.winner, opponent);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).tanks, 5);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).elites?.tanks, 1);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, opponent).tanks, 0);
|
||||
assert.equal(ixHomeworldRevivalBonusPopulation(game, owner).side, 'high');
|
||||
reach(state => state.turn === 2 && state.phase === 4 && ixHomeworldRevivalBonusClean(state));
|
||||
const beforePriorFree = structuredClone(game);
|
||||
const priorFree = options.priorFree ? transition(() => {
|
||||
act(owner, { type: 'revive', amount: 1, elite: 0 }); reach(ixHomeworldRevivalBonusClean);
|
||||
}) : null;
|
||||
const paidAction: Action = options.priorFree
|
||||
? { type: 'revive', amount: 2, elite: 1, freeElite: 0 }
|
||||
: { type: 'revive', amount: 3, elite: 1, freeElite: 0 };
|
||||
const freeCyborg: Action = { type: 'revive', amount: 1, elite: 1, freeElite: 1 };
|
||||
const suboidAction: Action = { type: 'revive', amount: 2, elite: 0, freeElite: 0 };
|
||||
const gholaAction: Action | null = gholaCard ? { type: 'card', card: gholaCard.id, amount: 1, elite: 1 } : null;
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).tanks, options.priorFree ? 4 : 5);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).elites?.tanks, 1);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).revived, options.priorFree ? 1 : 0);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).forces[MOBILE_LOCATION], 6);
|
||||
assert.deepEqual(ixHomeworldRevivalBonusInventory(game), originalInventory);
|
||||
return { game, source: structuredClone(game), initial, enableHomeworlds, lobby, setup, beforeStartingDeal,
|
||||
beforeDraft, startingDraft, afterSetup, hmsPlacement, beforeShipment, shipmentStep, shipment,
|
||||
beforeBattle, battleStep, beforePreparation, beforePlans, plans, planSteps: plans, revealed, afterBattle,
|
||||
beforePriorFree, priorFree, advanced, owner, ixians: owner, opponent, atreides: opponent, observer, emperor: observer,
|
||||
territory: 'arrakeen', location: 'arrakeen:10', event: game.lastBattleContext!.event,
|
||||
originalInventory, originalCards: originalInventory, originalSpice, dealOrder, spiceOrder,
|
||||
action: paidAction, paidAction, freeCyborg, suboidAction, ghola: gholaCard?.id ?? null, gholaAction,
|
||||
amount: options.priorFree ? 2 : 3, elite: 1, freeElite: 0, cost: 5, bonus: 2,
|
||||
actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Actual public Basic/Advanced Ixians/Atreides/Emperor lobby enables only Homeworlds, then performs native ready/start with the original Ix47 deck. No optional Skills, Nexus, Discovery, Tech or Stronghold cards are introduced.',
|
||||
'Choose only original scalar shuffles, three unopened Treachery positions and four unopened ordinary Spice positions. Original private Traitor choices and Ixian three-card starting draft really occur. The requested Ghola variant selects the actual original Ghola in that draft; the default selects an ordinary card so existing normal bot revival candidates remain visible.',
|
||||
'Native Ixian setup puts three Cyborgs and three Suboids in the HMS and fourteen counters at Ix. Complete the first actual safe Storm and place the HMS at Rock Outcroppings14 through its original decision, then actual Spice/Charity/Bidding/Revival controls.',
|
||||
'Ixians pay five own spice to ship one original Cyborg and four original Suboids from the explicit native Homeworld pool into Atreides’ native ten Arrakeen counters. Ix population becomes nine and stays HIGH. Actual movement endings, native battle choice, original preparation, two zero-dial unarmed plans and declined Traitors produce the Atreides victory. All five invaded counters actually enter Tanks; Atreides loses none and declines its optional Caladan reinforcement.',
|
||||
'Real Collection/Mentat, second Storm/Spice/Charity/Bidding reach clean turn-two Revival. The HMS and its original six typed counters stay intact. No phase, force, wallet, casualty, hand, revival ledger, occupation or receipt is installed.',
|
||||
options.priorFree
|
||||
? 'The optional prior-free branch really returns one ordinary Suboid before the source. Its base/free ledgers remain recorded; the returned paid request uses the two remaining ordinary slots and still has two physical bonus Suboids available.'
|
||||
: 'Stop before any return. The original paid request is amount3/elite1/freeElite0: one ordinary free Suboid, one paid Suboid and one paid Cyborg for five spice. The requested bonus is additional to its three ordinary returns, using the two other real Suboid Tanks under the root’s explicitly provisional quota/shortage composition.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type IxHomeworldRevivalBonusFixture = ReturnType<typeof createIxHomeworldRevivalBonusFixture>;
|
||||
|
||||
export function reviveIxHomeworldRevivalBonus(source: Game, fixture: IxHomeworldRevivalBonusFixture,
|
||||
action: Action = fixture.action, actions: IxHomeworldRevivalBonusStep[] = []) {
|
||||
const before = structuredClone(source), start = actions.length;
|
||||
let game = actIxHomeworldRevivalBonus(source, fixture.owner, action, actions);
|
||||
const requested = structuredClone(game);
|
||||
game = advanceIxHomeworldRevivalBonus(game, ixHomeworldRevivalBonusClean, actions);
|
||||
return { game, before, requested, after: structuredClone(game), steps: structuredClone(actions.slice(start)) };
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { MOBILE_LOCATION } from '../game/board';
|
||||
import { forceRevivalQuote } from '../game/revival';
|
||||
import { quoteIxSuboidBonus } from '../game/ix-homeworld-revival';
|
||||
import {
|
||||
createIxHomeworldRevivalBonusFixture as fixture,
|
||||
reviveIxHomeworldRevivalBonus as revive,
|
||||
ixHomeworldRevivalBonusPlayer as player,
|
||||
ixHomeworldRevivalBonusInventory as inventory,
|
||||
ixHomeworldRevivalBonusPopulation as population,
|
||||
ixHomeworldRevivalBonusNativeForces as nativeForces,
|
||||
finishIxHomeworldRevivalBonus as finish,
|
||||
} from './fixture-ix-homeworld-revival-bonus';
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} actual paid Cyborg return adds two real Suboids beyond the ordinary three-force quota`, () => {
|
||||
const f = fixture({ advanced }), before = structuredClone(player(f.game, f.owner));
|
||||
const source = structuredClone(f.game), homeBefore = nativeForces(f.game, f.owner);
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 4);
|
||||
assert.equal(population(f.game, f.owner).population, 9);
|
||||
assert.equal(population(f.game, f.owner).side, 'high');
|
||||
assert.equal(before.tanks, 5); assert.equal(before.elites?.tanks, 1);
|
||||
assert.equal(f.afterBattle.lastBattleContext?.winner, f.opponent);
|
||||
assert.equal(player(f.afterBattle, f.opponent).tanks, 0);
|
||||
assert.equal(f.shipment.steps[0].action.type, 'ship');
|
||||
assert.ok(f.actions.some(step => step.action.type === 'homeworlds' && step.action.enabled === true));
|
||||
assert.ok(f.actions.some(step => step.action.type === 'start'));
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
assert.equal(viewGame(f.game, f.owner).revival.ixSuboidBonusEnabled, true);
|
||||
assert.equal(viewGame(f.game, f.opponent).revival.ixSuboidBonusEnabled, false);
|
||||
const quote = forceRevivalQuote(f.game, before, 3, 1, 0);
|
||||
assert.deepEqual(quote, { free: 1, normalCost: 5, cost: 5 });
|
||||
assert.equal(quoteIxSuboidBonus({ enabled: true, amount: 3, elite: 1, free: quote.free,
|
||||
freeElite: 0, normalTanks: before.tanks - before.elites!.tanks }), 2);
|
||||
const result = revive(f.game, f), after = player(result.game, f.owner);
|
||||
assert.deepEqual(f.game, source, 'The original source remains available for the other native branches');
|
||||
assert.equal(after.reserves, before.reserves + 5);
|
||||
assert.equal(after.elites?.reserves, before.elites!.reserves + 1);
|
||||
assert.equal(after.tanks, 0); assert.equal(after.elites?.tanks, 0);
|
||||
assert.equal(after.revived, 3, 'Only the declared three forces consume normal revival allowance');
|
||||
assert.equal(after.freeForcesRevived, 1, 'Additional Suboids do not consume the ordinary free quota');
|
||||
assert.equal(after.elites?.revived, 1);
|
||||
assert.equal(before.spice - after.spice, 5);
|
||||
assert.deepEqual(nativeForces(result.game, f.owner), { normal: homeBefore.normal + 4, elite: homeBefore.elite + 1 });
|
||||
assert.equal(after.forces[MOBILE_LOCATION], 6);
|
||||
assert.equal(after.elites?.forces[MOBILE_LOCATION], 3);
|
||||
assert.equal(viewGame(result.game, f.owner).revival.forcesRemaining, 0);
|
||||
assert.deepEqual(inventory(result.game), f.originalInventory);
|
||||
const movement = finish(result.game);
|
||||
assert.equal(movement.turn, 2); assert.equal(movement.phase, 5);
|
||||
assert.equal(player(movement, f.owner).reserves, after.reserves);
|
||||
assert.equal(player(movement, f.owner).tanks, 0);
|
||||
assert.deepEqual(inventory(movement), f.originalInventory);
|
||||
});
|
||||
}
|
||||
|
||||
void test('an actual earlier free Suboid remains completed and the later paid Cyborg bonus uses no extra ordinary slots', () => {
|
||||
const f = fixture({ priorFree: true }), before = structuredClone(player(f.game, f.owner));
|
||||
assert.ok(f.priorFree);
|
||||
assert.equal(player(f.beforePriorFree, f.owner).tanks, 5);
|
||||
assert.equal(before.tanks, 4); assert.equal(before.revived, 1); assert.equal(before.freeForcesRevived, 1);
|
||||
const result = revive(f.game, f), after = player(result.game, f.owner);
|
||||
assert.equal(after.reserves - before.reserves, 4);
|
||||
assert.equal(after.elites!.reserves - before.elites!.reserves, 1);
|
||||
assert.equal(after.tanks, 0); assert.equal(after.elites?.tanks, 0);
|
||||
assert.equal(before.spice - after.spice, 5);
|
||||
assert.equal(after.revived, 3); assert.equal(after.freeForcesRevived, 1);
|
||||
assert.deepEqual(inventory(result.game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('a genuine free Cyborg return gives no extra Suboids or paid invoice', () => {
|
||||
const f = fixture(), before = structuredClone(player(f.game, f.owner));
|
||||
const result = revive(f.game, f, f.freeCyborg), after = player(result.game, f.owner);
|
||||
assert.equal(after.reserves, before.reserves + 1);
|
||||
assert.equal(after.elites?.reserves, before.elites!.reserves + 1);
|
||||
assert.equal(after.tanks, 4); assert.equal(after.elites?.tanks, 0);
|
||||
assert.equal(after.spice, before.spice);
|
||||
assert.equal(after.revived, 1); assert.equal(after.freeForcesRevived, 1);
|
||||
assert.deepEqual(inventory(result.game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('ordinary Suboid revivals create no bonus and a later paid Cyborg returns only the two still in Tanks', () => {
|
||||
const f = fixture(), before = structuredClone(player(f.game, f.owner));
|
||||
const suboids = revive(f.game, f, f.suboidAction).game;
|
||||
const middle = structuredClone(player(suboids, f.owner));
|
||||
assert.equal(middle.reserves, before.reserves + 2);
|
||||
assert.equal(middle.elites?.reserves, before.elites?.reserves);
|
||||
assert.equal(middle.tanks, 3); assert.equal(middle.elites?.tanks, 1);
|
||||
assert.equal(before.spice - middle.spice, 2);
|
||||
assert.equal(middle.revived, 2); assert.equal(middle.freeForcesRevived, 1);
|
||||
const paidCyborg: Action = { type: 'revive', amount: 1, elite: 1, freeElite: 0 };
|
||||
const result = revive(suboids, f, paidCyborg), after = player(result.game, f.owner);
|
||||
assert.equal(after.reserves - middle.reserves, 3);
|
||||
assert.equal(after.elites!.reserves - middle.elites!.reserves, 1);
|
||||
assert.equal(middle.spice - after.spice, 3);
|
||||
assert.equal(after.tanks, 0); assert.equal(after.revived, 3); assert.equal(after.freeForcesRevived, 1);
|
||||
assert.deepEqual(inventory(result.game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('the original privately drafted Ghola returns its Cyborg without the paid-revival bonus or normal allowance use', () => {
|
||||
const f = fixture({ ghola: true }), before = structuredClone(player(f.game, f.owner));
|
||||
assert.ok(f.ghola && f.gholaAction);
|
||||
assert.equal(f.startingDraft.steps[0].action.card, f.ghola);
|
||||
assert.ok(before.hand.some(card => card.id === f.ghola));
|
||||
const result = revive(f.game, f, f.gholaAction), after = player(result.game, f.owner);
|
||||
assert.equal(after.reserves, before.reserves + 1);
|
||||
assert.equal(after.elites?.reserves, before.elites!.reserves + 1);
|
||||
assert.equal(after.tanks, 4); assert.equal(after.elites?.tanks, 0);
|
||||
assert.equal(after.spice, before.spice);
|
||||
assert.equal(after.revived, before.revived);
|
||||
assert.equal(after.freeForcesRevived, before.freeForcesRevived);
|
||||
assert.equal(after.hand.some(card => card.id === f.ghola), false);
|
||||
assert.equal(result.game.discard.filter(card => card.id === f.ghola).length, 1);
|
||||
assert.deepEqual(inventory(result.game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('all four existing Basic profiles retain legal original revival choices without strategic tuning', () => {
|
||||
const f = fixture();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(f.game, f.owner); view.players.find(player => player.id === f.owner)!.bot = difficulty;
|
||||
const action = botActions(view).find(action => action.type === 'revive');
|
||||
assert.ok(action, `${difficulty} proposes an ordinary revival from the genuine native source`);
|
||||
const result = revive(structuredClone(f.game), f, action);
|
||||
const before = player(f.game, f.owner), after = player(result.game, f.owner);
|
||||
assert.ok(after.tanks < before.tanks);
|
||||
assert.ok(after.spice >= 0 && after.spice <= before.spice);
|
||||
assert.ok(after.revived > before.revived && after.revived <= 3);
|
||||
assert.equal(population(result.game, f.owner).side, 'high');
|
||||
assert.deepEqual(inventory(result.game), f.originalInventory);
|
||||
assert.equal(finish(result.game).phase, 5);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer } from '../game/engine';
|
||||
import { createHomeworldCustody } from '../game/homeworld-custody';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { snapshotHomeworldRevival } from '../game/homeworld-benefits';
|
||||
import { quoteRevivalResume } from '../game/revival-resume';
|
||||
import { quoteRevivalCancellation, continuationCustody } from '../game/revival-cancellation';
|
||||
import { newRevivalRules, forceRevivalQuote } from '../game/revival';
|
||||
import { quoteIxSuboidBonus } from '../game/ix-homeworld-revival';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import {
|
||||
createIxHomeworldRevivalBonusFixture as fixture,
|
||||
actIxHomeworldRevivalBonus as act,
|
||||
advanceIxHomeworldRevivalBonus as advance,
|
||||
ixHomeworldRevivalBonusClean as clean,
|
||||
ixHomeworldRevivalBonusPlayer as player,
|
||||
ixHomeworldRevivalBonusInventory as inventory,
|
||||
reviveIxHomeworldRevivalBonus as revive,
|
||||
} from './fixture-ix-homeworld-revival-bonus';
|
||||
|
||||
void test('a genuinely drafted Ghola leaves only one physical bonus Suboid after the selected base group', () => {
|
||||
const f = fixture({ ghola: true });
|
||||
let game = act(f.game, f.owner, { type: 'card', card: f.ghola!, amount: 2, elite: 0 });
|
||||
game = advance(game, clean);
|
||||
assert.equal(player(game).tanks, 3);
|
||||
const before = structuredClone(player(game));
|
||||
const returned = revive(game, f, { type: 'revive', amount: 2, elite: 1, freeElite: 0 }).game;
|
||||
assert.equal(player(returned).reserves - before.reserves, 3);
|
||||
assert.equal(before.spice - player(returned).spice, 3);
|
||||
assert.equal(player(returned).revived, 2);
|
||||
assert.equal(player(returned).tanks, 0);
|
||||
assert.match(returned.log.at(-1)!.text, /1 physical Suboid for free/);
|
||||
inventory(returned);
|
||||
});
|
||||
|
||||
void test('controlled prior La La La permits the paid Cyborg without a new free bonus', () => {
|
||||
const f = fixture();
|
||||
// Isolate an already effective restriction, not a claim of earning CHOAM's card.
|
||||
f.game.revivalRules!.freeBlocked = [f.owner];
|
||||
const before = structuredClone(player(f.game));
|
||||
const result = revive(f.game, f, { type: 'revive', amount: 1, elite: 1, freeElite: 0 }).game;
|
||||
assert.equal(player(result).reserves - before.reserves, 1);
|
||||
assert.equal(player(result).tanks, 4);
|
||||
assert.equal(player(result).spice, before.spice - 3);
|
||||
assert.equal(player(result).freeForcesRevived, 0);
|
||||
inventory(result);
|
||||
});
|
||||
|
||||
void test('controlled active Recruits quote changes the ordinary free rate without multiplying the physical bonus', () => {
|
||||
const f = fixture();
|
||||
// Quote-only composition test; no unplayed card is offered to applyAction.
|
||||
f.game.recruits = { turn: f.game.turn, player: f.owner, card: 'ecaz-recruits' };
|
||||
const before = structuredClone(f.game), owner = player(f.game);
|
||||
const quote = forceRevivalQuote(f.game, owner, 3, 1, 0);
|
||||
assert.equal(quote.free, 2);
|
||||
assert.equal(quote.cost, 3);
|
||||
assert.equal(quoteIxSuboidBonus({ enabled: true, amount: 3, elite: 1,
|
||||
free: quote.free, freeElite: 0, normalTanks: owner.tanks - owner.elites!.tanks }), 2);
|
||||
assert.deepEqual(f.game, before);
|
||||
});
|
||||
|
||||
/** Controlled physical contexts for pure pending-quote tests. These do not
|
||||
* claim public setup, card play or saved-game recovery evidence. */
|
||||
function pendingContext() {
|
||||
const game = createGame('IXBONUSPENDING', newPlayer('ix', 'Ixians', 'ixians'), false, ['ix']);
|
||||
joinGame(game, newPlayer('t', 'Tleilaxu', 'tleilaxu'));
|
||||
Object.assign(game, { status: 'playing', phase: 4, turn: 2, revivalRules: newRevivalRules() });
|
||||
const ix = game.players[0], tleilaxu = game.players[1];
|
||||
Object.assign(ix, { reserves: 9, tanks: 5, forces: { 'polar_sink:0': 6 },
|
||||
revived: 1, freeForcesRevived: 1, spice: 8,
|
||||
elites: { reserves: 3, tanks: 1, forces: { 'polar_sink:0': 3 }, revived: 0 } });
|
||||
Object.assign(tleilaxu, { reserves: 12, tanks: 0, forces: { 'polar_sink:0': 8 } });
|
||||
game.homeworlds = { custody: createHomeworldCustody(homeworldContext(game)) };
|
||||
game.homeworldRevival = snapshotHomeworldRevival(game);
|
||||
game.techTokens = createTechTokens(game.players);
|
||||
game.pendingRevival = { player: 'ix', kind: 'forces', amount: 1, elite: 1,
|
||||
free: 0, normalCost: 3, cost: 3, checks: [], ixSuboidBonus: 2 };
|
||||
return game;
|
||||
}
|
||||
|
||||
const income = (quote: ReturnType<typeof quoteRevivalResume>) =>
|
||||
quote.nextResponse?.kind === 'revivalIncome' ? quote.nextResponse.amount : undefined;
|
||||
|
||||
void test('bonus-only free event shares existing Tleilaxu and Axlotl receipts without spending normal quota', () => {
|
||||
const game = pendingContext(), before = structuredClone(game);
|
||||
const quote = quoteRevivalResume(game, 'finish');
|
||||
assert.equal(quote.outcome, 'revive');
|
||||
assert.deepEqual(quote.nextResponse, { kind: 'revivalIncome', owner: 't', recipient: 'ix', amount: 4, passed: [] });
|
||||
assert.deepEqual(quote.freeIncome, { player: 'ix', turn: 2 });
|
||||
assert.equal(quote.techIncome?.spice, 1);
|
||||
assert.deepEqual(game, before);
|
||||
game.revivalFreeIncome = { ix: game.turn };
|
||||
game.techTokens!.axlotl.triggeredTurn = game.turn;
|
||||
const repeated = quoteRevivalResume(game, 'finish');
|
||||
assert.equal(income(repeated), 3);
|
||||
assert.equal(repeated.techIncome, undefined);
|
||||
game.homeworldRevival!.tleilaxu.low = true;
|
||||
game.revivalFreeIncome = {};
|
||||
assert.equal(income(quoteRevivalResume(game, 'finish')), 3);
|
||||
});
|
||||
|
||||
void test('discount cancellation retains the physical grant only on a funded original return', () => {
|
||||
const game = pendingContext();
|
||||
game.pendingRevival!.cost = 2;
|
||||
const discounted = quoteRevivalResume(game, 'finish');
|
||||
assert.equal(discounted.pending.ixSuboidBonus, 2);
|
||||
assert.equal(income(discounted), 3);
|
||||
const full = quoteRevivalCancellation(game, { kind: 'revivalDiscount', owner: 't', recipient: 'ix' });
|
||||
assert.equal(full.outcome, 'revive');
|
||||
assert.equal(full.pending?.cost, 3);
|
||||
assert.equal(full.pending?.ixSuboidBonus, 2);
|
||||
game.players[0].spice = 2;
|
||||
assert.equal(quoteRevivalCancellation(game,
|
||||
{ kind: 'revivalDiscount', owner: 't', recipient: 'ix' }).outcome, 'unfunded');
|
||||
game.pendingRevival!.cost = 3;
|
||||
const unfunded = quoteRevivalResume(game, 'finish');
|
||||
assert.equal(unfunded.outcome, 'unfunded');
|
||||
assert.equal(unfunded.techIncome, undefined);
|
||||
assert.equal(unfunded.nextResponse, undefined);
|
||||
});
|
||||
|
||||
void test('malformed frozen bonus rejects before physical accounting and old pending requests gain no retrofit', () => {
|
||||
const game = pendingContext();
|
||||
game.pendingRevival!.ixSuboidBonus = 1;
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => continuationCustody(game, game.players[0], game.pendingRevival!), /pending Ix bonus/);
|
||||
assert.deepEqual(game, before);
|
||||
delete game.pendingRevival!.ixSuboidBonus;
|
||||
const old = quoteRevivalResume(game, 'finish');
|
||||
assert.equal(old.pending.ixSuboidBonus, undefined);
|
||||
assert.equal(old.techIncome, undefined);
|
||||
assert.equal(income(old), 3);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { IxHomeworldRevivalBonus } from '../components/ix-homeworld-revival';
|
||||
|
||||
void test('the automatic Ix quote persists and describes the selected physical bonus', () => {
|
||||
const props = { enabled: true, amount: 3, elite: 1, free: 1,
|
||||
freeElite: 0, normalTanks: 4 };
|
||||
const html = renderToStaticMarkup(createElement(IxHomeworldRevivalBonus, props));
|
||||
assert.match(html, /also returns 2 Suboids for free/);
|
||||
assert.match(html, /do not use normal revival allowance/);
|
||||
assert.match(html, /aria-live="polite"/);
|
||||
assert.doesNotMatch(html, /button|timeout|Continue/);
|
||||
const one = renderToStaticMarkup(createElement(IxHomeworldRevivalBonus,
|
||||
{ ...props, normalTanks: 3 }));
|
||||
assert.match(one, /also returns 1 Suboid for free/);
|
||||
assert.doesNotMatch(one, /1 Suboids/);
|
||||
});
|
||||
|
||||
void test('free Cyborg selection quotes zero and stale selections remain readable', () => {
|
||||
const props = { enabled: true, amount: 3, elite: 1, free: 1,
|
||||
freeElite: 1, normalTanks: 4 };
|
||||
assert.match(renderToStaticMarkup(createElement(IxHomeworldRevivalBonus, props)),
|
||||
/also returns 0 Suboids/);
|
||||
assert.match(renderToStaticMarkup(createElement(IxHomeworldRevivalBonus,
|
||||
{ ...props, elite: 4 })), /Select an available revival group/);
|
||||
assert.equal(renderToStaticMarkup(createElement(IxHomeworldRevivalBonus,
|
||||
{ ...props, enabled: false })), '');
|
||||
const blocked = renderToStaticMarkup(createElement(IxHomeworldRevivalBonus,
|
||||
{ ...props, blocked: true }));
|
||||
assert.match(blocked, /La La La prevents/);
|
||||
assert.match(blocked, /Paid Cyborg revival remains available/);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { newPlayer, type Game } from '../game/engine';
|
||||
import { createHomeworldCustody, quoteHomeworldCustody } from '../game/homeworld-custody';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import {
|
||||
ixHomeworldRevivalEnabled, quoteIxSuboidBonus, IxHomeworldRevivalError,
|
||||
type IxSuboidBonusInput,
|
||||
} from '../game/ix-homeworld-revival';
|
||||
|
||||
/** Controlled physical population context, not an earned game chronology. */
|
||||
function populationContext(population = 5, advanced = false): Pick<Game, 'advanced' | 'players' | 'homeworlds'> {
|
||||
const players = [newPlayer('ix', 'Ixians', 'ixians'), newPlayer('other', 'Guild', 'guild')];
|
||||
const native = players[0];
|
||||
native.reserves = population;
|
||||
native.tanks = 20 - population;
|
||||
native.elites!.reserves = Math.min(population, 2);
|
||||
native.elites!.tanks = 7 - native.elites!.reserves;
|
||||
return { advanced, players, homeworlds: { custody: createHomeworldCustody(homeworldContext({ advanced, players })) } };
|
||||
}
|
||||
|
||||
void test('native Ix High activates at five current counters in Basic and Advanced without another gate', () => {
|
||||
for (const advanced of [false, true]) for (const population of [0, 4, 5, 6, 14]) {
|
||||
const context = populationContext(population, advanced), before = structuredClone(context);
|
||||
assert.equal(ixHomeworldRevivalEnabled(context, 'ix'), population >= 5);
|
||||
assert.deepEqual(context, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('absent Homeworld custody and nonnative viewers do not receive the Ix revival benefit', () => {
|
||||
const context = populationContext();
|
||||
assert.equal(ixHomeworldRevivalEnabled(context, 'other'), false);
|
||||
assert.equal(ixHomeworldRevivalEnabled(context, 'missing'), false);
|
||||
assert.equal(ixHomeworldRevivalEnabled({ ...context, homeworlds: undefined }, 'ix'), false);
|
||||
assert.equal(ixHomeworldRevivalEnabled({ ...context, homeworlds: { custody: null } }, 'ix'), false);
|
||||
});
|
||||
|
||||
void test('foreign visitors do not raise Ix population and native counters visiting elsewhere do not count', () => {
|
||||
const context = populationContext(4);
|
||||
const visit = quoteHomeworldCustody(homeworldContext(context), context.homeworlds!.custody!, [
|
||||
{ homeworld: 'homeworld:guild', player: 'other', withdraw: { normal: 8, elite: 0 }, deposit: { normal: 0, elite: 0 } },
|
||||
{ homeworld: 'homeworld:ixians', player: 'other', withdraw: { normal: 0, elite: 0 }, deposit: { normal: 8, elite: 0 } },
|
||||
]);
|
||||
for (const next of visit.players) context.players.find(player => player.id === next.id)!.reserves = next.reserves;
|
||||
context.homeworlds!.custody = visit.state;
|
||||
assert.equal(ixHomeworldRevivalEnabled(context, 'ix'), false);
|
||||
assert.equal(ixHomeworldRevivalEnabled(context, 'other'), false);
|
||||
|
||||
const high = populationContext(5);
|
||||
const departed = quoteHomeworldCustody(homeworldContext(high), high.homeworlds!.custody!, [
|
||||
{ homeworld: 'homeworld:ixians', player: 'ix', withdraw: { normal: 1, elite: 0 }, deposit: { normal: 0, elite: 0 } },
|
||||
{ homeworld: 'homeworld:guild', player: 'ix', withdraw: { normal: 0, elite: 0 }, deposit: { normal: 1, elite: 0 } },
|
||||
]);
|
||||
for (const next of departed.players) high.players.find(player => player.id === next.id)!.reserves = next.reserves;
|
||||
high.homeworlds!.custody = departed.state;
|
||||
assert.equal(ixHomeworldRevivalEnabled(high, 'ix'), false);
|
||||
});
|
||||
|
||||
const group = (patch: Partial<IxSuboidBonusInput> = {}): IxSuboidBonusInput => ({
|
||||
enabled: true, amount: 1, elite: 1, free: 0, freeElite: 0, normalTanks: 6, ...patch,
|
||||
});
|
||||
|
||||
void test('each paid Cyborg adds two physical Suboids while free Cyborgs and ordinary returns add none', () => {
|
||||
assert.equal(quoteIxSuboidBonus(group()), 2);
|
||||
assert.equal(quoteIxSuboidBonus(group({ amount: 2, elite: 2 })), 4);
|
||||
assert.equal(quoteIxSuboidBonus(group({ amount: 1, elite: 1, free: 1, freeElite: 1 })), 0);
|
||||
assert.equal(quoteIxSuboidBonus(group({ amount: 3, elite: 0, free: 1, normalTanks: 6 })), 0);
|
||||
assert.equal(quoteIxSuboidBonus(group({ enabled: false })), 0);
|
||||
});
|
||||
|
||||
void test('mixed free type allocation counts only paid Cyborgs and reserves the declared ordinary returns first', () => {
|
||||
const input = group({ amount: 4, elite: 3, free: 2, freeElite: 1, normalTanks: 7 });
|
||||
const before = structuredClone(input);
|
||||
assert.equal(quoteIxSuboidBonus(input), 4);
|
||||
assert.deepEqual(input, before);
|
||||
assert.equal(quoteIxSuboidBonus(group({ amount: 3, elite: 2, free: 1, freeElite: 0, normalTanks: 5 })), 4);
|
||||
assert.equal(quoteIxSuboidBonus(group({ amount: 4, elite: 2, free: 1, freeElite: 1, normalTanks: 5 })), 2);
|
||||
});
|
||||
|
||||
void test('physical Suboid shortages give a partial or zero bonus without replacing the declared group', () => {
|
||||
assert.equal(quoteIxSuboidBonus(group({ normalTanks: 1 })), 1);
|
||||
assert.equal(quoteIxSuboidBonus(group({ normalTanks: 0 })), 0);
|
||||
assert.equal(quoteIxSuboidBonus(group({ amount: 3, elite: 1, normalTanks: 3 })), 1);
|
||||
assert.equal(quoteIxSuboidBonus(group({ amount: 3, elite: 1, normalTanks: 2 })), 0);
|
||||
assert.equal(quoteIxSuboidBonus(group({ amount: 20, elite: 7, free: 1, normalTanks: 13 })), 0);
|
||||
});
|
||||
|
||||
void test('malformed counts and impossible free type allocations reject even when the benefit is disabled', () => {
|
||||
const invalid: Partial<IxSuboidBonusInput>[] = [
|
||||
{ amount: 0 }, { amount: 21 }, { elite: 2 }, { free: 2 },
|
||||
{ freeElite: 1 }, { amount: 2, elite: 0, free: 1, freeElite: 1 },
|
||||
{ amount: 2, elite: 2, free: 1, freeElite: 0 },
|
||||
{ amount: 3, elite: 1, normalTanks: 1 },
|
||||
];
|
||||
for (const field of ['amount', 'elite', 'free', 'freeElite', 'normalTanks'] as const)
|
||||
for (const value of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) invalid.push({ [field]: value });
|
||||
for (const enabled of [false, true]) for (const patch of invalid) {
|
||||
const input = group({ ...patch, enabled }), before = structuredClone(input);
|
||||
assert.throws(() => quoteIxSuboidBonus(input), IxHomeworldRevivalError);
|
||||
assert.deepEqual(input, before);
|
||||
}
|
||||
assert.throws(() => quoteIxSuboidBonus({ ...group(), enabled: 'yes' as unknown as boolean }), IxHomeworldRevivalError);
|
||||
});
|
||||
Reference in new issue
Block a user