From b89c225d5bad857845ae21f272c6b5f06af7d4b0 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:27:49 +1100 Subject: [PATCH] Support group Guild-rate reserve shipments --- components/game-table.tsx | 5 +- docs/CURRENT_STATUS.md | 17 +- docs/OPTIONAL_ALLIANCE_VICTORY.md | 24 ++- docs/RULE_DECISIONS.md | 10 +- game/bots.ts | 5 +- game/engine.ts | 35 ++-- game/reference.ts | 4 +- game/shipment-price.ts | 11 ++ tests/coalition-guild-shipment.test.ts | 212 +++++++++++++++++++++++++ 9 files changed, 297 insertions(+), 26 deletions(-) create mode 100644 tests/coalition-guild-shipment.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 8fdc411b..fe60e23b 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -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 diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 3330b06d..261198e7 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 diff --git a/docs/OPTIONAL_ALLIANCE_VICTORY.md b/docs/OPTIONAL_ALLIANCE_VICTORY.md index 8b8ca381..ee9ed839 100644 --- a/docs/OPTIONAL_ALLIANCE_VICTORY.md +++ b/docs/OPTIONAL_ALLIANCE_VICTORY.md @@ -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. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 31102e6f..ddd1a6ff 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -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 diff --git a/game/bots.ts b/game/bots.ts index 766f2f59..e14f8372 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -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); diff --git a/game/engine.ts b/game/engine.ts index fd1c15ec..f029b772 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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[number]) { diff --git a/game/reference.ts b/game/reference.ts index 8ee926a3..0b1ff306 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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'], }, diff --git a/game/shipment-price.ts b/game/shipment-price.ts index 2b9b16b3..b552d4c3 100644 --- a/game/shipment-price.ts +++ b/game/shipment-price.ts @@ -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( diff --git a/tests/coalition-guild-shipment.test.ts b/tests/coalition-guild-shipment.test.ts new file mode 100644 index 00000000..1fe38959 --- /dev/null +++ b/tests/coalition-guild-shipment.test.ts @@ -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); +});