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