Support group Guild-rate reserve shipments
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-28 23:27:49 +11:00
1 parent 1eeeb32566
commit b89c225d5b
9 files changed
+297 -26

No files matched your search

+3 -2
View File
@@ -218,7 +218,7 @@ import {
import { kullPurposeBlock, kullSpecialPurpose, type KullPurpose } from '@/game/kull-purpose';
import { LeaderPortrait } from './leader-portrait';
import { ShipmentQuote } from './shipment-quote';
import { reserveShipmentCost } from '@/game/shipment-price';
import { groupGuildShipmentRate, reserveShipmentCost } from '@/game/shipment-price';
import { quoteSmugglerShipment } from '@/game/smuggler-shipment';
import { SmugglerShipmentChoice } from './smuggler-shipment';
import { nexusGuildSecretAllyAction, nexusGuildSecretAllyQuote } from '@/game/nexus-guild-secret-ally-options';
@@ -759,11 +759,12 @@ export function GameTable({
`Choose ${minimum} to ${maximum} elite forces from reserves.`,
);
}
const groupGuildRate = groupGuildShipmentRate(g.players, me.id, g.coalitionState);
const shipmentRate = {
faction: me.faction,
halfRate: g.karamaShipping?.player === me.id ||
(!g.guildRateCanceled &&
(me.faction === 'guild' ||
(me.faction === 'guild' || groupGuildRate ||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally))),
};
const smugglerOffer = validShipmentAmount && !useGuildSecret && !useRicheseShipment
+13 -4
View File
@@ -17,6 +17,16 @@ open mode gates.
## Current checkpoint and work
**Staged group Guild-rate shipments — 28 October. Prototyped / Partial.**
Guild group members use the native ordinary reserve rate and unpaid Karama
response. A canceled rate preserves the selected donor shares, charging the
shipper the remainder or returning an unaffordable shipment unused. Guild's
own payment goes to the bank; other named payers fund Guild. This staged Basic
path does not enable group games or special transports. The related eight-file
batch passes 65 cases; group formation uses actual Nexus actions after a staged
starting position, with the Karama card transferred from the undealt deck.
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-guild-rate-shipments--28-october-2026).
**Basic independent cards: Residual Poison and Semuta — 28 October.
Prototyped / Partial.** The existing clean pre-leader continuation offers the
public Poison discard to Semuta while preserving the committed death and the
@@ -45,8 +55,7 @@ allies can pledge separately to a group member, who may use named shares for
one ordinary Basic shipment; an outside Guild receives the payment and unspent
pledges return to their donors. The staged table has private pledge and share
controls. The related eight-file batch passes 48 cases, with real Nexus and
shipment actions following a staged starting position. Guild-member groups,
special transports remain
shipment actions following a staged starting position. Special transports remain
unsupported; group play is still not exposed in the lobby.
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-ordinary-shipment-funding--28-october-2026).
@@ -155,8 +164,8 @@ The staged Nexus negotiation is connected; group funding, faction benefits
and other engine paths still need integration before the option can be offered.
Connected staged Nexus and shipment controls model unanimous consent, explicit
donor allocations and edge-bound pledge authorizations. The engine issues and
revokes those authorizations and refunds escrow; special auctions, transport
and Guild-allied rates still require integration.
revokes those authorizations and refunds escrow; special auctions and transport
still require integration.
The separate printed unlimited-size / three-stronghold variant remains missing;
the current group model deliberately caps membership at three. The combined
earlier batch passed 189 cases; the shipment batch passed 58 cases. The latest
+21 -3
View File
@@ -1,5 +1,23 @@
# Optional three-stronghold alliance victory
## Group Guild-rate shipments — 28 October 2026
**Staged Basic prototype, not a selectable game mode.** Guild and both members
of its group receive the ordinary rounded Guild reserve-shipment rate. When
that rate changes the price, the original unpaid Karama response resolves
before movement or payment. If canceled, the declared donor shares stay fixed
and the shipper must fund the full-price remainder; otherwise the declaration
returns unused and a replacement uses the full rate. Each payer's contribution
is routed individually: Guild's own payment goes to the bank, while other
payments go to Guild. The table quote and minimal bot use the same group rate.
The [publisher rules, p. 19](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf)
grant the Guild rate to its ally; the [FAQ, p. 7](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
permits Karama to prevent that rate. Applying the benefit and original split
to both members of a larger group is a provisional composition. Special
transports, independent purchased rates, optional modules and full group play
remain separate; no lobby exposes the group option.
## Group ordinary-bid funding — 28 October 2026
**Staged Basic prototype, not a selectable game mode.** A group member can
@@ -32,9 +50,9 @@ formation, preserved for surviving edges and revoked on departure, with
aggregated refunds for removed edges. Controls show only the viewer's own
outgoing and incoming pledges; bots may continue using their own spice.
This first path is limited to Basic classic-faction staged groups without a
Guild member, optional modules, Karama shipment pricing or special transport.
Guild-allied rates, other shipment types, special auctions, and the
This first path is limited to Basic classic-faction staged groups without
optional modules, independently purchased Karama shipment pricing or special transport.
Other shipment types, special auctions, and the
remaining faction benefits and interactions are not connected. No lobby can
start a group game yet. The recipient-specific pledge and allocation procedure
is a provisional online convention rather than a printed worked example.
+8 -2
View File
@@ -1,5 +1,11 @@
# Rule decision index
## Group Guild-rate shipments — 28 October 2026
Guild's native rate provisionally applies to each group ally. Karama reprices
the same donor split and Guild's own contribution goes to the bank.
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-guild-rate-shipments--28-october-2026).
## Group ordinary-bid funding — 28 October 2026
The unexposed Basic group prototype keeps donor-specific pledges locked while
@@ -10,8 +16,8 @@ and escrow procedure is provisional. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-
The unexposed Basic group prototype uses recipient-specific escrow and
explicit donor allocations for an ordinary physical shipment. Those online
controls are provisional; Guild-allied pricing, bidding and other transports
remain separate. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-ordinary-shipment-funding--28-october-2026).
controls are provisional; special auctions and other transports remain
separate. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-ordinary-shipment-funding--28-october-2026).
## Group Nexus negotiations — 28 October 2026
+3 -2
View File
@@ -89,7 +89,7 @@ import {
canUseAsKaramaRole,
shrineTruthtranceBotAction,
} from './shrine';
import { reserveShipmentCost } from './shipment-price';
import { groupGuildShipmentRate, reserveShipmentCost } from './shipment-price';
import { moritaniSpecialKaramaBotAction } from './moritani-special-karama-options';
import { quoteSmugglerShipment } from './smuggler-shipment';
import { quoteSmugglerNoField } from './smuggler-no-field';
@@ -3541,9 +3541,10 @@ function policyActions(g: GameView): Action[] {
if (g.ornithopter?.active && me.shipped) return [{ type: 'endMovement' }];
const actions: Action[] = [...guildHomeworldShipmentActions(g, level), ...homeworldShipmentActions(g, level)];
const targets = destinations(g);
const groupGuildRate = groupGuildShipmentRate(g.players, me.id, g.coalitionState);
const halfRate = g.karamaShipping?.player === me.id ||
(!g.guildRateCanceled &&
(me.faction === 'guild' ||
(me.faction === 'guild' || groupGuildRate ||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally)));
const shipmentBudget = (me.spice ?? 0) + g.aid.available;
const guildSecret = nexusGuildSecretAllyCanAct(g);
+24 -11
View File
@@ -623,6 +623,7 @@ import { IxStartingCardError, ixStartingCardPoolSize, ixStartingCardDealPlan } f
import { ecazDukeRevivalBlock, quoteEcazDukeRevival } from './ecaz-duke-revival';
import {
reserveShipmentCost,
groupGuildShipmentRate,
guildShipmentCost,
guildShipmentIncome,
shipmentPaymentBounds,
@@ -16047,8 +16048,8 @@ function applyCoalitionTransition(g: Game, result: CoalitionTransition) {
}
syncCoalitionEscrow(g, before);
}
/** Initial staged group-funding scope: ordinary Basic planet shipments, with
* Guild outside the group. Guild-allied rate and other purchases remain separate. */
/** Staged group-funding scope: ordinary Basic planet shipments and bids;
* independent purchased rates and special transactions remain separate. */
function coalitionFundingAvailable(g: Game, p: Player, committedBid = false): boolean {
if (!g.coalitionEscrow || !g.coalitionState || g.status !== 'playing' || g.advanced || ![3, 5].includes(g.phase) ||
g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens ||
@@ -16057,8 +16058,10 @@ function coalitionFundingAvailable(g: Game, p: Player, committedBid = false): bo
(!committedBid && (g.bidPromises?.length || g.phaseBidPromises?.length || g.turnActionPromises?.length ||
g.revivalPromises?.length || g.bribePromises?.length))) return false;
const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
const guild = byFaction(g, 'guild');
return group?.members.length === 3 && (g.phase === 3 || !guild || !group.members.includes(guild.id));
return group?.members.length === 3;
}
function guildGroupAlly(g: Game, p: Player): boolean {
return coalitionRule(() => groupGuildShipmentRate(g.players, p.id, g.coalitionState));
}
function coalitionShipmentQuote(g: Game, p: Player, cost: number, contributions: CoalitionContribution[]) {
requireRule(g.phase === 5 && coalitionFundingAvailable(g, p), 'Group funding is unavailable for this shipment.');
@@ -38028,7 +38031,8 @@ function ordinaryGuildRateFullCost(g: Game, shipment: PendingShipment): number |
const pricedAmount = shipment.noField ? 1 + (shipment.noFieldReserves ?? 0)
: shipment.alliedNoField || shipment.nexusEvent ? 1
: shipment.amount - (shipment.smuggler ? 1 : 0);
if (!guild || (p.id !== guild.id && !(p.ally === guild.id && guild.ally === p.id)) ||
if (!guild || (p.id !== guild.id && !(p.ally === guild.id && guild.ally === p.id) &&
!guildGroupAlly(g, p)) ||
g.karamaShipping?.player === p.id ||
(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id) ||
shipment.source || shipment.guildSecretEvent || shipment.richesePair)
@@ -38132,10 +38136,16 @@ function finishGuildRateResponse(g: Game, response: ResponseWindow, canceled: bo
g.guildRateBlocked = { turn: g.turn, player: shipper.id };
const bounds = shipmentPaymentBounds(fullCost, shipper.spice,
aidFor(g, shipper)?.amount ?? 0);
let groupFunded = true;
if (shipment.coalitionContributions !== undefined) {
try { coalitionShipmentQuote(g, shipper, fullCost, shipment.coalitionContributions); }
catch (error) { if (!(error instanceof RuleError)) throw error; groupFunded = false; }
}
// A loan supplier approved the original whole price, not an increased gift.
const supplierApprovalChanges = shipment.alliedNoField &&
shipment.alliedNoField.payer === shipment.alliedNoField.owner;
if (supplierApprovalChanges || shipment.allyPayment < bounds.minimum || shipment.allyPayment > bounds.maximum) {
if (supplierApprovalChanges || (shipment.coalitionContributions !== undefined ? !groupFunded
: shipment.allyPayment < bounds.minimum || shipment.allyPayment > bounds.maximum)) {
if (cunning) {
delete cunning.shipment;
saveGuildCunning(g, cunning);
@@ -38680,7 +38690,10 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
const guild = byFaction(g, 'guild');
const groupShares = shipment.coalitionContributions;
const groupTotal = groupShares?.reduce((sum, share) => sum + share.amount, 0) ?? 0;
const guildPayment = groupShares ? (guild ? cost : 0) : guildShipmentIncome({
const groupContributors = groupShares ? [{payer:p.id, amount:cost - groupTotal},
...groupShares.map(share => ({payer:share.donor, amount:share.amount}))]
.filter(share => share.payer !== guild?.id && share.amount > 0) : null;
const guildPayment = groupContributors ? groupContributors.reduce((sum, share) => sum + share.amount, 0) : guildShipmentIncome({
guild: guild?.id,
shipper: p.id,
ally: p.ally,
@@ -38690,9 +38703,8 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
!!(shipment.alliedNoField && alliedNoFieldPricing(g, p).bankOnly),
});
if (!settlement?.recipient && guild && guildPayment > 0) {
const named = groupShares ? [{payer:p.id, amount:cost - groupTotal},
...groupShares.map(share => ({payer:share.donor, amount:share.amount}))]
: [{ payer: p.id, amount: p.id === guild.id ? 0 : cost - allyPayment },
const named = groupContributors ??
[{ payer: p.id, amount: p.id === guild.id ? 0 : cost - allyPayment },
...(allyPayment && p.ally !== guild.id ? [{ payer: p.ally!, amount: allyPayment }] : [])];
g.response = guildPaymentResponse(g, guild.id,
groupShares ? named.map(share => share.amount) : shipmentIncomeContributions(g, p, cost, allyPayment),
@@ -39292,8 +39304,9 @@ function richesePairIntegrity(g: Game) {
}
}
function shipmentHalfRate(g: Game, p: Player) {
const guild = byFaction(g, 'guild');
return g.karamaShipping?.player === p.id ||
((p.faction === 'guild' || byFaction(g,'guild')?.id === p.ally) &&
((p.faction === 'guild' || guild?.id === p.ally || guildGroupAlly(g, p)) &&
!(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id));
}
function nexusRicheseSignature(record: NonNullable<Game['nexusRicheseHistory']>[number]) {
+2 -2
View File
@@ -2795,10 +2795,10 @@ export const RULE_TOPICS: RuleTopic[] = [
example:
'You have two spice and your ally pledges four. You may bid six; winning uses two of yours and four of theirs.',
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Ordinary pair funding is connected. Staged Basic group games support recipient-specific pledges and named contributions for ordinary bids, plus physical shipments when Guild is outside the group; Guild-allied shipment rates and special transactions remain open.', evidence: ['game/coalition-escrow.ts', 'game/coalition-funding.ts', 'game/engine.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Ordinary pair funding is connected. Staged Basic group games support recipient-specific pledges and named contributions for ordinary bids and physical shipments, including the native Guild rate and its Karama response. Special transactions remain open.', evidence: ['game/coalition-escrow.ts', 'game/coalition-funding.ts', 'game/engine.ts', 'game/shipment-price.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Pair funding has normal table controls. Staged group bid and shipment controls project each player’s own outgoing and incoming pledges; group play is not selectable yet.', evidence: ['components/coalition-funding.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Existing bots can participate using their own spice; group funding strategy is deferred.' },
{ area: 'Verification', status: 'Partial', detail: 'Focused staged cases exercise multiple donors, an outside Guild, ordinary auctions, Emperor income, rejected splits and individual refunds. Full group play and deployed acceptance remain open.', evidence: ['tests/coalition-bid-funding.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-escrow.test.ts', 'tests/coalition-funding-controls.test.tsx'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused staged cases exercise multiple donors, Guild and Emperor income, ordinary auctions, rate cancellation, rejected splits and individual refunds. Full group play and deployed acceptance remain open.', evidence: ['tests/coalition-guild-shipment.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-escrow.test.ts', 'tests/coalition-funding-controls.test.tsx'] },
],
related: ['bidding', 'movement', 'faction-guild', 'card-karama'],
},
+11
View File
@@ -1,4 +1,15 @@
import type { FactionId } from './catalog';
import { areCoalitionAllies, type CoalitionState } from './coalition-alliance';
/** Public group membership only; legacy pair and Guild self rates stay with
* the caller's native rules. This grants no separate Guild transport action. */
export function groupGuildShipmentRate(
players: readonly {id:string; faction:FactionId; ally:string|null; allySinceTurn?:number}[],
player: string, state?: CoalitionState,
): boolean {
const guild = players.find(seat => seat.faction === 'guild');
return !!state && !!guild && guild.id !== player && areCoalitionAllies(players, state, player, guild.id);
}
/** Ordinary reserve shipment; other transport powers have separate prices. */
export function reserveShipmentCost(
+212
View File
@@ -0,0 +1,212 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { territory } from '../game/board';
import { reserveShipmentCost } from '../game/shipment-price';
const send = (game: Game, player: string, action: Action) => applyAction(game, player, action);
const player = (game: Game, id: string) => game.players.find(seat => seat.id === id)!;
const pledged = (game: Game, donor: string, recipient: string) =>
game.coalitionEscrow!.pledges.find(entry => entry.donor === donor && entry.recipient === recipient)?.amount ?? 0;
/** The Basic Nexus, empty group ledger and following movement boundary are
* staged because group play has no lobby entry. The pair and three-member
* group form through real actions. Wallets and forces are then reset to a
* controlled, conserved position; one original undealt Karama is moved to
* the outside Harkonnen hand so the native response remains actionable. */
function fixture(shipper: 'a' | 'g' = 'a') {
let game = createGame('GROUPGUILD', newPlayer('a', 'Atreides', 'atreides'), false);
game.players.push(newPlayer('g', 'Guild', 'guild'), newPlayer('e', 'Emperor', 'emperor'),
newPlayer('h', 'Harkonnen', 'harkonnen'));
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['a','g','e','h'],
ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null});
game.coalitionState = emptyCoalitionState();
game = send(game, 'a', {type:'alliance', target:'g'});
game = send(game, 'g', {type:'alliance', target:'a'});
const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair);
game = send(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'e',
expectedRevision:game.coalitionState!.revision});
for (const id of ['g', 'e']) {
const offer = game.coalitionState!.proposal!;
game = send(game, id, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
}
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a','e','g']);
assert.ok(game.coalitionEscrow);
Object.assign(game, {phase:5, nexus:false, active:shipper, ready:[],
movementRemaining:[shipper, ...['a','g','e','h'].filter(id => id !== shipper)],
guildTimingGranted:false, guildTimingLocked:false});
for (const seat of game.players) {
seat.forces = {}; seat.reserves = 20; seat.tanks = 0; seat.spice = 10;
seat.hand = []; seat.traitors = []; seat.traitorChoices = []; seat.shipped = false; seat.moved = 0;
}
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const [karama] = game.deck.splice(index, 1);
player(game, 'h').hand.push(karama);
return {game, karama};
}
function passResponses(game: Game): Game {
for (let i = 0; game.response && i < 24; i++) {
const next = game.players.find(seat => !game.response!.passed.includes(seat.id));
assert.ok(next, 'Each response needs a remaining eligible player.');
game = send(game, next.id, {type:'passResponse'});
}
assert.equal(game.response ?? null, null);
assert.equal(game.pendingShipment ?? null, null);
return game;
}
function rejected(game: Game, id: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => send(game, id, action));
assert.deepEqual(game, before);
}
function conserved(game: Game) {
for (const seat of game.players)
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((a,b) => a+b, 0), 20);
}
const spiceIncludingEscrow = (game: Game) => game.players.reduce((sum, seat) => sum + seat.spice, 0) +
(game.coalitionEscrow?.pledges.reduce((sum, entry) => sum + entry.amount, 0) ?? 0);
function fundAlly(game: Game) {
game = send(game, 'g', {type:'coalitionPledge', recipient:'a', amount:3});
return send(game, 'e', {type:'coalitionPledge', recipient:'a', amount:2});
}
const discounted: Action = {type:'ship', territory:'red_chasm', sector:7, amount:3, elite:0,
allyPayment:0, coalitionContributions:[{donor:'g', amount:1}, {donor:'e', amount:1}]};
void test('Guild as sole contributor pays the bank without receiving its own income', () => {
const {game: initial} = fixture();
let game = send(initial, 'g', {type:'coalitionPledge', recipient:'a', amount:3});
game = send(game, 'a', {...discounted, coalitionContributions:[{donor:'g', amount:3}]});
assert.equal(game.response?.kind, 'guildRate');
game = passResponses(game);
assert.equal(player(game, 'g').spice, 7);
assert.equal(player(game, 'a').spice, 10);
assert.equal(pledged(game, 'g', 'a'), 0);
assert.equal(spiceIncludingEscrow(game), 37);
conserved(game);
});
void test('a rounded one-force stronghold price has no separate Guild-rate cancellation', () => {
const {game: initial} = fixture();
const game = send(initial, 'a', {type:'ship', territory:'arrakeen', sector:10,
amount:1, elite:0, allyPayment:0});
assert.notEqual(game.response?.kind, 'guildRate');
assert.equal(game.pendingShipment ?? null, null);
assert.equal(player(game, 'a').spice, 9);
assert.equal(player(game, 'a').forces['arrakeen:10'], 1);
});
for (const difficulty of DIFFICULTIES) void test(`${difficulty} proposes an affordable group Guild-rate shipment`, () => {
const {game} = fixture();
player(game, 'a').spice = 3;
const view = viewGame(game, 'a');
view.players.find(seat => seat.id === 'a')!.bot = difficulty;
const shipment = botActions(view).find(action => action.type === 'ship');
assert.ok(shipment);
const cost = reserveShipmentCost({faction:'atreides', halfRate:true},
territory(String(shipment.territory)).type, Number(shipment.amount));
assert.ok(cost <= 3);
const result = send(game, 'a', shipment);
if (result.pendingShipment) assert.equal(result.pendingShipment.cost, cost);
else assert.equal(player(game, 'a').spice - player(result, 'a').spice, cost);
});
void test('a self-funded group ally receives the Guild rate without declaring donor shares', () => {
const {game: initial} = fixture();
const declared = send(initial, 'a', {type:'ship', territory:'red_chasm', sector:7,
amount:3, elite:0, allyPayment:0});
assert.equal(declared.response?.kind, 'guildRate');
assert.equal(declared.pendingShipment?.cost, 3);
const done = passResponses(declared);
assert.equal(player(done, 'a').spice, 7);
assert.equal(player(done, 'g').spice, 13);
assert.equal(player(done, 'a').forces['red_chasm:7'], 3);
conserved(done);
});
void test('Guild-allied group shipment uses half price and sends each non-Guild payment to Guild', () => {
const {game: initial} = fixture();
const funded = fundAlly(initial);
const declared = send(funded, 'a', discounted);
assert.equal(declared.response?.kind, 'guildRate');
assert.equal(declared.pendingShipment?.cost, 3);
assert.equal(player(declared, 'a').spice, 10);
assert.equal(player(declared, 'a').reserves, 20);
assert.equal(pledged(declared, 'g', 'a'), 3);
assert.equal(pledged(declared, 'e', 'a'), 2);
const done = passResponses(declared);
assert.equal(player(done, 'a').spice, 9);
assert.equal(player(done, 'g').spice, 9, 'Guild receives Atreides and Emperor payments, not its own contribution.');
assert.equal(player(done, 'e').spice, 8);
assert.equal(pledged(done, 'g', 'a'), 2);
assert.equal(pledged(done, 'e', 'a'), 1);
assert.equal(player(done, 'a').forces['red_chasm:7'], 3);
assert.equal(player(done, 'a').reserves, 17);
assert.equal(spiceIncludingEscrow(done), 39, 'Guild’s own one-spice contribution went to the bank.');
conserved(done);
});
void test('Karama removes the group Guild discount and preserves the declared donor shares', () => {
const {game: initial, karama} = fixture();
const declared = send(fundAlly(initial), 'a', discounted);
assert.equal(declared.response?.kind, 'guildRate');
const after = send(declared, 'h', {type:'card', mode:'cancel', card:karama.id});
const done = passResponses(after);
assert.equal(player(done, 'a').spice, 6, 'The shipper covers the full-price remainder of four.');
assert.equal(player(done, 'g').spice, 12, 'Only the non-Guild payer legs reach Guild.');
assert.equal(player(done, 'e').spice, 8);
assert.equal(pledged(done, 'g', 'a'), 2);
assert.equal(pledged(done, 'e', 'a'), 1);
assert.equal(player(done, 'a').forces['red_chasm:7'], 3);
assert.equal(done.discard.filter(card => card.id === karama.id).length, 1);
assert.equal(spiceIncludingEscrow(done), 39);
rejected(done, 'h', {type:'card', mode:'cancel', card:karama.id});
conserved(done);
});
void test('unaffordable canceled group rate leaves all pledged shares and forces untouched', () => {
const {game: initial, karama} = fixture();
player(initial, 'a').spice = 1;
const declared = send(fundAlly(initial), 'a', discounted);
assert.equal(declared.pendingShipment?.cost, 3);
const after = send(declared, 'h', {type:'card', mode:'cancel', card:karama.id});
assert.equal(after.pendingShipment ?? null, null);
assert.equal(player(after, 'a').spice, 1);
assert.equal(player(after, 'a').reserves, 20);
assert.equal(player(after, 'a').shipped, false);
assert.equal(pledged(after, 'g', 'a'), 3);
assert.equal(pledged(after, 'e', 'a'), 2);
assert.equal(after.discard.filter(card => card.id === karama.id).length, 1);
assert.equal(viewGame(after, 'a').guildRateCanceled, true);
const replacement: Action = {...discounted, coalitionContributions:[{donor:'g', amount:3}, {donor:'e', amount:2}]};
const done = passResponses(send(after, 'a', replacement));
assert.equal(player(done, 'a').spice, 0);
assert.equal(player(done, 'a').forces['red_chasm:7'], 3);
assert.equal(pledged(done, 'g', 'a'), 0);
assert.equal(pledged(done, 'e', 'a'), 0);
conserved(done);
});
void test('Guild shipping as a group member charges rounded half price and collects both allied contributions', () => {
const {game: initial} = fixture('g');
let game = send(initial, 'a', {type:'coalitionPledge', recipient:'g', amount:1});
game = send(game, 'e', {type:'coalitionPledge', recipient:'g', amount:1});
game = send(game, 'g', {type:'ship', territory:'arrakeen', sector:10, amount:3, elite:0, allyPayment:0,
coalitionContributions:[{donor:'a', amount:1}, {donor:'e', amount:1}]});
assert.equal(game.response?.kind, 'guildRate');
assert.equal(game.pendingShipment?.cost, 2, 'Three stronghold forces cost ceil(3 / 2).');
game = passResponses(game);
assert.equal(player(game, 'g').spice, 12);
assert.equal(player(game, 'a').spice, 9);
assert.equal(player(game, 'e').spice, 9);
assert.equal(pledged(game, 'a', 'g'), 0);
assert.equal(pledged(game, 'e', 'g'), 0);
assert.equal(player(game, 'g').forces['arrakeen:10'], 3);
assert.equal(spiceIncludingEscrow(game), 40);
conserved(game);
});