Add prepayment Guild rate Karama response
This commit is contained in:
1 parent
745e4fa8ab
commit
8a23b5ccd2
17 files changed
+567
-21
No files matched your search
@@ -606,10 +606,10 @@ export function GameTable({
|
|||||||
}
|
}
|
||||||
const shipmentRate = {
|
const shipmentRate = {
|
||||||
faction: me.faction,
|
faction: me.faction,
|
||||||
halfRate:
|
halfRate: g.karamaShipping?.player === me.id ||
|
||||||
me.faction === 'guild' ||
|
(!g.guildRateCanceled &&
|
||||||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally) ||
|
(me.faction === 'guild' ||
|
||||||
g.karamaShipping?.player === me.id,
|
g.players.some((p) => p.faction === 'guild' && p.id === me.ally))),
|
||||||
};
|
};
|
||||||
const smugglerOffer = validShipmentAmount && !useGuildSecret && !useRicheseShipment
|
const smugglerOffer = validShipmentAmount && !useGuildSecret && !useRicheseShipment
|
||||||
? quoteSmugglerShipment(g, me.id, selected, amount) : null;
|
? quoteSmugglerShipment(g, me.id, selected, amount) : null;
|
||||||
@@ -2000,6 +2000,7 @@ export function GameTable({
|
|||||||
harkonnenBonus: 'Harkonnen bonus card',
|
harkonnenBonus: 'Harkonnen bonus card',
|
||||||
harkonnenTraitor: 'Harkonnen ally traitor',
|
harkonnenTraitor: 'Harkonnen ally traitor',
|
||||||
guildIncome: 'Guild shipment income',
|
guildIncome: 'Guild shipment income',
|
||||||
|
guildRate: 'Guild half-price shipment rate',
|
||||||
richeseNoField: 'Richese No-Field shipment',
|
richeseNoField: 'Richese No-Field shipment',
|
||||||
advisor: 'Spiritual advisor',
|
advisor: 'Spiritual advisor',
|
||||||
emperorGift: 'Emperor spice gift',
|
emperorGift: 'Emperor spice gift',
|
||||||
|
|||||||
@@ -6,6 +6,19 @@ Expansion starts and full rules/publication acceptance remain gated.**
|
|||||||
|
|
||||||
## Current checkpoint and work
|
## 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:
|
Moritani Extortion now has a bounded local reveal-to-Mentat continuation:
|
||||||
five bank spice stays reserved until Moritani finishes normal Terror placement
|
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
|
and any Karama response; then non-Moritani seats in storm order may pay three
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
# Guild shipment rates and ordinary Karama
|
# 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
|
## 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
|
## 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.
|
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.
|
- `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.
|
- `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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
# Dune implementation status
|
# 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
|
## 28 September 2026 — Moritani Extortion saved Mentat continuation
|
||||||
|
|
||||||
The real Terror-entry reveal now reserves five bank spice without crediting
|
The real Terror-entry reveal now reserves five bank spice without crediting
|
||||||
|
|||||||
@@ -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) |
|
| 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) |
|
| 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 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) |
|
| 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 | 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) |
|
| 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) |
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ const responses = {
|
|||||||
harkonnenBonus: true,
|
harkonnenBonus: true,
|
||||||
harkonnenTraitor: true,
|
harkonnenTraitor: true,
|
||||||
guildIncome: true,
|
guildIncome: true,
|
||||||
|
guildRate: true,
|
||||||
emperorGift: true,
|
emperorGift: true,
|
||||||
emperorRevival: true,
|
emperorRevival: true,
|
||||||
stormPeek: true,
|
stormPeek: true,
|
||||||
|
|||||||
+4
-4
@@ -3110,10 +3110,10 @@ function policyActions(g: GameView): Action[] {
|
|||||||
if (g.ornithopter?.active && me.shipped) return [{ type: 'endMovement' }];
|
if (g.ornithopter?.active && me.shipped) return [{ type: 'endMovement' }];
|
||||||
const actions: Action[] = [...guildHomeworldShipmentActions(g, level), ...homeworldShipmentActions(g, level)];
|
const actions: Action[] = [...guildHomeworldShipmentActions(g, level), ...homeworldShipmentActions(g, level)];
|
||||||
const targets = destinations(g);
|
const targets = destinations(g);
|
||||||
const halfRate =
|
const halfRate = g.karamaShipping?.player === me.id ||
|
||||||
me.faction === 'guild' ||
|
(!g.guildRateCanceled &&
|
||||||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally) ||
|
(me.faction === 'guild' ||
|
||||||
g.karamaShipping?.player === me.id;
|
g.players.some((p) => p.faction === 'guild' && p.id === me.ally)));
|
||||||
const shipmentBudget = (me.spice ?? 0) + g.aid.available;
|
const shipmentBudget = (me.spice ?? 0) + g.aid.available;
|
||||||
const guildSecret = nexusGuildSecretAllyCanAct(g);
|
const guildSecret = nexusGuildSecretAllyCanAct(g);
|
||||||
const noField = g.richeseNoField;
|
const noField = g.richeseNoField;
|
||||||
|
|||||||
+111
-4
@@ -917,6 +917,8 @@ export type ResponseWindow = {
|
|||||||
source?: 'ambassador';
|
source?: 'ambassador';
|
||||||
intent?: string;
|
intent?: string;
|
||||||
advisors?: boolean;
|
advisors?: boolean;
|
||||||
|
/** Saved exact ordinary Guild-rate response; not the independent Karama rate. */
|
||||||
|
guildRateEvent?: string;
|
||||||
advisorResume?: 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry';
|
advisorResume?: 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry';
|
||||||
discoveryEntry?: DiscoveryEntryArrivalChild;
|
discoveryEntry?: DiscoveryEntryArrivalChild;
|
||||||
advisorAmbassadorEvent?: string;
|
advisorAmbassadorEvent?: string;
|
||||||
@@ -946,6 +948,7 @@ export type ResponseWindow = {
|
|||||||
| 'harkonnenBonus'
|
| 'harkonnenBonus'
|
||||||
| 'harkonnenTraitor'
|
| 'harkonnenTraitor'
|
||||||
| 'guildIncome'
|
| 'guildIncome'
|
||||||
|
| 'guildRate'
|
||||||
| 'emperorGift'
|
| 'emperorGift'
|
||||||
| 'emperorRevival'
|
| 'emperorRevival'
|
||||||
| 'stormPeek'
|
| 'stormPeek'
|
||||||
@@ -1018,6 +1021,7 @@ type PendingShipment = {
|
|||||||
smugglerCompanion?: SmugglerNoFieldCompanion;
|
smugglerCompanion?: SmugglerNoFieldCompanion;
|
||||||
noFieldSkillProof?: string;
|
noFieldSkillProof?: string;
|
||||||
guildSecretEvent?: string;
|
guildSecretEvent?: string;
|
||||||
|
guildRateEvent?: string;
|
||||||
guildNexusEvent?: string;
|
guildNexusEvent?: string;
|
||||||
nexusEvent?: string;
|
nexusEvent?: string;
|
||||||
richesePair?: { event: string; revealedTokenId: string };
|
richesePair?: { event: string; revealedTokenId: string };
|
||||||
@@ -1565,6 +1569,8 @@ export type Game = {
|
|||||||
>;
|
>;
|
||||||
} | null;
|
} | null;
|
||||||
pendingShipment?: PendingShipment | null;
|
pendingShipment?: PendingShipment | null;
|
||||||
|
/** One canceled unaffordable declaration may be redeclared at full price this turn. */
|
||||||
|
guildRateBlocked?: { turn: number; player: string };
|
||||||
pendingHomeworldShipment?: PendingHomeworldShipment | null;
|
pendingHomeworldShipment?: PendingHomeworldShipment | null;
|
||||||
junctionOffer?: JunctionOffer | null;
|
junctionOffer?: JunctionOffer | null;
|
||||||
pendingExchange?: {
|
pendingExchange?: {
|
||||||
@@ -14059,6 +14065,7 @@ function completePhase(g: Game) {
|
|||||||
observeOccupation(g, 'turnEnd');
|
observeOccupation(g, 'turnEnd');
|
||||||
g.turn++;
|
g.turn++;
|
||||||
g.recruits = null;
|
g.recruits = null;
|
||||||
|
delete g.guildRateBlocked;
|
||||||
g.phase = 0;
|
g.phase = 0;
|
||||||
observeOccupation(g, 'turnStart');
|
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 === 'nexusFremenCunning') currentFremenCunningResponse(g, response);
|
||||||
if (response.kind === 'nexusEcazBetrayal') currentEcazBetrayalResponse(g, response);
|
if (response.kind === 'nexusEcazBetrayal') currentEcazBetrayalResponse(g, response);
|
||||||
if (response.kind === 'nexusGuildCunning') validateGuildCunningResponse(g,response);
|
if (response.kind === 'nexusGuildCunning') validateGuildCunningResponse(g,response);
|
||||||
|
if (response.kind === 'guildRate') validateGuildRateResponse(g, response);
|
||||||
if (response.kind === 'moritaniPlacement') {
|
if (response.kind === 'moritaniPlacement') {
|
||||||
// Both outcomes need the same physical inventory and declared source.
|
// Both outcomes need the same physical inventory and declared source.
|
||||||
// This detached denial quote is only validation here; allowance still
|
// This detached denial quote is only validation here; allowance still
|
||||||
@@ -18900,6 +18908,10 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
|
|||||||
: null;
|
: null;
|
||||||
if (!canceled && bureaucratDiversion === undefined && offerBureaucratPayment(g,response.bureaucratPayment,{kind:'response'})) return;
|
if (!canceled && bureaucratDiversion === undefined && offerBureaucratPayment(g,response.bureaucratPayment,{kind:'response'})) return;
|
||||||
g.response = null;
|
g.response = null;
|
||||||
|
if (response.kind === 'guildRate') {
|
||||||
|
finishGuildRateResponse(g, response, canceled);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (response.kind === 'nexusEcazBetrayal') {
|
if (response.kind === 'nexusEcazBetrayal') {
|
||||||
finishEcazBetrayalResponse(g, response, canceled);
|
finishEcazBetrayalResponse(g, response, canceled);
|
||||||
return;
|
return;
|
||||||
@@ -20358,6 +20370,90 @@ function validateGuildShipmentDecision(
|
|||||||
validatePhysicalShipment(g, shipment);
|
validatePhysicalShipment(g, shipment);
|
||||||
return 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) {
|
function offerShipment(g: Game, shipment: PendingShipment) {
|
||||||
validatePhysicalShipment(g, shipment);
|
validatePhysicalShipment(g, shipment);
|
||||||
validateShipmentArrival(g, shipment);
|
validateShipmentArrival(g, shipment);
|
||||||
@@ -20390,7 +20486,7 @@ function offerShipment(g: Game, shipment: PendingShipment) {
|
|||||||
g,
|
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.` : ''}`,
|
`${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) {
|
function commitShipment(g: Game, shipment: PendingShipment) {
|
||||||
if (shipment.source === 'ambassador') {
|
if (shipment.source === 'ambassador') {
|
||||||
@@ -20992,7 +21088,9 @@ function richesePairIntegrity(g: Game) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function shipmentHalfRate(g: Game, p: Player) {
|
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]) {
|
function nexusRicheseSignature(record: NonNullable<Game['nexusRicheseHistory']>[number]) {
|
||||||
return JSON.stringify([record.receipt.signature,record.stage,record.frame,record.halfRate]);
|
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);
|
ixRicheseTechnologyIntegrity(state);
|
||||||
leaderSkillsIntegrity(state);
|
leaderSkillsIntegrity(state);
|
||||||
moritaniExtortionIntegrity(state);
|
moritaniExtortionIntegrity(state);
|
||||||
|
guildRateIntegrity(state);
|
||||||
discoveryIntegrity(state);
|
discoveryIntegrity(state);
|
||||||
greatMakerIntegrity(state);
|
greatMakerIntegrity(state);
|
||||||
nexusChoamTradeIntegrity(state);
|
nexusChoamTradeIntegrity(state);
|
||||||
@@ -22361,6 +22460,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
|||||||
ixRicheseTechnologyIntegrity(g);
|
ixRicheseTechnologyIntegrity(g);
|
||||||
harkonnenExchangeIntegrity(g);
|
harkonnenExchangeIntegrity(g);
|
||||||
moritaniExtortionIntegrity(g);
|
moritaniExtortionIntegrity(g);
|
||||||
|
guildRateIntegrity(g);
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
function finishActionContinuations(g: Game) {
|
function finishActionContinuations(g: Game) {
|
||||||
@@ -22469,6 +22569,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
|||||||
ixRicheseTechnologyIntegrity(state);
|
ixRicheseTechnologyIntegrity(state);
|
||||||
leaderSkillsIntegrity(state);
|
leaderSkillsIntegrity(state);
|
||||||
moritaniExtortionIntegrity(state);
|
moritaniExtortionIntegrity(state);
|
||||||
|
guildRateIntegrity(state);
|
||||||
discoveryIntegrity(state);
|
discoveryIntegrity(state);
|
||||||
greatMakerIntegrity(state);
|
greatMakerIntegrity(state);
|
||||||
nexusChoamTradeIntegrity(state);
|
nexusChoamTradeIntegrity(state);
|
||||||
@@ -22523,6 +22624,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
|||||||
ixRicheseTechnologyIntegrity(g);
|
ixRicheseTechnologyIntegrity(g);
|
||||||
harkonnenExchangeIntegrity(g);
|
harkonnenExchangeIntegrity(g);
|
||||||
moritaniExtortionIntegrity(g);
|
moritaniExtortionIntegrity(g);
|
||||||
|
guildRateIntegrity(g);
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23823,8 +23925,10 @@ function applyActionInner(
|
|||||||
);
|
);
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
g.pendingShipment = null;
|
if (!openGuildRate(g, shipment)) {
|
||||||
commitShipment(g, shipment);
|
g.pendingShipment = null;
|
||||||
|
commitShipment(g, shipment);
|
||||||
|
}
|
||||||
} else if (decision.kind === 'nullentropy') {
|
} else if (decision.kind === 'nullentropy') {
|
||||||
throw new RuleError(
|
throw new RuleError(
|
||||||
'No matching paid search was saved; restore the saved search before continuing.',
|
'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);
|
ixRicheseTechnologyIntegrity(state);
|
||||||
leaderSkillsIntegrity(state);
|
leaderSkillsIntegrity(state);
|
||||||
moritaniExtortionIntegrity(state);
|
moritaniExtortionIntegrity(state);
|
||||||
|
guildRateIntegrity(state);
|
||||||
discoveryFlightIntegrity(state);
|
discoveryFlightIntegrity(state);
|
||||||
discoveryIntegrity(state);
|
discoveryIntegrity(state);
|
||||||
greatMakerIntegrity(state);
|
greatMakerIntegrity(state);
|
||||||
@@ -26791,6 +26896,8 @@ export function viewGame(state: Game, id: string) {
|
|||||||
karamaShipping: g.karamaShipping
|
karamaShipping: g.karamaShipping
|
||||||
? { player: g.karamaShipping.player, owner: g.karamaShipping.owner }
|
? { player: g.karamaShipping.player, owner: g.karamaShipping.owner }
|
||||||
: null,
|
: null,
|
||||||
|
guildRateCanceled: g.guildRateBlocked?.turn === g.turn &&
|
||||||
|
g.guildRateBlocked.player === id,
|
||||||
stormPending: g.stormPending ?? null,
|
stormPending: g.stormPending ?? null,
|
||||||
stormForecast:
|
stormForecast:
|
||||||
me.faction === 'fremen' && g.stormCardKnown ? g.stormCard : null,
|
me.faction === 'fremen' && g.stormCardKnown ? g.stormCard : null,
|
||||||
|
|||||||
@@ -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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'If the game reaches its end without another qualifying victory, Guild and its ally win through the Guild special victory.',
|
||||||
|
|||||||
@@ -132,6 +132,12 @@ const cancellationSources = {
|
|||||||
calls: g.battle?.traitorCalls ?? null,
|
calls: g.battle?.traitorCalls ?? null,
|
||||||
}),
|
}),
|
||||||
guildIncome: () => null,
|
guildIncome: () => null,
|
||||||
|
guildRate: (g) => ({
|
||||||
|
pending: g.pendingShipment ?? null,
|
||||||
|
active: g.active,
|
||||||
|
discountedRateBlocked: g.guildRateBlocked ?? null,
|
||||||
|
purchasedRate: g.karamaShipping ?? null,
|
||||||
|
}),
|
||||||
emperorGift: () => null,
|
emperorGift: () => null,
|
||||||
emperorRevival: () => null,
|
emperorRevival: () => null,
|
||||||
stormPeek: (g) => ({ card: g.stormCard, dialers: g.stormDialers }),
|
stormPeek: (g) => ({ card: g.stormCard, dialers: g.stormDialers }),
|
||||||
|
|||||||
@@ -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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ const RESPONSES = {
|
|||||||
harkonnenBonus: true,
|
harkonnenBonus: true,
|
||||||
harkonnenTraitor: true,
|
harkonnenTraitor: true,
|
||||||
guildIncome: true,
|
guildIncome: true,
|
||||||
|
guildRate: true,
|
||||||
emperorGift: true,
|
emperorGift: true,
|
||||||
emperorRevival: true,
|
emperorRevival: true,
|
||||||
stormPeek: true,
|
stormPeek: true,
|
||||||
|
|||||||
@@ -297,7 +297,14 @@ for (const cancel of [false, true])
|
|||||||
for (const route of ['reserve', 'cross'] as const) {
|
for (const route of ['reserve', 'cross'] as const) {
|
||||||
const f = await fixture(route, true);
|
const f = await fixture(route, true);
|
||||||
try {
|
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);
|
const pending = await restored(f);
|
||||||
paidOnce(f, pending, 19);
|
paidOnce(f, pending, 19);
|
||||||
assert.equal(pending.response?.kind, 'guildIncome');
|
assert.equal(pending.response?.kind, 'guildIncome');
|
||||||
|
|||||||
@@ -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 { g, shipper, donor } = fixture(direction, route);
|
||||||
const card = holdKarama(g, 'a');
|
const card = holdKarama(g, 'a');
|
||||||
const beforeGuild = player(g, 'g').spice;
|
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?.kind, 'guildIncome');
|
||||||
assert.equal(pending.response?.owner, 'g');
|
assert.equal(pending.response?.owner, 'g');
|
||||||
assert.equal(pending.response?.amount, 1);
|
assert.equal(pending.response?.amount, 1);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 });
|
g = applyAction(g, 'e', { type: 'card', card: box.id });
|
||||||
assert.equal(g.decision?.kind, 'nullentropy');
|
assert.equal(g.decision?.kind, 'nullentropy');
|
||||||
assert.equal(g.pendingNullentropy?.resume.decision?.kind, 'moritaniExtortion');
|
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;
|
const selected = g.discard[0].id;
|
||||||
g = applyAction(reload(g), 'e', { type: 'decision', event: g.pendingNullentropy!.event, card: selected });
|
g = applyAction(reload(g), 'e', { type: 'decision', event: g.pendingNullentropy!.event, card: selected });
|
||||||
assert.equal(g.decision?.kind, 'moritaniExtortion');
|
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 });
|
g = applyAction(g, 'b', { type: 'richeseGift', card: gift.id });
|
||||||
assert.equal(g.response?.kind, 'richeseGift');
|
assert.equal(g.response?.kind, 'richeseGift');
|
||||||
assert.equal(g.pendingRicheseGift?.resume.decision?.kind, 'moritaniExtortion');
|
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' });
|
g = applyAction(reload(g), 'e', { type: 'passResponse' });
|
||||||
assert.equal(g.decision?.kind, 'moritaniExtortion');
|
assert.equal(g.decision?.kind, 'moritaniExtortion');
|
||||||
assert.equal(g.decision.event, event);
|
assert.equal(g.decision.event, event);
|
||||||
|
|||||||
Reference in new issue
Block a user