Support staged group Guild transport
Verify and publish checkpoint container / container (push) Successful in 12m5s

This commit is contained in:
Quintine committed 2026-10-28 23:52:21 +11:00
1 parent 478b38f5de
commit 2fe97ddd1f
10 files changed
+424 -23

No files matched your search

+7
View File
@@ -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<Difficulty>('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,
+9
View File
@@ -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;
+18 -1
View File
@@ -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.
+6
View File
@@ -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
+1 -1
View File
@@ -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;
+1
View File
@@ -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
+47 -15
View File
@@ -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} : {}),
};
+2 -2
View File
@@ -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',
+22 -4
View File
@@ -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<string>();
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(
+311
View File
@@ -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\b[^>]*>(?:(?!<\/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);
});