Combine High Richese reserves with Nexus Cunning

This commit is contained in:
Quintine committed 2026-10-25 20:35:34 +11:00
1 parent ce676f0e54
commit 61c269123c
15 files changed
+643 -60

No files matched your search

+30 -19
View File
@@ -8,7 +8,7 @@ import { fighterCount } from '@/game/advisors';
import { reserveShipmentCost } from '@/game/shipment-price';
import type { RicheseNoFieldView } from '@/game/richese-no-field';
import { quoteSmugglerNoField } from '@/game/smuggler-no-field';
import { quoteRicheseHomeworldForces } from '@/game/richese-homeworld-shipment';
import { quoteRicheseHomeworldForces, quoteRicheseHomeworldPair } from '@/game/richese-homeworld-shipment';
import { RicheseHomeworldForces } from './richese-homeworld-forces';
import { ShipmentQuote } from './shipment-quote';
import { Button } from './ui/button';
@@ -145,7 +145,6 @@ export function RicheseNoFieldControls({
))) ||
game.karamaShipping?.player === me.id,
};
const markerCost = reserveShipmentCost(rate, selectedTerritory.type, 1);
const smuggler = info.canShip
? quoteSmugglerNoField(game, me.id, destination)
: null;
@@ -161,24 +160,25 @@ export function RicheseNoFieldControls({
? Math.max(0, cost - (me.spice ?? 0))
: Number(allyPayment);
const validShare = Number.isSafeInteger(share) && share >= 0 && share <= cost;
const problems: string[] = [];
if (groupProblem) problems.push(groupProblem);
const commonProblems: string[] = [];
if (!info.canShip)
problems.push(info.shipBlock ?? 'No-Field shipment is not available now.');
if (!selected) problems.push('No different No-Field token is available.');
commonProblems.push(info.shipBlock ?? 'No-Field shipment is not available now.');
if (!selected) commonProblems.push('No different No-Field token is available.');
if (destination === MOBILE_STRONGHOLD)
problems.push('You cannot ship directly into the mobile stronghold.');
commonProblems.push('You cannot ship directly into the mobile stronghold.');
if (
selectedTerritory.type === 'stronghold' &&
game.players.filter(
(player) => player.id !== me.id && fighterCount(player, destination) > 0,
).length >= 2
)
problems.push('Two other factions already occupy this stronghold.');
commonProblems.push('Two other factions already occupy this stronghold.');
if (!selectedTerritory.sectors.includes(sector))
problems.push('Select a sector in the destination territory.');
commonProblems.push('Select a sector in the destination territory.');
if (sector === game.storm)
problems.push('You cannot ship into the storm sector.');
commonProblems.push('You cannot ship into the storm sector.');
const problems = [...commonProblems];
if (groupProblem) problems.push(groupProblem);
if (!validShare) problems.push(`Choose an ally payment from 0 to ${cost}.`);
else {
if (share > game.aid.available)
@@ -188,8 +188,14 @@ export function RicheseNoFieldControls({
if (cost - share > (me.spice ?? 0))
problems.push('Your spice does not cover the chosen payment.');
}
const pairProblems = [...problems];
if (reserveForces > 0) pairProblems.push('Set paid reserve forces to 0 to use the separate two-token Cunning shipment.');
const pairProblems = [...commonProblems];
let pairGroup: ReturnType<typeof quoteRicheseHomeworldPair> | null = null;
try {
pairGroup = quoteRicheseHomeworldPair({ enabled: info.homeworldForcesEnabled,
reserves: me.reserves, amount: reserveForces, revealedValue: pairRevealed?.value ?? 0 });
} catch (error) { pairProblems.push(error instanceof Error ? error.message : 'Choose available paid reserve forces.'); }
const pairCost = reserveShipmentCost({ faction: me.faction, halfRate: false }, selectedTerritory.type, pairGroup?.priced ?? 1);
if (pairCost > (me.spice ?? 0)) pairProblems.push(`This Cunning group needs ${pairCost} own spice.`);
if (nexus?.blocked) pairProblems.push(nexus.blocked);
if (!pairConcealed || !pairRevealed)
pairProblems.push('Choose two different available No-Field tokens.');
@@ -288,7 +294,7 @@ export function RicheseNoFieldControls({
{(game.homeworlds || reserveForces > 0) && <RicheseHomeworldForces
enabled={info.homeworldForcesEnabled}
amount={reserveForces}
max={Math.max(0, me.reserves - (useSmuggler && smuggler ? 1 : 0))}
max={me.reserves}
busy={busy}
onChange={setReserveForces}
/>}
@@ -348,10 +354,13 @@ export function RicheseNoFieldControls({
<p className="fine mt-2">
Choose one token to reveal immediately and one to keep
concealed at {selectedTerritory.name}, sector {sector}. The
revealed token brings up to its printed value in forces from
your current reserves; the concealed marker still counts as
one force. Both ship for the price of one marker ({markerCost} spice).
This is separate from the ordinary shipment and Smuggler.
selected paid group reserves {reserveForces} physical counters
first. The faceup token then brings {pairGroup?.revealed ?? 0}
{' '}from the remaining reserves; the concealed marker still
counts as one force. Both tokens are priced as one, plus the
paid group: {pairCost} spice. The paid selection above also
applies to this pair. Smuggler is a separate selection for
the ordinary single-marker route.
</p>
<label htmlFor={`${id}-pair-revealed`}>
Private token to reveal immediately
@@ -431,12 +440,14 @@ export function RicheseNoFieldControls({
event: info.event,
territory: destination,
sector,
allyPayment: share,
allyPayment: 0,
...(reserveForces ? { noFieldReserves: reserveForces } : {}),
});
}}
>
Reveal No-Field {pairRevealed?.value ?? ''} and ship concealed
No-Field {pairConcealed?.value ?? ''} · {markerCost} spice
No-Field {pairConcealed?.value ?? ''} · {pairCost} spice
{reserveForces ? ` · + ${reserveForces} paid forces` : ''}
</Button>
</div>
)}
+33
View File
@@ -16,6 +16,39 @@ open mode gates.
## Current checkpoint and work
**High Richese with Nexus Cunning — 25 October. Prototyped / Partial.**
Paid reserve forces now accompany the original two-token Cunning shipment.
Its signed frame retains the explicit count and one invoice: reserve paid
counters first, then immediately materialize the faceup token from remaining
stock. The other token stays concealed until native reveal. Whole-group High
timing, combined pricing and paid-first allocation are explicit provisional
compositions. Original token/event/history, card spend, No-Field cancellation
and Guild stop remain. Shared controls/quotes and one-companion/fallback minimal
policies are connected; no strategy refinement or new mode gate.
[Source, protocol and limits](NEXUS_RICHESE_RULES.md#high-homeworld-cunning-accompaniment--25-october-2026).
Disjoint helper/native-fixture work, bounded review and root integration pass
**16 files, 134/134**, types/lint/build and four native selector-callback/engine/
GameTable SSR journeys. Basic 91 and Advanced 97 source actions earn original
main 35/cache 10, real Emperor/Atreides alliance, actual unallied Richese singleton
draw and completed bribe 7. Eleven paid counters plus immediate 5 leave 4 reserves
and a concealed 3, whose later reveal leaves 1. Capped 134 earns actual 10/2 bribes
and Charity: sixteen paid counters reserve first, so the immediate 5 returns 4
and later concealed 3 returns 0. Cancellation 91 spends original opposing Karama,
preserves the unused whole shipment/stock/payment and permits normal replacement;
its already-declared Nexus remains spent. Default two-seat sources and ordinary
zero-count pairs remain. All four existing profiles complete legal positive
pairs and the payment guard rejects a physically available but unfunded group.
Review repairs shared-input stock clipping from ordinary Smuggler and an
Advanced fixture's non-protocol amount field. The first union exposes an actual
shared AI payment adapter still excluding every Homeworld pair; it now uses
the same allocation and invoice quote, and the final full related union passes.
No played hand, wallet, alliance, forces or phase is installed. Wider routes/
modules, full modes, browser/mobile/deployed and final assurance remain open.
No saved-room/D1/server/reset/restart/deployment operation occurred. SSR/callbacks
are not parent hydration acceptance.
**Low Homeworld revival prevention — 25 October. Prototyped / Partial.**
Tleilaxu's native special Karama now admits Low Homeworld targets at quiet
Revival windows and the original pending counter. The printed Low free rate
+4 -2
View File
@@ -40,8 +40,10 @@ Physical shipment commitments count only the companions. The persistent private
selector explains current stock, costs and immediate versus concealed forces.
Existing minimal policies offer one affordable paid companion and retain the
ordinary marker fallback; no strategic tuning is performed. The two-token
Richese Nexus Cunning route retains its separate one-marker invoice and cannot
silently inherit a selected ordinary paid group. Its combined High timing,
[Richese Nexus Cunning composition](NEXUS_RICHESE_RULES.md#high-homeworld-cunning-accompaniment--25-october-2026)
now explicitly carries the selected paid group in its own original receipt,
prices the tokens as one plus those counters and reserves paid stock before
immediate faceup materialization. Its allocation/timing remains provisional;
other specialized routes/modules and comprehensive acceptance remain unfinished.
Evidence paths: [shared quote](../game/richese-homeworld-shipment.ts),
+49
View File
@@ -1,5 +1,54 @@
# Dune implementation status
## 25 October 2026 — High Richese retains paid forces through Nexus Cunning
The original two-token Cunning action now accepts explicit `noFieldReserves`.
Its original signed frame binds the positive count, invoice and physical token
choices without a second Skill proof. The shared quote reserves paid counters
first, then materializes the faceup token from remaining stock; the concealed
one uses later actual reserves. The combined invoice prices the pair as one plus
paid quantity, under visibly provisional combined pricing/allocation/High timing.
One commit moves both physical groups, emits one actual arrival count and leaves
Low restrictions for later actions. Original No-Field cancellation leaves the
whole group unused but retains its already spent Nexus; Guild's later stop keeps
its separate consumed-shipment policy. Old no-count histories remain unchanged.
Private pair controls have independent funding/stock quotes; the shared paid
selector permits the full pair stock without inheriting ordinary Smuggler's
reserved extra. Existing profiles try one affordable positive companion before
the ordinary pair fallback. The shared Homeworld payment guard now checks the
native offer, physical tokens, allocation and own funded invoice, replacing its
old blanket Homeworld exclusion. An actual unfunded group is still rejected;
this is legal participation, not AI refinement or strength calibration.
Disjoint helper/native-fixture work, bounded source/correctness review and root
integration pass **16 files, 134/134**, final types/lint/build and four native
selector-callback/engine/GameTable SSR journeys. Basic 91/Advanced 97 source
actions earn original main 35/cache 10, native public Homeworld+Nexus/E2 setup,
real Emperor/Atreides alliance at a real Worm and actual unallied Richese draw.
Bribe7 really settles at Mentat before the unused shipment. Eleven paid plus
immediate 5 arrive for 12, leaving 4 and a concealed 3; later reveal leaves 1.
Capped 134 earns actual bribes 10/2 and Charity: sixteen paid reserve first,
faceup 5 materializes 4 and the concealed 3 later materializes 0. Cancellation 91
uses original Emperor Karama, preserves stock/invoice/opportunity and completes
a normal replacement, with Nexus already spent. All four existing profiles
complete positive paired actions. No played hand, wallet, phase, alliance or
force pool is installed. The old two-seat default remains in the related proof.
Review fixes an ordinary-Smuggler cap leaking into shared input and removes a
fixture-only amount field forbidden by the preserved pair protocol. Initial
133-case union passes 132 and reveals the actual shared AI payment exclusion;
its narrow case passes after the adapter fix and the final 134-case union passes.
No test or authority requirement is weakened. Private proof/HTML:
`/home/quintine/.local/share/dune/checkpoints/20261025-richese-homeworld-cunning/`.
`frozen-payment-fix/report.json` passes unchanged tree
`fdc882f08107706189a892eec377a2a6fc741207981e67a8cd4dffd3501baf34`,
2805 files, base `ce676f0e54cefbcec606aa8aba46d09a5b4be1a6`.
[Combined source and contract](NEXUS_RICHESE_RULES.md#high-homeworld-cunning-accompaniment--25-october-2026).
Wider routes/module combinations, full modes and final assurance remain open.
SSR/callbacks are not parent hydration/browser/mobile/deployed acceptance.
No saved-room/D1/server/reset/restart/deployment operation occurred.
## 25 October 2026 — Low Homeworlds retain their rate under Tleilaxu revival prevention
Removed the old target guard from the Homeworld helper and native Tleilaxu
+70
View File
@@ -2,6 +2,76 @@
Audited 10 September 2026, with a bounded Betrayal implementation contract added 30 September. This separates the ordinary Secret Ally shipment, native Cunning's two-marker operation and Betrayal's two auction outcomes. It does not certify full-family implementation or open the Nexus release gate. See [common Nexus rules](NEXUS_CARD_RULES.md), [auction custody and source boundaries](RICHESE_AUCTION_RULES.md), [No-Field custody](RICHESE_NO_FIELD_RULES.md), and [allied No-Field distinctions](RICHESE_ALLIED_SHIPMENT_AUDIT.md).
## High Homeworld Cunning accompaniment — 25 October 2026
**Prototyped / Partial.** An unallied native Richese holder may explicitly add
paid physical reserve forces to its original two-token Cunning declaration
while its Homeworld is High. The same `noFieldReserves` count used by ordinary
High shipment now belongs to the original signed pair frame; omitting it keeps
the original pair and its one-force price. The Nexus is still spent at accepted
declaration, before its native No-Field counter. There is no extra Skill proof,
Homeworld-only counter, refund coupon or additional shipment opportunity.
The photographed Cunning card prices the two tokens as one shipment for one
force; the High Homeworld component permits reserve accompaniment. The supplied
revision's physical pages 22/23/36 do not give a combined price or physical
allocation order. Under the 14 October choose-recommended instruction, the
explicit provisional composition is one invoice for **one pair unit plus the
selected paid count**. Reserve those paid normal counters first, then immediately
materialize `min(faceup value, reserves − paid count)` from the faceup token.
This is allocation within the one atomic shipment, not a second action or a
delayed faceup reveal. The other token remains concealed and debits no physical
stock until its later native reveal. No paid force is displaced by the free
materialization, substituted or manufactured. The hidden denomination never
changes this invoice.
Observe native High before the whole group, permit its arrival to cross Low,
then apply current Low movement rules to later actions. The source's ordinary
unallied/no-purchased-rate and distinct available token/nonrepeat conditions
remain. Smuggler retains its separate single-marker route. Zero paid forces
need no High entitlement and preserve ordinary Cunning, including scarce stock.
The arrival counts actual paid plus immediate faceup counters and one effective
concealed marker; physical shipment commitments count only actual counters.
The original No-Field counter can prevent the entire bundle without changing
tokens, paid stock, invoice balances or the unused ordinary shipment, while
the declared Nexus stays spent. A later Advanced Guild stop retains its existing
consumed-shipment outcome. Original token/event/history, selected paid quantity
and price remain bound through the original parent; later reveal uses the
remaining native reserve pool. Historical frames with no extra quantity retain
their old shape and are not retrofitted.
The persistent private pair controls show the selected paid group, exact
immediate physical materialization and combined cost. Pair funding is its own
unallied invoice, independent of the single-marker/Smuggler selection. Minimal
policies try one affordable paid companion and retain the ordinary pair fallback;
there is no strategic refinement. The [shared Homeworld payment adapter](../game/homeworld-payment-options.ts)
now validates the actual pair allocation and funded invoice instead of excluding
all Homeworld pairs.
Evidence paths: [physical allocation quote](../game/richese-homeworld-shipment.ts),
[native source](../tests/fixture-richese-homeworld-cunning.ts),
[native cases](../tests/richese-homeworld-cunning-runtime.test.ts),
[shared selector](../components/richese-no-field.tsx) and
[quote cases](../tests/richese-homeworld-shipment.test.ts).
Frozen 16-file 134/134, types/lint/build and four native runtime/SSR journeys
pass. Basic 91/Advanced 97 sources earn an actual Emperor/Atreides alliance and
unallied Richese singleton draw, original main 35/cache 10 and a completed bribe 7.
Eleven paid plus immediate 5 leave 4 and concealed 3, then later reveal leaves 1.
Capped 134 earns real 10/2 bribes/Charity: reserve 16 paid first, materialize 4 of the
immediate 5 and later materialize 0. Original Karama cancellation 91 keeps physical
stock and the ordinary opportunity while retaining the spent Nexus; a normal
replacement really completes. Original two-seat sources and no-count pairs stay
in the related union. Four existing profiles complete legal positive groups;
the shared payment adapter also rejects an unfunded actual source.
The first 133-case union exposed that adapter's old blanket Homeworld exclusion;
that behavior is fixed, not bypassed. No played phase/hand/wallet/alliance/force
pool is installed. Private source-bound proof/HTML:
`/home/quintine/.local/share/dune/checkpoints/20261025-richese-homeworld-cunning/`.
Wider routes/modules, complete games and final assurance remain unfinished.
This dated section supersedes the older High-accompaniment boundary below,
while retaining the separate Secret Ally and Betrayal contracts.
## Native Guild rate composition — 15 October 2026
Secret Ally retains its printed **one charged counter / up to five physical
+1
View File
@@ -1255,6 +1255,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| 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) |
| Low Homeworld / Tleilaxu revival prevention | Provisionally prevent the qualifying return as a whole while preserving the printed Low free rate. No independent bonus-only return, cost, quota use, revival income or new High follow-up arises from a stopped event. Quiet and pending native special-Karama windows share the same eligibility. Retain the 24 October Ghola phase4 cutover and Emperor special immunity. This event/advantage composition is not a located combined FAQ. | [Current source and policy](HOMEWORLD_BENEFITS_RULES.md#provisional-low-homeworld-revival-prevention--25-october-2026) |
| High Richese reserve accompaniment | The printed High card permits reserve forces beside a No-Field. Provisionally bill one marker plus the actual paid companions as one rounded ordinary group, observe High before that group and permit its completed shipment to cross Low. Hidden value stays in reserves; optional Smuggler reserves an additional real free counter. Original No-Field/Guild decisions and invoices remain, and the two-token Cunning route retains its separate contract. Combined tariff and grouped threshold timing are composition policy, not a printed worked example. | [Current prototype and boundaries](HOMEWORLD_RULES.md#high-richese-mixed-shipment-prototype--25-october-2026) |
| High Richese / Nexus Cunning paid group | Provisionally price the pair as one unit plus explicit paid ordinary counters; reserve the paid group first, then immediately materialize only the remaining stock for the faceup token. Observe High before the atomic group, permit crossing Low and retain the concealed token's later reserve-capped reveal. Original Nexus spend, two-token history, No-Field cancellation and Guild stop remain. No new Smuggler/purchased-rate pair or retrofitted quantity. This is combined pricing/allocation policy, not a located worked example. | [Current combination contract](NEXUS_RICHESE_RULES.md#high-homeworld-cunning-accompaniment--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) |
+14 -9
View File
@@ -59,7 +59,7 @@ import {
paidForceRevivalCost,
} from './revival';
import { controlsLeader, nativeAvailable } from './leader-control';
import { quoteRicheseHomeworldForces } from './richese-homeworld-shipment';
import { quoteRicheseHomeworldForces, quoteRicheseHomeworldPair } from './richese-homeworld-shipment';
import { CHEAP_HERO_TRAITOR, matchingTraitor } from './traitors';
import {
treacheryDeck,
@@ -3421,14 +3421,19 @@ function policyActions(g: GameView): Action[] {
if (cunning && !cunning.blocked && tokens.length >= 2) {
const revealed = tokens.reduce((best, token) => token.value > best.value ? token : best);
const concealed = tokens.find(token => token.id !== revealed.id)!;
const target = markerTargets.slice(0, 24).find(to =>
reserveShipmentCost({ faction: me.faction, halfRate: false },
territory(to.t).type, 1) <= (me.spice ?? 0));
if (target) return [{
type: 'ship', noField: concealed.id, revealedToken: revealed.id,
nexus: cunning.event, event: noField.event,
territory: target.t, sector: target.s, allyPayment: 0,
}];
for (const amount of noField.homeworldForcesEnabled && me.reserves > 0 ? [1, 0] : [0]) {
const group = quoteRicheseHomeworldPair({ enabled: !!noField.homeworldForcesEnabled,
reserves: me.reserves, amount, revealedValue: revealed.value });
const target = markerTargets.slice(0, 24).find(to =>
reserveShipmentCost({ faction: me.faction, halfRate: false },
territory(to.t).type, group.priced) <= (me.spice ?? 0));
if (target) return [{
type: 'ship', noField: concealed.id, revealedToken: revealed.id,
nexus: cunning.event, event: noField.event,
territory: target.t, sector: target.s, allyPayment: 0,
...(amount ? { noFieldReserves: amount } : {}),
}];
}
}
if (selected)
for (const to of markerTargets.slice(0, 24)) {
+40 -15
View File
@@ -569,7 +569,7 @@ import {
import type { MixedShipmentExpression } from './mixed-shipment-question';
import { controlsLeader, nativeAvailable } from './leader-control';
import { ixHomeworldRevivalEnabled, quoteIxSuboidBonus } from './ix-homeworld-revival';
import { richeseHomeworldShipmentEnabled, quoteRicheseHomeworldForces, RicheseHomeworldShipmentError } from './richese-homeworld-shipment';
import { richeseHomeworldShipmentEnabled, quoteRicheseHomeworldForces, quoteRicheseHomeworldPair, RicheseHomeworldShipmentError } from './richese-homeworld-shipment';
import type { EcazRevivalCohort } from './ecaz-revival-cohort';
import {
newRevivalRules,
@@ -16549,7 +16549,9 @@ function quoteShipmentArrival(
const p = getPlayer(g, shipment.player);
const amount = shipment.alliedNoField
? Math.max(1, shipment.amount)
: shipment.amount + (shipment.noFieldReserves ?? 0) + (shipment.smugglerCompanion ? 1 : 0);
: shipment.richesePair
? 1 + currentRichesePairForces(g, p, shipment.noFieldReserves ?? 0, shipment.richesePair.revealedTokenId).physical
: shipment.amount + (shipment.noFieldReserves ?? 0) + (shipment.smugglerCompanion ? 1 : 0);
const guild = byFaction(g, 'guild');
const currentDecision = !!(
g.decision &&
@@ -30180,6 +30182,17 @@ function currentRicheseHomeworldForces(g: Game, p: Player, amount: number, smugg
throw error;
}
}
function currentRichesePairForces(g: Game, p: Player, amount: number, revealedToken: string) {
const token = p.noField?.tokens.find(t => t.id === revealedToken);
requireRule(token, 'Choose the original physical token to reveal.');
try {
return quoteRicheseHomeworldPair({ enabled: richeseHomeworldShipmentEnabled(g, p.id),
reserves: p.reserves, amount, revealedValue: token.value });
} catch (error) {
if (error instanceof RicheseHomeworldShipmentError) throw new RuleError(error.message);
throw error;
}
}
function validateLeaderSkillNoFieldShipment(g: Game, shipment: PendingShipment) {
const mixed = shipment.noFieldReserves !== undefined;
requireRule((mixed || g.leaderSkills && smugglerNoFieldModeSupported(g)) && shipment.noField &&
@@ -30259,7 +30272,7 @@ function leaderSkillNoFieldIntegrity(g: Game): boolean {
for (const rate of rates) validateGuildRateResponse(g, rate);
return true;
}
const pair = !!g.leaderSkills && !!shipment?.richesePair;
const pair = !!shipment?.richesePair && (!!g.leaderSkills || shipment.noFieldReserves !== undefined);
if (!(pair || shipment?.noFieldReserves !== undefined || shipment?.smugglerCompanion !== undefined || shipment?.noFieldSkillProof !== undefined ||
(g.leaderSkills && shipment?.noField) || responses.some(r => r.noFieldSkillProof !== undefined) ||
decisions.some(d => 'noFieldSkillProof' in d))) return false;
@@ -30281,8 +30294,8 @@ function leaderSkillNoFieldIntegrity(g: Game): boolean {
return true;
}
function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
requireRule(shipment.noFieldReserves === undefined || shipment.noField && !shipment.richesePair,
'Paid No-Field reserves require a single native concealed marker.');
requireRule(shipment.noFieldReserves === undefined || shipment.noField,
'Paid No-Field reserves require a native concealed marker.');
requireRule(!atomicsShipmentBlocked(g.moritaniAtomics, shipment.territory),
'Atomics Aftermath permanently blocks shipments into this territory.');
// The pair has its original signed two-token validator below; skill proofs bind single markers.
@@ -30904,7 +30917,9 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
finishShipmentPromises(
g,
getPlayer(g, shipment.player),
shipment.noField ? (shipment.noFieldReserves || shipment.smugglerCompanion
shipment.noField ? (shipment.richesePair
? { territory: shipment.territory, amount: currentRichesePairForces(g, getPlayer(g, shipment.player), shipment.noFieldReserves ?? 0, shipment.richesePair.revealedTokenId).physical }
: shipment.noFieldReserves || shipment.smugglerCompanion
? { territory: shipment.territory, amount: (shipment.noFieldReserves ?? 0) + (shipment.smugglerCompanion ? 1 : 0) }
: null) : shipment.alliedNoField ? null : shipment,
);
@@ -30931,7 +30946,7 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
tokenId: shipment.noField!.tokenId,
controller: p.id,
location: { territory: to, sector: s },
}, shipment.richesePair!.revealedTokenId, p.reserves));
}, shipment.richesePair!.revealedTokenId, p.reserves - (shipment.noFieldReserves ?? 0)));
p.noField = pair.state;
pairRevealed = pair.revealed;
} else p.noField = noFieldRule(() => deployRicheseNoField(p.noField!, {
@@ -31045,7 +31060,7 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
? `${getPlayer(g, shipment.alliedNoField.owner).name} shipped ${p.name} with No-Field ${quoteAlliedTokenValue(g, shipment.alliedNoField)}, immediately placing ${n} physical forces (${elite} elite) in ${territory(to).name}, sector ${s}. ${shipment.alliedNoField.payer === 'both' ? 'Each ally paid 1 spice' : `${getPlayer(g, shipment.alliedNoField.payer).name} paid ${cost} spice`}.`
: shipment.noField
? pairRevealed
? `${p.name} shipped two No-Fields with Richese Nexus Cunning to ${territory(to).name}, sector ${s}, for ${cost} spice. The faceup ${pairRevealed.value} token placed ${pairRevealed.forces} physical forces from reserves${pairRevealed.forces < pairRevealed.value ? ' (only the available reserves)' : ''}; the other token remains concealed and counts as one force.`
? `${p.name} shipped two No-Fields with Richese Nexus Cunning to ${territory(to).name}, sector ${s}, for ${cost} spice.${shipment.noFieldReserves ? ` ${shipment.noFieldReserves} paid reserve forces accompanied the tokens.` : ''} The faceup ${pairRevealed.value} token placed ${pairRevealed.forces} physical forces from reserves${pairRevealed.forces < pairRevealed.value ? ' (only the available reserves after the paid group)' : ''}; the other token remains concealed and counts as one force.`
: `${p.name} shipped one concealed No-Field to ${territory(to).name}, sector ${s}, for ${cost} spice. ${shipment.noFieldReserves ? `${shipment.noFieldReserves} paid reserve forces${shipment.smugglerCompanion ? ' and one free Smuggler force' : ''} arrived beside the marker; the concealed token remains unchanged until reveal.` : shipment.smugglerCompanion ? 'One free Smuggler force left reserves and arrived beside the marker; the concealed token remains unchanged until reveal.' : 'It counts as one force; physical reserves remain unchanged until reveal.'}`
: `${p.name} shipped ${n} forces to ${territory(to).name}, sector ${s}.${shipment.guildSecretEvent ? ` Guild Secret Ally is spent; Guild shipping prices cost ${cost} spice paid to the bank.` : ''}${shipment.nexusEvent ? ` Richese Secret Ally priced the ${n} physical forces as one: ${cost} spice.` : ''}${homeworldOrigins ? ` Homeworld sources: ${homeworldOrigins}. Total shipment cost: ${cost} spice.` : ''}`,
shipment.noField || shipment.alliedNoField
@@ -31476,11 +31491,17 @@ function currentRichesePairShipment(g: Game, shipment: PendingShipment) {
p.noFieldEvent === record.tokenEvent && !p.noField.deployed &&
g.status === 'playing' && g.phase === 5 && g.active === p.id &&
shipmentAvailable(g, p), 'Richese Cunning lost its unused native shipment.');
if (shipment.noFieldReserves !== undefined) integer(shipment.noFieldReserves, 1, p.reserves, 'Paid Cunning reserve forces');
const group = currentRichesePairForces(g, p, shipment.noFieldReserves ?? 0, pair.revealedTokenId);
if (g.homeworlds?.custody && group.physical) homeworldRule(() =>
quoteNativeReserveWithdrawal(homeworldContext(g), g.homeworlds!.custody!, p.id,
{ normal: group.physical, elite: 0 }));
const cost = reserveShipmentCost({ faction: p.faction, halfRate: false },
territory(shipment.territory).type, 1);
territory(shipment.territory).type, group.priced);
requireRule(shipment.cost === cost && Number.isSafeInteger(shipment.sector) &&
validGameLocation(g, shipment.territory, shipment.sector),
'Richese Cunning lost its original one-marker price or currently revealed destination.');
shipment.noFieldReserves ? 'Richese Cunning lost its original paid-group invoice or currently revealed destination.'
: 'Richese Cunning lost its original one-marker price or currently revealed destination.');
noFieldRule(() => validateRicheseNoFieldPair(p.noField!, {
tokenId: shipment.noField!.tokenId,
controller: p.id,
@@ -31536,7 +31557,10 @@ function richesePairIntegrity(g: Game) {
'Richese Cunning changed its original card, owner or shipment receipt.');
const frame = JSON.parse(record.frame) as PendingShipment;
requireRule(frame && typeof frame === 'object' && !Array.isArray(frame) &&
Object.keys(frame).sort().join(',') === 'advisors,allyPayment,amount,cost,elite,noField,player,richesePair,sector,territory,turn' &&
Object.keys(frame).sort().join(',') === (frame.noFieldReserves === undefined
? 'advisors,allyPayment,amount,cost,elite,noField,player,richesePair,sector,territory,turn'
: 'advisors,allyPayment,amount,cost,elite,noField,noFieldReserves,player,richesePair,sector,territory,turn') &&
(frame.noFieldReserves === undefined || Number.isSafeInteger(frame.noFieldReserves) && frame.noFieldReserves > 0 && frame.noFieldReserves <= 20) &&
frame.richesePair?.event === record.event && frame.player === record.owner &&
frame.noField?.event === record.tokenEvent && frame.turn === record.turn &&
typeof frame.noField.tokenId === 'string' && typeof frame.richesePair.revealedTokenId === 'string' &&
@@ -36718,7 +36742,7 @@ function applyActionInner(
const offer = nexusRicheseCunningOffer(g, id);
requireRule(offer && !offer.blocked && action.nexus === offer.event,
offer?.blocked ?? 'Richese Cunning is unavailable or stale.');
requireRule(Object.keys(action).sort().join(',') ===
requireRule(Object.keys(action).filter(key => key !== 'noFieldReserves').sort().join(',') ===
'allyPayment,event,nexus,noField,revealedToken,sector,territory,type',
'Choose exactly two physical No-Field tokens and a shipment destination.');
richesePair = { event: offer.event, revealedTokenId: stringField(action.revealedToken) };
@@ -36734,8 +36758,8 @@ function applyActionInner(
'Richese Secret Ally requires an ordinary physical reserve shipment.');
nexusEvent = offer.event;
}
requireRule(action.noFieldReserves === undefined || action.noField !== undefined && !richesePair,
'Paid No-Field reserves accompany one native concealed marker.');
requireRule(action.noFieldReserves === undefined || action.noField !== undefined,
'Paid No-Field reserves accompany a native concealed marker.');
if (action.noField === undefined) checkShipmentPromises(g, p, {
territory: String(action.territory), amount: Number(action.amount),
});
@@ -36794,7 +36818,8 @@ function applyActionInner(
'Smuggler needs a living face-up native trainer and an empty territory, with two ordinary shipment forces or one reserve companion beside your No-Field.');
const noFieldReserves = action.noFieldReserves === undefined ? 0
: integer(action.noFieldReserves, 1, p.reserves, 'Paid No-Field reserve forces');
const noFieldGroup = noField ? currentRicheseHomeworldForces(g, p, noFieldReserves, !!smugglerCompanion) : null;
const noFieldGroup = richesePair ? currentRichesePairForces(g, p, noFieldReserves, richesePair.revealedTokenId)
: noField ? currentRicheseHomeworldForces(g, p, noFieldReserves, !!smugglerCompanion) : null;
const cost = guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(to).type,n)).cost : nexusEvent
? nexusRule(() => quoteNexusRicheseShipment(price,territory(to).type,n)).cost
: reserveShipmentCost(price,territory(to).type,noFieldGroup?.priced ?? n - (smuggler ? 1 : 0));
+20 -2
View File
@@ -1,6 +1,9 @@
import { nexusGuildSecretAllyQuote } from './nexus-guild-secret-ally-options';
import type { Action, GameView } from './engine';
import { nexusRicheseQuote } from './nexus-richese-options';
import { quoteRicheseHomeworldPair } from './richese-homeworld-shipment';
import { reserveShipmentCost } from './shipment-price';
import { territory } from './board';
export function botHomeworldShipmentPaymentAllowed(g: GameView, action: Action): boolean {
@@ -9,8 +12,23 @@ export function botHomeworldShipmentPaymentAllowed(g: GameView, action: Action):
const amount = action.noField ? 1 : action.forces ? Object.values(action.forces as Record<string, number>).reduce((sum, n) => sum + n, 0) : Number(action.amount);
if (action.revealedToken !== undefined) {
const offer = g.nexusRicheseCunning;
return action.noField !== undefined && !g.homeworlds && !!offer && !offer.blocked &&
action.nexus === offer.event;
const info = g.richeseNoField;
const me = g.players.find(player => player.id === g.me);
const revealed = info?.private?.tokens.find(token => token.id === action.revealedToken);
const concealed = info?.private?.tokens.find(token => token.id === action.noField);
const paid = action.noFieldReserves ?? 0;
if (action.type !== 'ship' || !offer || offer.blocked || action.nexus !== offer.event ||
info?.owner !== g.me || !me || !revealed || !concealed ||
revealed.id === concealed.id || revealed.id === info.private?.lastShipped ||
concealed.id === info.private?.lastShipped || action.allyPayment !== 0 ||
typeof paid !== 'number' || (action.noFieldReserves !== undefined && paid <= 0)) return false;
try {
const group = quoteRicheseHomeworldPair({ enabled: !!info.homeworldForcesEnabled,
reserves: me.reserves, amount: paid,
revealedValue: revealed.value });
return reserveShipmentCost({ faction: me.faction, halfRate: false },
territory(to).type, group.priced) <= (me.spice ?? 0);
} catch { return false; }
}
const guildSource = action.nexus !== undefined && action.nexus === g.nexusGuildSecretAlly?.event;
const nexusQuote = action.nexus === undefined ? null : guildSource ? nexusGuildSecretAllyQuote(g, to, amount) : nexusRicheseQuote(g, to, amount);
+5 -2
View File
@@ -4602,7 +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.',
'At High, Richese may send paid physical reserve forces beside one concealed No-Field. The persistent selector names only actual counters; the marker stays priced as one and its hidden value stays in reserves until reveal. One rounded invoice for marker plus paid group and pre-group High timing are provisional. Actual departure can cross Low; later reveal uses remaining reserves. The optional Smuggler force stays separately free. Original No-Field/Guild responses stay; the two-token Cunning route remains separate.',
'At High, Richese may send paid physical reserve forces beside one concealed No-Field. The persistent selector names only actual counters; the marker stays priced as one and its hidden value stays in reserves until reveal. One rounded invoice for marker plus paid group and pre-group High timing are provisional. Actual departure can cross Low; later reveal uses remaining reserves. The optional Smuggler force stays separately free. Original No-Field/Guild responses stay. A two-token Cunning pair now explicitly retains its paid group and original receipt: reserve paid counters first, then cap immediate faceup materialization by remaining stock; both tokens share one priced unit.',
'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.',
@@ -4622,7 +4622,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. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. 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.",
"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. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Nexus Cunning retains that explicit paid group in its pair receipt, reserving paid counters before immediate materialization; original counter and spent-card outcomes remain. 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',
@@ -4631,6 +4631,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/nexus-world-independent-battles.test.ts',
'game/homeworld-cards.ts',
'game/richese-homeworld-shipment.ts',
'game/homeworld-payment-options.ts',
'tests/fixture-richese-homeworld-cunning.ts',
'tests/richese-homeworld-cunning-runtime.test.ts',
'components/richese-homeworld-forces.tsx',
'tests/fixture-richese-homeworld-shipment.ts',
'tests/richese-homeworld-shipment-runtime.test.ts',
+14
View File
@@ -10,6 +10,10 @@ export type RicheseHomeworldForcesInput = {
smuggler?: boolean;
};
export type RicheseHomeworldForcesQuote = { paid: number; physical: number; priced: number };
export type RicheseHomeworldPairInput = Pick<RicheseHomeworldForcesInput, 'enabled' | 'reserves' | 'amount'> & {
revealedValue: 0 | 3 | 5;
};
export type RicheseHomeworldPairQuote = RicheseHomeworldForcesQuote & { revealed: number };
export class RicheseHomeworldShipmentError extends Error {
constructor(message: string) {
@@ -43,3 +47,13 @@ export function quoteRicheseHomeworldForces(input: RicheseHomeworldForcesInput):
throw new RicheseHomeworldShipmentError('The paid forces and any Smuggler force must fit the available physical reserves.');
return { paid: amount, physical, priced: 1 + amount };
}
/** Public pair settlement reserves the paid group first, then supplies only
* the revealed denomination's remaining physical stock. No Smuggler is added. */
export function quoteRicheseHomeworldPair(input: RicheseHomeworldPairInput): RicheseHomeworldPairQuote {
const quote = quoteRicheseHomeworldForces({ enabled: input.enabled, reserves: input.reserves, amount: input.amount });
if (!Number.isSafeInteger(input.revealedValue) || ![0, 3, 5].includes(input.revealedValue))
throw new RicheseHomeworldShipmentError('Choose a publicly revealed No-Field value of zero, three or five.');
const revealed = Math.min(input.revealedValue, input.reserves - quote.paid);
return { ...quote, physical: quote.paid + revealed, revealed };
}
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import { viewGame, type Action } from '../game/engine';
import type { NoFieldValue } from '../game/richese-no-field';
import { quoteRicheseHomeworldPair } from '../game/richese-homeworld-shipment';
import {
createRicheseHomeworldShipmentFixture,
actRicheseHomeworldShipment, nextRicheseHomeworldShipment, advanceRicheseHomeworldShipment,
passRicheseHomeworldShipment, finishRicheseHomeworldShipment,
commitRicheseHomeworldShipment, revealRicheseHomeworldShipment,
richeseHomeworldShipmentPlayer, richeseHomeworldShipmentClean,
richeseHomeworldShipmentInventory, richeseHomeworldShipmentNexusInventory,
richeseHomeworldShipmentPopulation,
} from './fixture-richese-homeworld-shipment';
export const actRicheseHomeworldCunning = actRicheseHomeworldShipment;
export const nextRicheseHomeworldCunning = nextRicheseHomeworldShipment;
export const advanceRicheseHomeworldCunning = advanceRicheseHomeworldShipment;
export const passRicheseHomeworldCunning = passRicheseHomeworldShipment;
export const finishRicheseHomeworldCunning = finishRicheseHomeworldShipment;
export const commitRicheseHomeworldCunning = commitRicheseHomeworldShipment;
export const revealRicheseHomeworldCunning = revealRicheseHomeworldShipment;
export const richeseHomeworldCunningPlayer = richeseHomeworldShipmentPlayer;
export const richeseHomeworldCunningClean = richeseHomeworldShipmentClean;
export const richeseHomeworldCunningInventory = richeseHomeworldShipmentInventory;
export const richeseHomeworldCunningNexusInventory = richeseHomeworldShipmentNexusInventory;
export const richeseHomeworldCunningPopulation = richeseHomeworldShipmentPopulation;
/** Original Homeworld+Nexus setup and paid native funding, before any pair
* declaration. Emperor and Atreides actually ally at the turn-two worm; the
* unallied Richese really draws its original Cunning singleton afterward. */
export function createRicheseHomeworldCunningFixture(options: {
advanced?: boolean; companions?: number; revealedValue?: NoFieldValue; concealedValue?: NoFieldValue;
} = {}) {
const revealedValue = options.revealedValue ?? 5, concealedValue = options.concealedValue ?? 3;
assert.notEqual(revealedValue, concealedValue, 'The two original physical tokens must be distinct');
const f = createRicheseHomeworldShipmentFixture({ advanced: options.advanced,
companions: options.companions, value: concealedValue, nexus: true });
assert.ok(f.nexusDraw && f.nexusAlliance && f.observer);
const p = richeseHomeworldCunningPlayer(f.game, f.owner), offer = viewGame(f.game, f.owner).nexusRicheseCunning;
assert.ok(offer && !offer.blocked, offer?.blocked ?? 'The actually drawn native Richese Cunning is live');
assert.equal(p.ally, null); assert.equal(f.game.nexusCards!.cards!.hands[f.owner], 'richese');
const revealed = p.noField!.tokens.find(token => token.value === revealedValue); assert.ok(revealed);
const concealed = p.noField!.tokens.find(token => token.value === concealedValue); assert.ok(concealed);
const action: Action = { type: 'ship', noField: concealed.id, event: p.noFieldEvent,
revealedToken: revealed.id, nexus: offer.event, noFieldReserves: f.companions,
territory: f.territory, sector: f.sector, allyPayment: 0 };
const quote = quoteRicheseHomeworldPair({ enabled: true, reserves: p.reserves,
amount: f.companions, revealedValue });
return { ...f, source: structuredClone(f.game), beforeShipment: structuredClone(f.game),
singleAction: f.action, action, shipmentAction: action, shipmentStep: { actor: f.owner, action },
nexusEvent: offer.event, offer: structuredClone(offer),
revealedValue, revealedToken: revealed.id, concealedValue, concealedToken: concealed.id,
value: concealedValue, markerValue: concealedValue, token: concealed.id, tokenId: concealed.id,
paid: quote.paid, physical: quote.physical, priced: quote.priced, revealedForces: quote.revealed, pairQuote: quote,
staging: [...f.staging,
'This native three-seat branch stops before the original two-token Cunning declaration. Its Emperor/Atreides alliance is earned, Richese is unallied, the original Richese Nexus is actually held and empty Carthag11 is the funded destination. No two-seat all-unallied draw is manufactured.',
'The proposed pair uses one concealed three token and one immediate faceup five token by default, plus eleven paid original reserves. Root’s explicitly provisional composition reserves the paid group first, materializes only the remaining stock for the immediate token and leaves the other token concealed. The original Nexus spend, No-Field Karama counter, one combined invoice and later reserve-capped reveal remain real caller actions.',
],
};
}
export type RicheseHomeworldCunningFixture = ReturnType<typeof createRicheseHomeworldCunningFixture>;
+89 -9
View File
@@ -3,6 +3,7 @@ import { createGame, joinGame, newPlayer, type Action, type Game } from '../game
import type { NoFieldValue } from '../game/richese-no-field';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldPopulations } from '../game/homeworld-population';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { actTruthMovement, type TruthMovementStep } from './fixture-truth-movement';
import { nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
@@ -36,12 +37,24 @@ export const richeseHomeworldShipmentPopulation = (game: Game, owner = 'richese'
const home = homeworldPopulations(homeworldContext(game), game.homeworlds!.custody!)
.find(home => home.native === owner && home.card === 'richese'); assert.ok(home); return home;
};
export const richeseHomeworldShipmentNexusInventory = (game: Game) => {
const cards = game.nexusCards?.cards;
if (!cards) return [];
const ids = [...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort();
assert.equal(new Set(ids).size, ids.length, 'Every original Nexus singleton retains one physical slot');
return ids;
};
/** Native pass/unbid controls only. Cache selection always uses an actual
* remaining card, so a prior retired lot is never requested again. Emperor
* really takes ordinary Charity when eligible; no balance is installed. */
export function nextRicheseHomeworldShipment(game: Game): RicheseHomeworldShipmentStep | null {
if (game.truthtrance) return null;
if (game.nexusCards?.phase?.stage === 'drawing') {
const phase = game.nexusCards.phase, actor = phase.eligible.find(id => !phase.done.includes(id)); assert.ok(actor);
return { actor, action: { type: 'nexusCardChoice', turn: phase.turn,
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
}
if (richeseHomeworldShipmentClean(game)) {
if (game.status === 'playing' && game.phase === 2) {
const emperor = richeseHomeworldShipmentPlayer(game, 'emperor');
@@ -83,15 +96,18 @@ export function finishRicheseHomeworldShipment(game: Game, actions: RicheseHomew
* cards and undeployed original No-Fields. Native deferred bribes fund one
* combined invoice without changing either physical army. Stop BEFORE ship. */
export function createRicheseHomeworldShipmentFixture(options: {
advanced?: boolean; companions?: number; value?: NoFieldValue;
advanced?: boolean; companions?: number; value?: NoFieldValue; nexus?: boolean;
} = {}) {
const advanced = options.advanced ?? false, companions = options.companions ?? 11, value = options.value ?? 5;
const nexus = options.nexus ?? false;
assert.ok(Number.isSafeInteger(companions) && companions >= 1 && companions <= 16,
'The bounded native funding programme supports one through sixteen companions');
const owner = 'richese', holder = 'emperor', territory = 'arrakeen', sector = 10, location = 'arrakeen:10';
const owner = 'richese', holder = 'emperor', observer = nexus ? 'atreides' : null;
const territory = nexus ? 'carthag' : 'arrakeen', sector = nexus ? 11 : 10, location = `${territory}:${sector}`;
const cost = 1 + companions;
let game = createGame('RICHESEHOMEWORLDSHIPMENT', newPlayer(owner, 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer(holder, 'Emperor', 'emperor'));
if (observer) joinGame(game, newPlayer(observer, 'Atreides', 'atreides'));
const initial = structuredClone(game), actions: RicheseHomeworldShipmentStep[] = [];
const act = (actor: string, action: Action) => { game = actRicheseHomeworldShipment(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceRicheseHomeworldShipment(game, until, actions); };
@@ -100,18 +116,24 @@ export function createRicheseHomeworldShipmentFixture(options: {
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
const enableHomeworlds = transition(() => { act(game.host, { type: 'homeworlds', enabled: true }); });
const enableNexus = nexus ? transition(() => {
act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
}) : null;
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, ['choam']);
assert.ok(game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
assert.equal(!!game.nexusCards, nexus);
assert.ok(game.homeworlds && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => !player.hand.length));
const originalMainDeck = game.deck.map(card => card.id), originalCache = game.richeseCache!.map(card => card.id);
assert.equal(originalMainDeck.length, 35); assert.equal(originalCache.length, 10);
const ordinary = game.deck.find(card => card.kind === 'projectile');
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(ordinary && karama);
const deal = [ordinary.id, karama.id];
const observerCard = observer ? game.deck.find(card => card.kind === 'shield') : null;
assert.ok(!observer || observerCard);
const deal = [ordinary.id, karama.id, ...(observerCard ? [observerCard.id] : [])];
const dealOrder = deal.map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
@@ -125,9 +147,13 @@ export function createRicheseHomeworldShipmentFixture(options: {
assert.equal(originalNoField.deployed, null); assert.equal(originalNoField.lastShipped, null);
assert.deepEqual(richeseHomeworldShipmentPlayer(game, owner).hand.map(card => card.id), [ordinary.id]);
assert.deepEqual(richeseHomeworldShipmentPlayer(game, holder).hand.map(card => card.id), [karama.id]);
if (observer && observerCard)
assert.deepEqual(richeseHomeworldShipmentPlayer(game, observer).hand.map(card => card.id), [observerCard.id]);
assert.equal(richeseHomeworldShipmentPlayer(game, owner).reserves, 20);
assert.deepEqual(richeseHomeworldShipmentPlayer(game, owner).forces, {});
const originalInventory = richeseHomeworldShipmentInventory(game); assert.equal(originalInventory.length, 45);
const originalNexusInventory = richeseHomeworldShipmentNexusInventory(game);
assert.deepEqual(originalNexusInventory, nexus ? [...NEXUS_FACTIONS].sort() : []);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1, 2, 3, 4, 5].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
@@ -137,14 +163,55 @@ export function createRicheseHomeworldShipmentFixture(options: {
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && richeseHomeworldShipmentClean(state));
const beforeFunding = structuredClone(game), funding: RicheseHomeworldShipmentTransition[] = [];
let beforeWorm: Game | null = null, beforeNexusDraw: Game | null = null;
let nexusAlliance: RicheseHomeworldShipmentTransition | null = null, nexusDraw: RicheseHomeworldShipmentTransition | null = null;
let wormOrder: { position: number; from: number; card: Game['spiceDeck'][number] } | null = null;
let nexusOrder: { position: number; from: number; card: 'richese' } | null = null;
const wormFollowupOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
const reachOwnTurn = (turn: number) => {
if (nexus && !nexusDraw) {
assert.ok(observer);
reach(state => state.turn === 2 && state.phase === 1 && richeseHomeworldShipmentClean(state) &&
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
beforeWorm = structuredClone(game);
const inventory = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const from = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
assert.ok(from >= 0); const worm = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(worm);
wormOrder = { position: 0, from, card: structuredClone(worm) };
for (const position of [1, 2]) {
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);
wormFollowupOrder.push({ position, from, card: structuredClone(card) });
}
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), inventory);
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && richeseHomeworldShipmentClean(state));
nexusAlliance = transition(() => {
act(holder, { type: 'alliance', target: observer }); act(observer, { type: 'alliance', target: holder });
});
assert.equal(richeseHomeworldShipmentPlayer(game, holder).ally, observer);
assert.equal(richeseHomeworldShipmentPlayer(game, observer).ally, holder);
assert.equal(richeseHomeworldShipmentPlayer(game, owner).ally, null);
reach(state => state.nexusCards?.phase?.stage === 'drawing' && richeseHomeworldShipmentClean(state));
const cards = game.nexusCards!.cards!, cardIndex = cards.deck.indexOf('richese'); assert.ok(cardIndex >= 0);
assert.equal(cards.hands[owner], null);
cards.deck.unshift(cards.deck.splice(cardIndex, 1)[0]);
nexusOrder = { position: 0, from: cardIndex, card: 'richese' };
beforeNexusDraw = structuredClone(game);
nexusDraw = transition(() => { act(owner, { type: 'nexusCardChoice', turn: game.turn,
card: null, choice: 'draw', ownRedraws: 0 }); });
assert.equal(game.nexusCards!.cards!.hands[owner], 'richese');
}
reach(state => state.turn === turn && state.phase === 5 && state.active === owner && richeseHomeworldShipmentClean(state));
};
for (let count = 0; richeseHomeworldShipmentPlayer(game, owner).spice < cost && count < 3; count++) {
const donor = richeseHomeworldShipmentPlayer(game, holder);
const amount = Math.min(cost - richeseHomeworldShipmentPlayer(game, owner).spice, donor.spice);
assert.ok(amount > 0, 'Actual Emperor income must fund the next deferred bribe');
funding.push(transition(() => { act(holder, { type: 'bribe', target: owner, amount }); }));
const turn = game.turn;
reach(state => state.turn === turn + 1 && state.phase === 5 && state.active === owner && richeseHomeworldShipmentClean(state));
reachOwnTurn(turn + 1);
}
if (nexus && !nexusDraw) reachOwnTurn(2);
assert.ok(richeseHomeworldShipmentPlayer(game, owner).spice >= cost);
const player = richeseHomeworldShipmentPlayer(game, owner);
assert.equal(player.reserves, 20); assert.deepEqual(player.forces, {}); assert.equal(player.tanks, 0);
@@ -152,6 +219,8 @@ export function createRicheseHomeworldShipmentFixture(options: {
assert.equal(richeseHomeworldShipmentPopulation(game, owner).side, 'high');
assert.ok(richeseHomeworldShipmentPlayer(game, holder).hand.some(card => card.id === karama.id));
assert.deepEqual(richeseHomeworldShipmentInventory(game), originalInventory);
assert.deepEqual(richeseHomeworldShipmentNexusInventory(game), originalNexusInventory);
if (nexus) assert.equal(game.nexusCards!.cards!.hands[owner], 'richese');
const token = player.noField!.tokens.find(token => token.value === value); assert.ok(token);
const event = player.noFieldEvent; assert.ok(event);
const action: Action = { type: 'ship', noField: token.id, event, noFieldReserves: companions,
@@ -159,22 +228,33 @@ export function createRicheseHomeworldShipmentFixture(options: {
const cancel: Action = { type: 'card', card: karama.id, mode: 'cancel' };
const replacement: Action = { type: 'ship', amount: 1, elite: 0, territory, sector, allyPayment: 0,
homeworldSources: { 'homeworld:richese': { normal: 1, elite: 0 } } };
return { game, source: structuredClone(game), beforeShipment: structuredClone(game), initial, enableHomeworlds,
return { game, source: structuredClone(game), beforeShipment: structuredClone(game), initial, enableHomeworlds, enableNexus,
lobby, setup, beforeStartingDeal, afterSetup, beforeFunding, funding,
advanced, owner, richese: owner, actor: owner, holder, emperor: holder,
beforeWorm: beforeWorm as Game | null,
wormOrder: wormOrder as { position: number; from: number; card: Game['spiceDeck'][number] } | null,
wormFollowupOrder,
nexusAlliance: nexusAlliance as RicheseHomeworldShipmentTransition | null,
beforeNexusDraw: beforeNexusDraw as Game | null,
nexusDraw: nexusDraw as RicheseHomeworldShipmentTransition | null,
nexusOrder: nexusOrder as { position: number; from: number; card: 'richese' } | null,
advanced, nexus, owner, richese: owner, actor: owner, holder, emperor: holder, observer, atreides: observer,
companions, noFieldReserves: companions, amount: companions, physical: companions, priced: companions + 1,
cost, shipmentCost: cost, value, markerValue: value, token: token.id, tokenId: token.id, event, shipmentEvent: event,
territory, sector, location, action, shipmentAction: action,
shipmentStep: { actor: owner, action } satisfies RicheseHomeworldShipmentStep,
karama: karama.id, card: karama.id, cancel, cancelAction: cancel, replacement,
ordinary: ordinary.id, originalNoField, noField: structuredClone(player.noField),
originalInventory, originalCards: originalInventory, originalMainDeck, originalCache, originalSpice, deal, dealOrder, spiceOrder,
originalInventory, originalCards: originalInventory, originalMainDeck, originalCache, originalSpice,
originalNexusInventory, deal, dealOrder, spiceOrder,
actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Actual public Basic/Advanced Richese/Emperor lobby enables only Homeworlds, then native ready/start with original main35/cache10 and all three native No-Field components. Only original scalar lotteries, two unopened Treachery positions and six unopened ordinary Spice positions are selected.',
nexus
? 'Actual public Basic/Advanced Richese/Emperor/Atreides lobby enables Homeworlds and Nexus Cards, then native ready/start with original main35/cache10, all12 Nexus singletons and all three native No-Field components. Only original scalar lotteries, three unopened Treachery positions and six unopened ordinary Spice positions are selected.'
: 'Actual public Basic/Advanced Richese/Emperor lobby enables only Homeworlds, then native ready/start with original main35/cache10 and all three native No-Field components. Only original scalar lotteries, two unopened Treachery positions and six unopened ordinary Spice positions are selected.',
'Richese really receives an ordinary projectile and Emperor its printed original Karama. Every original Storm, Spice, Charity, native cache/normal auction, Black Market decline in Advanced, Revival, Movement ending and Mentat control is used. Real cache lots are passed and retired, never replaced or acquired by assigning a hand.',
'The default eleven-companion stronghold invoice needs twelve spice. Emperor really pays Richese a seven-spice bribe during turn one; its pending recipient share becomes available only at the actual Mentat. Richese’s next unused turn retains all twenty original reserves and HIGH population before declaration.',
'The sixteen-companion cap variant uses an actual ten-spice first bribe, next-turn Emperor Charity for two and a second two-spice bribe, both completed through real Mentat. Its third unused turn has seventeen spendable spice and the same untouched original twenty reserves. Smaller companion requests use only the native funding they need.',
...(nexus ? ['The original turn-two unopened Spice order supplies a real Shai-Hulud and ordinary land follow-ups. Emperor actually proposes and Atreides accepts alliance; Richese stays unallied. Only the unopened Richese Nexus singleton is moved to the next original draw position, then Richese really draws it at the closing Nexus choice. Two seats cannot earn that unallied draw; the native third seat supplies its real eligibility. Empty Carthag11 avoids Atreides’ original Arrakeen army.'] : []),
'Stop BEFORE the marker/physical shipment. The original token/event, opponent-held Karama, quiet unused shipment and exact funded combined invoice remain live caller inputs. No force, wallet, phase, hand, stance, token custody, canceled history or pending proof is installed.',
],
};
@@ -0,0 +1,162 @@
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 { presenceAt } from '../game/force-presence';
import { territory } from '../game/board';
import { reserveShipmentCost } from '../game/shipment-price';
import { quoteRicheseHomeworldPair } from '../game/richese-homeworld-shipment';
import { botHomeworldShipmentPaymentAllowed } from '../game/homeworld-payment-options';
import {
createRicheseHomeworldCunningFixture as fixture,
actRicheseHomeworldCunning as act,
commitRicheseHomeworldCunning as commit,
revealRicheseHomeworldCunning as reveal,
finishRicheseHomeworldCunning as finish,
richeseHomeworldCunningPlayer as player,
richeseHomeworldCunningInventory as inventory,
richeseHomeworldCunningNexusInventory as nexusInventory,
richeseHomeworldCunningPopulation as population,
} from './fixture-richese-homeworld-cunning';
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} native HIGH Cunning pays for eleven companions before its immediate five and retains a concealed three`, () => {
const f = fixture({ advanced }); assert.ok(f.observer && f.nexusAlliance && f.nexusDraw);
const before = structuredClone(player(f.game, f.owner));
assert.equal(f.game.turn, 2); assert.equal(f.game.active, f.owner);
assert.equal(player(f.game, f.holder).ally, f.observer);
assert.equal(player(f.game, f.observer).ally, f.holder);
assert.equal(before.ally, null); assert.equal(before.reserves, 20); assert.equal(before.spice, 12);
assert.equal(f.game.nexusCards!.cards!.hands[f.owner], 'richese');
assert.equal(f.nexusDraw.steps[0].action.type, 'nexusCardChoice');
assert.equal(f.nexusDraw.steps[0].action.choice, 'draw');
assert.equal(viewGame(f.game, f.owner).richeseNoField?.homeworldForcesEnabled, true);
assert.deepEqual(f.pairQuote, { paid: 11, physical: 16, priced: 12, revealed: 5 });
const action: Action = f.action;
const result = commit(f.game, f, action), requested = player(result.requested, f.owner), after = player(result.game, f.owner);
assert.equal(result.requested.response?.kind, 'richeseNoField');
assert.equal(result.requested.pendingShipment?.amount, 1);
assert.equal(result.requested.pendingShipment?.noFieldReserves, 11);
assert.equal(result.requested.pendingShipment?.richesePair?.revealedTokenId, f.revealedToken);
assert.equal(result.requested.pendingShipment?.noFieldSkillProof, undefined, 'The original pair frame owns its optional physical group');
assert.equal(result.requested.pendingShipment?.cost, 12);
assert.equal(requested.reserves, 20); assert.deepEqual(requested.forces, {}); assert.equal(requested.spice, 12);
assert.deepEqual(requested.noField, before.noField); assert.equal(requested.shipped, false);
assert.equal(result.requested.nexusCards!.cards!.hands[f.owner], null);
assert.equal(result.requested.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
assert.equal(result.requested.nexusRicheseCunningLast?.stage, 'pending');
assert.equal(after.reserves, 4); assert.deepEqual(after.forces, { [f.location]: 16 });
assert.equal(after.spice, 0); assert.equal(after.shipped, true); assert.equal(after.moved, 0);
assert.equal(after.noField?.deployed?.tokenId, f.concealedToken);
assert.equal(after.noField?.lastShipped, f.concealedToken);
assert.equal(presenceAt(after, f.territory), 17);
assert.equal(population(result.game).side, 'low');
assert.equal(result.game.nexusRicheseCunningLast?.stage, 'shipped');
assert.equal(result.game.nexusRicheseCunningHistory?.length, 1);
const frame = JSON.parse(result.game.nexusRicheseCunningHistory![0].frame) as { noFieldReserves?: number };
assert.equal(frame.noFieldReserves, 11);
assert.equal(viewGame(result.game, f.holder).richeseNoField?.private, null);
assert.equal(viewGame(result.game, f.holder).richeseNoField?.public.lastUsed, null);
assert.ok(player(result.game, f.holder).hand.some(card => card.id === f.karama));
const revealed = reveal(result.game, f), done = player(revealed.game, f.owner);
assert.equal(done.reserves, 1); assert.deepEqual(done.forces, { [f.location]: 19 });
assert.equal(done.noField?.deployed, null); assert.equal(done.noField?.lastShipped, f.concealedToken);
assert.equal(done.spice, 0); assert.equal(done.moved, 0);
assert.deepEqual(inventory(revealed.game), f.originalInventory);
assert.deepEqual(nexusInventory(revealed.game), f.originalNexusInventory);
assert.equal(f.sourceActionCount, f.actions.length);
});
}
void test('sixteen genuine paid companions reserve stock before the immediate five; the concealed three later reveals zero', () => {
const f = fixture({ companions: 16 });
assert.equal(f.game.turn, 3);
assert.deepEqual(f.funding.map(record => record.steps[0].action.amount), [10, 2]);
assert.equal(player(f.game, f.owner).reserves, 20);
assert.equal(player(f.game, f.owner).spice, 17);
assert.deepEqual(f.pairQuote, { paid: 16, physical: 20, priced: 17, revealed: 4 });
const result = commit(f.game, f), after = player(result.game, f.owner);
assert.equal(after.spice, 0); assert.equal(after.reserves, 0);
assert.deepEqual(after.forces, { [f.location]: 20 }); assert.equal(after.tanks, 0);
assert.equal(after.noField?.deployed?.tokenId, f.concealedToken);
const revealed = reveal(result.game, f), done = player(revealed.game, f.owner);
assert.equal(done.reserves, 0); assert.deepEqual(done.forces, after.forces);
assert.equal(done.noField?.deployed, null); assert.equal(done.noField?.lastShipped, f.concealedToken);
assert.equal(done.spice, 0); assert.equal(done.tanks, 0);
assert.equal(revealed.game.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
assert.deepEqual(inventory(revealed.game), f.originalInventory);
assert.deepEqual(nexusInventory(revealed.game), f.originalNexusInventory);
});
void test('the original Emperor Karama prevents the whole unpaid pair and paid group while its declared Nexus stays spent', () => {
const f = fixture(), before = structuredClone(player(f.game, f.owner));
const pending = act(f.game, f.owner, f.action);
assert.equal(pending.response?.kind, 'richeseNoField');
assert.equal(pending.nexusCards!.cards!.hands[f.owner], null);
const stopped = finish(act(pending, f.holder, f.cancel)), after = player(stopped, f.owner);
assert.equal(after.reserves, before.reserves); assert.deepEqual(after.forces, before.forces);
assert.equal(after.spice, before.spice); assert.equal(after.shipped, false); assert.equal(after.moved, 0);
assert.deepEqual(after.noField, before.noField); assert.equal(after.noFieldEvent, before.noFieldEvent);
assert.equal(stopped.pendingShipment ?? null, null);
assert.equal(stopped.nexusRicheseCunningLast?.stage, 'stopped');
assert.equal(stopped.nexusCards!.cards!.hands[f.owner], null);
assert.equal(stopped.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
assert.equal(stopped.discard.filter(card => card.id === f.karama).length, 1);
assert.equal(viewGame(stopped, f.owner).nexusRicheseCunning, null);
const replacement = finish(act(stopped, f.owner, f.replacement)), replaced = player(replacement, f.owner);
assert.equal(replaced.reserves, 19); assert.deepEqual(replaced.forces, { [f.location]: 1 });
assert.equal(replaced.spice, before.spice - 1); assert.equal(replaced.shipped, true);
assert.deepEqual(replaced.noField, before.noField);
assert.deepEqual(inventory(replacement), f.originalInventory);
assert.deepEqual(nexusInventory(replacement), f.originalNexusInventory);
});
void test('omitting the new reserve count preserves an original native pair priced as one with only its immediate token materialized', () => {
const f = fixture();
const legacy: Action = { type: 'ship', noField: f.concealedToken, event: f.event, revealedToken: f.revealedToken,
nexus: f.nexusEvent, territory: f.territory, sector: f.sector, allyPayment: 0 };
const result = commit(f.game, f, legacy), after = player(result.game, f.owner);
assert.equal(result.requested.pendingShipment?.noFieldReserves, undefined);
assert.equal(result.requested.pendingShipment?.noFieldSkillProof, undefined);
assert.equal(result.requested.pendingShipment?.cost, 1);
assert.equal(after.spice, player(f.game, f.owner).spice - 1);
assert.equal(after.reserves, 15); assert.deepEqual(after.forces, { [f.location]: 5 });
assert.equal(after.noField?.deployed?.tokenId, f.concealedToken);
assert.equal(after.noField?.lastShipped, f.concealedToken);
assert.equal(result.game.nexusRicheseCunningLast?.stage, 'shipped');
assert.equal(result.game.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
assert.deepEqual(inventory(result.game), f.originalInventory);
assert.deepEqual(nexusInventory(result.game), f.originalNexusInventory);
});
void test('all four existing profiles retain a legal positive paid-group pair from the original Cunning offer', () => {
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 === 'ship' && action.revealedToken &&
action.nexus === f.nexusEvent && typeof action.noFieldReserves === 'number' && action.noFieldReserves > 0);
assert.ok(action, `${difficulty} has a minimal positive paid Cunning companion path`);
const token = player(f.game, f.owner).noField!.tokens.find(token => token.id === action.revealedToken); assert.ok(token);
const quote = quoteRicheseHomeworldPair({ enabled: true, reserves: 20, amount: Number(action.noFieldReserves), revealedValue: token.value });
const price = reserveShipmentCost({ faction: 'richese', halfRate: false }, territory(String(action.territory)).type, quote.priced);
const result = commit(structuredClone(f.game), f, action), after = player(result.game, f.owner);
assert.equal(after.reserves, 20 - quote.physical);
assert.equal(Object.values(after.forces).reduce((total, count) => total + count, 0), quote.physical);
assert.equal(after.spice, player(f.game, f.owner).spice - price);
assert.equal(after.noField?.deployed?.tokenId, action.noField);
assert.equal(after.shipped, true); assert.equal(after.moved, 0);
assert.equal(result.game.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
assert.equal(result.game.nexusRicheseCunningLast?.stage, 'shipped');
assert.deepEqual(inventory(result.game), f.originalInventory);
assert.deepEqual(nexusInventory(result.game), f.originalNexusInventory);
}
});
void test('the shared AI payment guard admits the actual funded pair and rejects an unfunded paid group', () => {
const f = fixture(), view = viewGame(f.game, f.owner), before = structuredClone(view);
assert.equal(botHomeworldShipmentPaymentAllowed(view, f.action), true);
assert.equal(botHomeworldShipmentPaymentAllowed(view, { ...f.action, noFieldReserves: 16 }), false,
'Twenty available counters cannot fund a seventeen-spice invoice from the actual twelve-spice wallet');
assert.deepEqual(view, before);
});
+51 -2
View File
@@ -4,8 +4,8 @@ import { newPlayer, type Game } from '../game/engine';
import { createHomeworldCustody, quoteHomeworldCustody } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import {
richeseHomeworldShipmentEnabled, quoteRicheseHomeworldForces, RicheseHomeworldShipmentError,
type RicheseHomeworldForcesInput,
richeseHomeworldShipmentEnabled, quoteRicheseHomeworldForces, quoteRicheseHomeworldPair,
RicheseHomeworldShipmentError, type RicheseHomeworldForcesInput, type RicheseHomeworldPairInput,
} from '../game/richese-homeworld-shipment';
/** Controlled native population context, not an earned shipment chronology. */
@@ -75,3 +75,52 @@ void test('malformed physical counts and source flags reject before producing a
assert.throws(() => quoteRicheseHomeworldForces(shipment({ enabled: 'yes' as unknown as boolean })), RicheseHomeworldShipmentError);
assert.throws(() => quoteRicheseHomeworldForces(shipment({ smuggler: 1 as unknown as boolean })), RicheseHomeworldShipmentError);
});
void test('public pair reserves paid counters before materializing a scarce revealed denomination', () => {
const input: RicheseHomeworldPairInput = { enabled: true, reserves: 5, amount: 4, revealedValue: 5 };
const before = structuredClone(input);
assert.deepEqual(quoteRicheseHomeworldPair(input), { paid: 4, physical: 5, priced: 5, revealed: 1 });
assert.deepEqual(input, before);
assert.deepEqual(quoteRicheseHomeworldPair({ enabled: true, reserves: 3, amount: 3, revealedValue: 3 }),
{ paid: 3, physical: 3, priced: 4, revealed: 0 });
});
void test('public zero, three and five affect physical reveal without changing the combined paid invoice', () => {
for (const revealedValue of [0, 3, 5] as const) {
const input = { enabled: true, reserves: 10, amount: 2, revealedValue };
const before = structuredClone(input);
assert.deepEqual(quoteRicheseHomeworldPair(input),
{ paid: 2, physical: 2 + revealedValue, priced: 3, revealed: revealedValue });
assert.deepEqual(input, before);
}
});
void test('ordinary zero-paid public pairs need no High benefit and reveal only actual remaining stock', () => {
for (const revealedValue of [0, 3, 5] as const) {
const input = { enabled: false, reserves: 2, amount: 0, revealedValue };
const revealed = Math.min(2, revealedValue);
assert.deepEqual(quoteRicheseHomeworldPair(input), { paid: 0, physical: revealed, priced: 1, revealed });
assert.deepEqual(quoteRicheseHomeworldPair({ ...input, reserves: 0 }),
{ paid: 0, physical: 0, priced: 1, revealed: 0 });
}
assert.throws(() => quoteRicheseHomeworldPair({ enabled: false, reserves: 10, amount: 1, revealedValue: 3 }), /High population/);
assert.throws(() => quoteRicheseHomeworldPair({ enabled: true, reserves: 2, amount: 3, revealedValue: 5 }), /physical reserves/);
});
void test('public pair rejects invalid denominations and reuses safe physical count validation without mutation', () => {
const invalid: unknown[] = [-1, 1, 2, 4, 6, 3.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '3', null, undefined];
for (const value of invalid) {
const input: RicheseHomeworldPairInput = {
enabled: true, reserves: 10, amount: 2, revealedValue: value as RicheseHomeworldPairInput['revealedValue'],
};
const before = structuredClone(input);
assert.throws(() => quoteRicheseHomeworldPair(input), RicheseHomeworldShipmentError);
assert.deepEqual(input, before);
}
for (const field of ['reserves', 'amount'] as const) for (const value of [-1, 0.5, 21, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
const input: RicheseHomeworldPairInput = { enabled: true, reserves: 10, amount: 2, revealedValue: 3, [field]: value };
const before = structuredClone(input);
assert.throws(() => quoteRicheseHomeworldPair(input), RicheseHomeworldShipmentError);
assert.deepEqual(input, before);
}
});