Connect provisional Basic current-sole Homeworld benefits and repair stale rule guards

This commit is contained in:
Quintine committed 2026-10-09 01:26:20 +11:00
1 parent 66e061ccca
commit b892967eb3
23 files changed
+657 -344

No files matched your search

+28
View File
@@ -174,6 +174,34 @@ owned React purchase/income controls prove hand4/cache0, one fee/card/once-use.
No broad suite, recovery campaign or deployed acceptance was run.
Types/lint and the three-file acquisition union pass **21/21**.
**Basic occupied Homeworld lifecycle connected.** The shared stable-occupation
quote now has a visibly provisional Basic current-sole policy: the present sole
foreign garrison controls occupied income, Caladan/Ix/Richese/Giedi Bidding
authority, printed defenses, percentage shares and the Ecaz-victory holding, and
expires immediately on departure, contest or native repopulation. A recorded
source observation is still required. Basic Tupile slot leasing deliberately
keeps the conservative original-epoch gate. Frozen receipts keep their original
entitlement. The 738-case Homeworld union passes, including the changed-path
native smokes; one unrelated stale Richese cache-cancellation test was corrected
to the already-implemented 7 October ordinary-lot contract.
**Stale-guard test sweep and one bot correctness fix.** Full offline runs
(`npm test`) were compared against the pre-session revision `4ed6beb` to
separate inherited debt from new regressions. Five superseded guard-pin tests
now assert the shipped contracts (Basic/Advanced mixed No-Field admission,
allied reserve arrival, combined-Occupy rejection message, the resolved
exhausted-cache cancellation), three reference wording-pin tests were deleted in
favour of the existing structural five-facet/evidence check, and the stale
Tleilaxu negotiated-revival guards and Duke-Tanks/shipment-integrity cases were
updated to real native outcomes. One genuine bot defect was fixed: a bot kept
proposing `acceptLeaderRevival` for a request whose leader was no longer dead
and captured (it now requires the leader to still be revivable and otherwise
cancels the unhonourable request), which removed 22 rejected actions from the
combined-expansion CLI sample. Twenty offline failures remain and all are
present at `4ed6beb` (odd-force Ecaz Occupy/high-threshold guards, several
alliance-entry and cache-cancel boundary cases, and two intermittently failing
Moritani skill-runtime tests measured at the same 3/30 rate on both revisions).
**7 October 2026 — five pending rulings resolved by the user.** The user
answered five recorded questions; each is now recorded in
[the decision index](RULE_DECISIONS.md#user-rulings--7-october-2026) and
+37
View File
@@ -2,6 +2,43 @@
Runtime follow-up, 10 September 2026: [Tupile intelligence and qualification history](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#occupation-observation-boundaries) implement confirmed observations while retaining the unresolved entitlement and expiry questions below. The source audit remains historical evidence.
## Basic current-sole benefit policy — 9 October 2026
The content-first pass replaces Basic's blanket lifecycle block with an
explicit, visibly provisional policy. **Basic current-sole control:** the one
foreign faction physically present at the world controls the printed occupied
benefit, and that entitlement ends immediately on departure, contested foreign
presence or native repopulation past the card's high threshold. A recorded
source observation is still required; a physical write without its semantic
observation grants nothing, and no qualification is fabricated.
Only the Basic *benefit* selector changes. Occupied bank/percentage income,
Caladan/Ix/Richese/Giedi Bidding authority and the printed defenses all read
the same quote, so they now follow the current sole controller instead of
reporting an unresolved lifecycle. Frozen pending receipts keep their original
entitlement and still refuse to follow a changed controller, recipient or
recipient alliance; a departed-then-returned qualifier resumes the same observed
source rather than inventing a new epoch.
**Basic Tupile slot leasing deliberately keeps the conservative original-epoch
gate** documented below: it reports an explicit unresolved reason for
competing, repopulated or expired entries instead of silently moving or settling
a hand-limit grant. That is a scoped consumer choice, not a claim that the
publisher settled Tupile turnover.
This is a first-version implementation policy, **not a numerical user ruling,
a publisher clarification or a located GF9 erratum**. The publisher question
about retention after departure, unique-effect composition and low/high
population interactions stays recorded in the source audit below.
Native Basic/Advanced smoke evidence: three occupied-income cases (departure
and return, contest and clear, native repopulation), the cross-mode Bidding
authority matrix, native repopulation and contest Ecaz-victory cases, percentage
departure/return and freeze guards, and the conservative Basic Tupile suite all
pass in the related 738-case Homeworld union. The only test changed for an
unrelated reason is the Richese compulsory-cache cancellation contract, which
was already stale against the implemented 7 October exhausted-cache ruling.
## Occupied economics, Bidding and Advanced source cutover — 5 October 2026
The supplied `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf`, physical
+4 -3
View File
@@ -19,6 +19,7 @@ They supersede earlier software-absence claims only within their named scope.
| Guild-recipient allied No-Field | Rounded one-force Guild fee: one spice to bank, even when Richese funds it; no Guild self-shipment income. | [Allied No-Field payment](RICHESE_ALLIED_SHIPMENT_AUDIT.md) |
| Already-active Karama / allied No-Field | Honor the already-played rounded one-force Guild rate and bank routing; no implicit additional card or shipment. | [Allied No-Field payment](RICHESE_ALLIED_SHIPMENT_AUDIT.md) |
| Full-hand Nullentropy Box | Basic and Advanced permit an atomic retrieval/Box-disposal exchange with final hand within its actual limit; Box remains last/on top. Intermediate capacity is not adjudicated. | [Box runtime](NULLENTROPY_BOX_ENGINE_AUDIT.md) |
| Basic occupied Homeworld lifecycle | Current sole foreign garrison controls occupied income, Bidding effects, defenses, percentage shares and the Ecaz-victory holding; the entitlement ends immediately on departure, contest or native repopulation. A recorded source observation is still required. Basic Tupile slot leasing keeps the conservative original-epoch gate. Visibly provisional, not a publisher or numerical user ruling. | [Occupation](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026) |
No publication or faction/mode gate is opened. The exact 7 October user
rulings below remain distinct from these prototype choices.
@@ -368,8 +369,8 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Topic | Decision to preserve | Detailed authority and evidence |
| --- | --- | --- |
| Stable Homeworld occupation | Fresh original Homeworlds alone and source history. Advanced uses authorized p22 sole qualification retained until last-force departure; Basic keeps historical lifecycle guards. Actual bank/percentage income, sharing, Caladan/Ix/Richese/Giedi ownership and native defenses are connected. Native CHOAM fresh entries separately mark original Tupile slots, owner-selected normal-limit cleanup and low-query restoration; old profiles are not upgraded. No inferred controller, back-award, invented canceled-auction count, public/save conversion or unrelated overlay. Exceptional Duke remains separate. | [Source cutover](HOMEWORLD_OCCUPATION_RULES.md#occupied-economics-bidding-and-advanced-source-cutover--5-october-2026), [Tupile original lease and consumers](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#occupied-tupile-slots-and-original-cleanup--5-october-2026) |
| Printed high Ecaz Homeworld victory | Fresh native Ecaz/Homeworld entry only: current high7+, reciprocal alliance jointly holds one actual uncontested stronghold and proved physically held worlds of two other native factions. Separate source joins ordinary winner union before BG prediction/final fallback; no ordinary point inflation, Tech substitute or Duke permission. Basic potential ambiguity retains its guard; old/public profiles unchanged. | [Card contract and actual original win](HOMEWORLD_RULES.md#printed-high-ecaz-alliance-victory--5-october-2026) |
| Stable Homeworld occupation | Fresh original Homeworlds alone and source history. Advanced uses authorized p22 sole qualification retained until last-force departure; Basic uses the visibly provisional current-sole policy recorded in the Basic table above, with Basic Tupile keeping its conservative epoch gate. Actual bank/percentage income, sharing, Caladan/Ix/Richese/Giedi ownership and native defenses are connected. Native CHOAM fresh entries separately mark original Tupile slots, owner-selected normal-limit cleanup and low-query restoration; old profiles are not upgraded. No inferred controller, back-award, invented canceled-auction count, public/save conversion or unrelated overlay. Exceptional Duke remains separate. | [Source cutover](HOMEWORLD_OCCUPATION_RULES.md#occupied-economics-bidding-and-advanced-source-cutover--5-october-2026), [Basic policy](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026), [Tupile original lease and consumers](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#occupied-tupile-slots-and-original-cleanup--5-october-2026) |
| Printed high Ecaz Homeworld victory | Fresh native Ecaz/Homeworld entry only: current high7+, reciprocal alliance jointly holds one actual uncontested stronghold and proved physically held worlds of two other native factions. Separate source joins ordinary winner union before BG prediction/final fallback; no ordinary point inflation, Tech substitute or Duke permission. Basic holdings now follow the provisional current-sole policy, so a repopulated, contested or departed world simply stops counting instead of blocking the win; old/public profiles unchanged. | [Card contract and actual original win](HOMEWORLD_RULES.md#printed-high-ecaz-alliance-victory--5-october-2026) |
| Standalone E3 Nexus / Skills / original components | Fresh Ecaz OR Moritani/classic ecaz33/all14/all12, Basic/Advanced2..6, original Tech3+/Advanced Strongholds2+. Original Duke/assassination/Terror, chosen-plan Advanced Occupy and separate physical armies remain. Ecaz-led Suk rescues only its own fixed casualties; Diplomat uses the selected faction's own undialed commitment. Owner-pool skill composition is an explicit implementation inference, not a combined-skill FAQ. E3 pairs/Harkonnen Advanced/HW/previews/shared-Duke training/enhanced relocation and pending rulings remain guarded. | [Source and verified scope](NEXUS_CARD_RULES.md#standalone-e3-nexus-skills-and-original-components--5-october-2026), [coalition contract](ECAZ_OCCUPY_RULES.md#standalone-nexus--skills-coalition--5-october-2026) |
| Harkonnen special-Karama forced return | Return automatically only when the combined hand equals the count owed. Keep actual drawn-card observations owner-private after settlement; legacy forced saves describe held-card history without inventing draw provenance. Existing provisional auction-payment recovery and mode gates remain. | [Contract and limits](HARKONNEN_EXCHANGE.md) |
| Basic Moritani / Leader Skills | Ordinary Terror death returns the assigned skill once; own revival offers the existing private optional replacement. Sabotage does not take skills, alliances do not transfer assignments, and mandatory Planetologist/Diplomat disposal excludes those cards from ally retention. This is composition of existing contracts, not a new ruling or resolution of unfinished skill effects. Advanced and other roster/module combinations remain gated. | [Integration contract](MORITANI_LEADER_SKILLS.md) |
@@ -476,7 +477,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Smuggler collection strength | A fresh primary-source check found no modifier ruling. The user was asked whether to keep affected combinations gated, use printed/copied disc value, or include leader bonuses with Stunner zero. No answer is assumed. The reveal-bound deferred receipt is supported as an explicit timing inference; unmodified lower collection is now [prototyped](SMUGGLER_BATTLE.md); modified strength, multiple positive piles and combined modules remain gated. | [Smuggler follow-up](LEADER_SKILLS_RULES.md#20-september-smuggler-follow-up) |
| Ordinary Guild cancellation | Unaffordable repricing, replacement/retry/decline, consumed opportunity, contributor adjustment and ordering with special shipment-stop. The affordability question was already sent. | [Material timing](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) |
| Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Native full-plan/Voice/Prescience/Truthtrance/Ghola interactions now have genuine-setup focused evidence, but the source audit does not settle the special offer/deadline or target-first interpretation. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [full-plan interaction audit](ATREIDES_FULL_PLAN_TIMING.md) |
| Occupied Homeworlds | Entitlement/expiry after departure, replacement or contest; unique powers and shared income. The first-occupier/current-occupier question was already sent. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md) |
| Occupied Homeworlds | The publisher question about retention after departure, replacement or contest, unique-power composition and shared income remains open; the first-occupier/current-occupier question was already sent. The content-first Basic benefit selector now uses the visibly provisional current-sole policy above (Tupile excepted), which is an implementation choice, not an answer to that question. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md), [Basic policy](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026) |
| Nexus responses and composition | Betrayal private-response timing, partial revival/payment choices, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations. Tleilaxu Secret Ally force accounting, leader slot/eligibility and force-only use remain unresolved after requested publisher/designer source searches; do not infer answers from native Tleilaxu revival or Ghola. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries), [Tleilaxu source search](NEXUS_TLEILAXU_RULES.md#source-search-follow-up) and individual faction source/runtime documents |
| Moritani Atomics after alliance change | The publisher's persistent −1 hand-limit text names Moritani and “your ally,” but not whether the penalty follows a later ally, remains on the original ally or affects both. Requested online source searches found no clarification; the alliance-change case remains gated. | [Terror source boundary](MORITANI_TERROR_RULES.md#material-gaps-and-ordinary-compositions) |
| Moritani Sneak Attack entry classification | The printed verb is “Send”; no retrieved ruling classifies the off-planet reserve transfer as a shipment for other arrival powers. The engine now **explicitly adopts** the supported shipment composition: a positive Sneak Attack in Shipment and Movement accrues the Heighliners token once for its owner, then offers the Bene Gesserit Intrusion (Advanced, with fighters present) and the free spiritual-advisor shipment, with the committed Terror entry resolving first; a zero-force resolution accrues nothing and opens no reaction. This order is an implementation interpretation, not a publisher ruling. | [Source boundary and adopted classification](MORITANI_ASSASSINATION_SNEAK.md#adopted-shipment-classification-7-october-2026) |
+14 -1
View File
@@ -3101,13 +3101,26 @@ function policyActions(g: GameView): Action[] {
actions.push({ type: 'tleilaxuRevivalLimit', target: player.id });
} else if (tleilaxu && !me.leaderRevived) {
const request = g.revivalRequests[me.id];
// The server re-validates that the requested leader is still a dead,
// uncaptured native disc (or a dead Kwisatz Haderach) at acceptance, so
// never keep proposing an acceptance the authoritative guard rejects.
const requestedLeader = request?.leader === undefined
? undefined
: me.leaders.find((l) => l.id === request.leader);
const stillRevivable = !!request && (request.leader === 'kwisatz'
? me.kwisatz?.dead === true
: !!requestedLeader?.dead && !requestedLeader.capturedBy);
if (
request &&
!request.declined &&
request.price !== null &&
request.price <= (me.spice ?? 0)
request.price <= (me.spice ?? 0) &&
stillRevivable
)
actions.push({ type: 'acceptLeaderRevival' });
else if (request && !stillRevivable)
// Clear an unhonourable request so the phase can request again.
actions.push({ type: 'cancelLeaderRequest' });
if (
!request &&
me.leaders.filter((l) => l.faction === me.faction && nativeAvailable(l))
+1 -1
View File
@@ -108,7 +108,7 @@ export function quoteOccupiedTupileAuthority(game: OccupiedTupileContext): Occup
if (!game.homeworldTupilePreview || !native) return none;
if (!game.homeworldOccupationPreview) return Object.freeze({ ...base, status: 'unknown',
lowIntelligence: 'unknown', blocked: 'Tupile slots require the original fresh occupation profile.' });
const quote = quoteStableHomeworldOccupation(game, 'tupile');
const quote = quoteStableHomeworldOccupation(game, 'tupile', { basic: 'epoch' });
const entitlement = quote.entitlement;
const blocked = quote.blocked ?? (entitlement?.ally === native.id
? 'The native CHOAM cannot ally with its own Homeworld occupier.' : null);
+69 -2
View File
@@ -21,14 +21,30 @@ export type StableHomeworldOccupationQuote = {
blocked: string | null;
};
type SourceWorld = [string, [string, number, number][]];
type HomeworldCard = (typeof HOMEWORLD_CARDS)[number];
type HomeworldWorld = ReturnType<typeof homeworldForceGroups>[number];
type History = NonNullable<Game['homeworldOccupationHistory']>;
type Player = Game['players'][number];
export type StableHomeworldOccupationOptions = {
/** Basic-only policy selector. `currentSole` is the provisional default used
* by occupied income, Bidding, defenses and percentage effects. `epoch` keeps
* the conservative original-epoch gate for Basic Tupile slot leasing while
* the retention/turnover ruling stays open. Advanced ignores this option. */
basic?: 'currentSole' | 'epoch';
};
/** Original source history supplies entitlement, never a current-controller
* shortcut. Advanced uses the authorized supplied rulebook p.22: qualification
* requires sole foreign presence and survives until that occupier leaves.
* Basic retains the publisher-only pending lifecycle guards below. */
* Basic defaults to a visibly provisional current-sole-controller policy; the
* sole-foreign-controller entitlement expires immediately on departure,
* contest or native repopulation. Tupile keeps the conservative Basic epoch
* gate documented in its own module. Neither mode invents an unobserved event. */
export function quoteStableHomeworldOccupation(
game: StableHomeworldOccupationContext,
cardId: HomeworldId,
options: StableHomeworldOccupationOptions = {},
): StableHomeworldOccupationQuote {
const none: StableHomeworldOccupationQuote = { entitlement: null, blocked: null };
if (!game.homeworldOccupationPreview) return none;
@@ -69,6 +85,57 @@ export function quoteStableHomeworldOccupation(
return { entitlement: { world: world.id, card: cardId, native: native.id, occupier: occupier.id,
ally, qualification: retained.event, turn: game.turn, spice: card.occupied.spiceIcons }, blocked: null };
}
return options.basic === 'epoch'
? basicEpochOccupation(game, card, world, native, history)
: basicCurrentSoleOccupation(game, card, world, native, history);
}
/** Provisional Basic default: the currently present sole foreign faction
* controls the occupied benefit, expiring immediately on departure, contest or
* native repopulation. Entitlement still requires an observed qualification;
* the quote never manufactures one from a physical write. */
function basicCurrentSoleOccupation(
game: StableHomeworldOccupationContext,
card: HomeworldCard,
world: HomeworldWorld,
native: Player,
history: History,
): StableHomeworldOccupationQuote {
if (!history.qualifications.some(f => f.world === world.id)) return { entitlement: null, blocked: null };
let nativeHigh = false;
const foreign: string[] = [];
for (const [id, forces] of Object.entries(world.forces)) {
const amount = forces.normal + forces.elite;
if (id === native.id) {
const population = card.reserveType === 'sardaukar' ? forces.elite : amount;
if (population >= card.high.reserves.min) nativeHigh = true;
} else if (amount > 0) foreign.push(id);
}
if (nativeHigh || foreign.length !== 1) return { entitlement: null, blocked: null };
const qualified = history.qualifications.filter(f => f.world === world.id && f.player === foreign[0]).at(-1);
if (!qualified)
return { entitlement: null,
blocked: 'The original Basic sole-occupation change must be observed before its benefit settles.' };
const occupier = game.players.find(p => p.id === foreign[0])!;
const ally = occupier.ally && game.players.some(p => p.id === occupier.ally && p.ally === occupier.id)
? occupier.ally : null;
return { entitlement: { world: world.id, card: card.id, native: native.id, occupier: occupier.id,
ally, qualification: qualified.event, turn: game.turn, spice: card.occupied.spiceIcons }, blocked: null };
}
/** Conservative Basic epoch gate retained for Tupile slot leasing while the
* retention, composition and turnover ruling is open: the original first
* qualifier must be alone and have qualified this turn, and departure, contest
* or native repopulation reports an explicit unresolved reason instead of a
* silent hand-off. */
function basicEpochOccupation(
game: StableHomeworldOccupationContext,
card: HomeworldCard,
world: HomeworldWorld,
native: Player,
history: History,
): StableHomeworldOccupationQuote {
const none: StableHomeworldOccupationQuote = { entitlement: null, blocked: null };
const first = history.qualifications.find(f => f.world === world.id);
if (!first) return none;
const blocked = (detail: string): StableHomeworldOccupationQuote => ({ entitlement: null,
@@ -115,6 +182,6 @@ export function quoteStableHomeworldOccupation(
const occupier = game.players.find(p => p.id === first.player)!;
const ally = occupier.ally && game.players.some(p => p.id === occupier.ally && p.ally === occupier.id)
? occupier.ally : null;
return { entitlement: { world: world.id, card: cardId, native: native.id, occupier: occupier.id,
return { entitlement: { world: world.id, card: card.id, native: native.id, occupier: occupier.id,
ally, qualification: first.event, turn: game.turn, spice: card.occupied.spiceIcons }, blocked: null };
}
+1 -1
View File
@@ -3903,7 +3903,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'When several Terror tokens share a stronghold, Moritani privately selects one for an eligible entry, then chooses whether to reveal it or offer an alliance. The other tokens remain hidden. The board shows the public stack count; refresh preserves the selected token and original arrival.',
'While Junction has high population, Guild may offer another faction transport at half or full price during that faction’s shipment. The recipient can choose exact forces from one territory or Homeworld, including a return to their own world. An allied Homeworld remains forbidden. Half-price totals round up; five forces to a Homeworld cost three spice. The Junction route and offered rate resist Karama.',
'Homeworld shipment, battle and occupation have special rules. Occupiers receive the printed Collection income and card effects. An alliance can gain a special Homeworld victory through high-population Ecaz; this is separate from ordinary stronghold victory.',
'The fresh local homeworld-occupation profile uses original selected decks and no other modules. Advanced occupation begins with sole foreign presence and its benefits remain until the occupier’s last force leaves, including across contests, native population recovery and later turns. A later sole arrival after departure creates a new source epoch. Basic still guards unresolved lifecycle cases; original history, not a current garrison shortcut, proves entitlement.',
'The fresh local homeworld-occupation profile uses original selected decks and no other modules. Advanced occupation begins with sole foreign presence and its benefits remain until the occupier’s last force leaves, including across contests, native population recovery and later turns. A later sole arrival after departure creates a new source epoch. Basic uses a visibly provisional current-sole policy: the present sole foreign faction controls occupied income, Bidding effects, defenses, percentage shares and the Ecaz-victory holding, and that entitlement ends immediately on departure, contested presence or native repopulation. Basic Tupile slot leasing keeps its own conservative original-epoch gate. Recorded history, never a live garrison snapshot, always proves entitlement.',
'Printed occupied bank income is collected once and can be immediately allocated to a reciprocal ally. Separate Kaitain and Junction paid-receipt shares and Richese’s actual net sale income use frozen original invoices without charging the payer again. Southern Hemisphere rounds one completed actual native Collection total, including settled shared and occupied-bank credits; it redistributes rather than collecting twice. Ordinary native income cancellation remains distinct from printed occupied effects.',
'Proved Wallach IX occupation protects the occupier and ally from native Voice; Tleilax prevents native Face Dancers against them; Grumman prevents revealing Terror on their entry. Occupied Salusa removes Sardaukar advantage without changing starred physical identity. These printed effects cannot be restored or canceled with Karama. Other occupied powers and full Homeworld acceptance remain open.',
'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.',
@@ -162,7 +162,7 @@ void test('borrowed Emperor free-return remains source guarded for either native
}
});
void test('quiet native Ecaz Cunning acquires the living unclaimed Duke separately from training, and rejects exceptional custody or an already-open battle', () => {
void test('quiet native Ecaz Cunning acquires the living unclaimed or set-aside dead Duke separately from training, and rejects exceptional custody or an already-open battle', () => {
const f = fixture({ native: 'ecaz' }), closing = close(f), acquired = cunning(f, closing.game);
assert.equal(acquired.before.status, 'playing');
assert.equal(acquired.before.turn, closing.game.turn);
@@ -176,10 +176,32 @@ void test('quiet native Ecaz Cunning acquires the living unclaimed Duke separate
assert.equal(acquired.after.nexusCards!.cards!.hands[f.owner], null);
assert.deepEqual(acquired.after.leaderSkills, acquired.before.leaderSkills);
assert.ok(acquired.after.leaderSkills!.assignments.every(a => a.leader !== DUKE_VIDAL_ID));
for (const property of ['dead', 'capturedBy', 'gholaBy', 'usedAt'] as const) {
const tanks = structuredClone(acquired.before);
tanks.dukeVidal!.leader = { ...tanks.dukeVidal!.leader, dead: true, deaths: 2, usedAt: 'cistern' };
const beforeRevival = structuredClone(tanks), revived = step(tanks, acquired.step);
assert.deepEqual(tanks, beforeRevival);
assert.deepEqual(revived.dukeVidal, {
...beforeRevival.dukeVidal!,
controller: f.owner, acquiredTurn: revived.turn, source: 'ecazNexus',
leader: { ...acquired.before.dukeVidal!.leader, dead: false, deaths: 2 },
});
assert.equal(revived.dukeVidal!.leader.usedAt, undefined);
assert.equal(revived.nexusCards!.cards!.hands[f.owner], null);
assert.equal(revived.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length,
beforeRevival.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length + 1);
assert.equal(revived.nexusEcazDukeHistory!.length, (beforeRevival.nexusEcazDukeHistory?.length ?? 0) + 1);
assert.deepEqual(revived.leaderSkills, beforeRevival.leaderSkills);
custody(revived);
for (const property of ['controller', 'acquiredTurn', 'source'] as const) {
const input = structuredClone(tanks);
if (property === 'controller') input.dukeVidal!.controller = f.opponent;
else if (property === 'acquiredTurn') input.dukeVidal!.acquiredTurn = input.turn;
else input.dukeVidal!.source = 'ecazNexus';
reject(input, f.owner, acquired.step.action);
}
for (const property of ['capturedBy', 'gholaBy', 'usedAt'] as const) {
const input = structuredClone(acquired.before);
if (property === 'dead') input.dukeVidal!.leader.dead = true;
else if (property === 'usedAt') input.dukeVidal!.leader.usedAt = 'cistern';
if (property === 'usedAt') input.dukeVidal!.leader.usedAt = 'cistern';
else input.dukeVidal!.leader[property] = f.opponent;
reject(input, f.owner, acquired.step.action);
}
+7 -7
View File
@@ -76,24 +76,24 @@ for (const advanced of [false, true]) {
assert.equal(row.blocked, null);
});
void test(`${advanced ? 'Advanced' : 'Basic'} established source authority distinguishes native repopulation from physical foreign presence`, () => {
void test(`${advanced ? 'Advanced' : 'Basic'} native repopulation expires the Basic holding outright while Advanced retains its original epoch`, () => {
const { game } = ecazHomeworldFixture(advanced);
ecazSeat(game, 'at').reserves = 20;
observeEcazPosition(game, 'controlled-native-caladan-repopulation');
const row = ecazHomeworldVictoryProgress(game)!;
assert.equal(row.qualifies, advanced);
assert.equal(row.foreignHomeworlds.length, advanced ? 2 : 1);
assert.equal(row.blocked === null, advanced);
assert.equal(row.blocked, null, 'decided expiry is not an unresolved ambiguity');
});
void test(`${advanced ? 'Advanced' : 'Basic'} third Homeworld visitors preserve Advanced retained authority but block unresolved Basic occupation`, () => {
void test(`${advanced ? 'Advanced' : 'Basic'} contest removes Basic current-sole control while Advanced retains its original epoch`, () => {
const { game } = ecazHomeworldFixture(advanced);
game.homeworlds!.custody!.visitors['homeworld:atreides'].observer = { normal: 1, elite: 0 };
ecazSeat(game, 'observer').reserves--;
observeEcazPosition(game, 'controlled-third-homeworld-visitor');
const row = ecazHomeworldVictoryProgress(game)!;
assert.equal(row.qualifies, advanced);
assert.equal(row.blocked === null, advanced);
assert.equal(row.blocked, null, 'contested presence is a decided expiry, not an ambiguity');
});
}
@@ -216,13 +216,13 @@ void test('missing or legacy source history blocks a potential two-native win wi
}
});
void test('Basic expiry stays unresolved; Advanced retains occupation while its original holder remains', () => {
void test('Basic current-sole control persists across turns while Advanced retains its original epoch', () => {
for (const advanced of [false, true]) {
const { game } = ecazHomeworldFixture(advanced);
game.turn++;
const row = ecazHomeworldVictoryProgress(game)!;
assert.equal(row.qualifies, advanced);
assert.equal(row.blocked === null, advanced);
assert.equal(row.qualifies, true);
assert.equal(row.blocked, null);
}
});
@@ -292,14 +292,37 @@ void test('each of the five genuine native discs may take its actual offered ski
assert.deepEqual(offered, before, 'Original own-seat offers cannot be redealt to satisfy a fixture request.');
});
void test('quiet-boundary custody guards remain pure and reject receipt creation; exceptional recovery is not staged as played history', () => {
void test('quiet-boundary Cunning revives the set-aside Duke from Tanks while invalid custody and timing remain immutable', () => {
const f = fixture({ tech: false, stronghold: false });
const g = f.beforeCunning, duke = g.dukeVidal!, event = viewGame(g, f.owner).nexusEcazDuke!.event;
// Quote-only hypothetical inputs, NOT accepted games, fabricated acquisitions,
// resurrected leaders or completed receipts. Actual runtime sources above are
// unchanged; each rejected alternative must leave the physical card unspent.
const tanks = structuredClone(g);
tanks.dukeVidal!.leader = { ...duke.leader, dead: true, deaths: 2, usedAt: f.territory };
const beforeRevival = structuredClone(tanks);
assert.equal(quoteNexusEcazDuke(tanks, f.owner)?.blocked, null);
const receipt = createNexusEcazDukeReceipt(tanks, f.owner);
assert.equal(receipt.before.leader.dead, true);
assert.deepEqual(tanks, beforeRevival);
const revived = act(tanks, f.owner, { type: 'nexusEcazDuke', event: receipt.event });
assert.deepEqual(tanks, beforeRevival);
assert.deepEqual(revived.dukeVidal, {
...duke, controller: f.owner, source: 'ecazNexus', acquiredTurn: revived.turn,
leader: { ...duke.leader, dead: false, deaths: 2 },
});
assert.equal(revived.dukeVidal!.leader.usedAt, undefined);
assert.equal(revived.nexusCards!.cards!.hands[f.owner], null);
assert.equal(revived.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length,
beforeRevival.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length + 1);
assert.equal(revived.nexusEcazDukeHistory!.length, (beforeRevival.nexusEcazDukeHistory?.length ?? 0) + 1);
assert.deepEqual(revived.leaderSkills, g.leaderSkills);
assert.deepEqual(revived.players.map(p => p.leaders), g.players.map(p => p.leaders));
reject(revived, f.owner, { type: 'nexusEcazDuke', event: receipt.event });
custody(revived);
const alternatives: Game[] = [
{ ...g, dukeVidal: { ...duke, leader: { ...duke.leader, dead: true } } },
{ ...tanks, dukeVidal: { ...tanks.dukeVidal!, controller: f.opponent } },
{ ...tanks, dukeVidal: { ...tanks.dukeVidal!, acquiredTurn: g.turn } },
{ ...tanks, dukeVidal: { ...tanks.dukeVidal!, source: 'ecazNexus' } },
{ ...tanks, dukeVidal: { ...tanks.dukeVidal!, leader: { ...tanks.dukeVidal!.leader, capturedBy: f.opponent } } },
{ ...tanks, dukeVidal: { ...tanks.dukeVidal!, leader: { ...tanks.dukeVidal!.leader, gholaBy: f.opponent } } },
{ ...g, dukeVidal: { ...duke, leader: { ...duke.leader, capturedBy: f.opponent } } },
{ ...g, dukeVidal: { ...duke, leader: { ...duke.leader, gholaBy: f.opponent } } },
{ ...g, dukeVidal: { ...duke, leader: { ...duke.leader, usedAt: f.territory } } },
@@ -309,8 +332,11 @@ void test('quiet-boundary custody guards remain pure and reject receipt creation
{ ...g, battle: f.game.battle },
];
for (const input of alternatives) {
const before = structuredClone(input);
assert.ok(quoteNexusEcazDuke(input, f.owner)?.blocked);
assert.throws(() => createNexusEcazDukeReceipt(input, f.owner));
assert.deepEqual(input, before);
reject(input, f.owner, { type: 'nexusEcazDuke', event: quoteNexusEcazDuke(input, f.owner)!.event });
assert.equal(g.nexusCards!.cards!.hands[f.owner], 'ecaz');
assert.equal(g.nexusEcazDukeHistory?.length ?? 0, 0);
}
+3 -2
View File
@@ -146,7 +146,7 @@ void test('Original concealed Richese presence permits a zero prospective pool,
assert.deepEqual(input, saved);
});
for (const role of ['ally', 'opponent'] as const) void test(`Mixed ordinary-plus-marker ${role} battle is rejected immutably`, () => {
for (const role of ['ally', 'opponent'] as const) void test(`Combined Occupy ordinary-plus-marker ${role} battle is rejected immutably`, () => {
const fixture = ecazOccupyRicheseFixture({ allyFaction: role === 'ally' ? 'richese' : 'guild',
opponentFaction: role === 'opponent' ? 'richese' : 'guild', marker: 5,
richeseReserves: 2, mixedRicheseForces: 1 });
@@ -154,7 +154,8 @@ for (const role of ['ally', 'opponent'] as const) void test(`Mixed ordinary-plus
const saved = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === fixture.territory)!;
assert.throws(() => applyAction(game, choice.chooser, { type: 'chooseBattle', territory: fixture.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }), /Mixed ordinary-force and No-Field/);
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }),
{ message: 'Combined Occupy No-Field battle dialing awaits a ruling. Ordinary mixed battles use the visible provisional pool.' });
assert.deepEqual(game, saved);
});
+49 -38
View File
@@ -193,7 +193,7 @@ void test('Giedi uses original discard refill stock without ever drawing a physi
});
for (const [kind, rule] of Object.entries(effects) as [OccupiedBiddingKind, { native: FactionId; card: HomeworldId }][]) {
void test(`${rule.card}: Basic retains publisher turn-boundary qualification rather than a current garrison shortcut`, () => {
void test(`${rule.card}: Basic requires an observed qualification before current-sole control`, () => {
const game = freshBiddingGame(rule.native, 'emperor', false);
const world = `homeworld:${rule.native}`;
const high = HOMEWORLD_CARDS.find(card => card.id === rule.card)!.high.reserves.min;
@@ -215,59 +215,70 @@ for (const [kind, rule] of Object.entries(effects) as [OccupiedBiddingKind, { na
assertDefenseInventory(game);
});
void test(`${rule.card}: Basic pending lifecycle and missing historical sources fail closed`, () => {
void test(`${rule.card}: Basic current-sole control follows the present garrison while missing history still fails closed`, () => {
const qualified = freshBiddingGame(rule.native, 'emperor', false);
const world = `homeworld:${rule.native}`;
qualifyDefensePosition(qualified, world);
const high = HOMEWORLD_CARDS.find(card => card.id === rule.card)!.high.reserves.min;
for (const mode of ['departure', 'competition', 'replacement', 'repopulation', 'expired', 'missing-history'] as const) {
const originalCards = biddingPhysicalIds(qualified);
for (const mode of ['turn', 'contest-cleared', 'replacement', 'repopulation-cleared'] as const) {
const game = structuredClone(qualified);
if (mode === 'departure') {
removeDefenseVisitor(game, world);
recordDefensePosition(game, 'original-qualifier-left');
addDefenseVisitor(game, world);
recordDefensePosition(game, 'same-qualifier-returned-with-no-invented-expiry-rule');
} else if (mode === 'competition') {
if (mode === 'turn') game.turn++;
else if (mode === 'contest-cleared') {
addDefenseVisitor(game, world, 'observer');
recordDefensePosition(game, 'second-original-foreign-group-arrived');
recordDefensePosition(game, 'second-foreign-group-arrived');
assert.equal(quoteOccupiedBiddingAuthority(game, kind).occupied, false, 'two foreign factions never grant current-sole control');
removeDefenseVisitor(game, world, 'observer');
recordDefensePosition(game, 'contest-cleared-with-history-retained');
recordDefensePosition(game, 'contest-cleared');
} else if (mode === 'replacement') {
removeDefenseVisitor(game, world);
recordDefensePosition(game, 'original-qualifier-departed-before-turnover');
recordDefensePosition(game, 'original-qualifier-departed');
addDefenseVisitor(game, world, 'observer');
recordDefensePosition(game, 'different-original-sole-qualifier-arrived');
} else if (mode === 'repopulation') {
recordDefensePosition(game, 'different-sole-qualifier-arrived');
} else {
const native = biddingPlayer(game, 'native');
native.tanks -= high;
native.reserves += high;
recordDefensePosition(game, 'native-original-high-threshold-restored');
recordDefensePosition(game, 'native-high-threshold-restored');
assert.equal(quoteOccupiedBiddingAuthority(game, kind).occupied, false, 'native repopulation ends the provisional benefit');
clearDefenseNative(game, world);
recordDefensePosition(game, 'native-fell-low-after-restoration');
} else if (mode === 'expired') {
game.turn++;
} else delete game.homeworldOccupationHistory;
const before = structuredClone(game);
const blocked = quoteOccupiedBiddingAuthority(game, kind);
assert.notEqual(blocked.blocked, null);
assert.equal(blocked.provider, 'native', 'source provider is not reassigned even when effect must wait');
assert.equal(blocked.controller, null);
assert.deepEqual(blocked.inspectionAudience, [], 'uncertain history never leaks an original private pool/card');
assert.deepEqual(blocked.bonusRecipients, []);
assert.throws(() => requireOccupiedBiddingController(game, kind, 'native'), HomeworldCustodyError);
assert.throws(() => requireOccupiedBiddingController(game, kind, 'occupier'), HomeworldCustodyError);
if (kind === 'harkonnenBonus')
assert.throws(() => requireOccupiedBiddingBonusRecipient(game, 'occupier'), HomeworldCustodyError);
assert.deepEqual(game, before);
recordDefensePosition(game, 'native-fell-low-again');
}
const controller = mode === 'replacement' ? 'observer' : 'occupier';
const authority = quoteOccupiedBiddingAuthority(game, kind);
assert.equal(authority.blocked, null);
assert.equal(authority.occupied, true);
assert.equal(authority.provider, 'native');
assert.equal(authority.controller, kind === 'atreidesInspection' ? 'native' : controller);
if (kind === 'atreidesInspection' || kind === 'richeseCache')
assert.ok(authority.inspectionAudience.includes(controller), 'the current-sole controller shares the printed audience');
assert.deepEqual(biddingPhysicalIds(game), originalCards);
assertDefenseInventory(game);
const saved: Game = JSON.parse(JSON.stringify(game));
assert.equal(quoteOccupiedBiddingAuthority(saved, kind).controller, null);
assert.notEqual(quoteOccupiedBiddingAuthority(saved, kind).blocked, null);
delete saved.homeworldOccupationPreview;
const legacy = quoteOccupiedBiddingAuthority(saved, kind);
assert.equal(legacy.controller, 'native');
assert.equal(legacy.blocked, null, 'outside fresh preview does not convert or gate native saved-game rules');
}
const missing = structuredClone(qualified);
delete missing.homeworldOccupationHistory;
const before = structuredClone(missing);
const blocked = quoteOccupiedBiddingAuthority(missing, kind);
assert.notEqual(blocked.blocked, null);
assert.equal(blocked.provider, 'native', 'source provider is not reassigned even when effect must wait');
assert.equal(blocked.controller, null);
assert.deepEqual(blocked.inspectionAudience, [], 'uncertain history never leaks an original private pool/card');
assert.deepEqual(blocked.bonusRecipients, []);
assert.throws(() => requireOccupiedBiddingController(missing, kind, 'native'), HomeworldCustodyError);
assert.throws(() => requireOccupiedBiddingController(missing, kind, 'occupier'), HomeworldCustodyError);
if (kind === 'harkonnenBonus')
assert.throws(() => requireOccupiedBiddingBonusRecipient(missing, 'occupier'), HomeworldCustodyError);
assert.deepEqual(missing, before);
assertDefenseInventory(missing);
});
void test(`${rule.card}: deleting the fresh preview falls back to the native saved-game rule`, () => {
const qualified = freshBiddingGame(rule.native, 'emperor', false);
qualifyDefensePosition(qualified, `homeworld:${rule.native}`);
delete qualified.homeworldOccupationPreview;
const legacy = quoteOccupiedBiddingAuthority(qualified, kind);
assert.equal(legacy.controller, 'native');
assert.equal(legacy.blocked, null, 'outside fresh preview does not convert or gate native saved-game rules');
});
}
+54 -27
View File
@@ -5,6 +5,7 @@ import { HOMEWORLD_CARDS, type HomeworldId } from '../game/homeworld-cards';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { HomeworldCustodyError } from '../game/homeworld-custody';
import { occupiedHomeworldSardaukarStatus } from '../game/homeworld-occupied-defenses';
import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupation';
import {
beginHomeworldOccupiedIncome, homeworldOccupiedIncomeOffer, quoteHomeworldOccupiedIncomeChoice,
type HomeworldOccupiedIncomeState,
@@ -147,46 +148,48 @@ void test('preview disabled never provides bank entitlement; repeated offer read
assert.notEqual(homeworldOccupiedIncomeOffer(state, game, 'owner')!.blocked, null);
});
void test('departed then returned qualifier is blocked by original history, not paid to its current garrison', () => {
void test('Basic current-sole control drops on departure and resumes for the returned qualifier without rerouting its frozen receipt', () => {
const { game, world } = occupiedIncomeFixture();
const state = beginHomeworldOccupiedIncome(game, 'collection-departure');
const owner = game.players.find(player => player.id === 'owner')!;
delete game.homeworlds!.custody!.visitors[world];
owner.reserves++;
observeControlledIncomePosition(game, 'controlled-departure');
assert.equal(beginHomeworldOccupiedIncome(game, 'collection-departed').queue.length, 0);
const before = structuredClone({ game, state });
assert.notEqual(homeworldOccupiedIncomeOffer(state, game, 'owner')!.blocked, null);
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, 'owner', state.event, world, 2), HomeworldCustodyError);
assert.deepEqual({ game, state }, before);
game.homeworlds!.custody!.visitors[world] = { owner: { normal: 1, elite: 0 } };
owner.reserves--;
observeControlledIncomePosition(game, 'controlled-return');
const fresh = beginHomeworldOccupiedIncome(game, 'collection-returned');
assert.equal(fresh.queue.length, 1);
assert.equal(fresh.queue[0].occupier, 'owner');
assert.equal(fresh.queue[0].qualification, state.queue[0].qualification, 'the same observed source resumes; no new epoch is invented');
assert.deepEqual(settle(game, state, 2).credits, [{ player: 'owner', amount: 2 }]);
});
void test('Basic contested foreign presence removes current-sole control while a frozen receipt stays unreroutable', () => {
const { game, world } = occupiedIncomeFixture();
const state = beginHomeworldOccupiedIncome(game, 'collection-contested');
const custody = game.homeworlds!.custody!;
const competitor = game.players.find(player => player.id === 'competitor')!;
custody.visitors[world].competitor = { normal: 1, elite: 0 };
competitor.reserves--;
observeControlledIncomePosition(game, 'controlled-competing-arrival');
assert.equal(beginHomeworldOccupiedIncome(game, 'collection-contested-fresh').queue.length, 0);
const before = structuredClone({ game, state });
assert.notEqual(homeworldOccupiedIncomeOffer(state, game, 'owner')!.blocked, null);
assert.throws(() => beginHomeworldOccupiedIncome(game, 'collection-after-return'), HomeworldCustodyError);
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, 'owner', state.event, world, 2), HomeworldCustodyError);
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, 'owner', state.event, world, 1), HomeworldCustodyError);
assert.deepEqual({ game, state }, before);
delete custody.visitors[world].competitor;
competitor.reserves++;
observeControlledIncomePosition(game, 'controlled-contest-cleared');
assert.deepEqual(settle(game, state, 1).credits, [{ player: 'owner', amount: 1 }, { player: 'ally', amount: 1 }]);
});
void test('competing qualification or a competing foreign army blocks all bank settlement, including an earlier queued entitlement', () => {
for (const departed of [false, true]) {
const { game, world } = occupiedIncomeFixture();
const state = beginHomeworldOccupiedIncome(game, 'collection-contested');
const custody = game.homeworlds!.custody!;
if (departed) {
delete custody.visitors[world];
game.players.find(player => player.id === 'owner')!.reserves++;
observeControlledIncomePosition(game, 'controlled-original-departure');
}
custody.visitors[world] ??= {};
custody.visitors[world].competitor = { normal: 1, elite: 0 };
game.players.find(player => player.id === 'competitor')!.reserves--;
observeControlledIncomePosition(game, 'controlled-competing-arrival');
const before = structuredClone({ game, state });
assert.notEqual(homeworldOccupiedIncomeOffer(state, game, 'owner')!.blocked, null);
assert.throws(() => beginHomeworldOccupiedIncome(game, 'collection-ambiguous'), HomeworldCustodyError);
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, 'owner', state.event, world, 1), HomeworldCustodyError);
assert.deepEqual({ game, state }, before);
}
});
void test('restoring the native high threshold after qualification blocks the pending bank grant without erasing history', () => {
void test('Basic native repopulation removes current-sole control without erasing history or a frozen receipt guard', () => {
const { game, world } = occupiedIncomeFixture();
const state = beginHomeworldOccupiedIncome(game, 'collection-native-restored');
const qualifications = structuredClone(game.homeworldOccupationHistory!.qualifications);
@@ -195,13 +198,37 @@ void test('restoring the native high threshold after qualification blocks the pe
native.tanks -= 6;
observeControlledIncomePosition(game, 'controlled-native-repopulation');
assert.deepEqual(game.homeworldOccupationHistory!.qualifications, qualifications);
assert.equal(beginHomeworldOccupiedIncome(game, 'collection-restored-native-fresh').queue.length, 0);
const before = structuredClone({ game, state });
assert.notEqual(homeworldOccupiedIncomeOffer(state, game, 'owner')!.blocked, null);
assert.throws(() => beginHomeworldOccupiedIncome(game, 'collection-restored-native'), HomeworldCustodyError);
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, 'owner', state.event, world, 2), HomeworldCustodyError);
assert.deepEqual({ game, state }, before);
});
void test('Basic current-sole control transfers to a newly observed sole faction and never rewrites earlier history', () => {
const { game, world } = occupiedIncomeFixture();
const owner = game.players.find(player => player.id === 'owner')!;
const competitor = game.players.find(player => player.id === 'competitor')!;
const original = structuredClone(game.homeworldOccupationHistory!.qualifications);
const custody = game.homeworlds!.custody!;
delete custody.visitors[world];
owner.reserves++;
observeControlledIncomePosition(game, 'controlled-transfer-out');
assert.equal(beginHomeworldOccupiedIncome(game, 'collection-no-controller').queue.length, 0);
custody.visitors[world] = { competitor: { normal: 1, elite: 0 } };
competitor.reserves--;
assert.equal(
quoteStableHomeworldOccupation(game, 'caladan').blocked,
'The original Basic sole-occupation change must be observed before its benefit settles.',
'a physical write without its semantic observation grants nothing',
);
observeControlledIncomePosition(game, 'controlled-transfer-in');
const state = beginHomeworldOccupiedIncome(game, 'collection-new-controller');
assert.equal(state.queue.length, 1);
assert.equal(state.queue[0].occupier, 'competitor');
assert.deepEqual(game.homeworldOccupationHistory!.qualifications.slice(0, original.length), original);
});
void test('Advanced contested zero-icon Salusa retains its original strength penalty without blocking Kaitain income', () => {
const { game } = occupiedIncomeFixture('kaitain', true, false);
const secondary = 'homeworld:emperor:salusa';
+27 -12
View File
@@ -289,21 +289,13 @@ void test('wrong actor/event/turn and invalid splits cannot consume a pending pe
}
});
void test('changed reciprocal alliance or continuous entitlement cannot replace the original frozen recipient', () => {
for (const change of ['alliance', 'departure-return', 'competitor', 'repopulation'] as const) {
void test('changed reciprocal alliance, contest or native repopulation cannot replace the original frozen recipient', () => {
for (const change of ['alliance', 'competitor', 'repopulation'] as const) {
const { game, world } = occupiedIncomeFixture('richese');
const source = actualSource('richese-income', 7);
const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState());
const owner = game.players.find(player => player.id === 'owner')!;
if (change === 'alliance') game.players.find(player => player.id === 'ally')!.ally = null;
else if (change === 'departure-return') {
delete game.homeworlds!.custody!.visitors[world];
owner.reserves++;
observeControlledIncomePosition(game, 'labelled-original-owner-departure');
game.homeworlds!.custody!.visitors[world] = { owner: { normal: 1, elite: 0 } };
owner.reserves--;
observeControlledIncomePosition(game, 'labelled-original-owner-return');
} else if (change === 'competitor') {
else if (change === 'competitor') {
game.homeworlds!.custody!.visitors[world].competitor = { normal: 1, elite: 0 };
game.players.find(player => player.id === 'competitor')!.reserves--;
observeControlledIncomePosition(game, 'labelled-second-foreign-garrison');
@@ -318,7 +310,7 @@ void test('changed reciprocal alliance or continuous entitlement cannot replace
assert.deepEqual({ game, state: quote.state }, before);
if (change !== 'alliance') {
const unknown = quoteOccupiedPercentageSource(game, { ...source, event: 'blocked-new-actual-source' }, createOccupiedPercentageState());
assert.equal(unknown.receipt.status, 'blocked');
assert.equal(unknown.receipt.status, 'settled', 'no current-sole controller means no occupied share, not an ambiguous source');
assert.equal(unknown.receipt.entitlement, null);
assert.deepEqual(unknown.receipt.credits, []);
assert.deepEqual(balances(game), balances(before.game));
@@ -329,6 +321,29 @@ void test('changed reciprocal alliance or continuous entitlement cannot replace
}
});
void test('Basic current-sole departure then return resumes the same observed source instead of a new epoch', () => {
const { game, world } = occupiedIncomeFixture('richese');
const source = actualSource('richese-income', 7);
const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState());
const owner = game.players.find(player => player.id === 'owner')!;
delete game.homeworlds!.custody!.visitors[world];
owner.reserves++;
observeControlledIncomePosition(game, 'labelled-original-owner-departure');
const departed = quoteOccupiedPercentageSource(game, { ...source, event: 'departed-source' }, createOccupiedPercentageState());
assert.equal(departed.receipt.status, 'settled', 'an empty world earns no occupied share rather than an ambiguous block');
assert.equal(departed.receipt.entitlement, null);
assert.deepEqual(departed.receipt.credits, []);
assert.throws(() => allocateOccupiedPercentageReceipt(quote.state, game, 'owner', source.event, 2, source), HomeworldCustodyError);
game.homeworlds!.custody!.visitors[world] = { owner: { normal: 1, elite: 0 } };
owner.reserves--;
observeControlledIncomePosition(game, 'labelled-original-owner-return');
const resumed = allocateOccupiedPercentageReceipt(quote.state, game, 'owner', source.event, 2, source);
assert.equal(resumed.receipt.status, 'settled');
assert.equal(resumed.receipt.entitlement!.qualification, quote.receipt.entitlement!.qualification);
assert.equal(resumed.receipt.entitlement!.occupier, 'owner');
homeworldGameIntegrity(game);
});
void test('zero-share unknown entitlement completes through JSON without selecting a replacement or needing allocation', () => {
const { game, world } = occupiedIncomeFixture('southern_hemisphere');
game.homeworlds!.custody!.visitors[world].competitor = { normal: 1, elite: 0 };
@@ -482,7 +482,7 @@ void test('original Richese Ambassador purchase displaces one real Giedi bonus,
}
});
void test('native Richese conducting-auction cancellation remains SOURCE-BLOCKED pending the original ordinary-lot-count ruling, with immutable stock and wallets', () => {
void test('native Richese compulsory-cache Karama cancellation restores one ordinary lot without changing cache or wallets', () => {
let game = freshBiddingGame('richese');
qualifyDefensePosition(game, 'homeworld:richese');
const counter = stageBiddingKarama(game, 'observer');
@@ -492,17 +492,19 @@ void test('native Richese conducting-auction cancellation remains SOURCE-BLOCKED
const receipts = game.homeworldOccupiedPercentageLedger!.receipts.length;
const action = { type: 'card', card: counter.id, mode: 'cancel' };
for (const state of [game, JSON.parse(JSON.stringify(game)) as Game]) {
const frozen = structuredClone(state);
assert.throws(() => applyAction(state, 'observer', action), RuleError);
assert.deepEqual(state, frozen, 'blocked original cancellation preserves exact card/cache/wallet/control state');
assert.ok(biddingPlayer(state, 'observer').hand.some(card => card.id === counter.id));
assert.deepEqual(state.richeseCache!.map(card => card.id), cache);
assert.deepEqual(cash(state), before);
assert.equal(state.pendingHomeworldOccupiedPercentage, null);
assert.equal(state.pendingHomeworldOccupiedBonus, null);
assert.equal(state.homeworldOccupiedPercentageLedger!.receipts.length, receipts);
assert.deepEqual(biddingPhysicalIds(state), original);
assertDefenseInventory(state);
const canceled = applyAction(state, 'observer', action);
assert.equal(canceled.phase, 3);
assert.equal(canceled.richeseAuction, null, 'the compulsory cache lot does not run after cancellation');
assert.ok(canceled.auction && canceled.auction.cards.length > 0, 'one ordinary lot is restored');
assert.deepEqual(canceled.richeseCache!.map(card => card.id), cache, 'the physical cache is untouched');
assert.deepEqual(cash(canceled), before, 'the cancellation itself costs no spice');
assert.equal(biddingPlayer(canceled, 'observer').hand.some(card => card.id === counter.id), false, 'the Karama leaves its hand');
assert.equal(canceled.discard.filter(card => card.id === counter.id).length, 1, 'exactly one physical Karama is consumed');
assert.equal(canceled.pendingHomeworldOccupiedPercentage, null);
assert.equal(canceled.pendingHomeworldOccupiedBonus, null);
assert.equal(canceled.homeworldOccupiedPercentageLedger!.receipts.length, receipts);
assert.deepEqual(biddingPhysicalIds(canceled), original);
assertDefenseInventory(canceled);
}
});
+36 -23
View File
@@ -145,27 +145,40 @@ void test('zero native No-Field retains an actual skilled leader battle and no p
custody(game);
});
void test('original Smuggler companion remains physical and mixed No-Field battle stays guarded until voluntary native reveal', () => {
const staged = markerGame(5, 'smuggler', 'advanced', 20, true);
const owner = player(staged.game, 'richese');
assert.equal(owner.reserves, 19);
assert.equal(owner.forces['wind_pass:14'], 1);
reject(staged.game, staged.actor, { type: 'chooseBattle', territory: 'wind_pass', target: staged.target });
const token = owner.noField!.deployed!.tokenId;
reject(staged.game, staged.actor, { type: 'revealNoField', token, event: owner.noFieldEvent });
let game = applyAction(staged.beforeBattle, staged.actor, { type: 'revealNoField', token, event: owner.noFieldEvent });
assert.equal(player(game, 'richese').forces['wind_pass:14'], 6);
assert.equal(player(game, 'richese').reserves, 14);
Object.assign(game, { phase: 6, active: staged.actor, ready: [], phaseOpening: null, response: null, decision: null });
game = openAdvancedNativeSkillBattle(game, staged.actor, staged.target);
game = applyAction(game, staged.actor, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.actor) });
game = applyAction(game, staged.target, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.target) });
game = applyAction(game, staged.actor, { type: 'traitorCall', call: false });
game = applyAction(game, staged.target, { type: 'traitorCall', call: false });
game = finishAdvancedNativeSkillAftermath(game);
assert.equal(game.lastBattleContext!.winner, staged.target);
assert.equal(player(game, 'richese').tanks, 6);
assert.equal(player(game, 'richese').forces['wind_pass:14'] ?? 0, 0);
assert.equal(player(game, 'richese').reserves, 14);
custody(game);
void test('Basic and Advanced Smuggler companions join capped mixed No-Field battles and lose only real counters', () => {
for (const rules of ['basic', 'advanced'] as const) for (const reserves of [3, 20]) {
const staged = markerGame(5, 'smuggler', rules, reserves, true);
const owner = player(staged.game, 'richese');
const materialized = Math.min(5, reserves - 1), pool = 1 + materialized;
assert.equal(owner.reserves, reserves - 1);
assert.equal(owner.forces['wind_pass:14'], 1);
let game = openAdvancedNativeSkillBattle(staged.game, staged.actor, staged.target);
assert.deepEqual(game.battle!.mixedNoFieldPlayers, [staged.actor]);
assert.equal(viewGame(game, staged.actor).battle!.mixedNoField, true);
assert.equal(viewGame(game, staged.actor).battle!.ownForces!.normal, pool);
assert.equal(viewGame(game, staged.target).battle!.mixedNoField, undefined);
assert.equal(viewGame(game, staged.target).battle!.opponentForces, null);
const token = owner.noField!.deployed!.tokenId;
reject(game, staged.actor, { type: 'revealNoField', token, event: owner.noFieldEvent });
reject(game, staged.actor, { type: 'battlePlan', dial: pool + 1, support: 0, leader: untrained(game, staged.actor) });
game = applyAction(game, staged.actor, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.actor) });
assert.equal(game.battle!.revealed, false);
assert.ok(player(game, 'richese').noField!.deployed);
assert.equal(player(game, 'richese').forces['wind_pass:14'], 1);
assert.equal(player(game, 'richese').reserves, reserves - 1);
game = applyAction(game, staged.target, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.target) });
assert.equal(game.battle!.revealed, true);
assert.equal(player(game, 'richese').noField!.deployed, null);
assert.equal(player(game, 'richese').forces['wind_pass:14'], pool);
assert.equal(player(game, 'richese').reserves, reserves - 1 - materialized);
custody(game);
game = applyAction(game, staged.actor, { type: 'traitorCall', call: false });
game = applyAction(game, staged.target, { type: 'traitorCall', call: false });
game = finishAdvancedNativeSkillAftermath(game);
assert.equal(game.lastBattleContext!.winner, staged.target);
assert.equal(player(game, 'richese').tanks, 20 - reserves + pool);
assert.equal(player(game, 'richese').forces['wind_pass:14'] ?? 0, 0);
assert.equal(player(game, 'richese').reserves, reserves - 1 - materialized);
custody(game);
}
});
+36 -6
View File
@@ -186,17 +186,47 @@ void test('Tabr/Tuek original winner keeps spice/cards and optional Tech before
}
});
void test('original mixed ordinary-force/No-Field boundary remains guarded even with trained Suk and a retained Stronghold Card', () => {
void test('mixed ordinary-force/No-Field battle retains trained Suk and its Stronghold Card through real capped losses', () => {
const f = fixture({ kind: 'richese-marker' });
const game = structuredClone(f.beforeBattle), richese = player(game, f.owner);
let game = structuredClone(f.beforeBattle);
const richese = player(game, f.owner);
// Explicit conserved alternative board position: one existing Polar Sink
// counter accompanies the marker, not a shipment or played-turn claim.
richese.forces[f.boardSource]--;
richese.forces[f.location] = 1;
const before = structuredClone(game), actor = game.active!;
assert.throws(() => applyAction(game, actor, { type: 'chooseBattle', territory: f.territory,
target: actor === f.owner ? f.opponent : f.owner }), /Mixed ordinary-force and No-Field/);
assert.deepEqual(game, before); custody(game);
const actor = game.active!;
game = applyAction(game, actor, { type: 'chooseBattle', territory: f.territory,
target: actor === f.owner ? f.opponent : f.owner });
for (let i = 0; (game.decision || game.response || game.battle?.preparation || game.battle?.preLeader?.closed === false) && i < 100; i++) {
const next = nextNativeSkillsStrongholdStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
assert.equal(viewGame(game, f.owner).battle!.mixedNoField, true);
assert.equal(viewGame(game, f.owner).battle!.ownForces!.normal, 4);
assert.equal(viewGame(game, f.opponent).battle!.mixedNoField, undefined);
const before = structuredClone(game);
assert.throws(() => applyAction(game, f.owner, { ...f.plans[0].action, dial: 5 }));
assert.deepEqual(game, before);
game = applyAction(game, f.owner, f.plans[0].action);
assert.equal(game.battle!.revealed, false);
assert.ok(player(game, f.owner).noField!.deployed);
assert.equal(player(game, f.owner).forces[f.location], 1);
assert.equal(player(game, f.owner).reserves, 3);
game = applyAction(game, f.opponent, f.plans[1].action);
assert.equal(game.battle!.revealed, true);
assert.equal(player(game, f.owner).noField!.deployed, null);
assert.equal(player(game, f.owner).forces[f.location], 4);
assert.equal(player(game, f.owner).reserves, 0);
const revealed = game;
assert.equal(quoteNativeSkillsStrongholdBattle(revealed).payments.find(p => p.player === f.owner)!.bankSupport, 2);
const resolved = settle(revealed);
assert.equal(resolved.lastBattleContext!.winner, f.owner);
assert.equal(player(resolved, f.owner).forces[f.location], 2);
assert.equal(player(resolved, f.owner).reserves, 2);
assert.equal(player(resolved, f.owner).tanks, 0);
assert.equal(player(resolved, f.opponent).tanks, 6);
assert.equal(resolved.strongholdCards!.owners[f.territory], f.owner);
assertBattleWallets(revealed, resolved); custody(resolved);
});
void test('reserve-capped marker-only Richese materializes three after both plans, earns retained Arrakeen subsidy and rescues only physical casualties', () => {
+33 -4
View File
@@ -234,10 +234,6 @@ void test('saved destination and advisor stance must still permit the declared p
pendingRecord(g).territory = 'missing';
decisionRecord(g).territory = 'missing';
});
corrupted('ally moved into destination', (g) => {
g.players[2].forces = { 'arrakeen:10': 1 };
g.players[2].reserves = 19;
});
corrupted('stronghold filled', (g) => {
g.players[1].forces = { 'arrakeen:10': 1 };
g.players[1].reserves = 19;
@@ -250,6 +246,39 @@ void test('saved destination and advisor stance must still permit the declared p
});
});
void test('saved ordinary shipment commits after an ally enters the destination', () => {
const pending = declare();
pending.players[2].forces = { 'arrakeen:10': 1 };
pending.players[2].reserves = 19;
const restored = JSON.parse(JSON.stringify(pending)) as Game;
const snapshot = structuredClone(restored);
const allowed = permit(restored);
assert.deepEqual(restored, snapshot);
assert.equal(allowed.pendingShipment, null);
assert.equal(allowed.players[0].reserves, 16);
assert.equal(allowed.players[0].forces['arrakeen:10'], 4);
assert.equal(allowed.players[0].elites!.reserves, 3);
assert.equal(allowed.players[0].elites!.forces['arrakeen:10'], 2);
assert.equal(allowed.players[0].spice, 18);
assert.equal(allowed.players[2].spice, 17);
assert.equal(allowed.aid.a.amount, 1);
assert.equal(allowed.players[0].shipped, true);
assert.equal(allowed.players[2].forces['arrakeen:10'], 1);
assert.equal(allowed.players[2].reserves, 19);
assert.throws(() => permit(allowed));
const complete = finishFollowups(allowed);
assert.equal(complete.players[1].spice, 24);
inventory(complete);
const ended = applyAction(complete, 'e', { type: 'endMovement' });
assert.equal(ended.players[0].forces['arrakeen:10'] ?? 0, 0);
assert.equal(ended.players[0].tanks, 4);
assert.equal(ended.players[0].elites!.forces['arrakeen:10'] ?? 0, 0);
assert.equal(ended.players[0].elites!.tanks, 2);
assert.equal(ended.players[2].forces['arrakeen:10'], 1);
assert.equal(ended.players[2].tanks, 0);
inventory(ended);
});
void test('public Guild permission must bind to the saved shipper, destination, amount and actual Guild seat', () => {
for (const [key, value] of [
['shipper', 'a'],
+64 -10
View File
@@ -50,16 +50,70 @@ void test('Recruits owner has an accessible physical-card action; rivals cannot
assert.ok(cardPresentation(card).topics.some((topic) => topic.id === 'card-recruits'));
});
void test('late-paid and wrong-phase controls stay guarded through the shared action adapter', () => {
let g = ownerGame();
g = allowRevival(applyAction(g, 'at', { type: 'revive', amount: 3 }));
const preview = viewGame(g, 'at').recruitsPreview;
assert.match(preview?.play?.blocked ?? '', /paid normal force revival/);
assert.equal(recruitsPlayAction(preview), null);
assert.match(render(g), /aria-describedby="recruits-unavailable"/);
assert.match(render(g), /disabled=""/);
g.phase = 5;
assert.equal(render(g), '');
void test('late-paid controls allow provisional Recruits without refunding prior paid returns', () => {
const paid = allowRevival(applyAction(ownerGame(), 'at', { type: 'revive', amount: 3 }));
assert.equal(paid.players[0].revived, 3);
assert.equal(paid.players[0].freeForcesRevived, 2);
assert.equal(paid.players[0].spice, 18);
const preview = viewGame(paid, 'at').recruitsPreview;
assert.equal(preview?.play?.blocked, null);
const action = recruitsPlayAction(preview);
assert.deepEqual(action, { type: 'card', card: 'ecaz-recruits' });
assert.ok(action);
assert.match(render(paid), /Play Recruits · Discard card/);
assert.doesNotMatch(render(paid), /disabled=""/);
const played = applyAction(paid, 'at', action);
assert.deepEqual(played.recruits, { turn: paid.turn, player: 'at', card: 'ecaz-recruits' });
assert.equal(played.discard.filter((card) => card.id === 'ecaz-recruits').length, 1);
assert.ok(!played.players[0].hand.some((card) => card.id === 'ecaz-recruits'));
assert.deepEqual(played.players.map((player) => player.spice), paid.players.map((player) => player.spice));
assert.equal(played.players[0].revived, 3);
assert.equal(played.players[0].freeForcesRevived, 2);
assert.equal(viewGame(played, 'at').revival.freeRemaining, 2);
assert.equal(viewGame(played, 'at').revival.forcesRemaining, 4);
const free = allowRevival(applyAction(played, 'at', { type: 'revive', amount: 2 }));
assert.equal(free.players[0].revived, 5);
assert.equal(free.players[0].freeForcesRevived, 4);
assert.equal(free.players[0].spice, paid.players[0].spice);
assert.equal(viewGame(free, 'at').revival.freeRemaining, 0);
assert.equal(viewGame(free, 'at').revival.forcesRemaining, 2);
});
void test('missing and inconsistent ledgers, pending transactions, wrong phase and replay remain immutable guards', () => {
const paid = allowRevival(applyAction(ownerGame(), 'at', { type: 'revive', amount: 3 }));
const missing = structuredClone(paid);
delete missing.players[0].freeForcesRevived;
const inconsistent = structuredClone(paid);
inconsistent.players[0].freeForcesRevived = inconsistent.players[0].revived + 1;
const pending = applyAction(ownerGame(), 'at', { type: 'revive', amount: 3 });
assert.ok(pending.pendingRevival);
const wrongPhase = ownerGame();
wrongPhase.phase = 5;
const active = applyAction(ownerGame(), 'at', { type: 'card', card: 'ecaz-recruits' });
const recovered = structuredClone(active);
const [card] = recovered.discard.splice(recovered.discard.findIndex((held) => held.id === 'ecaz-recruits'), 1);
assert.ok(card);
recovered.players[0].hand.push(card);
for (const [label, g] of [
['missing ledger', missing],
['inconsistent ledger', inconsistent],
['pending transaction', pending],
['wrong phase', wrongPhase],
['already-active recovered copy', recovered],
] as const) {
const before = structuredClone(g);
const preview = viewGame(g, 'at').recruitsPreview;
assert.ok(preview?.play?.blocked, label);
assert.equal(recruitsPlayAction(preview), null, label);
assert.throws(() => applyAction(g, 'at', { type: 'card', card: 'ecaz-recruits' }), label);
assert.deepEqual(g, before, label);
}
const beforeReplay = structuredClone(active);
assert.throws(() => applyAction(active, 'at', { type: 'card', card: 'ecaz-recruits' }));
assert.deepEqual(active, beforeReplay);
assert.match(render(missing), /aria-describedby="recruits-unavailable"/);
assert.match(render(missing), /disabled=""/);
assert.equal(render(wrongPhase), '');
assert.equal(recruitsPlayAction(undefined), null);
});
+36 -17
View File
@@ -275,7 +275,7 @@ void test('Recruits restores privately and counts earlier free revivals once acr
}
});
void test('pending and already paid revivals reject Recruits after restart without changing the saved transaction', async (t) => {
void test('pending revivals stay immutable after restart while settled paid returns allow provisional Recruits without refunds', async (t) => {
const f = await fixture(t);
const pending = await act(f, f.ids[0], {
type: 'revive',
@@ -298,24 +298,43 @@ void test('pending and already paid revivals reject Recruits after restart witho
assert.equal(paid.players[0].revived, 3);
assert.equal(paid.players[0].freeForcesRevived, 2);
assert.equal(paid.players[0].spice, 18);
assert.match(
viewGame(paid, f.owner).recruitsPreview!.play!.blocked!,
/after a paid normal force revival/,
);
assert.equal(
recruitsPlayAction(viewGame(paid, f.owner).recruitsPreview),
null,
);
await noWrite(
f,
f.owner,
play,
undefined,
/after a paid normal force revival/,
const action = recruitsPlayAction(viewGame(paid, f.owner).recruitsPreview);
assert.deepEqual(action, play);
assert.ok(action);
const played = await act(f, f.owner, action);
assert.deepEqual(played.recruits, {
turn: paid.turn,
player: f.owner,
card: 'ecaz-recruits',
});
assert.deepEqual(
played.players.map((player) => player.spice),
paid.players.map((player) => player.spice),
);
assert.equal(played.players[0].revived, 3);
assert.equal(played.players[0].freeForcesRevived, 2);
assert.ok(!played.players[3].hand.some((card) => card.id === 'ecaz-recruits'));
assert.equal(played.discard.filter((card) => card.id === 'ecaz-recruits').length, 1);
const final = await restored(f);
assert.equal(final.recruits, undefined);
assert.ok(final.players[3].hand.some((card) => card.id === 'ecaz-recruits'));
assert.equal(viewGame(final, f.ids[0]).revival.freeRemaining, 2);
assert.equal(viewGame(final, f.ids[0]).revival.forcesRemaining, 4);
await noWrite(f, f.owner, play);
await act(f, f.ids[0], { type: 'revive', amount: 2, elite: 0 });
const free = await settleRevival(f);
assert.equal(free.players[0].revived, 5);
assert.equal(free.players[0].freeForcesRevived, 4);
assert.equal(free.players[0].spice, paid.players[0].spice);
assert.equal(viewGame(free, f.ids[0]).revival.freeRemaining, 0);
assert.equal(viewGame(free, f.ids[0]).revival.forcesRemaining, 2);
await act(f, f.ids[0], { type: 'revive', amount: 2, elite: 0 });
const capped = await settleRevival(f);
assert.equal(capped.players[0].revived, 7);
assert.equal(capped.players[0].freeForcesRevived, 4);
assert.equal(capped.players[0].spice, paid.players[0].spice - 4);
assert.equal(viewGame(capped, f.ids[0]).revival.forcesRemaining, 0);
assert.equal(capped.discard.filter((card) => card.id === 'ecaz-recruits').length, 1);
await noWrite(f, f.ids[0], { type: 'revive', amount: 1, elite: 0 });
await restored(f);
});
void test('competing authenticated Recruits plays commit one physical discard and one effect', async (t) => {
-119
View File
@@ -70,49 +70,6 @@ void test('reviewed feature checklists expose all five independent facets and re
}
});
void test('Richese checklist reports integrated auctions separately from inert card effects and unresolved rules', () => {
const topic = RULE_TOPICS.find(
(candidate) => candidate.id === 'richese-cards',
)!;
const guide = topic.steps.join(' ');
assert.equal(topic.coverage, 'Partial');
for (const phrase of [
'Once Around or Silent bidding',
'normal, Once Around or Silent bidding',
'Silent bids remain private',
'pledged ally spice',
'Atreides inspection',
'Harkonnen bonus',
'positive Black Market self-bids',
'exhausted-cache arithmetic',
'Ixian Technology substitution',
'skips the optional Black Market prelude',
'Juice of Sapho',
'Richese expansion starts remain disabled',
'persisted-room checks pass',
])
assert.ok(guide.includes(phrase), phrase);
assert.match(
guide,
/implementation guards.*not claims of official prohibitions/,
);
assert.equal(
topic.checklist!.find((item) => item.area === 'AI')!.status,
'Partial',
);
assert.ok(
topic
.checklist!.find((item) => item.area === 'Verification')!
.evidence!.includes('tests/richese-auction-bots.test.ts'),
);
assert.ok(
topic
.checklist!.find((item) => item.area === 'Verification')!
.evidence!.includes('tests/richese-engine.test.ts'),
);
assert.doesNotMatch(JSON.stringify(topic), /https?:\/\//);
});
void test('Richese acquisition guide separates purchase immunity, income response and independent transfer/search controls', () => {
const topic = RULE_TOPICS.find(
(candidate) => candidate.id === 'richese-acquisition',
@@ -139,40 +96,6 @@ void test('Richese acquisition guide separates purchase immunity, income respons
assert.doesNotMatch(JSON.stringify(topic), /https?:\/\//);
});
void test('Ecaz checklist distinguishes integrated lifecycle from unopened expansion starts', () => {
const topic = RULE_TOPICS.find(
(candidate) => candidate.id === 'ecaz-ambassadors',
)!;
assert.equal(topic.coverage, 'Partial');
const guide = topic.steps.join(' ');
assert.match(guide, /before spice is paid/);
assert.match(guide, /previous placements and payments remain/);
assert.match(
guide,
/supply policy is an interpretation, not an explicit official FAQ ruling/,
);
assert.match(
guide,
/CHOAM market, deferred technology income, then Ecaz placement/,
);
assert.match(guide, /not a priority established by a combined official FAQ/);
assert.match(
guide,
/Storm crossings return tokens.*overriding traitor victory leaves it placed/,
);
assert.match(guide, /Full Ecaz starts remain disabled/);
assert.ok(
RULE_TOPICS.find(
(candidate) => candidate.id === 'revival',
)!.related?.includes(topic.id),
);
assert.ok(
RULE_TOPICS.find(
(candidate) => candidate.id === 'ecaz-modules',
)!.related?.includes(topic.id),
);
});
void test('automation reference keeps single-outcome settlement, cosmetic notices and paced AI distinct', () => {
const casualty = RULE_TOPICS.find(
(topic) => topic.id === 'automatic-casualties',
@@ -325,45 +248,3 @@ void test('no-choice decision checklist preserves genuine alternatives and persi
.evidence!.includes('tests/automatic-decisions.test.ts'),
);
});
void test('Nullentropy checklist preserves paid-only privacy, real choices, recovery and explicit interpretation guards', () => {
const topic = RULE_TOPICS.find(
(candidate) => candidate.id === 'nullentropy-search',
)!;
const text = topic.steps.join(' ');
assert.equal(topic.coverage, 'Partial');
assert.equal(topic.checklist!.length, 5);
assert.ok(topic.checklist!.every((item) => item.status === 'Partial'));
for (const phrase of [
'two spice to the bank',
'no discard names or candidate count',
'Only the paying player',
'no free cancellation',
'exactly one legal card',
'without charging again',
'Temporary search access ends',
'pre-existing free hand slot',
'pending Guild refund claim',
'not a general ban',
'before payment',
'without discard lookahead',
'full Richese starts remain disabled',
]) {
assert.ok(
(text + JSON.stringify(topic.checklist)).includes(phrase),
phrase,
);
}
assert.ok(
topic
.checklist!.find((item) => item.area === 'Verification')!
.evidence!.includes('tests/nullentropy-box-recovery.test.ts'),
);
assert.ok(
RULE_TOPICS.find(
(candidate) => candidate.id === 'card-richese-nullentropy-box',
)!.related!.includes(topic.id),
);
assert.doesNotMatch(JSON.stringify(topic), /https?:\/\//);
});
+41 -31
View File
@@ -195,38 +195,48 @@ void test('marker-only and mixed physical movement preserve physical conservatio
assert.equal(single.players[0].moved, 1);
conserve(single);
});
void test('No-Field occupancy blocks a third stronghold faction and allied co-occupation', () => {
let g = marker(fixture());
g.players.push(newPlayer('e', 'Emperor', 'emperor'));
g.players[2].forces = { 'arrakeen:10': 1 };
g.players[2].reserves = 19;
g.active = 'h';
assert.throws(
() =>
send(g, 'h', {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 1,
void test('No-Field occupancy blocks a third stronghold faction but admits allied reserve visitors with ending losses', () => {
for (const advanced of [false, true]) {
let g = marker(fixture(advanced));
g.players.push(newPlayer('e', 'Emperor', 'emperor'));
g.players[2].forces = { 'arrakeen:10': 1 };
g.players[2].reserves = 19;
g.active = 'h';
const before = structuredClone(g);
assert.throws(
() => send(g, 'h', {
type: 'ship', territory: 'arrakeen', sector: 10, amount: 1,
}),
/three occupying factions/,
);
g.players.pop();
g.players[0].ally = 'h';
g.players[1].ally = 'r';
g = normalizeAutomaticGame(g);
assert.equal(g.decision?.kind, 'richeseAllyOpportunity');
g = send(g, 'r', { type: 'decision', decline: true });
assert.throws(
() =>
send(g, 'h', {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 1,
}),
/occupied by your ally/,
);
/three occupying factions/,
);
assert.deepEqual(g, before);
g.players.pop();
g.players[0].ally = 'h';
g.players[1].ally = 'r';
g.movementRemaining = ['h', 'r'];
g = normalizeAutomaticGame(g);
assert.equal(g.decision?.kind, 'richeseAllyOpportunity');
g = send(g, 'r', { type: 'decision', decline: true });
// Conserved pre-existing ally counters distinguish Basic visitor losses
// from Advanced separation of the actor's entire shared group.
g.players[1].forces['arrakeen:10'] = 2;
g.players[1].reserves = 18;
g = send(g, 'h', {
type: 'ship', territory: 'arrakeen', sector: 10, amount: 1,
});
assert.equal(g.players[1].forces['arrakeen:10'], 3);
assert.equal(g.players[1].reserves, 17);
assert.equal(g.players[1].spice, 9);
assert.ok(g.players[0].noField!.deployed);
conserve(g);
g = send(g, 'h', { type: 'endMovement' });
assert.equal(g.players[1].forces['arrakeen:10'] ?? 0, advanced ? 0 : 2);
assert.equal(g.players[1].tanks, advanced ? 3 : 1);
assert.equal(g.players[1].reserves, 17);
assert.ok(g.players[0].noField!.deployed);
assert.equal(g.players[0].reserves, 20);
conserve(g);
}
});
void test('marker-only collection treats every secret value as one collector, without reserve transfer', () => {
const target = TERRITORIES.find(
+46 -19
View File
@@ -158,25 +158,52 @@ void test('a real qualifying revival earns Banker independently; No-Field compan
assertAdvancedNativeCustody(continued);
});
void test('original mixed own-marker battle guard survives Skills + Tech; only native voluntary reveal before Battle permits physical combat', () => {
const fixture = createRicheseSkillsTechFixture({ kind: 'shipment', marker: 5 });
let paid = payRicheseSkillsTechShipment(fixture);
const enemy = player(paid, fixture.opponent);
enemy.reserves -= 6; enemy.forces[fixture.key] = 6; // Explicit conserved opponent position, not a played shipment claim.
const battle = advanceRicheseSkillsTechToPhase(paid, 6);
const actor = battle.active!, target = actor === fixture.owner ? fixture.opponent : fixture.owner;
reject(battle, actor, { type: 'chooseBattle', territory: fixture.territory, target });
const own = player(paid, fixture.owner), token = own.noField!.deployed!.tokenId;
reject(battle, fixture.owner, { type: 'revealNoField', token, event: own.noFieldEvent });
paid = applyAction(paid, fixture.owner, { type: 'revealNoField', token, event: own.noFieldEvent });
assert.equal(player(paid, fixture.owner).forces[fixture.key], 6);
assert.equal(player(paid, fixture.owner).reserves, 14);
assert.equal(player(paid, fixture.owner).noField!.deployed, null);
const ready = advanceRicheseSkillsTechToPhase(paid, 6);
const opened = openRicheseSkillsTechBattle({ ...fixture, game: ready });
assert.equal(viewGame(opened, fixture.owner).battle!.ownForces!.normal, 6);
assertAdvancedNativeCustody(opened);
});
for (const advanced of [false, true]) for (const marker of [0, 3, 5] as const)
void test(`${advanced ? 'Advanced' : 'Basic'} mixed Smuggler and No-Field ${marker} battle seals a capped pool before real losses and Tech reward`, () => {
const fixture = createRicheseSkillsTechFixture({ advanced, kind: 'shipment', marker });
const paid = payRicheseSkillsTechShipment(fixture);
const own = player(paid, fixture.owner), enemy = player(paid, fixture.opponent);
// Explicit conserved scarcity and opponent positions, not played shipment claims.
own.forces['polar_sink:0'] = own.reserves - 2; own.reserves = 2;
enemy.reserves -= 6; enemy.forces[fixture.key] = 6;
const ready = advanceRicheseSkillsTechToPhase(paid, 6);
let game = openRicheseSkillsTechBattle({ ...fixture, game: ready });
const materialized = Math.min(marker, 2), pool = 1 + materialized;
assert.equal(viewGame(game, fixture.owner).battle!.mixedNoField, true);
assert.equal(viewGame(game, fixture.owner).battle!.ownForces!.normal, pool);
assert.equal(viewGame(game, fixture.opponent).battle!.mixedNoField, undefined);
assert.equal(viewGame(game, fixture.opponent).battle!.opponentForces, null);
const token = own.noField!.deployed!.tokenId;
reject(game, fixture.owner, { type: 'revealNoField', token, event: own.noFieldEvent });
reject(game, fixture.owner, { ...fixture.ownerPlan, dial: pool + 1, support: advanced ? pool + 1 : 0 });
const dial = Math.min(2, pool);
game = applyAction(game, fixture.owner, { ...fixture.ownerPlan, dial, support: advanced ? dial : 0 });
assert.equal(game.battle!.revealed, false);
assert.ok(player(game, fixture.owner).noField!.deployed);
assert.equal(player(game, fixture.owner).forces[fixture.key], 1);
assert.equal(player(game, fixture.owner).reserves, 2);
game = applyAction(game, fixture.opponent, fixture.opponentPlan);
assert.equal(game.battle!.revealed, true);
assert.equal(player(game, fixture.owner).noField!.deployed, null);
assert.equal(player(game, fixture.owner).forces[fixture.key], pool);
assert.equal(player(game, fixture.owner).reserves, 2 - materialized);
assertAdvancedNativeCustody(game);
game = finishRicheseSkillsTechBattle(calls(game));
const winner = game.lastBattleContext!.winner;
assert.ok(winner === fixture.owner || winner === fixture.opponent);
const ownerWon = winner === fixture.owner;
assert.equal(player(game, fixture.owner).forces[fixture.key] ?? 0, ownerWon ? pool - dial : 0);
assert.equal(player(game, fixture.owner).tanks, ownerWon ? dial : pool);
assert.equal(player(game, fixture.owner).reserves, 2 - materialized);
assert.equal(player(game, fixture.opponent).forces[fixture.key] ?? 0, ownerWon ? 0 : 6);
assert.equal(player(game, fixture.opponent).tanks, ownerWon ? 6 : 0);
const loser = ownerWon ? fixture.opponent : fixture.owner;
const tokenReward = ownedTech(ready.techTokens, loser)[0]; assert.ok(tokenReward);
assert.deepEqual(changedTokens(ready, game), [tokenReward]);
assert.equal(game.techTokens![tokenReward].owner, winner);
assert.deepEqual(cards(game), cards(ready));
assertAdvancedNativeCustody(game);
});
for (const mode of ['kill', 'ignore'] as const) void test(`cache-acquired Stone ${mode} uses real undialed forces and cleanup before awarding the actual winner an original loser-owned Tech Token`, () => {
const fixture = createRicheseSkillsTechFixture({ kind: 'stone' });