Support staged group Guild transport
Verify and publish checkpoint container / container (push) Successful in 12m5s
Verify and publish checkpoint container / container (push) Successful in 12m5s
This commit is contained in:
1 parent
478b38f5de
commit
2fe97ddd1f
10 files changed
+424
-23
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user