Add prepayment Guild rate Karama response

This commit is contained in:
Quintine committed 2026-09-29 15:32:49 +10:00
1 parent 745e4fa8ab
commit 8a23b5ccd2
17 files changed
+565 -19

No files matched your search

+5 -4
View File
@@ -606,10 +606,10 @@ export function GameTable({
}
const shipmentRate = {
faction: me.faction,
halfRate:
me.faction === 'guild' ||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally) ||
g.karamaShipping?.player === me.id,
halfRate: g.karamaShipping?.player === me.id ||
(!g.guildRateCanceled &&
(me.faction === 'guild' ||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally))),
};
const smugglerOffer = validShipmentAmount && !useGuildSecret && !useRicheseShipment
? quoteSmugglerShipment(g, me.id, selected, amount) : null;
@@ -2000,6 +2000,7 @@ export function GameTable({
harkonnenBonus: 'Harkonnen bonus card',
harkonnenTraitor: 'Harkonnen ally traitor',
guildIncome: 'Guild shipment income',
guildRate: 'Guild half-price shipment rate',
richeseNoField: 'Richese No-Field shipment',
advisor: 'Spiritual advisor',
emperorGift: 'Emperor spice gift',
+13
View File
@@ -6,6 +6,19 @@ Expansion starts and full rules/publication acceptance remain gated.**
## Current checkpoint and work
A bounded [Guild reserve-rate Karama path](GUILD_RATE_KARAMA_RULES.md#selected-ordinary-reserve-interpretation--28-september-2026)
now interrupts a Guild or reciprocal ally's ordinary shipment before payment
in classic Basic/Advanced tables without optional modules. Canceling the rate
charges full price for the same forces and originally approved funding split.
If that split cannot pay, the Karama is spent but the uncommitted shipment
returns; its replacement uses full price until turn end. This withdrawal
policy is a conservative implementation interpretation, not a publisher
affordability ruling. Guild income and independently purchased Karama rates
remain separate. Saved room/CAS recovery, a legal AI path and a local
read-only browser preview of the cancel button and full-price replacement
quote are connected. Cross-shipment, return and optional-module rate
cancellation remain gated; this change is not in the verified live image.
Moritani Extortion now has a bounded local reveal-to-Mentat continuation:
five bank spice stays reserved until Moritani finishes normal Terror placement
and any Karama response; then non-Moritani seats in storm order may pay three
+13 -5
View File
@@ -1,10 +1,18 @@
# Guild shipment rates and ordinary Karama
Bounded primary-source and engine audit, 2026-09-06. This records the missing cancellation contract; it does not activate a new rule or resolve the pending affordability choice.
Initial primary-source and engine audit, 2026-09-06. The bounded ordinary-reserve cancellation policy below was selected on 2026-09-28 after a targeted publisher-source search found no affordability procedure; the later original audit remains historical evidence for unfinished transport and module combinations.
## Implemented payment follow-up
The shared contributor-income calculation and private Guild transport quotes are now integrated and verified. November2020 direct contribution routing now also credits a non-Guild ally’s contribution when Guild is the shipper; Guild’s own contribution still goes to the bank. This corrects the reverse-direction omission not identified in the original audit below. The source adjudication, 1,600-check checkpoint and browser evidence are in [GUILD_SHIPMENT_PAYMENTS.md](GUILD_SHIPMENT_PAYMENTS.md). It does not implement the unresolved cancellation/intent contract.
The shared contributor-income calculation and private Guild transport quotes were integrated in the earlier payment checkpoint. November 2020 direct contribution routing credits a non-Guild ally’s contribution when Guild ships; Guild’s own contribution still goes to the bank. The source adjudication, earlier 1,600-check checkpoint and browser evidence are in [GUILD_SHIPMENT_PAYMENTS.md](GUILD_SHIPMENT_PAYMENTS.md). That checkpoint preceded the bounded rate-cancellation path below.
## Selected ordinary-reserve interpretation — 28 September 2026
In classic-faction Basic and Advanced tables without optional modules, Guild or its reciprocal ally declaring an ordinary reserve shipment at the Guild rate opens a **prepayment** Karama response. Advanced Guild's separate special shipment-stop decision resolves first. A Karama spent against the rate does not cancel Guild income, permission to ship, or an independent Karama-purchased discount. The original number and types of forces, destination and approved ally contribution are fixed while opponents respond.
If Karama cancels the rate, recalculate the same force shipment at the normal one/two-spice-per-force price. If the shipper and the **original approved split** can fund it, transfer that exact amount and then commit the original shipment once; another faction's pledge is never silently increased. Otherwise the declaration returns with no spice, forces or shipment allowance spent. The responding Karama is still discarded. A replacement ordinary shipment in this turn uses full price; the restriction expires at turn end. An independent purchased Karama rate remains separate. This is the recommended conservative software interpretation where the publisher does not prescribe the unaffordable outcome: no forced debt, retroactive cancellation or unauthorized ally charge. It is **not** an asserted GF9 timing/refund ruling. [GF9 November FAQ, p. 7](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=7)
Guild cross-planet and return shipments, optional-module rate sources, Homeworld advantages and special combinations still need their own integration; this first version does not certify the complete Guild faction or public Advanced rules.
## Confirmed source contract
@@ -38,9 +46,9 @@ The retrieved publisher sources do **not** state whether a declared shipment can
Likewise, no retrieved primary passage orders ordinary rate cancellation against Guild's Advanced shipment-stop declaration, a second payment-contributor choice, or a newly played independent rate card. The base Advanced Guild power stops a shipment; it is not the ordinary discount cancellation. Existing implementation's canceled-special-shipment settlement is already marked provisional and must not silently become authority for this new case.
A pre-payment declaration window is a safe software architecture for applying the printed price effect without undoing arrival. Its exact user-visible sequencing remains implementation composition. Parent has asked the user about the unaffordable outcome; this audit does not supply that answer.
For the bounded ordinary reserve path, the user’s later instruction to choose the conservative recommended option where no publisher ruling exists is applied above. Other transport, repeated-declaration and combined-response questions retain the source boundaries below until connected.
## Current implementation findings
## Initial implementation findings (before the bounded rate response)
- `ship` computes a valid discounted quote and exact typed reserve count. Its existing `pendingShipment` only pauses for Advanced Guild's special shipment-stop choice. Ordinary games call `commitShipment` directly.
- `commitShipment` pays, transfers forces, marks shipment used and generates technology/arrival effects before opening `guildIncome`. That existing response can redirect income, but is too late for discount cancellation. Reusing it to reprice would require undoing already-settled effects and is unsafe.
@@ -51,7 +59,7 @@ A pre-payment declaration window is a safe software architecture for applying th
Proposed intent fields are shipper ID, source kind and exact force groups, destination/sector, typed amounts/advisor stance, turn/phase, rate source, quoted costs, and chosen ally contribution. Record which one-use windows have resolved, so JSON resume cannot reopen a canceled discount or replay payments. Revalidate custody, legal destination, alliance and contribution before committing once. This is a software proposal, not a stored arbitrary Action or printed rule.
## Focused checks before integration
## Initial focused acceptance targets
Cover own and allied reserve shipments at both destination prices; odd force counts; cross/return transport separately; free Fremen; independent Karama rates; explicit/default aid splits; Guild versus non-Guild contributors; separate income cancellation; nested Bene Gesserit Worthless conversion and cancellation; JSON reload/stale submissions; unchanged forces/payment/technology before settlement; and exactly one final commit. Include both sufficient and insufficient full-price budgets, but bind the latter tests only after the pending interpretation is selected.
+37
View File
@@ -1,5 +1,42 @@
# Dune implementation status
## 29 September 2026 — bounded Guild rate Karama before payment
In classic-faction Basic/Advanced tables without optional modules, an ordinary
Guild or reciprocal ally reserve shipment priced at the Guild half-rate waits
for a Karama response before any spice or forces move. Advanced Guild's
separate special stop resolves first. A canceled discount reprices the exact
declared force cohort at full destination tariff without enlarging the
original approved ally contribution. If that split cannot fund full price,
the spent Karama withdraws the uncommitted shipment; a later ordinary
declaration in the same turn uses full price. This conservative withdrawal is
an explicit table policy, not an identified GF9 refund ruling. Independent
Karama rates and later Guild-income cancellation remain distinct; Guild
cross-shipment, return and optional-module combinations remain gated.
New focused engine tests cover physical custody, exact funding, unaffordable
withdrawal/replacement and turn expiry, Advanced stop priority, BG Worthless
conversion, legal AI, corrupt saves and validated unstamped Advanced saves.
An in-memory SQLite room test exercises authenticated private views, restart,
CAS race and duplicate cancellation. A direct non-test engine smoke showed
20 Guild spice and 20 reserves held during response, then 17 spice, 17
reserves, three Arrakeen forces and exactly one discarded Karama after JSON
restore/cancellation. In a local read-only Chromium QA view, the cancel
button was visible before payment; after unaffordable cancellation the
replacement form quoted **two spice for two Arrakeen forces**, selected the
existing two-spice ally pledge and enabled shipment. No live room was
modified. Independent review identified and verified fixes for the quote
projection and older unstamped Guild-stop declarations. This is a bounded
prototype, not complete Guild or deployed evidence; see
[the publisher distinction and selected policy](GUILD_RATE_KARAMA_RULES.md).
The focused Guild rate, existing Guild income, pending shipment and BG Karama
group passed 43 cases in seven files. All 5,689 offline tests, the local
client/server build and all 54 HTTP integration cases passed. `npm run check`
stops in TypeScript on a separate concurrent untracked Fremen Nexus draft
referencing missing `Player.fremenNexusMovementBlockedTurn`; the separate lint
command reports only that same draft error. No source or saved room was reset.
## 28 September 2026 — Moritani Extortion saved Mentat continuation
The real Terror-entry reveal now reserves five bank spice without crediting
+1
View File
@@ -23,6 +23,7 @@ Updated 14 September 2026. This is the authoritative navigation index for existi
| Source and readiness | Separate published rules, explicit implementation choices and unfinished interpretations; Advanced/expansion gates stay closed. | [Base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md), [checklist](../game/reference.ts) |
| Setup and Fremen movement | Preserve source-ordered setup and the distinct declared-movement cancellation; do not recreate fixed historical gaps. | [Setup contract](ADVANCED_SETUP_TEST_SEAM.md), [Fremen Karama](FREMEN_MOVEMENT_KARAMA_RULES.md) |
| Guild payment | Rate, transport permission and income are distinct; power owner, shipper, contributors and independent rate-card beneficiary remain distinct. | [Rate/cancellation sources](GUILD_RATE_KARAMA_RULES.md), [implemented payment routing](GUILD_SHIPMENT_PAYMENTS.md) |
| Guild discounted reserve shipment Karama | In bounded classic-faction Basic/Advanced tables, a separate prepayment response can cancel Guild’s or its ally’s half rate on an ordinary reserve shipment without canceling transport permission or income. The original authorized contributor split must cover full price; otherwise the Karama is spent but the uncommitted declaration returns and a replacement this turn uses full price. This conservative withdrawal is an implementation interpretation, not a found publisher procedure; independent Karama-purchased rates, cross-shipments and modules remain separate. | [Publisher distinction and selected ordinary-reserve policy](GUILD_RATE_KARAMA_RULES.md#selected-ordinary-reserve-interpretation--28-september-2026) |
| Deferred shipment arrival | Quote unsupported ordinary arrivals before saving Guild interception. A validated legacy ordinary declaration may return unspent on the typed arrival error; corrupted declarations and special parent/card receipts are not silently cleared. This repairs continuation without deciding simultaneous reaction priority. | [Reproduction and bounded recovery](DEFERRED_SHIPMENT_ARRIVAL.md) |
| Truthtrance | Current facts are boolean disclosures, not promises to preserve resources. Structured Basic and base Advanced active-opportunity shipment promises bind within their documented scope (Advanced excludes Guild and optional modules); prose is not automatically enforced. | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card facts](TRUTHTRANCE_CARD_COUNT.md), [shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md), [remaining audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) |
| Truthtrance hand categories | Count current physical hand custody by explicitly named primary role; alternate battle slots and faction powers do not reclassify cards. Only an aggregate answer is disclosed. | [Hand inventory](TRUTHTRANCE_HAND_INVENTORY.md) |
+1
View File
@@ -121,6 +121,7 @@ const responses = {
harkonnenBonus: true,
harkonnenTraitor: true,
guildIncome: true,
guildRate: true,
emperorGift: true,
emperorRevival: true,
stormPeek: true,
+4 -4
View File
@@ -3110,10 +3110,10 @@ function policyActions(g: GameView): Action[] {
if (g.ornithopter?.active && me.shipped) return [{ type: 'endMovement' }];
const actions: Action[] = [...guildHomeworldShipmentActions(g, level), ...homeworldShipmentActions(g, level)];
const targets = destinations(g);
const halfRate =
me.faction === 'guild' ||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally) ||
g.karamaShipping?.player === me.id;
const halfRate = g.karamaShipping?.player === me.id ||
(!g.guildRateCanceled &&
(me.faction === 'guild' ||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally)));
const shipmentBudget = (me.spice ?? 0) + g.aid.available;
const guildSecret = nexusGuildSecretAllyCanAct(g);
const noField = g.richeseNoField;
+109 -2
View File
@@ -917,6 +917,8 @@ export type ResponseWindow = {
source?: 'ambassador';
intent?: string;
advisors?: boolean;
/** Saved exact ordinary Guild-rate response; not the independent Karama rate. */
guildRateEvent?: string;
advisorResume?: 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry';
discoveryEntry?: DiscoveryEntryArrivalChild;
advisorAmbassadorEvent?: string;
@@ -946,6 +948,7 @@ export type ResponseWindow = {
| 'harkonnenBonus'
| 'harkonnenTraitor'
| 'guildIncome'
| 'guildRate'
| 'emperorGift'
| 'emperorRevival'
| 'stormPeek'
@@ -1018,6 +1021,7 @@ type PendingShipment = {
smugglerCompanion?: SmugglerNoFieldCompanion;
noFieldSkillProof?: string;
guildSecretEvent?: string;
guildRateEvent?: string;
guildNexusEvent?: string;
nexusEvent?: string;
richesePair?: { event: string; revealedTokenId: string };
@@ -1565,6 +1569,8 @@ export type Game = {
>;
} | null;
pendingShipment?: PendingShipment | null;
/** One canceled unaffordable declaration may be redeclared at full price this turn. */
guildRateBlocked?: { turn: number; player: string };
pendingHomeworldShipment?: PendingHomeworldShipment | null;
junctionOffer?: JunctionOffer | null;
pendingExchange?: {
@@ -14059,6 +14065,7 @@ function completePhase(g: Game) {
observeOccupation(g, 'turnEnd');
g.turn++;
g.recruits = null;
delete g.guildRateBlocked;
g.phase = 0;
observeOccupation(g, 'turnStart');
}
@@ -18860,6 +18867,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
if (response.kind === 'nexusFremenCunning') currentFremenCunningResponse(g, response);
if (response.kind === 'nexusEcazBetrayal') currentEcazBetrayalResponse(g, response);
if (response.kind === 'nexusGuildCunning') validateGuildCunningResponse(g,response);
if (response.kind === 'guildRate') validateGuildRateResponse(g, response);
if (response.kind === 'moritaniPlacement') {
// Both outcomes need the same physical inventory and declared source.
// This detached denial quote is only validation here; allowance still
@@ -18900,6 +18908,10 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
: null;
if (!canceled && bureaucratDiversion === undefined && offerBureaucratPayment(g,response.bureaucratPayment,{kind:'response'})) return;
g.response = null;
if (response.kind === 'guildRate') {
finishGuildRateResponse(g, response, canceled);
return;
}
if (response.kind === 'nexusEcazBetrayal') {
finishEcazBetrayalResponse(g, response, canceled);
return;
@@ -20358,6 +20370,90 @@ function validateGuildShipmentDecision(
validatePhysicalShipment(g, shipment);
return shipment;
}
/** The independent Karama rate is a card benefit, not Guild's cancelable rate. */
function ordinaryGuildRateFullCost(g: Game, shipment: PendingShipment): number | null {
const guild = byFaction(g, 'guild');
const p = getPlayer(g, shipment.player);
if (!guild || (p.id !== guild.id && !(p.ally === guild.id && guild.ally === p.id)) ||
g.expansions.length || g.homeworlds || g.leaderSkills || g.nexusCards ||
g.discoveryEnabled || g.techTokens || g.strongholdCards ||
g.karamaShipping?.player === p.id ||
(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id) ||
shipment.source || shipment.noField || shipment.alliedNoField ||
shipment.smuggler || shipment.smugglerCompanion || shipment.guildSecretEvent ||
shipment.guildNexusEvent || shipment.nexusEvent || shipment.richesePair)
return null;
const fullCost = reserveShipmentCost({ faction: p.faction, halfRate: false },
territory(shipment.territory).type, shipment.amount);
return shipment.cost < fullCost ? fullCost : null;
}
function openGuildRate(g: Game, shipment: PendingShipment): boolean {
const fullCost = ordinaryGuildRateFullCost(g, shipment);
if (fullCost === null) return false;
// A validated older Guild-stop declaration may lack this newer turn stamp.
shipment.turn ??= g.turn;
const event = crypto.randomUUID();
shipment.guildRateEvent = event;
g.pendingShipment = shipment;
g.response = {
kind: 'guildRate',
owner: byFaction(g, 'guild')!.id,
passed: [],
guildRateEvent: event,
intent: `${getPlayer(g, shipment.player).name} declared ${shipment.amount} reserve forces to ${territory(shipment.territory).name} at the Guild rate of ${shipment.cost} spice. Karama can remove this discount before any payment or arrival. The original forces would cost ${fullCost} at full price; if the original authorized split cannot pay that amount, the declaration returns unused and a replacement this turn uses full price.`,
};
log(g, `${getPlayer(g, shipment.player).name} declared a Guild-rate reserve shipment; payment and arrival wait for the rate response.`);
return true;
}
function validateGuildRateResponse(g: Game, response: ResponseWindow) {
const shipment = g.pendingShipment;
const guild = byFaction(g, 'guild');
const p = shipment && getPlayer(g, shipment.player);
const fullCost = shipment ? ordinaryGuildRateFullCost(g, shipment) : null;
requireRule(response.kind === 'guildRate' && guild && p && shipment &&
g.status === 'playing' && g.phase === 5 && g.active === p.id && !p.shipped &&
response.owner === guild.id && typeof shipment.guildRateEvent === 'string' &&
shipment.guildRateEvent.length > 0 &&
response.guildRateEvent === shipment.guildRateEvent &&
shipment.turn === g.turn && fullCost !== null &&
shipment.cost === reserveShipmentCost({ faction: p.faction, halfRate: true },
territory(shipment.territory).type, shipment.amount),
'The saved Guild-rate response lost its original physical shipment.');
return { shipment, fullCost };
}
function guildRateIntegrity(g: Game) {
const pending = g.pendingShipment;
const responses = savedNoFieldResponses(g).filter(response => response.kind === 'guildRate');
requireRule(responses.length === Number(!!pending?.guildRateEvent),
'The saved Guild-rate shipment lost its single Karama response.');
if (responses.length) validateGuildRateResponse(g, responses[0]);
const blocked = g.guildRateBlocked;
if (blocked)
requireRule(blocked.turn === g.turn && g.players.some(p => p.id === blocked.player),
'The canceled Guild discount belongs to another turn or player.');
}
function finishGuildRateResponse(g: Game, response: ResponseWindow, canceled: boolean) {
const { shipment, fullCost } = validateGuildRateResponse(g, response);
const shipper = getPlayer(g, shipment.player);
g.pendingShipment = null;
delete shipment.guildRateEvent;
if (!canceled) {
commitShipment(g, shipment);
return;
}
g.guildRateBlocked = { turn: g.turn, player: shipper.id };
const bounds = shipmentPaymentBounds(fullCost, shipper.spice,
aidFor(g, shipper)?.amount ?? 0);
if (shipment.allyPayment < bounds.minimum || shipment.allyPayment > bounds.maximum) {
log(g, `Karama removed Guild's half-price rate from ${shipper.name}'s declaration. Its original approved payment split cannot fund the full price, so no spice or forces were spent and shipment remains available at full price this turn.`);
return;
}
shipment.cost = fullCost;
checkShipmentIncomeRounding(g, shipper, fullCost, shipment.allyPayment);
commitShipment(g, shipment);
delete g.guildRateBlocked;
log(g, `Karama removed Guild's half-price rate. ${shipper.name} paid the full ${fullCost} spice for the unchanged shipment.`);
}
function offerShipment(g: Game, shipment: PendingShipment) {
validatePhysicalShipment(g, shipment);
validateShipmentArrival(g, shipment);
@@ -20390,7 +20486,7 @@ function offerShipment(g: Game, shipment: PendingShipment) {
g,
`${p.name} declared a shipment of ${shipment.noField ? `one concealed No-Field${shipment.smugglerCompanion ? ' and one free Smuggler force' : ''}` : `${n} forces`} to ${territory(to).name}, sector ${s}.${shipment.nexusEvent ? ` Richese Secret Ally is spent; the shipment is priced as one force (${shipment.cost} spice) if allowed.` : ''}`,
);
} else commitShipment(g, shipment);
} else if (!openGuildRate(g, shipment)) commitShipment(g, shipment);
}
function commitShipment(g: Game, shipment: PendingShipment) {
if (shipment.source === 'ambassador') {
@@ -20992,7 +21088,9 @@ function richesePairIntegrity(g: Game) {
}
}
function shipmentHalfRate(g: Game, p: Player) {
return p.faction === 'guild' || byFaction(g,'guild')?.id === p.ally || g.karamaShipping?.player === p.id;
return g.karamaShipping?.player === p.id ||
((p.faction === 'guild' || byFaction(g,'guild')?.id === p.ally) &&
!(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id));
}
function nexusRicheseSignature(record: NonNullable<Game['nexusRicheseHistory']>[number]) {
return JSON.stringify([record.receipt.signature,record.stage,record.frame,record.halfRate]);
@@ -22179,6 +22277,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
ixRicheseTechnologyIntegrity(state);
leaderSkillsIntegrity(state);
moritaniExtortionIntegrity(state);
guildRateIntegrity(state);
discoveryIntegrity(state);
greatMakerIntegrity(state);
nexusChoamTradeIntegrity(state);
@@ -22361,6 +22460,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
ixRicheseTechnologyIntegrity(g);
harkonnenExchangeIntegrity(g);
moritaniExtortionIntegrity(g);
guildRateIntegrity(g);
return g;
}
function finishActionContinuations(g: Game) {
@@ -22469,6 +22569,7 @@ export function normalizeAutomaticGame(state: Game): Game {
ixRicheseTechnologyIntegrity(state);
leaderSkillsIntegrity(state);
moritaniExtortionIntegrity(state);
guildRateIntegrity(state);
discoveryIntegrity(state);
greatMakerIntegrity(state);
nexusChoamTradeIntegrity(state);
@@ -22523,6 +22624,7 @@ export function normalizeAutomaticGame(state: Game): Game {
ixRicheseTechnologyIntegrity(g);
harkonnenExchangeIntegrity(g);
moritaniExtortionIntegrity(g);
guildRateIntegrity(g);
return g;
}
@@ -23823,8 +23925,10 @@ function applyActionInner(
);
return g;
}
if (!openGuildRate(g, shipment)) {
g.pendingShipment = null;
commitShipment(g, shipment);
}
} else if (decision.kind === 'nullentropy') {
throw new RuleError(
'No matching paid search was saved; restore the saved search before continuing.',
@@ -26331,6 +26435,7 @@ export function viewGame(state: Game, id: string) {
ixRicheseTechnologyIntegrity(state);
leaderSkillsIntegrity(state);
moritaniExtortionIntegrity(state);
guildRateIntegrity(state);
discoveryFlightIntegrity(state);
discoveryIntegrity(state);
greatMakerIntegrity(state);
@@ -26791,6 +26896,8 @@ export function viewGame(state: Game, id: string) {
karamaShipping: g.karamaShipping
? { player: g.karamaShipping.player, owner: g.karamaShipping.owner }
: null,
guildRateCanceled: g.guildRateBlocked?.turn === g.turn &&
g.guildRateBlocked.player === id,
stormPending: g.stormPending ?? null,
stormForecast:
me.faction === 'fremen' && g.stormCardKnown ? g.stormCard : null,
+1
View File
@@ -116,6 +116,7 @@ export const FACTION_RULES: Record<
'Start with five forces in Tuek’s Sietch, fifteen in reserve and five spice. One force revival is free each turn.',
'Choose one shipment: reserves to Dune, one on-planet territory to another, or one on-planet territory back to reserves. Movement follows shipment.',
'Pay half the normal shipment rate, rounding the total cost up. Returning forces cost one spice per two tokens, rounded up.',
'In classic-faction games without optional modules, a Guild or allied ordinary reserve shipment at this rate waits for a Karama response before paying. A canceled discount charges the full tariff for the same forces if the original approved payment split can fund it. Otherwise the declaration is withdrawn without consuming shipment, and a replacement this turn uses full price. This safe withdrawal timing is a table interpretation; cross-shipment, return and optional-module rate cancellation still need integration.',
'Receive spice for other factions’ ordinary off-planet shipments and allied cross-shipments. Karama shipping and a canceled Guild payment go to the bank. Fremen’s normal reinforcements provide no income. Any spice the Guild contributes toward its ally’s shipment goes to the bank.',
'Allies can ship from off-planet or cross-ship on Dune at half price. They cannot ship back to reserves. Allied Fremen may pay to cross-ship from southern reserves to any legal destination. Allied cross-shipments pay the Guild; any Guild-funded share goes to the bank.',
'If the game reaches its end without another qualifying victory, Guild and its ally win through the Guild special victory.',
+6
View File
@@ -132,6 +132,12 @@ const cancellationSources = {
calls: g.battle?.traitorCalls ?? null,
}),
guildIncome: () => null,
guildRate: (g) => ({
pending: g.pendingShipment ?? null,
active: g.active,
discountedRateBlocked: g.guildRateBlocked ?? null,
purchasedRate: g.karamaShipping ?? null,
}),
emperorGift: () => null,
emperorRevival: () => null,
stormPeek: (g) => ({ card: g.stormCard, dialers: g.stormDialers }),
+2
View File
@@ -140,6 +140,7 @@ const phaseDetails = [
[
'At the start of this phase, resolve the Atreides spice foresight response. If allowed, Atreides privately inspects the next card, including when using double spice blow.',
'Select a destination and sector. Ship from reserves before making a force move.',
'A classic Guild or reciprocal ally’s discounted reserve shipment waits for a Karama response before paying. Cancellation uses the full normal rate for the same forces and approved payment split; if that split cannot pay, the declaration returns unused and a replacement this turn uses full price. This withdrawal timing is an implementation interpretation, not a printed FAQ procedure. Independent Karama-purchased rates, Guild cross-shipment and return have separate boundaries.',
'For movement, select the source forces. Enable Combine sectors to include several groups from the same territory.',
'A Fremen move using the two-territory advantage has a Karama response. Cancellation leaves the forces in place and limits this move to one territory; city ornithopters and a later extra move keep their normal rules.',
'Guild transport can move a group across the planet or return it to reserves. Ordinary movement is checked against range, storm and stronghold occupancy.',
@@ -2407,6 +2408,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'If eligible to bid, use the card to take the current auction card without paying spice. A full hand cannot bypass the bidding limit.',
'Holding Karama also permits bids beyond your available spice. A winner can choose to spend it; an outbid player keeps it.',
'Use Cancel with Karama during a power response. Supported responses include Voice, prescience, Atreides auction and spice peeks, spiritual advisors, Emperor gifts and revival, faction income, Harkonnen bonus cards and allied traitors, plus Fremen storm and worm powers, elite combat strength and Fremen free spice support.',
'In classic-faction games without optional modules, cancel Guild’s half-price rate on Guild’s or its ally’s declared ordinary reserve shipment before any spice or forces move. Full price applies if the original approved payment split can afford it; otherwise the declaration returns unused and its replacement is full price this turn. Guild income cancellation and the independent rate purchased by Karama remain distinct. The unaffordable withdrawal is a documented table policy, not a printed refund instruction.',
'During shipment, use Guild rates for the active player before they ship. They choose and pay for their shipment at the rounded half rate, paid to the bank. You may provide this benefit for another player; it expires if they move or finish without shipping.',
'Advanced Bene Gesserit may spend Worthless cards as Karama. Each conversion opens a separate cancellation response; the Worthless card is discarded even if the power is canceled. Normal Karama cards resolve without that conversion response.',
'A pending Worthless purchase keeps space for its original auction card. Gifts and hand exchanges must leave enough room for every committed incoming card. Refreshing restores the same auction, unused shipment opportunity or original canceled power.',
+1
View File
@@ -107,6 +107,7 @@ const RESPONSES = {
harkonnenBonus: true,
harkonnenTraitor: true,
guildIncome: true,
guildRate: true,
emperorGift: true,
emperorRevival: true,
stormPeek: true,
+8 -1
View File
@@ -297,7 +297,14 @@ for (const cancel of [false, true])
for (const route of ['reserve', 'cross'] as const) {
const f = await fixture(route, true);
try {
await f.action(0, f.shipment);
const announced = await f.action(0, f.shipment);
if (route === 'reserve') {
assert.equal(announced.response?.kind, 'guildRate');
assert.equal(announced.players[0].spice, 20);
assert.equal(announced.players[0].shipped, false);
const allowed = await f.action(2, { type: 'passResponse' });
assert.equal(allowed.response?.kind, 'guildIncome');
}
const pending = await restored(f);
paidOnce(f, pending, 19);
assert.equal(pending.response?.kind, 'guildIncome');
+6 -1
View File
@@ -168,7 +168,12 @@ void test('Guild income from either contributor direction can be allowed or canc
const { g, shipper, donor } = fixture(direction, route);
const card = holdKarama(g, 'a');
const beforeGuild = player(g, 'g').spice;
const pending = applyAction(g, shipper, shipment(route, 1));
let pending = applyAction(g, shipper, shipment(route, 1));
if (route === 'reserve') {
assert.equal(pending.response?.kind, 'guildRate');
assert.equal(player(pending, shipper).shipped, false);
pending = applyAction(pending, 'a', { type: 'passResponse' });
}
assert.equal(pending.response?.kind, 'guildIncome');
assert.equal(pending.response?.owner, 'g');
assert.equal(pending.response?.amount, 1);
+259
View File
@@ -0,0 +1,259 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { botActions } from '../game/bots';
const player = (g: Game, id: string) => g.players.find(seat => seat.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function fixture(shipper: 'g' | 'e', advanced = false) {
const g = createGame('GUILDRATE', newPlayer('g', 'Guild', 'guild'), advanced);
g.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('a', 'Atreides', 'atreides'));
g.status = 'playing';
g.phase = 5;
g.turn = 2;
g.storm = 18;
g.order = ['g', 'e', 'a'];
g.movementRemaining = [...g.order];
g.active = shipper;
g.deck = baseDeck();
for (const seat of g.players) {
seat.forces = {};
seat.reserves = 20;
seat.spice = 20;
seat.hand = [];
}
if (shipper === 'e') {
player(g, 'e').ally = 'g';
player(g, 'g').ally = 'e';
}
const index = g.deck.findIndex(card => card.effect === 'karama');
const karama = g.deck.splice(index, 1)[0];
player(g, 'a').hand.push(karama);
return { g, karama };
}
function declare(g: Game, id: 'g' | 'e', amount: number, destination = 'arrakeen'): Game {
return applyAction(g, id, {
type: 'ship', territory: destination, sector: destination === 'arrakeen' ? 10 : 7,
amount, allyPayment: 0,
});
}
function cancel(g: Game, card: string): Game {
assert.equal(g.response?.kind, 'guildRate');
return applyAction(reload(g), 'a', { type: 'card', mode: 'cancel', card });
}
function reject(g: Game, actor: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before);
}
void test('Karama removes Guild own discount before any forces or spice move, then charges full stronghold cost', () => {
const { g: initial, karama } = fixture('g');
const offered = declare(initial, 'g', 3);
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(offered.pendingShipment?.cost, 2);
assert.equal(player(offered, 'g').spice, 20);
assert.equal(player(offered, 'g').reserves, 20);
assert.equal(player(offered, 'g').shipped, false);
const ownerView = viewGame(offered, 'g');
assert.equal(ownerView.response?.kind, 'guildRate');
assert.equal('spice' in ownerView.players.find(seat => seat.id === 'a')!, false);
assert.equal(viewGame(offered, 'a').responseControls?.cancelCards.includes(karama.id), true);
assert.deepEqual(normalizeAutomaticGame(reload(offered)), reload(offered));
const done = cancel(offered, karama.id);
assert.equal(done.pendingShipment, null);
assert.equal(done.guildRateBlocked, undefined);
assert.equal(player(done, 'g').spice, 17);
assert.equal(player(done, 'g').reserves, 17);
assert.equal(player(done, 'g').forces['arrakeen:10'], 3);
assert.equal(player(done, 'g').shipped, true);
assert.equal(done.discard.filter(card => card.id === karama.id).length, 1);
reject(done, 'a', { type: 'card', mode: 'cancel', card: karama.id });
});
void test('the Guild ally can be repriced to full desert tariff without changing the approved payment split', () => {
const { g: initial, karama } = fixture('e');
const offered = declare(initial, 'e', 3, 'red_chasm');
assert.equal(offered.pendingShipment?.cost, 3);
const done = cancel(offered, karama.id);
assert.equal(player(done, 'e').spice, 14);
assert.equal(player(done, 'e').reserves, 17);
assert.equal(player(done, 'e').forces['red_chasm:7'], 3);
assert.equal(player(done, 'g').spice, 26);
assert.equal(done.guildRateBlocked, undefined);
});
void test('unaffordable full price releases the physical declaration, spends Karama, and preserves one full-price replacement', () => {
const { g: initial, karama } = fixture('e');
player(initial, 'e').spice = 2;
const offered = declare(initial, 'e', 4);
assert.equal(offered.pendingShipment?.cost, 2);
const withdrawn = cancel(offered, karama.id);
assert.equal(player(withdrawn, 'e').spice, 2);
assert.equal(player(withdrawn, 'e').reserves, 20);
assert.equal(player(withdrawn, 'e').shipped, false);
assert.equal(withdrawn.pendingShipment, null);
assert.deepEqual(withdrawn.guildRateBlocked, { turn: 2, player: 'e' });
assert.equal(viewGame(withdrawn, 'e').guildRateCanceled, true);
assert.equal(viewGame(withdrawn, 'a').guildRateCanceled, false);
const botState = reload(withdrawn);
player(botState, 'e').bot = 'Easy';
const botShipment = botActions(viewGame(botState, 'e')).find(action => action.type === 'ship');
assert.ok(botShipment, 'The payer must retain a legal full-price AI shipment.');
const botCompletion = applyAction(botState, 'e', botShipment);
assert.equal(player(botCompletion, 'e').shipped, true);
assert.ok(player(botCompletion, 'e').reserves < 20);
assert.equal(withdrawn.discard.filter(card => card.id === karama.id).length, 1);
reject(withdrawn, 'e', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 4 });
const replacement = declare(reload(withdrawn), 'e', 2);
assert.equal(replacement.response?.kind === 'guildRate', false);
assert.equal(player(replacement, 'e').spice, 0);
assert.equal(player(replacement, 'e').reserves, 18);
assert.equal(player(replacement, 'e').forces['arrakeen:10'], 2);
assert.equal(replacement.guildRateBlocked?.player, 'e');
});
void test('a canceled unspent Guild discount expires after a completed turn', () => {
const { g: initial, karama } = fixture('e');
player(initial, 'e').spice = 2;
let g = cancel(declare(initial, 'e', 4), karama.id);
assert.equal(viewGame(g, 'e').guildRateCanceled, true);
for (let turn = 0; g.phase === 5 && turn < g.players.length; turn++)
g = applyAction(g, g.active!, { type: 'endMovement' });
assert.equal(g.phase, 7);
for (const phase of [7, 8]) {
assert.equal(g.phase, phase);
for (const seat of g.players) g = applyAction(g, seat.id, { type: 'ready' });
}
assert.equal(g.turn, 3);
assert.equal(g.guildRateBlocked, undefined);
assert.equal(viewGame(g, 'e').guildRateCanceled, false);
});
void test('approved allied contribution stays fixed while full-price Guild shipment routes only that contribution back', () => {
const { g: initial, karama } = fixture('g');
player(initial, 'g').ally = 'e';
player(initial, 'e').ally = 'g';
const funded = applyAction(initial, 'e', { type: 'pledgeAid', amount: 2 });
const offered = applyAction(funded, 'g', {
type: 'ship', territory: 'arrakeen', sector: 10, amount: 3, allyPayment: 1,
});
assert.equal(offered.pendingShipment?.cost, 2);
assert.equal(offered.pendingShipment?.allyPayment, 1);
const done = cancel(offered, karama.id);
assert.equal(player(done, 'g').spice, 19);
assert.equal(player(done, 'e').spice, 18);
assert.equal(done.aid.e.amount, 1);
assert.equal(player(done, 'g').forces['arrakeen:10'], 3);
});
void test('unused pledged aid is not silently added to a canceled split; the payer can authorize it on replacement', () => {
const { g: initial, karama } = fixture('e');
player(initial, 'e').spice = 2;
const funded = applyAction(initial, 'g', { type: 'pledgeAid', amount: 2 });
const offered = declare(funded, 'e', 4);
assert.equal(offered.pendingShipment?.allyPayment, 0);
const withdrawn = cancel(offered, karama.id);
assert.equal(player(withdrawn, 'e').spice, 2);
assert.equal(withdrawn.aid.g.amount, 2);
assert.equal(player(withdrawn, 'e').shipped, false);
const replacement = applyAction(reload(withdrawn), 'e', {
type: 'ship', territory: 'arrakeen', sector: 10, amount: 4, allyPayment: 2,
});
assert.equal(player(replacement, 'e').spice, 0);
assert.equal(replacement.aid.g.amount, 0);
assert.equal(player(replacement, 'e').forces['arrakeen:10'], 4);
});
void test('a tampered saved rate opportunity cannot spend Karama, forces or payment', () => {
const { g: initial, karama } = fixture('e');
const offered = declare(initial, 'e', 3);
const corrupt = reload(offered);
corrupt.response!.guildRateEvent = 'obsolete';
assert.throws(() => viewGame(corrupt, 'a'));
reject(corrupt, 'a', { type: 'card', mode: 'cancel', card: karama.id });
});
void test('the Advanced Guild stop decision precedes the separate Karama rate response', () => {
const { g: initial, karama } = fixture('e', true);
const declared = declare(initial, 'e', 3);
assert.equal(declared.decision?.kind, 'guildShipment');
assert.equal(declared.response, null);
const offered = applyAction(reload(declared), 'g', { type: 'decision', allow: true });
assert.equal(offered.decision, null);
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(player(offered, 'e').spice, 20);
const done = cancel(offered, karama.id);
assert.equal(player(done, 'e').spice, 17);
assert.equal(player(done, 'e').forces['arrakeen:10'], 3);
});
void test('an unstamped saved Advanced declaration gains a turn stamp before its new rate response', () => {
const { g: initial, karama } = fixture('e', true);
const legacy = declare(initial, 'e', 3);
assert.equal(legacy.decision?.kind, 'guildShipment');
delete legacy.pendingShipment!.turn;
const offered = applyAction(reload(legacy), 'g', { type: 'decision', allow: true });
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(offered.pendingShipment?.turn, offered.turn);
assert.equal(player(offered, 'e').spice, 20);
const done = cancel(offered, karama.id);
assert.equal(player(done, 'e').spice, 17);
assert.equal(player(done, 'e').forces['arrakeen:10'], 3);
});
void test('Advanced BG Worthless converts into a saved rate cancellation without losing shipment custody', () => {
const { g: initial, karama } = fixture('g', true);
initial.players.push(newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
initial.order.push('b');
initial.movementRemaining!.push('b');
player(initial, 'b').spice = 20;
player(initial, 'b').forces = {};
player(initial, 'b').reserves = 20;
const index = initial.deck.findIndex(card => card.kind === 'worthless');
const worthless = initial.deck.splice(index, 1)[0];
player(initial, 'b').hand.push(worthless);
let g = declare(initial, 'g', 3);
assert.equal(g.decision?.kind, 'guildShipment');
g = applyAction(g, 'g', { type: 'decision', allow: true });
assert.equal(g.response?.kind, 'guildRate');
g = applyAction(reload(g), 'b', { type: 'card', mode: 'cancel', card: worthless.id });
assert.equal(g.response?.kind, 'worthlessKarama');
assert.equal(g.pendingKarama?.use.kind, 'cancel');
assert.equal(g.pendingShipment?.guildRateEvent, g.pendingKarama?.use.kind === 'cancel'
? g.pendingKarama.use.response.guildRateEvent : null);
assert.equal(viewGame(reload(g), 'a').response?.kind, 'worthlessKarama');
for (let turn = 0; g.response && turn < 12; turn++) {
const responder = g.players.find(seat => !g.response!.passed.includes(seat.id))!;
g = applyAction(reload(g), responder.id, { type: 'passResponse' });
}
assert.equal(g.response, null);
assert.equal(g.pendingShipment, null);
assert.equal(player(g, 'g').spice, 17);
assert.equal(player(g, 'g').forces['arrakeen:10'], 3);
assert.equal(g.discard.filter(card => card.id === worthless.id).length, 1);
assert.equal(player(g, 'a').hand.some(card => card.id === karama.id), true);
});
void test('all four AI profiles can pass the physical Guild rate response and finish a legal shipment', () => {
for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const { g: initial } = fixture('g');
player(initial, 'a').bot = level;
let g = declare(initial, 'g', 3);
const action = botActions(viewGame(g, 'a'))[0];
assert.ok(action, `${level} must answer the Guild rate response`);
g = applyAction(reload(g), 'a', action);
assert.equal(player(g, 'g').forces['arrakeen:10'], 3);
assert.equal(player(g, 'g').spice, 18);
assert.equal(g.pendingShipment, null);
}
});
+97
View File
@@ -0,0 +1,97 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { baseDeck } from '../game/cards';
import { newPlayer, type Game } from '../game/engine';
import type * as Rooms from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} };
void test('restarted rooms expose the unpaid Guild rate choice and commit one Karama cancellation under a CAS race', async () => {
const store = unitStore();
try {
const made = await store.rooms.createRoom('Guild rate CAS', 'guild', false, []);
const code = made.view.code;
const joinedEmperor = await store.rooms.joinRoom(code, 'Emperor', 'emperor');
const joinedAtreides = await store.rooms.joinRoom(code, 'Atreides', 'atreides');
const tokens = [made.token, joinedEmperor.token!, joinedAtreides.token!];
const seats = await Promise.all(tokens.map(token => store.restart().authenticate(code, token)));
const ids = seats.map(seat => seat.playerId);
const initial = await store.restart().readRoom(code);
const g: Game = {
...initial,
players: [
newPlayer(ids[0], 'Guild', 'guild'),
newPlayer(ids[1], 'Emperor', 'emperor'),
newPlayer(ids[2], 'Atreides', 'atreides'),
],
status: 'playing', phase: 5, turn: 2, storm: 18,
order: ids, movementRemaining: ids, active: ids[0],
ready: [], decision: null, response: null, phaseOpening: null,
deck: baseDeck(), discard: [],
};
for (const player of g.players) {
player.spice = 20;
player.reserves = 20;
player.forces = {};
player.hand = [];
}
const index = g.deck.findIndex(card => card.effect === 'karama');
const karama = g.deck.splice(index, 1)[0];
g.players[2].hand.push(karama);
store.sqlite.prepare('UPDATE rooms SET state=?, version=? WHERE code=?')
.run(JSON.stringify(g), g.version, code);
store.writes.length = 0;
await store.restart().act(code, seats[0], g.version, {
type: 'ship', territory: 'arrakeen', sector: 10, amount: 3, allyPayment: 0,
}, clock);
const offered = await store.restart().readRoom(code);
assert.equal(offered.version, g.version + 1);
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(offered.pendingShipment?.cost, 2);
assert.equal(offered.players[0].spice, 20);
assert.equal(offered.players[0].reserves, 20);
const responderView = await store.restart().readSeatView(code, seats[2]);
assert.equal(responderView.responseControls?.cancelCards.includes(karama.id), true);
assert.equal(responderView.players.find(player => player.id === ids[0])?.spice, undefined);
assert.equal((await store.restart().readSeatView(code, seats[1])).response?.kind, 'guildRate');
const before = JSON.parse(JSON.stringify(offered));
await assert.rejects(store.restart().act(code, seats[0], offered.version,
{ type: 'card', mode: 'cancel', card: karama.id }, clock));
assert.deepEqual(await store.restart().readRoom(code), before);
let arrivals = 0;
let release!: () => void;
const waiting = new Promise<void>(resolve => { release = resolve; });
store.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await waiting;
};
let results: PromiseSettledResult<unknown>[];
try {
results = await Promise.allSettled([0, 1].map(() => store.restart().act(
code, seats[2], offered.version,
{ type: 'card', mode: 'cancel', card: karama.id }, clock,
)));
} finally {
delete store.hooks.beforeWrite;
}
assert.equal(arrivals, 2);
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
assert.deepEqual(store.writes.slice(-2).map(write => write.changes).sort((a, b) => a - b), [0, 1]);
const done = await store.restart().readRoom(code);
assert.equal(done.version, offered.version + 1);
assert.equal(done.pendingShipment, null);
assert.equal(done.response, null);
assert.equal(done.players[0].spice, 17);
assert.equal(done.players[0].reserves, 17);
assert.equal(done.players[0].forces['arrakeen:10'], 3);
assert.equal(done.discard.filter(card => card.id === karama.id).length, 1);
await assert.rejects(store.restart().act(code, seats[2], offered.version,
{ type: 'card', mode: 'cancel', card: karama.id }, clock));
assert.deepEqual(await store.restart().readRoom(code), done);
} finally {
store.sqlite.close();
}
});
+2 -2
View File
@@ -274,7 +274,7 @@ void test('a paid Nullentropy search suspends and restores the payer without los
g = applyAction(g, 'e', { type: 'card', card: box.id });
assert.equal(g.decision?.kind, 'nullentropy');
assert.equal(g.pendingNullentropy?.resume.decision?.kind, 'moritaniExtortion');
assert.doesNotThrow(() => viewGame(reload(g), 'e'));
assert.equal(viewGame(reload(g), 'e').decision?.kind, 'nullentropy');
const selected = g.discard[0].id;
g = applyAction(reload(g), 'e', { type: 'decision', event: g.pendingNullentropy!.event, card: selected });
assert.equal(g.decision?.kind, 'moritaniExtortion');
@@ -307,7 +307,7 @@ void test('a Richese gift response suspends its payer and restores the same save
g = applyAction(g, 'b', { type: 'richeseGift', card: gift.id });
assert.equal(g.response?.kind, 'richeseGift');
assert.equal(g.pendingRicheseGift?.resume.decision?.kind, 'moritaniExtortion');
assert.doesNotThrow(() => viewGame(reload(g), 'e'));
assert.equal(viewGame(reload(g), 'e').response?.kind, 'richeseGift');
g = applyAction(reload(g), 'e', { type: 'passResponse' });
assert.equal(g.decision?.kind, 'moritaniExtortion');
assert.equal(g.decision.event, event);