From 2fe97ddd1fbea3f3c6d0baac37b99f321dd905df Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:52:21 +1100 Subject: [PATCH] Support staged group Guild transport --- components/game-table.tsx | 7 + docs/CURRENT_STATUS.md | 9 + docs/OPTIONAL_ALLIANCE_VICTORY.md | 19 +- docs/RULE_DECISIONS.md | 6 + game/bot-mobility.ts | 2 +- game/bots.ts | 1 + game/engine.ts | 62 +++-- game/reference.ts | 4 +- game/transport-quote.ts | 26 +- tests/coalition-guild-transport.test.ts | 311 ++++++++++++++++++++++++ 10 files changed, 424 insertions(+), 23 deletions(-) create mode 100644 tests/coalition-guild-transport.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index c223b9a5..89eea884 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -423,6 +423,7 @@ export function GameTable({ const guildTransport = useGuildSecret || me.faction === 'guild' || + !!g.coalitionFunding && groupGuildShipmentRate(g.players, me.id, g.coalitionState) || g.players.some((p) => p.faction === 'guild' && p.id === me.ally); const [botFaction, setBotFaction] = useState(''); const [botDifficulty, setBotDifficulty] = useState('Medium'); @@ -897,6 +898,8 @@ export function GameTable({ type: 'guildShip', ...(useGuildSecret ? {nexus: guildSecretEvent} : {}), ...payment, + ...(g.coalitionFunding && currentCoalitionShares.length && !useGuildSecret && !includesNoField + ? {allyPayment:0, coalitionContributions:currentCoalitionShares} : {}), ...movementGroup, territory: selected, sector, @@ -910,11 +913,15 @@ export function GameTable({ sector, amount, ...payment, + ...(g.coalitionFunding && currentCoalitionShares.length + ? {allyPayment:0, coalitionContributions:currentCoalitionShares} : {}), }; const returnTransportAction: Action = { type: 'guildShip', ...(useGuildSecret ? {nexus: guildSecretEvent} : {}), ...payment, + ...(g.coalitionFunding && currentCoalitionShares.length && !useGuildSecret && !includesNoField + ? {allyPayment:0, coalitionContributions:currentCoalitionShares} : {}), ...movementGroup, territory: 'reserves', amount, diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index ccf79ad8..b670af72 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,15 @@ open mode gates. ## Current checkpoint and work +**Staged group Guild transport — 28 October. Prototyped / Partial.** Either +Guild ally can use native cross-planet transport, including Fremen southern +reserves, with explicit donor-specific funding. The original permission and +rate responses retain the declared shares. Applying the privilege to both +allies is provisional; other group capabilities and lobby access remain +unfinished. Seven related files pass 68 cases, with real Nexus and transport +actions after a staged setup, repricing and control/quote smoke. +[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-guild-transport--28-october-2026). + **Staged group Harkonnen traitor support — 28 October. Prototyped / Partial.** Harkonnen can use its own traitor in either ally's battle through the native vote, Karama and outcome flow. Applying this to two allies is provisional; diff --git a/docs/OPTIONAL_ALLIANCE_VICTORY.md b/docs/OPTIONAL_ALLIANCE_VICTORY.md index cc54468b..059d3408 100644 --- a/docs/OPTIONAL_ALLIANCE_VICTORY.md +++ b/docs/OPTIONAL_ALLIANCE_VICTORY.md @@ -1,5 +1,22 @@ # Optional three-stronghold alliance victory +## Group Guild transport — 28 October 2026 + +**Staged Basic prototype, not a selectable game mode.** Both Guild group +allies can use native cross-planet transport, including a Fremen ally's +southern-reserve source. Only Guild may return its own forces to reserves. +The shipper can name separate pledged shares from its group allies. Permission +and the discounted rate retain their distinct Karama windows before payment +or transfer. Rate cancellation keeps the exact original shares and requires +the shipper to fund the full-price remainder; otherwise the unused declaration +returns without spending. Guild's own contribution goes to the bank and +other named contributions go to Guild. Table quotes and minimal bots use the +same available group permission. The [publisher rules, pp. 9 and 19](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf) +and [FAQ, pp. 2 and 7](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf) +describe the pair power and cancellations. Applying it to both allies and +the donor allocation procedure are provisional. Advanced, optional modules, +No-Field and purchased-rate combinations remain outside this staged path. + ## Group Guild-rate shipments — 28 October 2026 **Staged Basic prototype, not a selectable game mode.** Guild and both members @@ -14,7 +31,7 @@ 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 +to both members of a larger group is a provisional composition. Other special transports, independent purchased rates, optional modules and full group play remain separate; no lobby exposes the group option. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 5609d113..27f2fbaf 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,11 @@ # Rule decision index +## Group Guild transport — 28 October 2026 + +The staged Basic group prototype provisionally gives each Guild ally its +native cross-planet privilege and permits explicit separate donor shares. +[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-guild-transport--28-october-2026). + ## Group Harkonnen traitor support — 28 October 2026 The staged Basic group prototype provisionally allows Harkonnen to support diff --git a/game/bot-mobility.ts b/game/bot-mobility.ts index a37146a6..f737a44b 100644 --- a/game/bot-mobility.ts +++ b/game/bot-mobility.ts @@ -67,7 +67,7 @@ export function botEntryAllowed( return false; try { return !territoryEntryBlock(g.players, p.id, to, advisors, - kind === 'ship' && isAlliedShipmentVisit(g, p, to)); + kind === 'ship' && isAlliedShipmentVisit(g, p, to), g.coalitionState); } catch (error) { if (error instanceof EcazOccupancyError) return false; throw error; diff --git a/game/bots.ts b/game/bots.ts index c9ce5ecf..1aad17a2 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -3743,6 +3743,7 @@ function policyActions(g: GameView): Action[] { const guildTransport = guildSecret || me.faction === 'guild' || + !!g.coalitionFunding && groupGuildRate || g.players.some((p) => p.faction === 'guild' && p.id === me.ally); if (shipmentAvailable && guildTransport) for (const to of targets diff --git a/game/engine.ts b/game/engine.ts index 008b8a2a..bc55c2fb 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1407,6 +1407,7 @@ type PendingGuildTransport = { elite: number; cost: number; allyPayment: number; + coalitionContributions?: CoalitionContribution[]; advisors: boolean; /** Fresh board transports retain their actual source stance at foreign arrivals. * Absent on declarations recorded before this source cutover. */ @@ -38198,12 +38199,14 @@ function guildTransportSignature(intent: PendingGuildTransport): string { if (intent.permission !== undefined) fields.push(intent.permission); if (intent.advisorArrivalSource !== undefined) fields.push(intent.advisorArrivalSource); if (intent.noField !== undefined) fields.push(intent.noField); + if (intent.coalitionContributions !== undefined) fields.push(intent.coalitionContributions); return JSON.stringify(fields); } function ordinaryGuildTransportFullCost(g: Game, p: Player, frame: GuildTransportOrder): number | null { const guild = byFaction(g, 'guild'); if (!guild || - (p.id !== guild.id && !(p.ally === guild.id && guild.ally === p.id)) || + (p.id !== guild.id && !(p.ally === guild.id && guild.ally === p.id) && + !(coalitionFundingAvailable(g, p) && guildGroupAlly(g, p))) || g.karamaShipping?.player === p.id || (g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id)) return null; @@ -38256,6 +38259,8 @@ function validateGuildTransportWindow(g: Game, response: ResponseWindow) { Number.isSafeInteger(intent.elite) && intent.elite >= 0 && intent.elite <= intent.amount - Number(!!marker) && Number.isSafeInteger(intent.allyPayment) && intent.allyPayment >= 0 && intent.allyPayment <= intent.cost && typeof intent.advisors === 'boolean' && + (intent.coalitionContributions === undefined || Array.isArray(intent.coalitionContributions) && + intent.allyPayment === 0 && !marker) && (intent.advisorArrivalSource === undefined || g.advanced && shipper.faction === 'beneGesserit' && !intent.fromReserves && intent.to !== 'reserves' && intent.advisorArrivalSource === intent.origin) && (marker === undefined || typeof marker === 'object' && marker !== null && @@ -38299,6 +38304,7 @@ function guildTransportOrder(intent: PendingGuildTransport, cost = intent.cost): group: intent.group, eliteGroup: intent.eliteGroup, to: intent.to, sector: intent.sector, amount: intent.amount, elite: intent.elite, cost, allyPayment: intent.allyPayment, advisors: intent.advisors, + ...(intent.coalitionContributions !== undefined ? {coalitionContributions:intent.coalitionContributions.map(share => ({...share}))} : {}), ...(intent.noField ? {noField:{...intent.noField}} : {}), ...(intent.advisorArrivalSource !== undefined ? {advisorArrivalSource:intent.advisorArrivalSource} : {}), }; @@ -38361,7 +38367,8 @@ function nativeGuildTransportPermission(g: Game, p: Player): boolean { if (guildTransportCanceled(g, p.id)) return false; if (p.faction === 'guild') return true; const guild = byFaction(g, 'guild'); - return !!guild && p.ally === guild.id && guild.ally === p.id; + return !!guild && (p.ally === guild.id && guild.ally === p.id || + coalitionFundingAvailable(g, p) && guildGroupAlly(g, p)); } function validateGuildTransportForCommit( g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean, guildSecretEvent?:string, @@ -38410,8 +38417,14 @@ function validateGuildTransportForCommit( 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); - checkShipmentIncomeFunding(g, p, cost, allyPayment); + if (frame.coalitionContributions !== undefined) { + requireRule(allyPayment === 0 && !frame.noField && !guildSecretEvent && + Array.isArray(frame.coalitionContributions), 'Group funding requires a physical native Guild transport.'); + coalitionShipmentQuote(g, p, cost, frame.coalitionContributions); + } else { + contribution(g, p, cost, allyPayment); + checkShipmentIncomeFunding(g, p, cost, allyPayment); + } checkShipmentPromises(g, p, fromReserves ? { territory: to, amount, sector } : null); } function commitGuildTransport( @@ -38433,18 +38446,32 @@ function commitGuildTransport( if (guildSecretEvent) recordGuildSecretShipment(g, p.id, guildSecretEvent, 'cross', { ...frame, guildSecretEvent }); bindGuildCunningShipment(g, p, 'guild', frame); - payWithAlly(g, p, cost, allyPayment); + if (frame.coalitionContributions !== undefined) { + const result = coalitionRule(() => settleCoalitionFunding({seats:g.players, coalitionState:g.coalitionState, + authorizations:g.coalitionEscrow!.authorizations, pledges:g.coalitionEscrow!.pledges, + purpose:'shipment', payer:p.id, cost, ownAvailable:uncommittedSpice(g, p), + contributions:frame.coalitionContributions!})); + p.spice -= result.quote.own; + g.coalitionEscrow = {...g.coalitionEscrow!, pledges:result.pledges}; + } else payWithAlly(g, p, cost, allyPayment); if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += cost; if (!movementPreparation) { const guild = byFaction(g, 'guild'); - const guildPayment = guildShipmentIncome({ + const groupShares = frame.coalitionContributions; + const groupTotal = groupShares?.reduce((sum, share) => sum + share.amount, 0) ?? 0; + 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, cost, allyPayment, bankOnly: g.karamaShipping?.player === p.id, }); if (!settlement?.recipient && guild && guildPayment > 0) - g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment), - allyPayment ? undefined : p.id, [{ 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, + groupContributors ? groupContributors.map(share => share.amount) : shipmentIncomeContributions(g, p, cost, allyPayment), + groupShares || allyPayment ? undefined : p.id, groupContributors ?? + [{ payer: p.id, amount: p.id === guild.id ? 0 : cost - allyPayment }, + ...(allyPayment && p.ally !== guild.id ? [{ payer: p.ally!, amount: allyPayment }] : [])]); settleBankerShipmentIncome(g, p, cost, allyPayment); } if (fromReserves) { @@ -45278,15 +45305,20 @@ function applyActionInner( !(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === id); const cost = guildShipmentCost( 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); - checkShipmentIncomeFunding(g, p, cost, allyPayment); + let coalitionContributions: CoalitionContribution[] | undefined; + if (action.coalitionContributions !== undefined) { + requireRule(execution === 'live' && Array.isArray(action.coalitionContributions) && + !noField && !guildSecretEvent && (action.allyPayment === undefined || action.allyPayment === 0), + 'Group funding requires a physical native Guild transport.'); + coalitionContributions = action.coalitionContributions as CoalitionContribution[]; + coalitionShipmentQuote(g, p, cost, coalitionContributions); + } else requireRule(p.spice + (aidFor(g, p)?.amount ?? 0) >= cost, 'Not enough spice.'); + const allyPayment = coalitionContributions === undefined ? contribution(g, p, cost, action.allyPayment) : 0; + if (coalitionContributions === undefined) checkShipmentIncomeFunding(g, p, cost, allyPayment); const frame: GuildTransportOrder = { turn: g.turn, player: id, origin, group, eliteGroup, to, sector, amount: n, elite, cost, allyPayment, advisors, + ...(coalitionContributions !== undefined ? {coalitionContributions:coalitionContributions.map(share => ({...share}))} : {}), ...(noField ? {noField:{...noField}} : {}), ...(advisorArrivalSource !== undefined ? {advisorArrivalSource} : {}), }; diff --git a/game/reference.ts b/game/reference.ts index 06613d35..6b27cb08 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4500,11 +4500,11 @@ export const RULE_TOPICS: RuleTopic[] = [ example: 'At the final turn with neither Guild nor Fremen, two players each hold two qualifying strongholds and a third holds one. The two leaders of the count share the win.', checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'A host-selectable shorter two-member target shares victory calculation. Unexposed staged Basic groups connect Nexus, selected funding, Emperor and Fremen aid, Voice, Prescience and Harkonnen traitor support; other group benefits remain open.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts', 'game/coalition-battle-preparation.ts', 'game/traitor-declarations.ts', 'game/terminal-cancellation.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'A host-selectable shorter two-member target shares victory calculation. Unexposed staged Basic groups connect Nexus, selected funding, native Guild transport, Emperor and Fremen aid, Voice, Prescience and Harkonnen traitor support; other group benefits remain open.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts', 'game/coalition-battle-preparation.ts', 'game/traitor-declarations.ts', 'game/terminal-cancellation.ts'] }, { area: 'Player controls', status: 'Partial', detail: 'The lobby offers the pair option. A staged group Emperor can choose either ally for spice and extra revival; group games cannot yet be started from the lobby.', evidence: ['components/game-table.tsx', 'components/coalition-emperor-aid.tsx', 'tests/short-alliance-victory-controls.test.tsx'] }, { area: 'AI', status: 'Partial', detail: 'Legal alliance and victory paths use the shared target; staged group Emperor bots can offer a targeted revival. Strategic tuning remains deferred.', evidence: ['game/bots.ts', 'game/victory-progress.ts'] }, { area: 'Documentation', status: 'Partial', detail: 'The printed option and provisional two-controller precedence are recorded.', evidence: ['docs/OPTIONAL_ALLIANCE_VICTORY.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, Emperor and Fremen aid, battle preparation and allied traitor votes; larger alliances and full combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] }, + { area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, Guild transport, Emperor and Fremen aid, battle preparation and allied traitor votes; larger alliances and full combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] }, ], related: [ 'mentat', diff --git a/game/transport-quote.ts b/game/transport-quote.ts index a8f67ae4..80ff3e83 100644 --- a/game/transport-quote.ts +++ b/game/transport-quote.ts @@ -3,7 +3,7 @@ import { nexusGuildSecretAllyCanAct } from './nexus-guild-secret-ally-options'; import type { Action, GameView } from './engine'; import { splitLocation, validGameLocation } from './board'; import { botEntryAllowed, guildTransportCost } from './bot-mobility'; -import { shipmentPaymentBounds } from './shipment-price'; +import { groupGuildShipmentRate, shipmentPaymentBounds } from './shipment-price'; import { quoteAdvisorTransportMatching, AdvisorTransportMatchingError } from './advisor-transport-matching'; import { guildNoFieldSelection } from './guild-no-field-selection'; import { isBoardNoFieldLocation } from './richese-no-field'; @@ -176,6 +176,7 @@ export function guildTransportQuote(g: GameView, action: Action) { !( nexusAllowed || p.faction === 'guild' || + !!g.coalitionFunding && groupGuildShipmentRate(g.players, p.id, g.coalitionState) || g.players.some((seat) => seat.id === p.ally && seat.faction === 'guild') ) ) @@ -192,15 +193,32 @@ export function guildTransportQuote(g: GameView, action: Action) { ) : null; const ownSpice = p.spice ?? 0; - const pledgedSpice = g.aid.available; + const groupShares = action.coalitionContributions; + const incoming = g.coalitionFunding?.incoming ?? []; + const pledgedSpice = groupShares === undefined ? g.aid.available : incoming.reduce((sum, entry) => sum + entry.amount, 0); const { minimum } = shipmentPaymentBounds(cost ?? 0, ownSpice, pledgedSpice); - const share = action.allyPayment ?? minimum; + let groupShareValid = groupShares === undefined; + let groupShareTotal = 0; + if (groupShares !== undefined && Array.isArray(groupShares) && g.coalitionFunding) { + const seen = new Set(); + groupShareValid = groupShares.every((entry: unknown) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false; + const share = entry as {donor?: unknown; amount?: unknown}; + const pledged = incoming.find(item => item.donor === share.donor); + if (typeof share.donor !== 'string' || seen.has(share.donor) || !pledged || + typeof share.amount !== 'number' || !Number.isSafeInteger(share.amount) || + share.amount <= 0 || share.amount > pledged.amount) return false; + seen.add(share.donor); groupShareTotal += share.amount; return Number.isSafeInteger(groupShareTotal); + }); + } + const share = groupShares === undefined ? action.allyPayment ?? minimum : groupShareTotal; const validShare = cost !== null && typeof share === 'number' && Number.isSafeInteger(share) && share >= 0 && - share <= cost; + share <= cost && groupShareValid && (groupShares === undefined || !nexus && !namesNoField && + (action.allyPayment === undefined || action.allyPayment === 0)); if (cost !== null) { if (!validShare) unavailableReasons.push( diff --git a/tests/coalition-guild-transport.test.ts b/tests/coalition-guild-transport.test.ts new file mode 100644 index 00000000..69265b0b --- /dev/null +++ b/tests/coalition-guild-transport.test.ts @@ -0,0 +1,311 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +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 { botEntryAllowed } from '../game/bot-mobility'; +import { guildTransportQuote } from '../game/transport-quote'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : + specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]])); + return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true}; + }, +}); +const {GameTable} = await import('../components/game-table'); +aliases.deregister(); + +const send = (game: Game, id: string, action: Action) => applyAction(game, id, action); +const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!; +const pledge = (game: Game, donor: string, recipient: string) => + game.coalitionEscrow!.pledges.find(entry => entry.donor === donor && entry.recipient === recipient)?.amount ?? 0; + +/** Three-member play has no lobby entry. Stage a Basic Nexus, use the actual + * pair and unanimous admission actions, then stage conserved forces and + * wallets at Shipment and Movement. A physical Karama moves from the undealt + * deck to the outside faction so both native response windows are exercised. */ +function fixture(shipper: 'e' | 'f' | 'g' = 'e', group = true) { + let game = createGame('GROUPGUILDTRANSPORT', newPlayer('g', 'Guild', 'guild'), false); + game.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('f', 'Fremen', 'fremen'), + newPlayer('h', 'Harkonnen', 'harkonnen')); + Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['g','e','f','h'], + ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null}); + game.coalitionState = emptyCoalitionState(); + const pairAlly = shipper === 'g' ? 'e' : shipper; + game = send(game, 'g', {type:'alliance', target:pairAlly}); + game = send(game, pairAlly, {type:'alliance', target:'g'}); + if (group) { + const pair = coalitionOf(game.players, game.coalitionState, 'g'); assert.ok(pair); + const entrant = pairAlly === 'e' ? 'f' : 'e'; + game = send(game, 'g', {type:'coalitionPropose', groupId:pair.id, entrant, + expectedRevision:game.coalitionState!.revision}); + for (const id of [pairAlly, entrant]) { + 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.ok(game.coalitionEscrow); + } + Object.assign(game, {phase:5, nexus:false, active:shipper, ready:[], + movementRemaining:[shipper,...game.order.filter(id => id !== shipper)], + guildTimingGranted:false, guildTimingLocked:false}); + for (const player of game.players) { + player.forces = {}; player.reserves = 20; player.tanks = 0; player.spice = 10; + player.hand = []; player.traitors = []; player.traitorChoices = []; player.shipped = false; player.moved = 0; + } + const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0); + const [karama] = game.deck.splice(index, 1); seat(game, 'h').hand.push(karama); + return {game, karama}; +} +function passKind(game: Game, kind: string): Game { + for (let count = 0; game.response?.kind === kind && count < 24; count++) { + const next = game.players.find(player => !game.response!.passed.includes(player.id)); + assert.ok(next, `The ${kind} response needs an eligible player.`); + game = send(game, next.id, {type:'passResponse'}); + } + assert.notEqual(game.response?.kind, kind); + return game; +} +function passAll(game: Game): Game { + for (let count = 0; game.response && count < 32; count++) { + const next = game.players.find(player => !game.response!.passed.includes(player.id)); + assert.ok(next); + game = send(game, next.id, {type:'passResponse'}); + } + assert.equal(game.response ?? null, null); + return game; +} +function conserved(game: Game) { + for (const player of game.players) + assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((a,b) => a+b,0),20); +} +function fund(game: Game, target: 'e' | 'f'): Game { + const other = target === 'e' ? 'f' : 'e'; + game = send(game, 'g', {type:'coalitionPledge', recipient:target, amount:2}); + return send(game, other, {type:'coalitionPledge', recipient:target, amount:2}); +} +const shares = (target: 'e' | 'f') => [{donor:'g',amount:1},{donor:target === 'e' ? 'f' : 'e',amount:1}]; + +void test('group cross-planet shipment retains donor shares across permission and rate responses', () => { + let {game} = fixture(); + seat(game,'e').forces = {'imperial_basin:10':3}; seat(game,'e').reserves = 17; + game = fund(game, 'e'); + game = send(game, 'e', {type:'guildShip', forces:{'imperial_basin:10':3}, + territory:'red_chasm', sector:7, allyPayment:0, coalitionContributions:shares('e')}); + assert.equal(game.response?.kind, 'guildTransportPermission'); + assert.equal(seat(game,'e').forces['imperial_basin:10'],3); + assert.equal(seat(game,'e').spice,10); + game = passKind(game, 'guildTransportPermission'); + assert.equal(game.response?.kind, 'guildRate'); + assert.equal(game.pendingGuildTransport?.cost,3); + assert.equal(seat(game,'e').forces['imperial_basin:10'],3); + game = passAll(game); + assert.equal(seat(game,'e').forces['red_chasm:7'],3); + assert.equal(seat(game,'e').forces['imperial_basin:10'] ?? 0,0); + assert.equal(seat(game,'e').spice,9); + assert.equal(seat(game,'g').spice,10, 'Guild receives the two non-Guild shares, not its own.'); + assert.equal(seat(game,'f').spice,8); + assert.equal(pledge(game,'g','e'),1); assert.equal(pledge(game,'f','e'),1); + assert.equal(seat(game,'e').shipped,true); + conserved(game); +}); + +void test('Karama reprices the selected group shares while preserving cross-planet shipment and income', () => { + const initial = fixture(); + let {game} = initial; + const {karama} = initial; + seat(game,'e').forces = {'imperial_basin:10':3}; seat(game,'e').reserves = 17; + game = fund(game, 'e'); + game = send(game, 'e', {type:'guildShip', forces:{'imperial_basin:10':3}, + territory:'red_chasm', sector:7, allyPayment:0, coalitionContributions:shares('e')}); + game = passKind(game,'guildTransportPermission'); + assert.equal(game.response?.kind,'guildRate'); + game = send(game,'h',{type:'card', mode:'cancel', card:karama.id}); + game = passAll(game); + assert.equal(seat(game,'e').forces['red_chasm:7'],3); + assert.equal(seat(game,'e').spice,6, 'The shipper pays the repriced four-spice remainder.'); + assert.equal(seat(game,'g').spice,13, 'Guild receives shipper plus Fremen shares only.'); + assert.equal(seat(game,'f').spice,8); + assert.equal(pledge(game,'g','e'),1); assert.equal(pledge(game,'f','e'),1); + assert.equal(game.discard.filter(card => card.id === karama.id).length,1); + conserved(game); +}); + +void test('Fremen group ally transports southern reserves with the original permission and Guild rate', () => { + let {game} = fixture('f'); + game = fund(game, 'f'); + game = send(game, 'f', {type:'guildShip', from:'reserves', amount:3, elite:0, + territory:'carthag', sector:11, allyPayment:0, coalitionContributions:shares('f')}); + assert.equal(game.response?.kind,'guildTransportPermission'); + assert.equal(seat(game,'f').reserves,20); + game = passKind(game,'guildTransportPermission'); + assert.equal(game.response?.kind,'guildRate'); + assert.equal(game.pendingGuildTransport?.cost,2); + game = passAll(game); + assert.equal(seat(game,'f').reserves,17); + assert.equal(seat(game,'f').forces['carthag:11'],3); + assert.equal(seat(game,'f').spice,10); + assert.equal(seat(game,'g').spice,9, 'Only Emperor’s share goes to Guild.'); + assert.equal(pledge(game,'g','f'),1); assert.equal(pledge(game,'e','f'),1); + conserved(game); +}); + +void test('Karama cancels group Guild permission before any payment or force movement', () => { + const initial = fixture(); + let {game} = initial; + const {karama} = initial; + seat(game,'e').forces = {'imperial_basin:10':3}; seat(game,'e').reserves = 17; + game = fund(game,'e'); + game = send(game,'e',{type:'guildShip', forces:{'imperial_basin:10':3}, territory:'red_chasm', + sector:7, allyPayment:0, coalitionContributions:shares('e')}); + assert.equal(game.response?.kind,'guildTransportPermission'); + game = send(game,'h',{type:'card',mode:'cancel',card:karama.id}); + game = passAll(game); + assert.equal(seat(game,'e').forces['imperial_basin:10'],3); + assert.equal(seat(game,'e').spice,10); assert.equal(seat(game,'e').shipped,false); + assert.equal(pledge(game,'g','e'),2); assert.equal(pledge(game,'f','e'),2); + assert.equal(viewGame(game,'e').guildTransportCanceled,true); + conserved(game); +}); + +void test('legacy pair Guild transport still accepts its implicit allied funding', () => { + let {game} = fixture('e', false); + seat(game,'e').forces = {'imperial_basin:10':3}; seat(game,'e').reserves = 17; + game = send(game,'g',{type:'pledgeAid', amount:2}); + game = send(game,'e',{type:'guildShip', forces:{'imperial_basin:10':3}, territory:'red_chasm', sector:7, allyPayment:1}); + assert.equal(game.response?.kind,'guildTransportPermission'); + game = passKind(game,'guildTransportPermission'); + assert.equal(game.response?.kind,'guildRate'); + game = passAll(game); + assert.equal(seat(game,'e').forces['red_chasm:7'],3); + assert.equal(seat(game,'e').spice,8); assert.equal(game.aid.g.amount,1); + conserved(game); +}); + +void test('an unaffordable canceled group rate releases the original transport and leaves both pledges intact', () => { + const initial = fixture(); + let {game} = initial; + seat(game,'e').forces = {'imperial_basin:10':3}; seat(game,'e').reserves = 17; + seat(game,'e').spice = 1; + game = fund(game,'e'); + game = send(game,'e',{type:'guildShip', forces:{'imperial_basin:10':3}, territory:'red_chasm', + sector:7, allyPayment:0, coalitionContributions:shares('e')}); + game = passKind(game,'guildTransportPermission'); + assert.equal(game.response?.kind,'guildRate'); + game = send(game,'h',{type:'card',mode:'cancel',card:initial.karama.id}); + game = passAll(game); + assert.equal(game.pendingGuildTransport ?? null,null); + assert.equal(seat(game,'e').forces['imperial_basin:10'],3); + assert.equal(seat(game,'e').forces['red_chasm:7'] ?? 0,0); + assert.equal(seat(game,'e').spice,1); assert.equal(seat(game,'e').shipped,false); + assert.equal(pledge(game,'g','e'),2); assert.equal(pledge(game,'f','e'),2); + assert.equal(viewGame(game,'e').guildRateCanceled,true); + conserved(game); +}); + +void test('Guild may return its own physical group with separate allied donor shares', () => { + let {game} = fixture('g'); + seat(game,'g').forces = {'imperial_basin:10':3}; seat(game,'g').reserves = 17; + game = send(game,'e',{type:'coalitionPledge',recipient:'g',amount:1}); + game = send(game,'f',{type:'coalitionPledge',recipient:'g',amount:1}); + game = send(game,'g',{type:'guildShip', forces:{'imperial_basin:10':3}, territory:'reserves', + allyPayment:0, coalitionContributions:[{donor:'e',amount:1},{donor:'f',amount:1}]}); + assert.equal(game.response?.kind,'guildTransportPermission'); + game = passKind(game,'guildTransportPermission'); + assert.equal(game.response?.kind,'guildRate'); + assert.equal(game.pendingGuildTransport?.cost,2); + game = passAll(game); + assert.equal(seat(game,'g').reserves,20); + assert.equal(seat(game,'g').forces['imperial_basin:10'] ?? 0,0); + assert.equal(seat(game,'g').spice,12); + assert.equal(seat(game,'e').spice,9); assert.equal(seat(game,'f').spice,9); + assert.equal(pledge(game,'e','g'),0); assert.equal(pledge(game,'f','g'),0); + conserved(game); +}); + +void test('invalid cross-transport allocations reject without changing funds, escrow or forces', () => { + let {game} = fixture(); + seat(game,'e').forces = {'imperial_basin:10':3}; seat(game,'e').reserves = 17; + game = fund(game,'e'); + const base: Action = {type:'guildShip', forces:{'imperial_basin:10':3}, territory:'red_chasm', + sector:7, allyPayment:0}; + for (const coalitionContributions of [ + [{donor:'g',amount:1},{donor:'g',amount:1}], + [{donor:'h',amount:1}], + [{donor:'e',amount:1}], + [{donor:'g',amount:3}], + [{donor:'g',amount:2},{donor:'f',amount:2}], + ]) { + const before = structuredClone(game); + assert.throws(() => send(game,'e',{...base,coalitionContributions})); + assert.deepEqual(game,before); + } + conserved(game); +}); + +void test('a Medium Fremen bot proposes an executable group southern-reserve transport', () => { + const {game} = fixture('f'); + const view = viewGame(game,'f'); + view.players.find(player => player.id === 'f')!.bot = 'Medium'; + const action = botActions(view).find(candidate => candidate.type === 'guildShip' && candidate.from === 'reserves'); + assert.ok(action); + const pending = send(game,'f',action); + assert.equal(pending.response?.kind,'guildTransportPermission'); + assert.equal(seat(pending,'f').reserves,20); +}); + +void test('group transport quote uses each pledged share and the table exposes its transport control', () => { + let {game} = fixture(); + seat(game,'e').forces = {'imperial_basin:10':3}; seat(game,'e').reserves = 17; + game = fund(game,'e'); + const action: Action = {type:'guildShip', forces:{'imperial_basin:10':3}, territory:'red_chasm', + sector:7, allyPayment:0, coalitionContributions:shares('e')}; + const view = viewGame(game,'e'); + const before = structuredClone(view); + const quote = guildTransportQuote(view,action); + assert.deepEqual(quote.unavailableReasons,[]); + assert.deepEqual(quote.quote,{cost:3,normalCost:3,ownPayment:1,pledgedPayment:2}); + assert.deepEqual(view,before); + for (const coalitionContributions of [ + [{donor:'g',amount:3}], + [{donor:'g',amount:1},{donor:'g',amount:1}], + [{donor:'h',amount:1}], + [{donor:'g',amount:2},{donor:'f',amount:2}], + ]) { + const invalid = guildTransportQuote(view,{...action,coalitionContributions}); + assert.ok(invalid.unavailableReasons.length); + assert.equal(invalid.quote,null); + } + const markup = renderToStaticMarkup(createElement(GameTable,{ + game:view, busy:false, send:async () => {}, onExit() {}, + })); + assert.match(markup,/]*>(?:(?!<\/button>)[\s\S])*Guild transport(?:(?!<\/button>)[\s\S])*<\/button>/); +}); + +void test('group cross-transport rejects a destination occupied by another ally in both quote and engine', () => { + let {game} = fixture(); + seat(game,'e').forces = {'imperial_basin:10':3}; seat(game,'e').reserves = 17; + seat(game,'f').forces = {'red_chasm:7':1}; seat(game,'f').reserves = 19; + game = fund(game,'e'); + const action: Action = {type:'guildShip', forces:{'imperial_basin:10':3}, territory:'red_chasm', + sector:7, allyPayment:0, coalitionContributions:shares('e')}; + const view = viewGame(game,'e'); + const own = view.players.find(player => player.id === 'e')!; + assert.equal(botEntryAllowed(view,own,'red_chasm',7,'guildShip','imperial_basin'),false); + assert.ok(guildTransportQuote(view,action).unavailableReasons.length); + const before = structuredClone(game); + assert.throws(() => send(game,'e',action)); + assert.deepEqual(game,before); + conserved(game); +});