Extend Guild rate Karama to cross and return transport

This commit is contained in:
Quintine committed 2026-09-29 16:22:13 +10:00
1 parent 8a23b5ccd2
commit 8ddd138e87
19 files changed
+889 -100

No files matched your search

+9 -10
View File
@@ -19,22 +19,21 @@ export function ShipmentPromises({
const me = g.players.find((p) => p.id === g.me)!;
const next = g.shipmentCompletion?.actions[0];
const shipping = next && (next.type === 'ship' || next.type === 'guildShip');
const halfRate = g.karamaShipping?.player === me.id ||
(!g.guildRateCanceled &&
(me.faction === 'guild' ||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally)));
const cost = shipping
? next.type === 'guildShip'
? guildShipmentCost(
territory(String(next.territory)).type,
String(next.territory) === 'reserves'
? 'reserves'
: territory(String(next.territory)).type,
Number(next.amount),
g.karamaShipping?.player === me.id || !g.guildRateCanceled,
)
: reserveShipmentCost(
{
faction: me.faction,
halfRate:
me.faction === 'guild' ||
g.players.some(
(p) => p.faction === 'guild' && p.id === me.ally,
) ||
g.karamaShipping?.player === me.id,
},
{ faction: me.faction, halfRate },
territory(String(next.territory)).type,
Number(next.amount),
)
+14 -12
View File
@@ -6,18 +6,20 @@ 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.
A bounded [Guild native rate Karama path](GUILD_RATE_KARAMA_RULES.md#native-transport-extension--29-september-2026)
now interrupts classic Guild/reciprocal-ally ordinary reserve shipments and
eligible cross-planet transport before payment, including Guild's own return
to abstract reserves and allied Fremen southern-reserve transport. Canceling
the rate charges normal destination price for the **exact saved force group**
and original approved funding split; return costs one spice per force under
the documented conservative doubled-tariff interpretation. If the group or
split cannot complete, Karama is spent but the declaration returns unused and
a replacement uses full price this turn. Saved typed custody, a legal AI path,
SQLite CAS recovery and local read-only browser previews of the cancel control
and replacement quote are connected. Guild income, transport permission and
independently purchased Karama rates remain separate. Optional-module and
Homeworld rate cancellation remain gated; this code is not in the verified
live image below.
Moritani Extortion now has a bounded local reveal-to-Mentat continuation:
five bank spice stays reserved until Moritani finishes normal Terror placement
+7 -1
View File
@@ -12,7 +12,13 @@ In classic-faction Basic and Advanced tables without optional modules, Guild or
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.
The initial reserve version preceded the native transport extension below. Optional-module rate sources, Homeworld advantages and special combinations still need their own integration; this does not certify the complete Guild faction or public Advanced rules.
## Native transport extension — 29 September 2026
The same prepayment Karama response now covers classic Guild cross-planet transport and Guild returns to abstract reserves, as well as reciprocal allies' cross-planet transport (including Fremen southern reserves). Persist the exact source-sector/elite groups, origin, destination, approved contributor split and current turn. Return and cross declarations move no spice or counters until this response settles. On cancellation, charge the normal destination tariff for those exact forces if the original split can fund it; otherwise release the declaration without transfer or shipment use, keeping a replacement at full price for the rest of this turn. A changed source or destination during the response withdraws the uncommitted attempt rather than inventing forces or debt. Independently purchased Karama shipping retains its separate half-rate and bank payment.
For a canceled **Guild return to reserves**, full price means **one spice per returned force** rather than the printed one spice per two, rounded up. The doubled-tariff computation follows the publisher's instruction to pay full price but lacks a return-specific numeric FAQ example. It is an explicit conservative table interpretation, not a newly found publisher ruling. Canceling transport *permission* or later Guild *income* remains a separate use of Karama. Homeworld/world-to-world/module combinations are not certified by this bounded classic continuation.
## Confirmed source contract
+44
View File
@@ -1,5 +1,49 @@
# Dune implementation status
## 29 September 2026 — native Guild cross/return rate response
The classic Basic/Advanced Guild rate interruption now owns a typed, persisted
cross-planet/return declaration as well as ordinary reserve shipment. Guild,
its reciprocal ally and paid allied Fremen southern reserves can cross-ship
one exact physical group; only Guild returns an on-planet group to abstract
reserves. The prepayment Karama response binds original sector/elite groups,
turn, destination, quoted half-price cost and approved ally contribution.
Canceled rate uses full normal destination price for that same group; a Guild
return costs one spice per force under the explicit doubled-tariff
interpretation. If original resources or source custody cannot support it,
the declaration withdraws without payment, force transfer or consumed
shipment. The spent Karama keeps a replacement at full price for this turn.
Independent purchased shipping rates, Guild income and canceling transport
permission remain distinct; Homeworld and optional-module combinations are
not certified.
Focused engine/quote/in-memory SQLite cases cover group conservation, paid
Fremen transport, exact funding and unaffordable replacement, Basic/Advanced
responses, BG Worthless conversion, source corruption or depletion, private
views, legal AI candidates and a concurrent CAS race. A direct non-test
multi-sector smoke preserved 20 Guild spice and the source forces through
JSON reloading, then canceled the rate and settled three Carthag forces for
three spice with one Karama discarded. A local read-only Chromium seat-view
preview displayed the public prepayment rate and owner-only Cancel with
Karama button without persisting a room. These bounded cases do not certify
complete Guild, public Advanced or deployed behavior; see
[the selected transport policy](GUILD_RATE_KARAMA_RULES.md#native-transport-extension--29-september-2026).
A second read-only Chromium seat preview selected two physical forces after
an unaffordable Guild return; the replacement quoted **two spice** instead
of the old one-spice half rate and enabled Return to reserves. It wrote no room.
Independent review found and verified a previously excluded affordable
four-force/two-spice Guild Nexus Secret Ally bot cross-shipment; its
independent card rate remains separate from the native rate. Existing
Moritani/Terror alliance continuations also required the classic-faction
roster gate rather than relying only on expansion flags. Eleven focused
Guild/Nexus/Moritani files passed 123 cases; all 5,706 offline tests, the
local client/server build and all 54 HTTP integration cases passed.
`npm run check`/lint remain blocked solely by the concurrent untracked
Fremen Nexus draft's missing `Player.fremenNexusMovementBlockedTurn` field,
not a transport diagnostic.
## 29 September 2026 — bounded Guild rate Karama before payment
In classic-faction Basic/Advanced tables without optional modules, an ordinary
+1 -1
View File
@@ -23,7 +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) |
| Guild native rate 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 or eligible cross-planet transport, and Guild’s own return. The original physical cohort and approved contributor split must cover full destination price; otherwise the Karama is spent, the declaration returns unused, and a replacement this turn uses full price. One spice per returned force is the conservative doubled-tariff interpretation without a return-specific FAQ example. Independent Karama-purchased rates, Guild income, transport permission and optional modules remain separate. | [Publisher distinctions and selected reserve/transport policy](GUILD_RATE_KARAMA_RULES.md#native-transport-extension--29-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) |
+2 -1
View File
@@ -140,9 +140,10 @@ export function fremenReserveEntry(to: string) {
);
}
/** Guild cross-shipping is paid transport, including allied Fremen reserves. */
export function guildTransportCost(to: string, amount: number) {
export function guildTransportCost(to: string, amount: number, halfRate = true) {
return guildShipmentCost(
to === 'reserves' ? 'reserves' : territory(to).type,
amount,
halfRate,
);
}
+2 -1
View File
@@ -3302,6 +3302,7 @@ function policyActions(g: GameView): Action[] {
guildTransportCost(
to.t,
Math.min(me.reserves, level === 0 ? 1 : 4),
halfRate,
) <= shipmentBudget
)
actions.push({
@@ -3327,7 +3328,7 @@ function policyActions(g: GameView): Action[] {
origin.id !== to.t &&
splitLocation(from).sector !== g.storm &&
botEntryAllowed(g, me, to.t, to.s, 'guildShip', origin.id) &&
guildTransportCost(to.t, amount) <= shipmentBudget &&
guildTransportCost(to.t, amount, halfRate || guildSecret) <= shipmentBudget &&
gameDistance(g, from, to.key) > 3
)
actions.push({
+247 -57
View File
@@ -1041,6 +1041,24 @@ type PendingShipment = {
noField?: { tokenId: string; event: string };
alliedNoField?: RicheseAllyOffer;
};
type PendingGuildTransport = {
event: string;
signature: string;
turn: number;
player: string;
fromReserves: boolean;
group: [string, number][];
eliteGroup: Record<string, number>;
origin: string;
to: string;
sector: number;
amount: number;
elite: number;
cost: number;
allyPayment: number;
advisors: boolean;
};
type GuildTransportOrder = Omit<PendingGuildTransport, 'event' | 'signature' | 'fromReserves'>;
type RicheseAllyOffer = {
event: string;
owner: string;
@@ -1569,6 +1587,7 @@ export type Game = {
>;
} | null;
pendingShipment?: PendingShipment | null;
pendingGuildTransport?: PendingGuildTransport | null;
/** One canceled unaffordable declaration may be redeclared at full price this turn. */
guildRateBlocked?: { turn: number; player: string };
pendingHomeworldShipment?: PendingHomeworldShipment | null;
@@ -18867,7 +18886,10 @@ 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 === 'guildRate') {
if (g.pendingGuildTransport) validateGuildTransportRateResponse(g, response);
else 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
@@ -18909,7 +18931,8 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
if (!canceled && bureaucratDiversion === undefined && offerBureaucratPayment(g,response.bureaucratPayment,{kind:'response'})) return;
g.response = null;
if (response.kind === 'guildRate') {
finishGuildRateResponse(g, response, canceled);
if (g.pendingGuildTransport) finishGuildTransportRateResponse(g, response, canceled);
else finishGuildRateResponse(g, response, canceled);
return;
}
if (response.kind === 'nexusEcazBetrayal') {
@@ -20370,13 +20393,22 @@ function validateGuildShipmentDecision(
validatePhysicalShipment(g, shipment);
return shipment;
}
const GUILD_RATE_CLASSIC_FACTIONS: Partial<Record<FactionId, true>> = {
atreides: true, harkonnen: true, emperor: true,
fremen: true, guild: true, beneGesserit: true,
};
/** The independent Karama rate is a card benefit, not Guild's cancelable rate. */
function guildRateModeSupported(g: Game): boolean {
return !g.expansions.length && !g.homeworlds && !g.leaderSkills &&
!g.nexusCards && !g.discoveryEnabled && !g.techTokens && !g.strongholdCards &&
g.players.every(player => GUILD_RATE_CLASSIC_FACTIONS[player.faction] === true);
}
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 ||
!guildRateModeSupported(g) ||
g.karamaShipping?.player === p.id ||
(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id) ||
shipment.source || shipment.noField || shipment.alliedNoField ||
@@ -20422,11 +20454,15 @@ function validateGuildRateResponse(g: Game, response: ResponseWindow) {
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),
const expected = Number(!!g.pendingShipment?.guildRateEvent) +
Number(!!g.pendingGuildTransport);
requireRule(expected <= 1 && responses.length === expected,
'The saved Guild-rate shipment lost its single Karama response.');
if (responses.length) validateGuildRateResponse(g, responses[0]);
if (responses.length) {
if (g.pendingGuildTransport) validateGuildTransportRateResponse(g, responses[0]);
else validateGuildRateResponse(g, responses[0]);
}
const blocked = g.guildRateBlocked;
if (blocked)
requireRule(blocked.turn === g.turn && g.players.some(p => p.id === blocked.player),
@@ -20454,6 +20490,201 @@ function finishGuildRateResponse(g: Game, response: ResponseWindow, canceled: bo
delete g.guildRateBlocked;
log(g, `Karama removed Guild's half-price rate. ${shipper.name} paid the full ${fullCost} spice for the unchanged shipment.`);
}
function guildTransportSignature(intent: PendingGuildTransport): string {
return JSON.stringify([intent.event, intent.turn, intent.player, intent.fromReserves,
intent.group, intent.eliteGroup, intent.origin, intent.to, intent.sector,
intent.amount, intent.elite, intent.cost, intent.allyPayment, intent.advisors]);
}
function ordinaryGuildTransportFullCost(g: Game, p: Player, frame: GuildTransportOrder): number | null {
const guild = byFaction(g, 'guild');
if (!guild || !guildRateModeSupported(g) ||
(p.id !== guild.id && !(p.ally === guild.id && guild.ally === p.id)) ||
g.karamaShipping?.player === p.id ||
(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id))
return null;
const fullCost = guildShipmentCost(
frame.to === 'reserves' ? 'reserves' : territory(frame.to).type,
frame.amount, false);
return frame.turn === g.turn && frame.cost < fullCost ? fullCost : null;
}
function openGuildTransportRate(g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean): boolean {
const fullCost = ordinaryGuildTransportFullCost(g, p, frame);
if (fullCost === null) return false;
const intent: PendingGuildTransport = {
...frame, fromReserves, event: crypto.randomUUID(), signature: '',
};
intent.signature = guildTransportSignature(intent);
g.pendingGuildTransport = intent;
g.response = {
kind: 'guildRate',
owner: byFaction(g, 'guild')!.id,
passed: [],
guildRateEvent: intent.event,
intent: `${p.name} declared ${frame.amount} forces from ${fromReserves ? 'southern reserves' : territory(frame.origin).name} to ${frame.to === 'reserves' ? 'reserves' : territory(frame.to).name} at the Guild transport rate of ${frame.cost} spice. Karama can remove only the discount before payment or force transfer. The same physical transport would cost ${fullCost} at full price; if its original approved payment split cannot cover that, it returns unused and a replacement this turn uses full price.`,
};
log(g, `${p.name} declared Guild-rate transport; payment and physical transfer wait for the Karama response.`);
return true;
}
function validateGuildTransportRateResponse(g: Game, response: ResponseWindow) {
const intent = g.pendingGuildTransport;
const guild = byFaction(g, 'guild');
const shipper = intent && g.players.find(p => p.id === intent.player);
const fullCost = intent && shipper
? ordinaryGuildTransportFullCost(g, shipper, intent) : null;
requireRule(response.kind === 'guildRate' && intent && guild && shipper &&
g.status === 'playing' && g.phase === 5 && g.active === shipper.id &&
!shipper.shipped && intent.turn === g.turn &&
typeof intent.event === 'string' && intent.event.length > 0 &&
response.owner === guild.id && response.guildRateEvent === intent.event &&
typeof intent.fromReserves === 'boolean' &&
Array.isArray(intent.group) && intent.group.every(([key, n]) =>
typeof key === 'string' && key.length > 0 && Number.isSafeInteger(n) && n > 0) &&
intent.eliteGroup && typeof intent.eliteGroup === 'object' &&
!Array.isArray(intent.eliteGroup) &&
typeof intent.origin === 'string' && intent.origin.length > 0 &&
typeof intent.to === 'string' && intent.to.length > 0 &&
Number.isSafeInteger(intent.sector) &&
Number.isSafeInteger(intent.amount) && intent.amount > 0 &&
Number.isSafeInteger(intent.elite) && intent.elite >= 0 && intent.elite <= intent.amount &&
Number.isSafeInteger(intent.allyPayment) && intent.allyPayment >= 0 &&
intent.allyPayment <= intent.cost && typeof intent.advisors === 'boolean' &&
(intent.fromReserves
? intent.origin === 'reserves' && shipper.faction === 'fremen' &&
intent.group.length === 0 && Object.keys(intent.eliteGroup).length === 0 &&
intent.to !== 'reserves'
: intent.group.length > 0 && intent.group.every(([key]) =>
splitLocation(key).territory === intent.origin) &&
intent.group.reduce((sum, [, n]) => sum + n, 0) === intent.amount &&
Object.keys(intent.eliteGroup).length === intent.group.length &&
intent.group.every(([key, n]) =>
Number.isSafeInteger(intent.eliteGroup[key]) &&
intent.eliteGroup[key] >= 0 && intent.eliteGroup[key] <= n) &&
intent.group.reduce((sum, [key]) => sum + intent.eliteGroup[key], 0) === intent.elite) &&
(intent.to !== 'reserves' || shipper.faction === 'guild') &&
typeof intent.signature === 'string' &&
intent.signature === guildTransportSignature(intent) &&
fullCost !== null &&
intent.cost === guildShipmentCost(
intent.to === 'reserves' ? 'reserves' : territory(intent.to).type,
intent.amount),
'The saved Guild transport lost its original physical rate response.');
return { intent, shipper, fullCost };
}
function validateGuildTransportForCommit(
g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean,
) {
const { origin, group, eliteGroup, to, sector, amount, elite, cost, allyPayment, advisors } = frame;
requireRule(g.status === 'playing' && g.phase === 5 && frame.turn === g.turn &&
frame.player === p.id && g.active === p.id && shipmentAvailable(g, p),
'This Guild transport no longer belongs to an unused shipment opportunity.');
if (fromReserves) {
requireRule(p.faction === 'fremen' && origin === 'reserves' &&
group.length === 0 && Object.keys(eliteGroup).length === 0,
'The southern-reserve transport lost its original source.');
integer(amount, 1, p.reserves, 'Forces');
requireRule(eliteChoice(amount, p.reserves, p.elites?.reserves ?? 0, elite) === elite,
'The original southern-reserve elite allocation is unavailable.');
} else {
const quoted = forceGroup(g, p, { type: 'guildShip',
forces: Object.fromEntries(group), eliteForces: eliteGroup });
requireRule(JSON.stringify(quoted.group) === JSON.stringify(group) &&
JSON.stringify(quoted.eliteGroup) === JSON.stringify(eliteGroup) &&
quoted.origin === origin && quoted.elite === elite && quoted.total === amount &&
group.every(([key]) => splitLocation(key).sector !== g.storm),
'The original Guild transport force group changed or entered the storm.');
}
requireRule((fromReserves || to === 'reserves' || to !== origin) &&
(to !== 'reserves' || p.faction === 'guild') &&
(to !== MOBILE_STRONGHOLD || p.faction === 'ixians') &&
advisors === (to !== 'reserves' && arrivalAsAdvisor(g, p, to)),
'The original Guild transport destination or advisor stance is unavailable.');
const sourceLock = p.advisors?.[origin]?.lockedTurn;
requireRule(to === 'reserves' || advisors || sourceLock !== g.turn ||
!g.players.some(other => other.id !== p.id && at(other, to)),
'New advisors cannot become fighters this turn.');
if (to !== 'reserves') allowedEntry(g, p, to, sector, false, advisors);
const halfRate = g.karamaShipping?.player === p.id ||
!(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id);
requireRule(cost === guildShipmentCost(
to === 'reserves' ? 'reserves' : territory(to).type, amount, halfRate),
'The declared Guild transport price no longer matches its rate.');
contribution(g, p, cost, allyPayment);
checkShipmentIncomeRounding(g, p, cost, allyPayment);
checkShipmentPromises(g, p, fromReserves ? { territory: to, amount } : null);
}
function commitGuildTransport(
g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean,
guildSecretEvent?: string,
) {
const { origin, group, eliteGroup, to, sector, amount, elite, cost, allyPayment, advisors } = frame;
const sourceLock = p.advisors?.[origin]?.lockedTurn;
if (guildSecretEvent) recordGuildSecretShipment(g, p.id, guildSecretEvent, 'cross',
{ ...frame, guildSecretEvent });
bindGuildCunningShipment(g, p, 'guild', frame);
payWithAlly(g, p, cost, allyPayment);
const guild = byFaction(g, 'guild');
const guildPayment = guildShipmentIncome({
guild: guild?.id, shipper: p.id, ally: p.ally, cost, allyPayment,
bankOnly: g.karamaShipping?.player === p.id,
});
if (guild && guildPayment > 0)
g.response = guildPaymentResponse(g, guild.id,
shipmentIncomeContributions(g, p, cost, allyPayment),
allyPayment ? undefined : p.id);
if (fromReserves) {
p.reserves -= amount;
if (p.elites) p.elites.reserves -= elite;
} else removeGroup(p, group, eliteGroup);
if (to === 'reserves') {
p.reserves += amount;
if (p.elites) p.elites.reserves += elite;
} else {
place(p, to, sector, amount, elite);
if (advisors)
(p.advisors ??= {})[to] = {
lockedTurn: Math.max(sourceLock ?? 0, p.advisors?.[to]?.lockedTurn ?? 0) || undefined,
};
else if (p.advisors) delete p.advisors[to];
}
observeOccupation(g);
finishShipmentPromises(g, p, fromReserves ? { territory: to, amount } : null);
finishGuildCunningShipment(g, p.id, 'guild', frame);
p.shipped = true;
g.karamaShipping = null;
log(g, `${p.name} used Guild transport for ${amount} forces.${guildSecretEvent ? ` Guild Secret Ally is spent; ${cost} spice was paid to the bank. Their ordinary movement remains available.` : ''}`,
guildSecretEvent
? { faction: 'guild', name: 'Secret Ally transport' }
: { faction: p.faction, name: 'Guild transport' });
if (to !== 'reserves') {
intrusion(g, p, to);
if (origin !== to)
openTerritoryEntry(g, p, to, sector, amount, elite, 'guildTransport');
}
}
function finishGuildTransportRateResponse(g: Game, response: ResponseWindow, canceled: boolean) {
const { intent, shipper, fullCost } = validateGuildTransportRateResponse(g, response);
g.pendingGuildTransport = null;
if (canceled) g.guildRateBlocked = { turn: g.turn, player: shipper.id };
const frame: GuildTransportOrder = {
turn: intent.turn, player: intent.player, origin: intent.origin,
group: intent.group, eliteGroup: intent.eliteGroup, to: intent.to,
sector: intent.sector, amount: intent.amount, elite: intent.elite,
cost: canceled ? fullCost : intent.cost,
allyPayment: intent.allyPayment, advisors: intent.advisors,
};
try {
validateGuildTransportForCommit(g, shipper, frame, intent.fromReserves);
} catch (error) {
if (!(error instanceof RuleError)) throw error;
log(g, `${shipper.name}'s Guild transport declaration returned without a payment or force transfer; its exact original source, destination or approved funding can no longer complete at ${canceled ? 'the full canceled rate' : 'the Guild rate'}.${canceled ? ' The spent Karama leaves a replacement at full price for this turn.' : ''}`);
return;
}
commitGuildTransport(g, shipper, frame, intent.fromReserves);
if (canceled) {
delete g.guildRateBlocked;
log(g, `Karama removed Guild's half-price transport rate. ${shipper.name} paid the full ${fullCost} spice for the unchanged physical transport.`);
}
}
function offerShipment(g: Game, shipment: PendingShipment) {
validatePhysicalShipment(g, shipment);
validateShipmentArrival(g, shipment);
@@ -25688,64 +25919,23 @@ function applyActionInner(
'Only Ixians may ship directly into the mobile stronghold.',
);
if (to !== 'reserves') allowedEntry(g, p, to, sector, false, advisors);
const halfRate = g.karamaShipping?.player === id ||
!(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === id);
const cost = guildShipmentCost(
to === 'reserves' ? 'reserves' : territory(to).type,
n,
);
to === 'reserves' ? 'reserves' : territory(to).type, n, halfRate);
requireRule(
p.spice + (aidFor(g, p)?.amount ?? 0) >= cost,
'Not enough spice.',
);
const allyPayment = contribution(g, p, cost, action.allyPayment);
checkShipmentIncomeRounding(g, p, cost, allyPayment);
const cunningFrame = {turn:g.turn,player:id,origin,group,eliteGroup,to,sector,amount:n,elite,cost,allyPayment,advisors};
if (guildSecretEvent) recordGuildSecretShipment(g,id,guildSecretEvent,'cross',{...cunningFrame,guildSecretEvent});
bindGuildCunningShipment(g,p,'guild',cunningFrame);
payWithAlly(g, p, cost, allyPayment);
const guild = byFaction(g, 'guild');
const guildPayment = guildShipmentIncome({
guild: guild?.id,
shipper: id,
ally: p.ally,
cost,
allyPayment,
bankOnly: g.karamaShipping?.player === id,
});
if (guild && guildPayment > 0)
g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment),allyPayment ? undefined : p.id);
if (fromReserves) {
p.reserves -= n;
if (p.elites) p.elites.reserves -= elite;
} else removeGroup(p, group, eliteGroup);
if (to === 'reserves') {
p.reserves += n;
if (p.elites) p.elites.reserves += elite;
} else {
place(p, to, sector, n, elite);
if (advisors)
(p.advisors ??= {})[to] = {
lockedTurn:
Math.max(sourceLock ?? 0, p.advisors?.[to]?.lockedTurn ?? 0) ||
undefined,
const frame: GuildTransportOrder = {
turn: g.turn, player: id, origin, group, eliteGroup, to, sector,
amount: n, elite, cost, allyPayment, advisors,
};
else if (p.advisors) delete p.advisors[to];
}
observeOccupation(g);
finishShipmentPromises(
g,
p,
fromReserves ? { territory: to, amount: n } : null,
);
finishGuildCunningShipment(g,id,'guild',cunningFrame);
p.shipped = true;
g.karamaShipping = null;
log(g, `${p.name} used Guild transport for ${n} forces.${guildSecretEvent ? ` Guild Secret Ally is spent; ${cost} spice was paid to the bank. Their ordinary movement remains available.` : ''}`,
guildSecretEvent ? {faction:'guild',name:'Secret Ally transport'} : { faction: p.faction, name: 'Guild transport' });
if (to !== 'reserves') {
intrusion(g, p, to);
if (origin !== to)
openTerritoryEntry(g, p, to, sector, n, elite, 'guildTransport');
}
if (!guildSecretEvent && openGuildTransportRate(g, p, frame, fromReserves))
return g;
commitGuildTransport(g, p, frame, fromReserves, guildSecretEvent);
return g;
}
if (t === 'emperorHomeworldMove') {
+1 -1
View File
@@ -116,7 +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.',
'In classic-faction games without optional modules, a Guild or allied ordinary reserve/cross-planet shipment and Guild’s own return to reserves wait for a Karama rate response before paying. Canceling the discount charges the normal one/two-spice-per-force destination tariff for the exact force group and original approved payment split; return uses one spice per force. If that split or the saved physical group cannot complete, the declaration returns unused, and a replacement this turn uses full price. The full-price return and safe withdrawal are table interpretations, not dedicated publisher examples; optional modules and separate transport-permission 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.',
+1
View File
@@ -134,6 +134,7 @@ const cancellationSources = {
guildIncome: () => null,
guildRate: (g) => ({
pending: g.pendingShipment ?? null,
...(g.pendingGuildTransport ? { transport: g.pendingGuildTransport } : {}),
active: g.active,
discountedRateBlocked: g.guildRateBlocked ?? null,
purchasedRate: g.karamaShipping ?? null,
+3 -3
View File
@@ -140,10 +140,10 @@ 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.',
'A classic Guild or reciprocal ally’s discounted ordinary reserve shipment or eligible cross-planet transport waits for a Karama response before paying. Guild’s own return to reserves waits too. Canceling the rate uses normal destination pricing for the same physical forces and approved payment split; a return costs one spice per force. If the split or exact group cannot complete, the declaration returns unused and a replacement this turn uses full price. Full-price return and unaffordable withdrawal are documented table interpretations, not separate publisher examples. Guild income, purchased Karama rates, transport permission and optional modules remain distinct.',
'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.',
'Guild transport can move one exact physical group across the planet or return it to reserves. In classic games its normal half-price rate can be canceled separately from its transport permission. Ordinary movement is checked against range, storm and stronghold occupancy.',
'You may pass through an ally’s territory on a legal route that ends elsewhere. The printed clarification also permits shipping into allied territory followed by departure; that temporary arrival and its departure obligation are not yet supported by the table.',
'Ecaz and its reciprocal ally may share territories permanently and count as one faction toward stronghold capacity. This applies to normal shipment, ground routes and Ambassador arrivals. It does not grant extra movement, bypass storm or mobile-stronghold entry rules, or apply to homeworlds. Their three jointly occupied stronghold victory and shared desert allocation are supported. Allied Ecaz and Fremen together in Sietch Tabr do not block the Fremen final-turn victory; its other conditions still apply. Combined combat and the Habbanya extension remain unfinished.',
'A restored pending physical shipment is checked again before forces or spice are committed. If its saved turn, force allocation, destination, price or authorized contribution no longer matches the table, it is rejected without changing the saved declaration.',
@@ -2408,7 +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.',
'In classic-faction games without optional modules, cancel Guild’s half-price rate before an ordinary reserve shipment, eligible cross-planet transport or Guild return moves any spice or forces. Full destination price applies to the same physical group if the original approved payment split can afford it; otherwise the declaration returns unused and its replacement is full price this turn. The full-price return tariff and unaffordable withdrawal are documented table policies, not printed refund examples. Guild income, permission to cross/return and the independent rate purchased by Karama remain separate.',
'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.',
+8 -8
View File
@@ -14,14 +14,14 @@ export function reserveShipmentCost(
/** Guild cross-planet transport and returns; Fremen southern reserves pay this
* tariff too. Pass 'reserves' for a return, not a board territory type.
*/
export function guildShipmentCost(destinationType: string, amount: number) {
return Math.ceil(
(amount *
(destinationType === 'reserves' || destinationType === 'stronghold'
? 1
: 2)) /
2,
);
export function guildShipmentCost(
destinationType: string,
amount: number,
halfRate = true,
) {
const normal = amount *
(destinationType === 'reserves' || destinationType === 'stronghold' ? 1 : 2);
return halfRate ? Math.ceil(normal / 2) : normal;
}
/** A pledge is already escrowed. Selecting a share never spends a donor's
+5 -1
View File
@@ -158,7 +158,11 @@ export function guildTransportQuote(g: GameView, action: Action) {
);
const cost =
validForces && total > 0 && validDestination
? guildTransportCost(to, total)
? guildTransportCost(
to,
total,
g.karamaShipping?.player === g.me || !g.guildRateCanceled,
)
: null;
const ownSpice = p.spice ?? 0;
const pledgedSpice = g.aid.available;
-2
View File
@@ -298,13 +298,11 @@ for (const cancel of [false, true])
const f = await fixture(route, true);
try {
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');
-2
View File
@@ -169,11 +169,9 @@ void test('Guild income from either contributor direction can be allowed or canc
const card = holdKarama(g, 'a');
const beforeGuild = player(g, 'g').spice;
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);
+352
View File
@@ -0,0 +1,352 @@
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' | 'f' = 'g', advanced = false) {
const g = createGame('GUILDTRANSPORT', newPlayer('g', 'Guild', 'guild'), advanced);
g.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('f', 'Fremen', 'fremen'),
newPlayer('a', 'Atreides', 'atreides'));
g.status = 'playing';
g.phase = 5;
g.turn = 2;
g.storm = 18;
g.order = ['g', 'e', 'f', 'a'];
g.movementRemaining = [...g.order];
g.active = shipper;
g.deck = baseDeck();
for (const seat of g.players) {
seat.spice = 20;
seat.reserves = 20;
seat.forces = {};
seat.hand = [];
}
if (shipper !== 'g') {
player(g, shipper).ally = 'g';
player(g, 'g').ally = shipper;
}
const karama = g.deck.splice(g.deck.findIndex(card => card.effect === 'karama'), 1)[0];
player(g, 'a').hand.push(karama);
return { g, karama };
}
function stationed(g: Game, id: string, sources: Record<string, number>) {
const seat = player(g, id);
seat.forces = { ...sources };
seat.reserves -= Object.values(sources).reduce((sum, n) => sum + n, 0);
}
function declare(g: Game, id: string, sources: Record<string, number> | 'reserves',
destination: string, sector = 0, allyPayment = 0, eliteForces?: Record<string, number>): Game {
const selection = sources === 'reserves'
? { from: 'reserves', amount: 3 }
: { forces: sources, ...(eliteForces ? { eliteForces } : {}) };
return applyAction(g, id, {
type: 'guildShip', ...selection, territory: destination, sector, allyPayment,
});
}
function canceled(g: Game, card: string): Game {
assert.equal(g.response?.kind, 'guildRate');
return applyAction(reload(g), 'a', { type: 'card', mode: 'cancel', card });
}
function rejectUnchanged(g: Game, actor: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before);
}
function physical(g: Game, id: string) {
const seat = player(g, id);
return { spice: seat.spice, reserves: seat.reserves, forces: seat.forces,
elites: seat.elites, shipped: seat.shipped };
}
void test('multi-sector Guild cross-shipment waits unpaid, then Karama charges full for the exact selected forces', () => {
const { g: initial, karama } = fixture();
stationed(initial, 'g', { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 });
const before = physical(initial, 'g');
const offered = declare(initial, 'g', { 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, 'carthag', 11);
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(offered.pendingGuildTransport?.cost, 2);
assert.equal(offered.pendingGuildTransport?.amount, 3);
assert.deepEqual(physical(offered, 'g'), before);
assert.equal(viewGame(offered, 'a').responseControls?.cancelCards.includes(karama.id), true);
assert.equal(viewGame(offered, 'a').players.find(seat => seat.id === 'g')?.spice, undefined);
assert.deepEqual(normalizeAutomaticGame(reload(offered)), reload(offered));
const done = canceled(offered, karama.id);
assert.equal(done.pendingGuildTransport, null);
assert.equal(done.response, null);
assert.equal(player(done, 'g').spice, 17);
assert.equal(player(done, 'g').reserves, 15);
assert.equal(player(done, 'g').forces['pasty_mesa:5'], 1);
assert.equal(player(done, 'g').forces['pasty_mesa:6'], 1);
assert.equal(player(done, 'g').forces['carthag:11'], 3);
assert.equal(player(done, 'g').shipped, true);
assert.equal(done.discard.filter(card => card.id === karama.id).length, 1);
rejectUnchanged(done, 'a', { type: 'card', mode: 'cancel', card: karama.id });
});
void test('Guild return to abstract reserves is still a cancelable shipment and restores its exact force group', () => {
const { g: initial, karama } = fixture();
stationed(initial, 'g', { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 });
const offered = declare(initial, 'g', { 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, 'reserves');
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(offered.pendingGuildTransport?.cost, 2);
assert.equal(player(offered, 'g').reserves, 15);
assert.equal(player(offered, 'g').spice, 20);
const done = canceled(offered, karama.id);
assert.equal(player(done, 'g').spice, 17);
assert.equal(player(done, 'g').reserves, 18);
assert.equal(player(done, 'g').forces['pasty_mesa:5'], 1);
assert.equal(player(done, 'g').forces['pasty_mesa:6'], 1);
assert.equal(player(done, 'g').shipped, true);
});
void test('passing a Guild transport rate response retains the half tariff and its Guild income', () => {
const { g: initial } = fixture('e');
stationed(initial, 'e', { 'imperial_basin:10': 2, 'imperial_basin:11': 3 });
const offered = declare(initial, 'e', { 'imperial_basin:10': 1, 'imperial_basin:11': 2 }, 'red_chasm', 7);
assert.equal(offered.pendingGuildTransport?.cost, 3);
assert.equal(player(offered, 'e').spice, 20);
assert.equal(player(offered, 'g').spice, 20);
const pendingIncome = applyAction(reload(offered), 'a', { type: 'passResponse' });
assert.equal(pendingIncome.pendingGuildTransport, null);
assert.equal(pendingIncome.response?.kind, 'guildIncome');
assert.equal(player(pendingIncome, 'e').spice, 17);
assert.equal(player(pendingIncome, 'g').spice, 20);
const done = applyAction(reload(pendingIncome), 'a', { type: 'passResponse' });
assert.equal(player(done, 'g').spice, 23);
assert.equal(player(done, 'e').forces['red_chasm:7'], 3);
assert.equal(player(done, 'e').shipped, true);
});
void test('the reciprocal Guild ally retains elite selections and original aid split at canceled full desert price', () => {
const { g: initial, karama } = fixture('e');
stationed(initial, 'e', { 'imperial_basin:10': 2, 'imperial_basin:11': 3 });
player(initial, 'e').elites = { reserves: 3, tanks: 0, forces: { 'imperial_basin:10': 1, 'imperial_basin:11': 1 }, revived: 0 };
player(initial, 'e').spice = 5;
const funded = applyAction(initial, 'g', { type: 'pledgeAid', amount: 2 });
const offered = declare(funded, 'e', { 'imperial_basin:10': 1, 'imperial_basin:11': 2 },
'red_chasm', 7, 1, { 'imperial_basin:10': 1, 'imperial_basin:11': 1 });
assert.equal(offered.pendingGuildTransport?.cost, 3);
assert.equal(offered.pendingGuildTransport?.allyPayment, 1);
assert.equal(player(offered, 'e').spice, 5);
assert.equal(player(offered, 'e').elites?.forces['imperial_basin:10'], 1);
const done = canceled(offered, karama.id);
assert.equal(player(done, 'e').spice, 0);
assert.equal(done.aid.g.amount, 1);
assert.equal(player(done, 'e').forces['imperial_basin:10'], 1);
assert.equal(player(done, 'e').forces['imperial_basin:11'], 1);
assert.equal(player(done, 'e').forces['red_chasm:7'], 3);
assert.equal(player(done, 'e').elites?.forces['red_chasm:7'], 2);
assert.equal(player(done, 'e').elites?.forces['imperial_basin:10'] ?? 0, 0);
assert.equal(player(done, 'e').elites?.forces['imperial_basin:11'] ?? 0, 0);
assert.equal(player(done, 'g').spice, 23);
assert.equal(done.guildRateBlocked, undefined);
});
void test('an unfunded full-price return withdraws intact; its replacement pays full using newly approved aid', () => {
const { g: initial, karama } = fixture();
stationed(initial, 'g', { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 });
player(initial, 'g').spice = 2;
player(initial, 'g').ally = 'e';
player(initial, 'e').ally = 'g';
const funded = applyAction(initial, 'e', { type: 'pledgeAid', amount: 2 });
const offered = declare(funded, 'g', { 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, 'reserves');
assert.equal(offered.pendingGuildTransport?.allyPayment, 0);
const untouched = physical(offered, 'g');
const withdrawn = canceled(offered, karama.id);
assert.deepEqual(physical(withdrawn, 'g'), untouched);
assert.equal(withdrawn.aid.e.amount, 2);
assert.equal(withdrawn.pendingGuildTransport, null);
assert.equal(withdrawn.guildRateBlocked?.player, 'g');
assert.equal(viewGame(withdrawn, 'g').guildRateCanceled, true);
assert.equal(viewGame(withdrawn, 'e').guildRateCanceled, false);
assert.equal(withdrawn.discard.filter(card => card.id === karama.id).length, 1);
rejectUnchanged(withdrawn, 'g', {
type: 'guildShip', forces: { 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, territory: 'reserves', allyPayment: 0,
});
const replacement = declare(reload(withdrawn), 'g',
{ 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, 'reserves', 0, 1);
assert.notEqual(replacement.response?.kind, 'guildRate');
assert.equal(player(replacement, 'g').spice, 1);
assert.equal(player(replacement, 'g').reserves, 18);
assert.equal(replacement.aid.e.amount, 1);
assert.equal(player(replacement, 'g').shipped, true);
});
void test('an allied Fremen southern-reserve cross uses the cancelable Guild rate, not off-planet reserves', () => {
const { g: initial, karama } = fixture('f');
const offered = declare(initial, 'f', 'reserves', 'carthag', 11);
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(offered.pendingGuildTransport?.cost, 2);
assert.equal(player(offered, 'f').reserves, 20);
assert.equal(player(offered, 'f').spice, 20);
const done = canceled(offered, karama.id);
assert.equal(player(done, 'f').reserves, 17);
assert.equal(player(done, 'f').spice, 17);
assert.equal(player(done, 'f').forces['carthag:11'], 3);
assert.equal(player(done, 'g').spice, 23);
assert.equal(player(done, 'f').shipped, true);
});
void test('a withdrawn Fremen southern-reserve declaration preserves its native forces and requires full-price replacement', () => {
const { g: initial, karama } = fixture('f');
player(initial, 'f').spice = 2;
const offered = declare(initial, 'f', 'reserves', 'carthag', 11);
const withdrawn = canceled(offered, karama.id);
assert.equal(withdrawn.pendingGuildTransport, null);
assert.equal(player(withdrawn, 'f').reserves, 20);
assert.equal(player(withdrawn, 'f').forces['carthag:11'] ?? 0, 0);
assert.equal(player(withdrawn, 'f').spice, 2);
assert.equal(player(withdrawn, 'f').shipped, false);
assert.equal(viewGame(withdrawn, 'f').guildRateCanceled, true);
rejectUnchanged(withdrawn, 'f', {
type: 'guildShip', from: 'reserves', amount: 3, territory: 'carthag', sector: 11,
});
const replacement = applyAction(reload(withdrawn), 'f', {
type: 'guildShip', from: 'reserves', amount: 2, territory: 'carthag', sector: 11,
});
assert.notEqual(replacement.response?.kind, 'guildRate');
assert.equal(player(replacement, 'f').spice, 0);
assert.equal(player(replacement, 'f').reserves, 18);
assert.equal(player(replacement, 'f').forces['carthag:11'], 2);
});
void test('Advanced cross-transport retains the Guild rate response separately from the reserve shipment stop', () => {
const { g: initial, karama } = fixture('e', true);
stationed(initial, 'e', { 'imperial_basin:10': 3 });
const offered = declare(initial, 'e', { 'imperial_basin:10': 3 }, 'arrakeen', 10);
assert.equal(offered.decision, null);
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(player(offered, 'e').forces['arrakeen:10'] ?? 0, 0);
const done = canceled(offered, karama.id);
assert.equal(player(done, 'e').spice, 17);
assert.equal(player(done, 'e').forces['arrakeen:10'], 3);
assert.equal(player(done, 'e').shipped, true);
});
void test('independently purchased Karama shipping remains half-price and bank-paid, not Guild-cancelable', () => {
const { g: initial, karama } = fixture('e');
player(initial, 'a').hand.splice(player(initial, 'a').hand.indexOf(karama), 1);
player(initial, 'e').hand.push(karama);
stationed(initial, 'e', { 'imperial_basin:10': 3 });
const purchased = applyAction(initial, 'e', { type: 'card', mode: 'shipment', card: karama.id });
assert.equal(purchased.karamaShipping?.player, 'e');
const done = declare(purchased, 'e', { 'imperial_basin:10': 3 }, 'arrakeen', 10);
assert.notEqual(done.response?.kind, 'guildRate');
assert.equal(player(done, 'e').spice, 18);
assert.equal(player(done, 'g').spice, 20);
assert.equal(player(done, 'e').forces['arrakeen:10'], 3);
assert.equal(done.karamaShipping, null);
});
void test('BG Worthless conversion preserves the signed cross-shipment while its cancellation waits', () => {
const { g: initial, karama } = fixture('g', true);
initial.players.push(newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
initial.order.push('b');
initial.movementRemaining!.push('b');
const bg = player(initial, 'b');
bg.spice = 20;
bg.reserves = 20;
bg.forces = {};
const index = initial.deck.findIndex(card => card.kind === 'worthless');
const worthless = initial.deck.splice(index, 1)[0];
bg.hand.push(worthless);
stationed(initial, 'g', { 'pasty_mesa:5': 3 });
let g = declare(initial, 'g', { 'pasty_mesa:5': 3 }, 'carthag', 11);
const signature = g.pendingGuildTransport!.signature;
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'
? g.pendingKarama.use.response.kind : null, 'guildRate');
assert.equal(g.pendingGuildTransport?.signature, signature);
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.pendingGuildTransport, null);
assert.equal(g.response, null);
assert.equal(player(g, 'g').spice, 17);
assert.equal(player(g, 'g').forces['carthag:11'], 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('a restored pending cross rejects stale or tampered physical and response data without mutation', () => {
const { g: initial, karama } = fixture();
stationed(initial, 'g', { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 });
const offered = reload(declare(initial, 'g',
{ 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, 'carthag', 11));
const cancel: Action = { type: 'card', mode: 'cancel', card: karama.id };
rejectUnchanged(offered, 'g', { type: 'guildShip', from: 'pasty_mesa:5', amount: 1, territory: 'reserves' });
for (const corrupt of [
(g: Game) => { g.response!.guildRateEvent = 'stale'; },
(g: Game) => { g.pendingGuildTransport!.signature = 'stale'; },
(g: Game) => { g.pendingGuildTransport!.turn = g.turn - 1; },
(g: Game) => { g.pendingGuildTransport!.group[0][1] = 2; },
(g: Game) => { g.pendingGuildTransport!.cost = 1; },
]) {
const stale = reload(offered);
corrupt(stale);
const before = structuredClone(stale);
assert.throws(() => viewGame(stale, 'a'));
assert.throws(() => normalizeAutomaticGame(stale));
assert.deepEqual(stale, before);
rejectUnchanged(stale, 'a', cancel);
}
});
void test('a depleted saved source cannot spend or arrive from the obsolete force group', () => {
const { g: initial } = fixture();
stationed(initial, 'g', { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 });
const offered = reload(declare(initial, 'g',
{ 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, 'carthag', 11));
player(offered, 'g').forces['pasty_mesa:5'] = 0;
const spice = player(offered, 'g').spice;
const remaining = structuredClone(player(offered, 'g').forces);
const released = applyAction(offered, 'a', { type: 'passResponse' });
assert.equal(released.pendingGuildTransport, null);
assert.equal(player(released, 'g').spice, spice);
assert.deepEqual(player(released, 'g').forces, remaining);
assert.equal(player(released, 'g').forces['carthag:11'] ?? 0, 0);
assert.equal(player(released, 'g').shipped, false);
});
void test('all AI profiles pass pending transport and a blocked ally still selects a legal full-price shipment', () => {
for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const { g: initial } = fixture();
player(initial, 'a').bot = level;
stationed(initial, 'g', { 'pasty_mesa:5': 3 });
const offered = declare(initial, 'g', { 'pasty_mesa:5': 3 }, 'carthag', 11);
const reply = botActions(viewGame(offered, 'a'))[0];
assert.ok(reply, `${level} must respond to a legal Guild transport`);
const completed = applyAction(reload(offered), 'a', reply);
assert.equal(completed.pendingGuildTransport, null);
assert.equal(player(completed, 'g').forces['carthag:11'], 3);
assert.equal(player(completed, 'g').spice, 18);
}
const { g: initial, karama } = fixture('e');
stationed(initial, 'e', { 'imperial_basin:10': 4 });
player(initial, 'e').spice = 2;
const withdrawn = canceled(declare(initial, 'e',
{ 'imperial_basin:10': 4 }, 'arrakeen', 10), karama.id);
player(withdrawn, 'e').bot = 'Easy';
const candidate = botActions(viewGame(withdrawn, 'e')).find(action =>
action.type === 'ship' || action.type === 'guildShip');
assert.ok(candidate, 'A canceled discount leaves the allied bot a legal full-price shipment.');
const done = applyAction(reload(withdrawn), 'e', candidate);
assert.equal(player(done, 'e').shipped, true);
assert.ok(player(done, 'e').spice < 2);
});
@@ -0,0 +1,99 @@
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('a restarted room resolves exactly one canceled multi-sector Guild transport across a CAS race', async () => {
const store = unitStore();
try {
const made = await store.rooms.createRoom('Guild transport 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 = [];
}
g.players[0].forces = { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 };
g.players[0].reserves = 15;
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: 'guildShip', forces: { 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 },
territory: 'carthag', sector: 11, 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.pendingGuildTransport?.cost, 2);
assert.equal(offered.players[0].spice, 20);
assert.equal(offered.players[0].reserves, 15);
assert.deepEqual(offered.players[0].forces, g.players[0].forces);
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);
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.pendingGuildTransport, null);
assert.equal(done.response, null);
assert.equal(done.players[0].spice, 17);
assert.equal(done.players[0].reserves, 15);
assert.equal(done.players[0].forces['pasty_mesa:5'], 1);
assert.equal(done.players[0].forces['pasty_mesa:6'], 1);
assert.equal(done.players[0].forces['carthag:11'], 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();
}
});
+73
View File
@@ -0,0 +1,73 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { guildTransportQuote } from '../game/transport-quote';
import { guildShipmentCost } from '../game/shipment-price';
function fixture(fremen = false): Game {
const g = createGame('GUILDTRANSPORTPRICE', newPlayer('p', 'Shipper', fremen ? 'fremen' : 'guild'), false);
g.players.push(newPlayer('q', 'Ally', fremen ? 'guild' : 'atreides'));
Object.assign(g, {
status: 'playing', phase: 5, turn: 2, active: 'p',
order: ['p', 'q'], movementRemaining: ['p', 'q'], storm: 18,
});
const shipper = g.players[0];
shipper.ally = 'q';
g.players[1].ally = 'p';
shipper.hand = [];
shipper.spice = 3;
shipper.forces = fremen ? {} : { 'red_chasm:7': 5 };
shipper.reserves = fremen ? 20 : 15;
return g;
}
const cross = (territory: string): Action => ({
type: 'guildShip', from: 'red_chasm:7', amount: 2,
territory, sector: territory === 'carthag' ? 11 : 0,
});
const quote = (g: Game, action: Action) => guildTransportQuote(viewGame(g, 'p'), action);
const botTransport = (g: Game) => botActions(viewGame(g, 'p')).filter(a => a.type === 'guildShip');
void test('a canceled Guild rate doubles cross and return quotes, with purchased Karama restoring the half rate', () => {
const g = fixture();
const returned = cross('reserves');
const desertDestination: Action = {
type: 'guildShip', from: 'red_chasm:7', amount: 2,
territory: 'imperial_basin', sector: 10,
};
assert.equal(quote(g, returned).quote?.cost, 1);
assert.equal(quote(g, desertDestination).quote?.cost, 2);
g.guildRateBlocked = { turn: 2, player: 'p' };
assert.equal(quote(g, returned).quote?.cost, 2);
assert.deepEqual(quote(g, returned).unavailableReasons, []);
assert.equal(quote(g, desertDestination).quote?.cost, 4);
assert.ok(quote(g, desertDestination).unavailableReasons.some(reason => reason.includes('spice')));
assert.equal(guildShipmentCost('reserves', 2, false), 2);
assert.equal(guildShipmentCost('other', 2, false), 4);
g.karamaShipping = { player: 'p', owner: 'q' };
assert.equal(quote(g, returned).quote?.cost, 1);
assert.equal(quote(g, desertDestination).quote?.cost, 2);
assert.deepEqual(quote(g, desertDestination).unavailableReasons, []);
});
void test('after an unaffordable Guild rate cancellation bots omit discounted-only cross and allied southern transports', () => {
const guild = fixture();
guild.players[0].bot = 'Medium';
const discounted = botTransport(guild)[0];
assert.ok(discounted);
assert.equal(applyAction(guild, 'p', discounted).players[0].shipped, true);
guild.guildRateBlocked = { turn: 2, player: 'p' };
assert.deepEqual(botTransport(guild), []);
const fremen = fixture(true);
fremen.players[0].bot = 'Medium';
fremen.players[0].spice = 2;
const southern = botTransport(fremen).find(a => a.from === 'reserves');
assert.ok(southern);
assert.equal(applyAction(fremen, 'p', southern).players[0].shipped, true);
fremen.guildRateBlocked = { turn: 2, player: 'p' };
assert.deepEqual(botTransport(fremen), []);
fremen.karamaShipping = { player: 'p', owner: 'q' };
assert.ok(botTransport(fremen).some(a => a.from === 'reserves'));
});
@@ -106,3 +106,24 @@ void test('all profiles can select a physically typed cross-shipment with the ex
assert.ok(botActions(other).every((a) => a.nexus === undefined));
}
});
void test('an unallied Guild Secret Ally bot budgets the independent half-price cross-shipment', () => {
const f = nexusGuildSecretAllyFixture({
ownerFaction: 'emperor', advanced: true, spice: 2,
});
const owner = f.g.players[0];
owner.forces['false_wall_west:16'] = 4;
owner.reserves -= 4;
const view = viewGame(f.g, f.owner);
view.players[0].bot = 'Medium';
const action = botActions(view).find(candidate =>
candidate.type === 'guildShip' &&
candidate.nexus === view.nexusGuildSecretAlly?.event &&
candidate.from === 'false_wall_west:16' &&
Number(candidate.amount) === 4);
assert.ok(action, 'Four physical forces at Guild price two must remain affordable.');
const done = nexusAllow(applyAction(f.g, f.owner, action));
assert.equal(done.players[0].spice, 0);
assert.equal(done.players[0].shipped, true);
nexusGuildSecretAllyInventory(done);
});