Bind Truthtrance Karama promises to shipment beneficiaries

This commit is contained in:
Quintine committed 2026-10-27 03:41:27 +11:00
1 parent fd0b2f0fa5
commit fd38c51ffd
19 files changed
+1147 -40

No files matched your search

+1
View File
@@ -605,6 +605,7 @@ export function Truthtrance({
{!g.truthTurnActionTargets.includes(target) && <p className="notice">Choose a player with supported native Voice or Prescience, printed card plays, current battle, Traitor declaration, alliance, ordinary bid, Revival, payment, shipment or ground-movement opportunities this turn.
Weather Control, Family Atomics and Harvester refer only to their original current Storm or fresh spice-blow window.
Printed Karama auction use refers only to the current ordinary lot, through immediate purchase or its winning invoice.
Printed Karama shipment use grants the named active recipient’s current native shipment price; it does not require an actual force shipment.
Ordinary payments may remain available after their Shipment and Movement opportunity ends.</p>}
<p className="notice" aria-label="Public turn-action question preview">{turnActionsError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
</>
+10 -4
View File
@@ -48,7 +48,8 @@ const newPowerClaim = (game: GameView, target: string): BattlePowerClaim =>
: nativeBattlePowerOpportunity(game, target, 'prescience') ? { kind: 'prescience', use: true }
: { kind: 'voice', use: true };
const windowRoleNames: Record<WindowCardEffect, string> = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester',
karamaAuction: 'Spend a printed Karama for this current ordinary lot' };
karamaAuction: 'Spend a printed Karama for this current ordinary lot',
karamaShipment: 'Spend a printed Karama for the current shipment-price opportunity' };
const config: MixedActionConfig<TurnActionLeaf> = {
prefix: 'turn-action',
legend: 'Current battle and this-turn action conditions, with optional current facts',
@@ -83,6 +84,10 @@ const config: MixedActionConfig<TurnActionLeaf> = {
A current-lot Karama condition requires spending a printed Karama for this original ordinary lot, through immediate purchase
or its winning invoice. Other Karama roles, Worthless conversion and a successful bid alone do not count.
Recovery alone does not count. This condition closes with the original ordinary lot.
A shipment-price Karama condition requires spending a printed Karama to grant the named active recipient this original
unused native shipment price. The giver may be another player. The price grant counts separately from an actual force shipment;
it does not require the recipient to ship forces. Other roles, Worthless conversion and recovery alone do not count.
This condition closes when that original shipment-price opportunity ends.
Shipment conditions concern the single future original reserve shipment after the answer.
Ground-movement conditions use the maximum physical forces in one actual future move to that destination; smaller moves never add together.
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
@@ -118,7 +123,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
if (action.kind === 'windowCardPlay') {
const opportunity = windowCardOpportunity(game, action.effect);
if (!opportunity || opportunity.turn !== game.turn || action.source !== opportunity.source)
throw new Error('Choose the original currently open Storm, fresh spice-blow or ordinary-lot window for this printed role.');
throw new Error('Choose the original current Storm, fresh spice-blow, ordinary-lot or active shipment-price opportunity for this printed role.');
}
return action;
},
@@ -153,7 +158,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
territory: value.kind === 'ship' || value.kind === 'move' ? value.territory : 'carthag', minimum });
}}>
<option value="cardPlay">Play Hajr or Tleilaxu Ghola in its printed role</option>
<option value="windowCardPlay">Use a printed card in this original Storm, spice-blow or ordinary-lot window</option>
<option value="windowCardPlay">Use a printed card in this original native window or shipment-price opportunity</option>
<option value="battlePower">Declare native Voice or Prescience in this battle</option>
<option value="battlePlan">Use an actual current battle-plan choice</option>
<option value="traitorCall">Make the current Traitor declaration</option>
@@ -180,7 +185,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
<select id={`${id}-window-effect`} value={WINDOW_CARD_EFFECTS.includes(value.effect) ? value.effect : ''}
disabled={disabled} aria-invalid={!WINDOW_CARD_EFFECTS.includes(value.effect)} onChange={event => {
const effect = event.currentTarget.value;
if (effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'karamaAuction')
if (effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'karamaAuction' || effect === 'karamaShipment')
onChange({ kind: 'windowCardPlay', effect, source: windowCardOpportunity(game, effect)?.source ?? '' });
}}>
<option value="">Choose a printed role</option>
@@ -197,6 +202,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
{currentWindow && <option value={currentWindow.source}>{value.effect === 'harvester'
? `Turn ${currentWindow.turn} · Fresh spice blow in ${game.combatLocations.find(location => location.id === game.spiceWindow?.territory)?.name ?? game.spiceWindow?.territory}`
: value.effect === 'karamaAuction' ? `Turn ${currentWindow.turn} · Ordinary lot ${game.bidPromiseLot?.lot}`
: currentWindow.effect === 'karamaShipment' ? `Turn ${currentWindow.turn} · ${game.players.find(player => player.id === currentWindow.recipient)?.name ?? currentWindow.recipient}’s current native shipment price`
: `Turn ${currentWindow.turn} · Before this Storm movement`}</option>}
</select>
{!currentWindow && <p className="fine">This printed role has no current original open native window.</p>}
+29 -6
View File
@@ -26,9 +26,9 @@ const publicBattleContext = (game: GameView) => game.battle && ({
preparation: game.battle.preparation,
traitorVoters: game.battle.traitorVoters, traitorSubmitted: game.battle.traitorSubmitted,
});
const isWindowEffect = (effect: unknown): effect is Exclude<WindowCardEffect, 'karamaAuction'> => effect === 'weather' || effect === 'atomics' || effect === 'harvester';
const isWindowEffect = (effect: unknown): effect is Exclude<WindowCardEffect, 'karamaAuction' | 'karamaShipment'> => effect === 'weather' || effect === 'atomics' || effect === 'harvester';
const windowRoleNames: Record<WindowCardEffect, string> = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester',
karamaAuction: 'printed Karama for this ordinary lot' };
karamaAuction: 'printed Karama for this ordinary lot', karamaShipment: 'printed Karama for this shipment price' };
const namesWindow = (mixed: MixedActionExpression<TurnActionLeaf>, effect: WindowCardEffect, source?: string): boolean =>
mixed.kind === 'action' ? mixed.action.kind === 'windowCardPlay' && mixed.action.effect === effect && (source === undefined || mixed.action.source === source)
: mixed.kind !== 'fact' && mixed.terms.some(term => namesWindow(term, effect, source));
@@ -67,12 +67,17 @@ export function TurnActionGuideButton(props: TurnActionProps) {
const currentAuctionWindow = windowCardOpportunity(game, 'karamaAuction');
const auctionRoleNamed = !!windowPromise && !!currentAuctionWindow && namesWindow(windowPromise.mixed, 'karamaAuction', currentAuctionWindow.source);
const auctionPurchaseAction = action.type === 'card' && action.mode === 'purchase';
const currentShipmentWindow = windowCardOpportunity(game, 'karamaShipment');
const shipmentRoleNamed = !!windowPromise && !!currentShipmentWindow && namesWindow(windowPromise.mixed, 'karamaShipment', currentShipmentWindow.source);
const shipmentPriceAction = action.type === 'card' && action.mode === 'shipment';
const printedShipmentAction = shipmentPriceAction && shipmentRoleNamed;
const printedInvoiceAction = action.type === 'decision' && action.karama === true && auctionRoleNamed;
const cardOwner = action.type === 'card' || printedInvoiceAction ? game.players.find(player => player.id === actor) : undefined;
const heldCard = cardOwner?.hand?.find(card => card.id === action.card);
const cardSignature = heldCard && JSON.stringify({ id: heldCard.id, kind: heldCard.kind, effect: heldCard.effect });
const windowEffect = (auctionPurchaseAction || printedInvoiceAction) && heldCard?.effect === windowCardNativeEffect('karamaAuction')
? 'karamaAuction' : isWindowEffect(heldCard?.effect) ? heldCard.effect : null;
? 'karamaAuction' : printedShipmentAction && heldCard?.effect === windowCardNativeEffect('karamaShipment')
? 'karamaShipment' : isWindowEffect(heldCard?.effect) ? heldCard.effect : null;
const cardWindow = windowEffect ? windowCardOpportunity(game, windowEffect) : null;
const earlyWindowReadyAction = action.type === 'ready' && (phase === 0 || phase === 1);
const publicWindowState = (current: GameView) => JSON.stringify({
@@ -82,6 +87,8 @@ export function TurnActionGuideButton(props: TurnActionProps) {
stormPending: current.stormPending, wallDestroyed: current.shieldWallDestroyed,
blow: current.spiceWindow,
...(current.phase === 3 ? { auction: auctionSource(current) } : {}),
...(current.phase === 5 ? { shipment: { active: current.active, remaining: current.movementRemaining,
windows: current.karamaShipmentWindows, grant: current.karamaShipping } } : {}),
...(windowEffect === 'atomics' ? { forces: current.players.find(player => player.id === actor)?.forces,
advisors: current.players.find(player => player.id === actor)?.advisors } : {}),
});
@@ -89,6 +96,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
const originalBlow = windowEffect === 'harvester' || earlyWindowReadyAction && phase === 1 ? game.spiceWindow : null;
const earlyBoxSignature = box && JSON.stringify({ card: box.card.id, blocked: box.blocked, search: box.search?.event });
const auctionCards = game.karamaAuctionCards, auctionCardsSignature = JSON.stringify(auctionCards);
const shipmentCards = game.karamaShipmentCards, shipmentCardsSignature = JSON.stringify(shipmentCards);
const auctionHandSource = (current: GameView) => {
const player = current.players.find(player => player.id === actor);
return JSON.stringify({ hand: player?.hand?.map(card => ({ id: card.id, kind: card.kind, effect: card.effect })), limit: player?.handLimit });
@@ -126,7 +134,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
? typeof action.leader === 'string' && (action.leader === 'kwisatz' ? ghola.kwisatz : ghola.leaders.some(leader => leader.id === action.leader))
: action.elite === undefined ? forceChoices.length === 1 : forceChoices.some(choice => choice.elite === action.elite));
const windowPacket = Object.keys(action).every(key => ['type', 'card', 'turnActionPromiseEvent', 'turnActionPromisePhase',
...(windowEffect === 'weather' ? ['amount'] : windowEffect === 'karamaAuction' ? ['mode'] : [])].includes(key)) &&
...(windowEffect === 'weather' ? ['amount'] : windowEffect === 'karamaAuction' ? ['mode'] : windowEffect === 'karamaShipment' ? ['mode', 'target'] : [])].includes(key)) &&
(windowEffect !== 'weather' || typeof action.amount === 'number' && Number.isSafeInteger(action.amount) && action.amount >= 0 && action.amount <= 10);
const printedWindowSource = ownPhysicalCard && !!heldCard && heldCard.kind === 'special' && !!windowEffect && !!cardWindow &&
heldCard.effect === windowCardNativeEffect(windowEffect) && !!windowPromise &&
@@ -137,14 +145,24 @@ export function TurnActionGuideButton(props: TurnActionProps) {
Array.isArray(current.karamaAuctionCards) &&
current.karamaAuctionCards === auctionCards && JSON.stringify(current.karamaAuctionCards) === auctionCardsSignature &&
current.karamaAuctionCards.includes(heldCard.id) && auctionHandSource(current) === auctionHandSignature;
const nativeShipmentCard = windowEffect === 'karamaShipment' && !!heldCard && cardWindow?.effect === 'karamaShipment' &&
phase === 5 && current.active === cardWindow.recipient && action.target === cardWindow.recipient &&
Array.isArray(current.karamaShipmentCards) && current.karamaShipmentCards === shipmentCards &&
JSON.stringify(current.karamaShipmentCards) === shipmentCardsSignature && current.karamaShipmentCards.includes(heldCard.id);
const printedWindow = action.type === 'card' && printedWindowSource && !!heldCard && windowPacket &&
(windowEffect === 'karamaAuction' ? action.mode === 'purchase' && nativeAuctionCard && !!me && !current.decision &&
Number.isSafeInteger(me.handLimit) && Array.isArray(me.hand) && me.hand.length < me.handLimit
: windowEffect === 'karamaShipment' ? action.mode === 'shipment' && nativeShipmentCard
: action.mode === undefined && ordinaryCardAvailability(current, heldCard.id, windowEffect === 'weather' ? { amount: action.amount } : undefined)?.available === true);
const printedAuctionInvoice = printedInvoiceAction && printedWindowSource && nativeAuctionCard &&
Object.keys(action).every(key => ['type', 'card', 'karama', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key));
const genericCard = ownCard && action.type === 'card' && !auctionPurchaseAction && !!heldCard && heldCard.effect !== 'ghola' && heldCard.effect !== 'hajr' && !isWindowEffect(heldCard.effect);
const earlyWindowBox = (phase === 0 || phase === 1 || phase === 3) && genericCard && heldCard?.effect === 'nullentropyBox' &&
const quotedBoxCard = heldCard?.effect !== 'nullentropyBox' || !!box && current.nullentropy === box &&
JSON.stringify({ card: box.card.id, blocked: box.blocked, search: box.search?.event }) === earlyBoxSignature &&
!box.blocked && !box.search && box.card.id === heldCard.id;
const genericCard = ownCard && action.type === 'card' && !auctionPurchaseAction && !printedShipmentAction && quotedBoxCard &&
(!shipmentPriceAction || (action.target ?? actor) === current.active) &&
!!heldCard && heldCard.effect !== 'ghola' && heldCard.effect !== 'hajr' && !isWindowEffect(heldCard.effect);
const earlyWindowBox = (phase === 0 || phase === 1 || phase === 3 || phase === 5) && genericCard && heldCard?.effect === 'nullentropyBox' &&
action.mode === undefined && Object.keys(action).every(key => ['type', 'card', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key)) &&
!!box && current.nullentropy === box &&
JSON.stringify({ card: box.card.id, blocked: box.blocked, search: box.search?.event }) === earlyBoxSignature &&
@@ -253,6 +271,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
? `Play Tleilaxu Ghola to return ${gholaLeader} while honoring your whole answer`
: `Play Tleilaxu Ghola to return ${amount} physical forces while honoring your whole answer`
: action.type === 'card' && windowEffect === 'karamaAuction' ? 'Spend a printed Karama to purchase this ordinary lot while honoring your whole answer'
: action.type === 'card' && windowEffect === 'karamaShipment' ? `Spend a printed Karama to grant ${recipient} this native shipment price while honoring your whole answer`
: action.type === 'card' && windowEffect ? `Play ${windowRoleNames[windowEffect]} in its original native window${windowEffect === 'weather' ? ` with storm distance ${amount}` : ''} while honoring your whole answer`
: action.type === 'battlePlan' ? 'Fill the quoted battle plan for review before sealing'
: action.type === 'voice' ? `Declare native Voice: opponent ${action.must ? 'must' : 'cannot'} play ${battleCardLabel(String(action.kind))}`
@@ -342,6 +361,10 @@ export function TurnActionPromises(props: TurnActionProps) {
A current-lot Karama condition requires spending a printed Karama for this original ordinary lot, through immediate purchase
or its winning invoice. Other Karama roles, Worthless conversion and a successful bid alone do not count.
Recovery alone does not count. This condition closes with the original ordinary lot.
A shipment-price Karama condition requires spending a printed Karama to grant the named active recipient this original
unused native shipment price. The giver may be another player. The price grant counts separately from an actual force shipment;
it does not require the recipient to ship forces. Other roles, Worthless conversion and recovery alone do not count.
This condition closes when that original shipment-price opportunity ends.
Shipment conditions concern one future original reserve shipment. Ground-movement conditions count the maximum physical forces in one actual future move,
never the sum of smaller moves. Earlier shipment and movement do not count.
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
+29
View File
@@ -20,6 +20,35 @@ open mode gates.
Continue remaining Basic functions before Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
**Truthtrance / printed Karama shipment pricing — 27 October. Prototyped / Partial.**
Whole answers can name a future printed price cost for the original beneficiary.
Own and foreign givers share actual fees correctly: own pay9 plus shipment1 is
funded by10; a foreign giver can pay10 while the beneficiary pays its own1.
Actual cost counts even when the price is unused. Original entitlements persist
through another actor; other beneficiaries, roles and Cunning events keep their
own intervals. Exact native quotation feeds human controls and four profiles.
Frozen **18 files,300/300**, types/lint/build and eight journeys pass, with
33 GameTable SSR renders, 9 actual answer callbacks and 9 exact guide callbacks.
Thirty-three new cases comprise8 source/parser,3 aggregate,5 controls,15 native
and2 foreign-handoff funding cases. Original H/A/E33/15 sources use Basic own34
or foreign32 actual actions. The actual H/A/E/G33/20 Advanced funding sources
use 52/52 actions before the question; their native pre-answer bribes
leave2/4. Unfunded delivery rejects before Guild's stop; funded delivery really
declines that stop, settles income and then pays the alternative. Source counts
come from arrays. Advanced source quotation is not complete Advanced acceptance.
Focused feedback fixes strict receipt normalization and two type guards.
The added four-player regression exposes an actual pre-response funding gap;
the repaired preflight uses successful native delivery, its real fee and its
canonical receipt before handing control away. A private-driver oracle is also
corrected: its guide may legitimately choose the printed OR branch, while the
manual payment alternative remains legal. Native Box/Cunning/conversion and
wider combinations, complete modes and final assurance remain open.
Proof/manifest: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-karama-shipment-proof-v4/`; journeys/HTML: adjacent `20261027-truth-karama-shipment-runtime-v4/`.
No HTTP/storage boundary, saved-room/database, reset/deletion, server restart
or deployed operation changes. [Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-karama-shipment-pricing--27-october-2026).
**Truthtrance / current-lot printed Karama — 27 October. Prototyped / Partial.**
A whole turn answer can now name the actual printed Karama cost for the current
ordinary lot: immediate free purchase or its winning invoice. Its public source
+12
View File
@@ -1,5 +1,17 @@
# Dune implementation status
## 27 October 2026 — Printed Karama shipment-price promises
Prototyped / Partial. Printed grant costs, beneficiary-specific original sources,
actual self/foreign fees and native guides are connected. The own closing
preflight checks successful delivery before foreign Guild control; an actual
four-player regression proves funded and unfunded branches. Final frozen
18 files/300, types/lint/build and eight native/SSR/callback journeys pass.
No deployment or saved-game operations occur. Broader combinations and final
acceptance remain. [Current evidence](CURRENT_STATUS.md#current-checkpoint-and-work)
and [canonical contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-karama-shipment-pricing--27-october-2026).
Private source-bound proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-karama-shipment-proof-v4/`.
## 27 October 2026 — Current-lot printed Karama promises
**Truthtrance / current-lot printed Karama — 27 October. Prototyped / Partial.**
+15
View File
@@ -1,5 +1,20 @@
# Rule decision index
## Truthtrance printed Karama shipment pricing — 27 October 2026
Prototyped / Partial. Compose the existing current-turn AND/OR authority with
an actual printed Karama cost for the named beneficiary's original unused
shipment. The cost counts independently of delivery or later use of its native
price. It halves the native tariff, not the physical force count or the tariff
to zero. Other roles, converted costs and earlier plays do not count. Keep an
inactive beneficiary's remaining entitlement; a distinct Cunning event never
inherits the original interval. Its source quotation does not open the existing
optional-module promise fence. Own shipment/turn close must retain the whole
answer before a foreign counter; foreign delivery/end retains its rights.
Private witnesses share the actual beneficiary fee and giver cost without
inventing another player's purse. This remains bounded prototype composition.
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-karama-shipment-pricing--27-october-2026).
## Truthtrance current-lot printed Karama — 27 October 2026
Prototyped / Partial. Reuse the current-turn AND/OR authority below for a
+75
View File
@@ -1,5 +1,80 @@
# Truthtrance combined current-turn actions
## Printed Karama shipment pricing — 27 October 2026
**Prototyped / Partial.** `windowCardPlay/karamaShipment` names the current
beneficiary's unused native shipment. Its public source is canonical JSON
`['karamaShipment',turn,recipient,'original']`. The separate Cunning source adds
`'cunning',event` instead; the existing optional-module promise fence remains.
Only an actual future physical printed Karama cost for that beneficiary counts.
Spending in another role, converted Worthless/Shrine costs, earlier uses, card
recovery alone, a later beneficiary or a later opportunity do not count.
The native price grant counts when its cost is committed, even if the recipient
never ships. It grants pricing rather than forces, movement or free shipment.
The actual recipient retains its native route, force/elite selection and bank
tariff, such as two spice becoming one for the original one-counter desert
shipment. A different giver spends the card; the recipient still pays the fee.
Whole answers share the real purse: own pay9 plus that fee1 can be funded by10,
whereas own pay10 plus fee1 cannot. A foreign giver's pay10 does not spend the
beneficiary's fee or turn the giver's promised bribe into collected income.
Exact accepted sources remain through another actor's turn while the original
recipient remains entitled. Actual grant, shipment, end or phase expiry closes
that source; the earlier No cap cannot become credit for another recipient.
Private quotations can advance through the native remaining turn order only
for the owned grant, without selecting foreign forces, payments or arrivals.
The guide waits until its actual beneficiary is active. New questions and
editors select the current opportunity rather than creating future turn rights.
Actual cost records before later discard/conversion continuation. Native own
shipping or ending quotes prospective physical delivery and price closure before
opening a foreign response, so neither a required printed play nor a whole No
can be evaded at that handoff. Foreign recipient actions retain their rights;
impossibility can release only the affected interval while a funded payment
alternative survives. Definite answers retain the original whole-AND/OR/frozen
fact interpretation and per-answer played/closed receipt.
Public source lists contain no cards or funding. Owner-only printed-card quotes
feed source/beneficiary/custody-bound controls, including a giver outside the
active seat. Existing unrelated shipment preparations retain native guards.
Four profiles use the shared guide; strategy and calibration remain deferred.
The original H/A/E source keeps33 Treachery/15 Traitors, both actual Harkonnen
Truthtrances and Emperor's printed Karama, then follows native auction passes
and Movement turns. No played hand, purse, army, allowance or promise is installed.
Broader Box/Kull/conversion, Cunning and other module combinations, complete modes
and final browser/deployed/recovery/privacy/custody acceptance remain open.
Frozen evidence: **18 files,300/300**, types/lint/build. Thirty-three new cases
include8 source/parser,3 aggregate,5 control,15 native and2 four-player funding
checks. Eight genuine engine/control journeys use 33 GameTable SSR renders, 9 actual answer callbacks and 9 exact guide callbacks.
They cover pay9/fee1, foreign pay10/recipient fee1, unused grants, whole No before
delivery, actual foreign release/two questions, late Mentat payment and both
foreign Guild funding handoffs. Four profiles use the native guide.
Basic original H/A/E prefixes are own34/foreign32, with33 Treachery/15 Traitors.
The additional original H/A/E/G source has33/20; its actual pre-answer bribe
produces the2/4 wallets and recorded 52/52 source actions. It opens an
actual Guild stop without giving Guild a Karama, then native income resolves
before the funded payment. This supplements the Advanced setup/source checks;
it does not certify a complete mode or a module combination.
Narrow feedback repairs full native receipt normalization and type guards.
The four-player case first fails because the full fee could remove the payment
alternative before Guild control. Successful native delivery is now projected
with its own canonical receipt and post-fee purse before that handoff. The
private runtime driver also stops assuming its OR guide must choose payment;
the legal manual payment route and the guide's printed branch both remain.
Proof/manifest: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-karama-shipment-proof-v4/`;
journeys/HTML: adjacent `20261027-truth-karama-shipment-runtime-v4/`.
Evidence: [original source adapter](../tests/fixture-truth-turn-karama-shipment.ts),
[native cases](../tests/truth-turn-karama-shipment-runtime.test.ts),
[four-player fee boundary](../tests/truth-turn-karama-shipment-funding.test.ts),
[controls](../tests/truth-turn-action-controls.test.tsx).
Local SSR/callbacks are not browser/hydration or deployed acceptance. No native
Box/Kull/conversion/Cunning combination or final assurance is claimed.
## Current-lot printed Karama — 27 October 2026
**Prototyped / Partial.** `windowCardPlay` now accepts `karamaAuction` with
+124 -13
View File
@@ -4,7 +4,8 @@ import { PrintedCardPlayError, printedCardPlayCount, recordPrintedCardPlay,
type PrintedCardEffect, type PrintedCardPlayCounts } from './printed-card-play';
import { battlePowerOf, battlePowerMatches, nativeBattlePowerOpportunity,
type NativeBattlePower, type BattlePowerDeclaration } from './battle-power-question';
import { WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, type WindowCardEffect } from './window-card-play';
import { WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, karamaShipmentOpportunities,
parseKaramaShipmentSource, type WindowCardEffect } from './window-card-play';
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, closeTurnActionPayments, finishTurnActionTurn,
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival, recordTurnActionCardPlay,
@@ -16246,10 +16247,12 @@ function karamaSpendingBlock(g: Game, p: Player, card: Card, purpose?: KullPurpo
if ((g as TurnActionCardProbe)._turnActionUnknownCards?.includes(card.id))
return 'An unseen future auction card cannot fund this completion.';
const policy = (g as TurnActionCardProbe)._turnActionWindowCardPolicy;
if (purpose === 'auction-free-purchase' && card.kind === 'special' && card.effect === 'karama' &&
policy?.player === p.id && policy.forbidden.some(required => required.effect === 'karamaAuction' &&
if (card.kind === 'special' && card.effect === 'karama' && policy?.player === p.id &&
policy.forbidden.some(required =>
(required.effect === 'karamaAuction' && purpose === 'auction-free-purchase' ||
required.effect === 'karamaShipment' && purpose === 'shipment-price') &&
windowCardOpportunity(g, required.effect)?.source === required.source))
return 'Retain this lot without a forbidden printed Karama purchase.';
return 'Retain this opportunity without a forbidden printed Karama cost.';
const kullBlock = kullKaramaBlock(g, p, purpose);
if (kullBlock) return kullBlock;
if (g.pendingKull?.stage === 'counter' &&
@@ -17446,6 +17449,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
if (use.kind === 'shipment') use = { ...use, card: card.id };
const auctionRows = liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn);
let auctionWindow: string | undefined;
const shipmentWindow = use.kind === 'shipment' ? windowCardOpportunity(g, 'karamaShipment') : null;
if (card.kind === 'special' && card.effect === 'karama' &&
(use.kind === 'purchase' || use.kind === 'auctionPayment')) {
const window = windowCardOpportunity(g, 'karamaAuction');
@@ -17453,12 +17457,21 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
auctionWindow = window.source;
recordNativeTurnActionWindowCard(g, p.id, 'karamaAuction', window.source);
}
if (shipmentWindow && card.kind === 'special' && card.effect === 'karama')
recordNativeTurnActionWindowCard(g, p.id, 'karamaShipment', shipmentWindow.source);
const used = discard(g, p, card.id);
if (auctionWindow && auctionRows.some(row => row.windowCards?.karamaAuction?.source === auctionWindow))
guardTurnActionAuctionClosure(g, p.id, auctionRows);
if (!auctionWindow && card.kind === 'special' && card.effect === 'karama' && auctionRows.some(row => {
const stage = row.windowCards?.karamaAuction;
return stage && !stage.closed && windowCardOpportunity(g, 'karamaAuction')?.source === stage.source;
if (shipmentWindow && use.kind === 'shipment') {
const trial = structuredClone(g);
trial.karamaShipping = { player: use.recipient, owner: p.id, card: card.id };
guardTurnActionShipmentPriceBoundary(trial, p.id, auctionRows, shipmentWindow.source);
}
if (!auctionWindow && !shipmentWindow && card.kind === 'special' && card.effect === 'karama' && auctionRows.some(row => {
return (['karamaAuction', 'karamaShipment'] as const).some(effect => {
const stage = row.windowCards?.[effect];
return stage && !stage.closed && windowCardOpportunity(g, effect, stage.source)?.source === stage.source;
});
})) guardTurnActionPrintedCardCost(g, p, used);
const semutaRoleCost = g.semutaPreview && semutaKaramaRoleCost(g, p.id, card);
const guildShipmentDiscard = semutaRoleCost &&
@@ -18878,6 +18891,8 @@ type TurnActionCardProbe = Game & {
_turnActionCardPolicy?: { player: string; forbidden: PrintedCardEffect[] };
_turnActionWindowCardPolicy?: { player: string; forbidden: { effect: WindowCardEffect; source: string }[] };
_turnActionUnknownCards?: string[];
_turnActionPriceCursor?: { order: string[]; index: number };
_turnActionShipmentReceipt?: { player: string; event?: { territory: string; amount: number } | null };
};
function printedCardRule<T>(operation: () => T): T {
try { return operation(); }
@@ -18963,6 +18978,18 @@ function quotedAuctionKaramaCards(g: Game, player: Player): string[] {
} catch (error) { if (error instanceof RuleError) return false; throw error; }
}).map(card => card.id);
}
function quotedShipmentKaramaCards(g: Game, player: Player): string[] {
const opportunity = windowCardOpportunity(g, 'karamaShipment');
if (!opportunity || opportunity.effect !== 'karamaShipment' || g.truthtrance || g.response || g.decision ||
g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingKull) return [];
return player.hand.filter(card => {
if (card.kind !== 'special' || card.effect !== 'karama' || karamaSpendingBlock(g, player, card, 'shipment-price')) return false;
try {
validateKaramaUse(g, player, { kind: 'shipment', recipient: opportunity.recipient }, card);
return true;
} catch (error) { if (error instanceof RuleError) return false; throw error; }
}).map(card => card.id);
}
function* nativeWindowCardActions(g: Game, actor: string, effect: WindowCardEffect): Generator<Action> {
if (!windowCardOpportunity(g, effect)) return;
const player = getPlayer(g, actor);
@@ -18971,6 +18998,12 @@ function* nativeWindowCardActions(g: Game, actor: string, effect: WindowCardEffe
? { type: 'decision', karama: true, card } : { type: 'card', card, mode: 'purchase' };
return;
}
if (effect === 'karamaShipment') {
const opportunity = windowCardOpportunity(g, effect);
if (opportunity?.effect === 'karamaShipment') for (const card of quotedShipmentKaramaCards(g, player))
yield { type: 'card', card, mode: 'shipment', target: opportunity.recipient };
return;
}
for (const card of player.hand.filter(card => card.effect === windowCardNativeEffect(effect) && !transferCardBlock(g, player, card)))
for (const amount of effect === 'weather' ? Array.from({ length: 11 }, (_, value) => value) : [undefined]) {
const availability = ordinaryCardAvailability({ status: g.status, phase: g.phase, turn: g.turn,
@@ -18993,8 +19026,27 @@ function* turnActionWindowCardSeeds(source: Game, actor: string, requirements: r
return;
}
for (const required of pending) {
if (windowCardOpportunity(game, required.effect)?.source !== required.source) continue;
if (windowCardOpportunity(game, required.effect, required.source)?.source !== required.source) continue;
const play = function* (state: Game, prefix: Action[]): Generator<{ game: Game; actions: Action[] }> {
if (required.effect === 'karamaShipment') {
const opportunity = windowCardOpportunity(state, required.effect, required.source);
if (opportunity?.effect !== 'karamaShipment') return;
const prior = (state as TurnActionCardProbe)._turnActionPriceCursor;
const order = prior?.order ?? [...(typeof state.active === 'string' ? [state.active] : []),
...(state.movementRemaining ?? []).filter(id => id !== state.active)];
const index = order.indexOf(opportunity.recipient);
if (index < (prior?.index ?? 0)) return;
const advanced = structuredClone(state);
advanced.active = opportunity.recipient;
if (advanced.karamaShipping && advanced.karamaShipping.player !== opportunity.recipient) {
if (order.indexOf(advanced.karamaShipping.player) >= index) return;
// A later native turn has retired the earlier recipient's unused
// price grant. Quote no foreign force, payment or arrival outcome.
advanced.karamaShipping = null;
}
(advanced as TurnActionCardProbe)._turnActionPriceCursor = { order, index };
state = advanced;
}
const lot = required.effect === 'karamaAuction' ? bidOpportunity(state) : null;
if (lot && bidRequired(state.bidPromises ?? [], actor, lot)) {
// Quote after any owned recovery, so its actual two-spice cost and
@@ -19064,7 +19116,7 @@ function reconcileTurnActionWindowCards(g: Game) {
const stage = promise.windowCards?.[effect];
const foreignInvoice = effect === 'karamaAuction' && g.decision?.kind === 'auctionPayment' &&
g.decision.player !== promise.player;
if (!stage || stage.closed || !foreignInvoice && promise.turn === g.turn && windowCardOpportunity(g, effect)?.source === stage.source) continue;
if (!stage || stage.closed || !foreignInvoice && promise.turn === g.turn && windowCardOpportunity(g, effect, stage.source)?.source === stage.source) continue;
g.turnActionPromises = turnActionRule(() => closeTurnActionWindowCardPlay(g.turnActionPromises!,
promise.player, promise.turn, effect, stage.source));
}
@@ -19086,7 +19138,7 @@ function reopenTurnActionWindowReady(g: Game, actor: string) {
if (g.ready.includes(actor) && liveTurnActionPromises(g.turnActionPromises ?? [], actor, g.turn).some(promise =>
WINDOW_CARD_EFFECTS.some(effect => {
const stage = promise.windowCards?.[effect];
return stage && !stage.closed && windowCardOpportunity(g, effect)?.source === stage.source;
return stage && !stage.closed && windowCardOpportunity(g, effect, stage.source)?.source === stage.source;
}))) g.ready = g.ready.filter(player => player !== actor);
}
/** Check the actual spent card and returned pieces before a later foreign
@@ -19125,6 +19177,49 @@ function guardTurnActionAuctionClosure(g: Game, actor: string, original: readonl
reconcileBidPromises(trial, { actor, action: { type: 'card' } });
requireRule(!!turnActionCompletion(trial, actor), 'Retain a whole Truthtrance completion after this auction closes.');
}
/** A native shipment declaration must retain the answer if delivery succeeds;
* the owner cannot rely on another faction stopping it. Grant costs instead
* quote the resulting native rate before its optional conversion/discard suffix. */
function guardTurnActionShipmentPriceBoundary(g: Game, actor: string, original: readonly TurnActionPromise[],
source?: string, shipment?: { territory: string; amount: number } | null) {
const relevant = original.filter(row => {
const stage = row.windowCards?.karamaShipment;
const identity = stage && parseKaramaShipmentSource(stage.source);
return !row.fulfilled && !row.released && identity?.turn === g.turn &&
(source ? stage!.source === source : identity.recipient === actor);
});
if (!relevant.length) return;
const trial = structuredClone(g);
trial.truthtrance = null; trial.response = null; trial.decision = null;
trial.pendingKarama = null; trial.phaseOpening = null; trial.pendingTreacheryDiscard = null;
const forbidden = relevant.map(row => ({ effect: 'karamaShipment' as const, source: row.windowCards!.karamaShipment!.source }));
(trial as TurnActionCardProbe)._turnActionWindowCardPolicy = { player: actor, forbidden };
for (const row of relevant) trial.turnActionPromises = turnActionRule(() => closeTurnActionWindowCardPlay(
trial.turnActionPromises ?? [], actor, row.turn, 'karamaShipment', row.windowCards!.karamaShipment!.source));
if (shipment !== undefined) trial.turnActionPromises = turnActionRule(() => recordTurnActionShipment(
trial.turnActionPromises ?? [], actor, g.turn, shipment ? { territory: shipment.territory, amount: shipment.amount } : null));
requireRule(!relevant.some(row => trial.turnActionPromises?.find(next => next.event === row.event)?.released),
'Play your promised printed Karama or complete its alternative before closing this shipment.');
requireRule(!!turnActionCompletion(trial, actor), 'Retain a whole Truthtrance completion after this shipment-price opportunity closes.');
}
/** Quote successful native delivery before another faction owns a response.
* Use its actual fee, forces and canonical shipment receipt, not a second price
* calculation or an assumption that the Guild will stop the declaration. */
function guardTurnActionShipmentPriceDelivery(g: Game, shipment: PendingShipment) {
const original = liveTurnActionPromises(g.turnActionPromises ?? [], shipment.player, g.turn);
if (!original.some(row => {
const source = parseKaramaShipmentSource(row.windowCards?.karamaShipment?.source);
return source?.turn === g.turn && source.recipient === shipment.player;
})) return;
const trial = structuredClone(g);
delete trial.turnActionPromises;
(trial as TurnActionCardProbe)._turnActionShipmentReceipt = { player: shipment.player };
commitShipment(trial, structuredClone(shipment), undefined, true);
const receipt = (trial as TurnActionCardProbe)._turnActionShipmentReceipt!.event;
requireRule(receipt !== undefined, 'Retain the actual native shipment delivery before its counter response.');
trial.turnActionPromises = structuredClone(g.turnActionPromises);
guardTurnActionShipmentPriceBoundary(trial, shipment.player, original, undefined, receipt);
}
function turnActionTargetAvailable(g: Game, _actor: string): boolean {
return bribeQuestionWindow(g) && movementPromiseModeSupported(g);
}
@@ -19378,7 +19473,11 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
const forbiddenWindows = leaves.flatMap(leaf => leaf.kind === 'windowCardPlay' && !leaf.answer
? [{ effect: leaf.effect, source: leaf.source }] : []);
if (forbiddenWindows.length) (trial as TurnActionCardProbe)._turnActionWindowCardPolicy = {
player: actor, forbidden: forbiddenWindows,
player: actor, forbidden: [
...((trial as TurnActionCardProbe)._turnActionWindowCardPolicy?.player === actor
? (trial as TurnActionCardProbe)._turnActionWindowCardPolicy!.forbidden : []),
...forbiddenWindows,
],
};
trial.bribePromises ??= [];
trial.shipmentPromises ??= [];
@@ -19704,7 +19803,10 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
let action = !bribeTimingBlock(g) ? completion.payments[0] : undefined;
if (!action) {
const preparation = completion.cardPreparations?.[0];
if (preparation?.type !== 'bid' || g.auction?.active === player.id) action = preparation;
const shipmentTarget = preparation?.type === 'card' && preparation.mode === 'shipment'
? preparation.target ?? player.id : null;
if ((preparation?.type !== 'bid' || g.auction?.active === player.id) &&
(shipmentTarget === null || shipmentTarget === g.active)) action = preparation;
}
if (!action && g.phase <= 5) {
const lot = g.phase === 3 && !g.decision ? bidOpportunity(g) : null;
@@ -19756,7 +19858,8 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
// answer remains live. Window controls must bind the row naming that source.
const held = action.type === 'card' ? player.hand.find(card => card.id === action.card) : undefined;
const printedWindow = held?.kind === 'special' && held.effect === 'karama' && action.mode === 'purchase'
? 'karamaAuction' : isWindowCardEffect(held?.effect) ? held.effect : null;
? 'karamaAuction' : held?.kind === 'special' && held.effect === 'karama' && action.mode === 'shipment'
? 'karamaShipment' : isWindowCardEffect(held?.effect) ? held.effect : null;
const windows: WindowCardEffect[] = printedWindow ? [printedWindow]
: action.type === 'decision' && action.karama === true ? ['karamaAuction']
: action.type === 'ready' || held?.effect === 'nullentropyBox' ? [...WINDOW_CARD_EFFECTS] : [];
@@ -28852,6 +28955,8 @@ function checkShipmentPromises(
),
'Honor your Truthtrance shipment answer before using this shipment opportunity.',
);
if (g.phase === 5 && g.active === p.id) guardTurnActionShipmentPriceBoundary(g, p.id,
liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn), undefined, shipment);
}
function finishShipmentPromises(
g: Game,
@@ -28859,6 +28964,9 @@ function finishShipmentPromises(
shipment: { territory: string; amount: number } | null,
) {
checkShipmentPromises(g, p, shipment);
const probe = (g as TurnActionCardProbe)._turnActionShipmentReceipt;
if (probe?.player === p.id && probe.event === undefined)
probe.event = shipment ? { territory: shipment.territory, amount: shipment.amount } : null;
if (g.phase === 5 && g.turnActionPromises) g.turnActionPromises = turnActionRule(() =>
recordTurnActionShipment(g.turnActionPromises!, p.id, g.turn, shipment ? { territory: shipment.territory, amount: shipment.amount } : null));
if (g.phase === 5 && g.travelPromises) g.travelPromises = travelRule(() => recordTravelShipment(g.travelPromises!, p.id, g.turn,
@@ -35299,6 +35407,7 @@ function finishGuildTransportRateResponse(g: Game, response: ResponseWindow, can
function offerShipment(g: Game, shipment: PendingShipment, matchingApproved = false) {
validatePhysicalShipment(g, shipment);
validateShipmentArrival(g, shipment);
guardTurnActionShipmentPriceDelivery(g, shipment);
if (!shipment.ambassadorMarker) checkShipmentIncomeFunding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment);
const p = getPlayer(g, shipment.player),
id = p.id;
@@ -42250,6 +42359,8 @@ export function viewGame(state: Game, id: string, now?: number) {
: null,
bidPromiseLot: (() => { const lot = bidOpportunity(g); return lot ? { turn: lot.turn, lot: lot.lot } : null; })(),
karamaAuctionCards: quotedAuctionKaramaCards(g, me),
karamaShipmentCards: quotedShipmentKaramaCards(g, me),
karamaShipmentWindows: karamaShipmentOpportunities(g),
truthBidTargets: bidQuestionWindow(g) ? g.players.filter(player => {
const lot = bidOpportunity(g)!;
return lot.eligible.includes(player.id) && !lot.passed.includes(player.id) && lot.bidder !== player.id;
+5 -4
View File
@@ -3212,7 +3212,7 @@ export const RULE_TOPICS: RuleTopic[] = [
id: 'truthtrance-turn-actions',
title: 'Truthtrance: combined card plays, battle and turn actions',
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
summary: 'Combine current-lot printed Karama costs, original-window Weather/Atomics/Harvester plays, native Voice/Prescience declarations, future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
summary: 'Combine printed Karama shipment pricing and current-lot costs, original-window Weather/Atomics/Harvester plays, native Voice/Prescience declarations, future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
steps: [
'Choose Combine printed card plays, native battle powers and this turn’s other actions. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of original-window card plays, native power declarations, printed Hajr/Ghola use, current battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
'A final-alliance clause names another current participant during this original quiet native Nexus. Existing invitations and reciprocal formation remain authoritative. Offers, temporary membership and your own Ready do not settle the clause; final actual membership is fixed only when the Nexus closes.',
@@ -3231,6 +3231,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Weather Control and Family Atomics conditions concern a future printed play in this original open Storm window, including after dial reveal and before movement starts. Harvester concerns this original fresh blow. A later blow has a different identity even in the same phase. Earlier plays, disposal, other roles and recovery alone do not count.',
'These original-window conditions describe playing the card, with the normal chosen Weather distance, Atomics qualifying forces/casualties and Harvester arithmetic. They do not describe a future selected distance or amount. Accepting the window must preserve the whole answer; a later payment alternative can remain. No caps last through actual source closure, rather than ending at your own Ready.',
'A current-lot Karama condition counts a future physical printed Karama cost for this original ordinary auction card, either immediate purchase or its winning invoice. Bidding alone, another Karama role, a converted Worthless card, an earlier play, recovery or a later lot does not count. A bid-and-Karama answer must retain the real bid before the purchase; free acquisition still uses the original native controls and hand limit. A whole No shares its payment and bid funding without assuming a forbidden free invoice. Unseen future auction cards cannot fund another promised card play; actual owned knowledge remains usable.',
'A printed Karama shipment-price clause names the current beneficiary’s unused native shipment. The giver spends the physical printed card; the beneficiary still pays the native bank tariff, such as two spice becoming one. Playing that cost counts even if its price is unused. The source remains while that original recipient is entitled, including another actor’s turn; a different beneficiary or a Cunning second opportunity has its own interval. Own shipping or ending must preserve the whole answer before a foreign counter, while foreign actions retain their rights.',
'Shipment clauses describe the same one future original reserve shipment. Each ground-movement minimum requires one actual physical group, never the sum of smaller moves. Existing allowances, cards, pieces and preparation remain authoritative.',
'Yes requires the whole expression and fulfills on qualifying actual events. OR preserves legal alternatives; No negates the whole expression. Current facts freeze at answer. A private native quotation shares funding with earlier commitments without storing or publishing a selected branch.',
'Transport and Hajr close at your actual Movement end. Ghola or a payment alternative may remain after that close, through the full current turn including Mentat. A known Inflation Double opening closes payments earlier without closing Ghola. Complete a required payment before its actual opportunity closes; No payment caps remain binding until then.',
@@ -3238,11 +3239,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-travel', 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-bribe-expression', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse current-lot printed Karama, original-window plays, native power declarations, printed Hajr/Ghola, battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['game/window-card-play.ts', 'game/battle-power-question.ts', 'game/turn-action-promises.ts', 'game/printed-card-play.ts', 'game/movement-promise-search.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse printed Karama shipment pricing and current-lot costs, original-window plays, native power declarations, printed Hajr/Ghola, battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['game/window-card-play.ts', 'game/battle-power-question.ts', 'game/turn-action-promises.ts', 'game/printed-card-play.ts', 'game/movement-promise-search.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Full editor, raw question, private answer, persistent own history and exact current-phase native guide.', evidence: ['components/turn-action-fields.tsx', 'components/turn-action-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-turn-action-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed Hajr/Ghola and original power declarations; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/truth-turn-karama-runtime.test.ts'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed Hajr/Ghola and original power declarations; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Current funding, physical event meaning, private alternatives, separate deadlines and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TURN_ACTIONS.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, original Storm/blow sources, shared Revival resources and native power declarations; broader composition and final acceptance remain.', evidence: ['tests/window-card-play.test.ts', 'tests/battle-power-question.test.ts', 'tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/fixture-truth-turn-power.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/fixture-truth-turn-window-card.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/fixture-truth-turn-karama.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-forecast.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, original Storm/blow sources, shared Revival resources and native power declarations; broader composition and final acceptance remain.', evidence: ['tests/window-card-play.test.ts', 'tests/battle-power-question.test.ts', 'tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/fixture-truth-turn-power.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/fixture-truth-turn-window-card.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/fixture-truth-turn-karama.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-forecast.test.ts', 'tests/fixture-truth-turn-karama-shipment.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-karama-shipment-funding.test.ts'] },
],
},
{
+1 -1
View File
@@ -389,7 +389,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
if (action.kind === 'windowCardPlay') {
const opportunity = windowCardOpportunity(g, action.effect);
check(opportunity && opportunity.turn === g.turn && opportunity.source === action.source,
'Choose the exact current original Storm or fresh-blow printed-card window.');
'Choose the exact current native printed-card opportunity.');
}
if (action.kind === 'battlePlan' || action.kind === 'traitorCall' || action.kind === 'battlePower') {
const battle = g.battle;
+1 -1
View File
@@ -145,7 +145,7 @@ export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
try {
if (value.kind === 'windowCardPlay') {
check(keys(value, 'effect,kind,source') && windowEffect(value.effect) && identifier(value.source),
'Choose one original Storm, fresh-blow or ordinary-lot printed-card play after this answer.');
'Choose one original Storm, fresh-blow, ordinary-lot or shipment-price printed-card play after this answer.');
return { kind: 'windowCardPlay', effect: value.effect, source: value.source };
}
if (value.kind === 'battlePower') {
+89 -4
View File
@@ -1,11 +1,15 @@
import { harvesterAvailable } from './cards';
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction'] as const;
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment'] as const;
export type WindowCardEffect = typeof WINDOW_CARD_EFFECTS[number];
export function windowCardNativeEffect(effect: WindowCardEffect): 'weather' | 'atomics' | 'harvester' | 'karama' {
return effect === 'karamaAuction' ? 'karama' : effect;
return effect === 'karamaAuction' || effect === 'karamaShipment' ? 'karama' : effect;
}
export type WindowCardOpportunity = { effect: WindowCardEffect; source: string; turn: number };
export type KaramaShipmentOpportunity = { effect: 'karamaShipment'; source: string; turn: number; recipient: string };
export type KaramaShipmentSource = { turn: number; recipient: string; kind: 'original' } |
{ turn: number; recipient: string; kind: 'cunning'; event: string };
export type WindowCardOpportunity = { effect: Exclude<WindowCardEffect, 'karamaShipment'>; source: string; turn: number } |
KaramaShipmentOpportunity;
export type WindowCardGame = {
status: string;
phase: number;
@@ -31,6 +35,13 @@ export type WindowCardGame = {
richeseAuction?: unknown;
currentAuctionSale?: unknown;
ixAuction?: unknown;
active?: string | null;
movementRemaining?: readonly string[] | null;
players?: readonly { id: string; shipped?: boolean }[];
karamaShipping?: { player: string } | null;
nexusGuildCunningHistory?: readonly { receipt: { turn: number; owner: string; event: string }; stage: string }[];
/** Public projection contains no physical cards or private funding. */
karamaShipmentWindows?: readonly KaramaShipmentOpportunity[];
};
const object = (value: unknown): value is Record<string, unknown> =>
@@ -40,9 +51,73 @@ const identifier = (value: unknown): value is string =>
const whole = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
/** Source decoding remains available after the actual grant closes its window. */
export function parseKaramaShipmentSource(source: unknown): KaramaShipmentSource | null {
if (!identifier(source)) return null;
let tuple: unknown;
try { tuple = JSON.parse(source); } catch { return null; }
if (!Array.isArray(tuple) || tuple[0] !== 'karamaShipment' || !whole(tuple[1]) || tuple[1] < 1 ||
!identifier(tuple[2]) || JSON.stringify(tuple) !== source) return null;
if (tuple.length === 4 && tuple[3] === 'original') return { turn: tuple[1], recipient: tuple[2], kind: 'original' };
return tuple.length === 5 && tuple[3] === 'cunning' && identifier(tuple[4])
? { turn: tuple[1], recipient: tuple[2], kind: 'cunning', event: tuple[4] } : null;
}
/** Every still-unused original entitlement remains bound through other actors'
* turns. A declared Cunning continuation permanently retires its original one. */
export function karamaShipmentOpportunities(game: WindowCardGame): KaramaShipmentOpportunity[] {
if (game.status !== 'playing' || game.phase !== 5 || !whole(game.turn) || game.turn < 1 ||
!Array.isArray(game.players) || !game.players.length ||
!game.players.every(player => object(player) && identifier(player.id)) ||
!Array.isArray(game.movementRemaining)) return [];
const players = game.players, seated = new Set(players.map(player => player.id));
const remaining = game.movementRemaining;
if (seated.size !== players.length || !remaining.every(recipient => identifier(recipient) && seated.has(recipient)) ||
new Set(remaining).size !== remaining.length ||
game.active != null && (!identifier(game.active) || !remaining.includes(game.active))) return [];
const grant = game.karamaShipping;
if (grant != null && (!object(grant) || !identifier(grant.player) || !seated.has(grant.player))) return [];
if ('karamaShipmentWindows' in game) {
if (!Array.isArray(game.karamaShipmentWindows)) return [];
const sources = new Set<string>(), recipients = new Set<string>(), result: KaramaShipmentOpportunity[] = [];
for (const window of game.karamaShipmentWindows) {
if (!object(window) || Object.keys(window).sort().join(',') !== 'effect,recipient,source,turn' ||
window.effect !== 'karamaShipment' || window.turn !== game.turn || !identifier(window.recipient) ||
!remaining.includes(window.recipient) || grant?.player === window.recipient ||
!identifier(window.source) || sources.has(window.source) || recipients.has(window.recipient)) return [];
const parsed = parseKaramaShipmentSource(window.source);
if (!parsed || parsed.turn !== game.turn || parsed.recipient !== window.recipient ||
parsed.kind === 'cunning' && window.recipient !== game.active) return [];
sources.add(window.source); recipients.add(window.recipient);
result.push({ effect: 'karamaShipment', turn: game.turn, source: window.source, recipient: window.recipient });
}
return result;
}
if (!players.every(player => typeof player.shipped === 'boolean')) return [];
const history = game.nexusGuildCunningHistory;
if (history !== undefined && (!Array.isArray(history) || !history.every(record => object(record) && object(record.receipt) &&
whole(record.receipt.turn) && record.receipt.turn > 0 && record.receipt.turn <= game.turn &&
identifier(record.receipt.owner) && seated.has(record.receipt.owner) && identifier(record.receipt.event) &&
typeof record.stage === 'string' && ['pending', 'secondShipment', 'extraMove', 'completed', 'canceled'].includes(record.stage)) ||
new Set(history.map(record => record.receipt.event)).size !== history.length)) return [];
const current = history?.at(-1);
return remaining.flatMap(recipient => {
if (grant?.player === recipient) return [];
const cunning = history?.some(record => record.receipt.turn === game.turn && record.receipt.owner === recipient);
if (cunning) {
if (!current || current.receipt.turn !== game.turn || current.receipt.owner !== recipient ||
current.stage !== 'secondShipment' || game.active !== recipient) return [];
return [{ effect: 'karamaShipment' as const, turn: game.turn, recipient,
source: JSON.stringify(['karamaShipment', game.turn, recipient, 'cunning', current.receipt.event]) }];
}
return players.find(player => player.id === recipient)!.shipped ? [] : [{ effect: 'karamaShipment' as const,
turn: game.turn, recipient, source: JSON.stringify(['karamaShipment', game.turn, recipient, 'original']) }];
});
}
/** Original global window only. Temporary modal priority, actual card custody,
* force conditions and chosen effect parameters remain native engine rules. */
export function windowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect): WindowCardOpportunity | null {
function globalWindowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect): WindowCardOpportunity | null {
if (game.status !== 'playing' || !whole(game.turn) || game.turn < 1) return null;
if (effect === 'karamaAuction') {
if (game.phase !== 3 || game.richeseAuction != null || game.currentAuctionSale != null || game.ixAuction != null ||
@@ -79,3 +154,13 @@ export function windowCardOpportunity(game: WindowCardGame, effect: WindowCardEf
if (blow.event !== undefined && !identifier(blow.event)) return null;
return { effect, source: blow.event ?? `legacy-spice-blow:${game.turn}:${blow.territory}:${blow.sector}`, turn: game.turn };
}
/** New shipment questions use the current recipient. Accepted rows can instead
* retain an exact original source while a different recipient is active. */
export function windowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect,
source?: string): WindowCardOpportunity | null {
if (effect === 'karamaShipment') return karamaShipmentOpportunities(game).find(window =>
source === undefined ? window.recipient === game.active : window.source === source) ?? null;
const window = globalWindowCardOpportunity(game, effect);
return window && (source === undefined || window.source === source) ? window : null;
}
+7 -2
View File
@@ -54,12 +54,14 @@ export function finishTruthBid(game: Game, actions: TruthBidStep[] = []): Game {
export function createTruthBidFixture(options: {
advanced?: boolean; window?: 'waiting' | 'active'; targetKarama?: boolean;
firstLotEffect?: 'hajr';
extraGuild?: boolean;
} = {}) {
const advanced = options.advanced ?? false, window = options.window ?? 'waiting';
const asker = 'harkonnen', opponent = 'atreides', target = 'emperor';
let game = createGame('TRUTHBIDSOURCE', newPlayer(asker, 'Harkonnen', 'harkonnen'), advanced);
joinGame(game, newPlayer(opponent, 'Atreides', 'atreides'));
joinGame(game, newPlayer(target, 'Emperor', 'emperor'));
if (options.extraGuild) joinGame(game, newPlayer('guild', 'Guild', 'guild'));
const initial = structuredClone(game), actions: TruthBidStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthBid(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthBid(game, until, actions); };
@@ -75,7 +77,9 @@ export function createTruthBidFixture(options: {
const lotCards = [game.deck.find(card => options.firstLotEffect ? card.effect === options.firstLotEffect : card.kind === 'projectile'), game.deck.find(card => card.kind === 'poison'),
game.deck.find(card => card.kind === 'snooper')];
assert.ok(filler && targetCard && lotCards.every(Boolean));
const deal = [truths[0].id, truths[1].id, filler.id, targetCard.id];
const guildCard = options.extraGuild ? game.deck.find(card => card.kind === 'hero') : null;
assert.ok(!options.extraGuild || guildCard);
const deal = [truths[0].id, truths[1].id, filler.id, targetCard.id, ...(guildCard ? [guildCard.id] : [])];
const prefix = [...deal, ...lotCards.map(card => card!.id)];
const dealOrder = prefix.map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
@@ -88,6 +92,7 @@ export function createTruthBidFixture(options: {
assert.deepEqual(truthBidPlayer(game, asker).hand.map(card => card.id), truths.map(card => card.id));
assert.deepEqual(truthBidPlayer(game, opponent).hand.map(card => card.id), [filler.id]);
assert.deepEqual(truthBidPlayer(game, target).hand.map(card => card.id), [targetCard.id]);
if (guildCard) assert.deepEqual(truthBidPlayer(game, 'guild').hand.map(card => card.id), [guildCard.id]);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
@@ -111,7 +116,7 @@ export function createTruthBidFixture(options: {
lotCard: game.auction!.cards[game.auction!.index].id, lotCards: lotCards.map(card => card!.id),
deal, dealOrder, spiceOrder, originalSpice, originalCards, originalInventory: originalCards,
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
staging: [] as string[], sourceDisclosure: 'Genuine public H/A/E base33 setup. Only original unopened four-card starting deal, three ordinary lot positions, two ordinary Spice positions and scalar lotteries are selected. Harkonnen owns both physical Truthtrances; active Emperor additionally follows real H/A passes. No played hand, wallet, force, lot, Truthtrance or promise is installed.' };
staging: [] as string[], sourceDisclosure: `Genuine public ${options.extraGuild ? 'H/A/E/G' : 'H/A/E'} base33 setup. Only original unopened ${options.extraGuild ? 'five' : 'four'}-card starting deal, three ordinary lot positions, two ordinary Spice positions and scalar lotteries are selected. Harkonnen owns both physical Truthtrances; active Emperor additionally follows real H/A passes. No played hand, wallet, force, lot, Truthtrance or promise is installed.` };
}
export type TruthBidFixture = ReturnType<typeof createTruthBidFixture>;
+120
View File
@@ -0,0 +1,120 @@
import assert from 'node:assert/strict';
import { type Action, type Game } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
import { windowCardOpportunity } from '../game/window-card-play';
import {
actTruthTurnKarama, advanceTruthTurnKarama, closeTruthTurnKaramaLot, createTruthTurnKaramaFixture,
declareTruthTurnKarama, finishTruthTurnKarama, nextTruthTurnKarama, nextTruthTurnKaramaNative,
settleTruthTurnKarama, truthTurnKaramaClean, truthTurnKaramaInventory, truthTurnKaramaPlayer,
truthTurnKaramaPromise, truthTurnKaramaTraitors, type TruthTurnKaramaLeaf, type TruthTurnKaramaStep,
} from './fixture-truth-turn-karama';
export type TruthTurnKaramaShipmentLeaf = TruthTurnKaramaLeaf |
{ kind: 'windowCardPlay'; effect: 'karamaShipment'; source: string };
export type TruthTurnKaramaShipmentExpression = MixedActionExpression<TruthTurnKaramaShipmentLeaf>;
export type TruthTurnKaramaShipmentStep = TruthTurnKaramaStep;
export const actTruthTurnKaramaShipment = actTruthTurnKarama;
export const truthTurnKaramaShipmentClean = truthTurnKaramaClean;
export const truthTurnKaramaShipmentPlayer = truthTurnKaramaPlayer;
export const truthTurnKaramaShipmentInventory = truthTurnKaramaInventory;
export const truthTurnKaramaShipmentTraitors = truthTurnKaramaTraitors;
export const truthTurnKaramaShipmentPromise = truthTurnKaramaPromise;
export const nextTruthTurnKaramaShipment = nextTruthTurnKarama;
export const nextTruthTurnKaramaShipmentNative = nextTruthTurnKaramaNative;
export const advanceTruthTurnKaramaShipment = advanceTruthTurnKarama;
export const settleTruthTurnKaramaShipment = settleTruthTurnKarama;
export const finishTruthTurnKaramaShipment = finishTruthTurnKarama;
export const truthTurnKaramaShipmentFact = (fact: TruthFact): TruthTurnKaramaShipmentExpression => ({ kind: 'fact', fact });
export const truthTurnKaramaShipmentPlay = (source: string): TruthTurnKaramaShipmentExpression =>
({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'karamaShipment', source } });
export const truthTurnKaramaShipmentPayment = (recipient: string, minimum = 2): TruthTurnKaramaShipmentExpression =>
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
export const truthTurnKaramaShipmentShip = (territory = 'polar_sink', minimum = 1): TruthTurnKaramaShipmentExpression =>
({ kind: 'action', action: { kind: 'ship', territory, minimum } });
export const truthTurnKaramaShipmentGroup = (kind: 'and' | 'or', ...terms: TruthTurnKaramaShipmentExpression[]): TruthTurnKaramaShipmentExpression =>
({ kind, terms });
/** Original H/A/E two-Truth source, followed only by native passes/readiness
* and actual prior Movement endings. The optional earlier printed auction use
* really spends that original card; it supplies no shipment-role receipt. */
export function createTruthTurnKaramaShipmentFixture(options: {
beneficiary?: 'own' | 'foreign'; advanced?: boolean; priorAuctionSpend?: boolean;
} = {}) {
const base = createTruthTurnKaramaFixture({ advanced: options.advanced, window: 'active' });
const kind = options.beneficiary ?? 'own', beneficiary = kind === 'own' ? base.target : base.asker;
const priorAuctionSpend = options.priorAuctionSpend ?? false;
let game = structuredClone(base.game); const actions = structuredClone(base.sourceActions), inherited = actions.length;
let auctionCost: { before: Game; steps: TruthTurnKaramaShipmentStep[]; after: Game } | null = null;
if (priorAuctionSpend) {
const before = structuredClone(game), from = actions.length;
game = actTruthTurnKaramaShipment(game, base.target, base.purchase, actions);
game = settleTruthTurnKaramaShipment(game, actions);
auctionCost = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
assert.equal(game.discard.filter(card => card.id === base.karama).length, 1);
assert.ok(truthTurnKaramaShipmentPlayer(game, base.target).hand.some(card => card.id === base.lotCard));
}
const beforeAuctionClose = structuredClone(game), closeAt = actions.length;
game = closeTruthTurnKaramaLot(game, base, actions);
const auctionClose = { before: beforeAuctionClose, steps: structuredClone(actions.slice(closeAt)), after: structuredClone(game) };
game = advanceTruthTurnKaramaShipment(game,
state => state.phase === 5 && state.active === beneficiary && truthTurnKaramaShipmentClean(state), actions, true);
const opportunity = windowCardOpportunity(game, 'karamaShipment'); assert.ok(opportunity?.effect === 'karamaShipment');
assert.equal(opportunity.recipient, beneficiary);
assert.equal(opportunity.source, JSON.stringify(['karamaShipment', game.turn, beneficiary, 'original']));
assert.equal(truthTurnKaramaShipmentPlayer(game, beneficiary).shipped, false); assert.equal(game.karamaShipping, null);
assert.deepEqual(truthTurnKaramaShipmentPlayer(game, base.asker).hand.map(card => card.id), base.truths);
assert.equal(truthTurnKaramaShipmentPlayer(game, base.target).hand.some(card => card.id === base.karama), !priorAuctionSpend);
assert.deepEqual(truthTurnKaramaShipmentInventory(game), base.originalCards);
assert.deepEqual(truthTurnKaramaShipmentTraitors(game), base.originalTraitors);
const source = opportunity.source, asker = base.asker, target = base.target, recipient = asker;
const mixed = truthTurnKaramaShipmentGroup('and', truthTurnKaramaShipmentPlay(source), truthTurnKaramaShipmentPayment(recipient));
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthTurnKaramaShipmentPlayer(game, target).spice };
const grant: Action = { type: 'card', card: base.karama, mode: 'shipment', target: beneficiary };
const shipment: Action = { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 };
return { game, base, initial: base.initial, setup: base.setup, afterSetup: base.afterSetup,
beforeStartingDeal: base.beforeStartingDeal, asker, target, recipient, observer: base.observer,
truths: base.truths, truth: base.truth, karama: base.karama, card: base.karama,
beneficiary, kind, priorAuctionSpend, opportunity, source, effect: 'karamaShipment' as const,
mixed, question, spiceFact, grant, shipment, ownGrant: { ...grant, target } satisfies Action,
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
endMovement: { type: 'endMovement' } satisfies Action,
auctionCost, beforeAuctionClose, auctionClose, originalCards: base.originalCards,
originalTraitors: base.originalTraitors, originalSpice: base.originalSpice,
actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { inherited, nativeContinuation: actions.length - inherited, selected: actions.length,
actualPriorAuctionCost: auctionCost?.steps.length ?? 0, actualAuctionClose: auctionClose.steps.length },
sourceDisclosure: `${base.sourceDisclosure} Actually finish the ordinary auction using native passes, readiness and any original responses; ` +
`stop at ${kind === 'own' ? 'Emperor' : 'Harkonnen'}'s real original unused Shipment turn. ` +
(priorAuctionSpend ? 'Before advancing, Emperor actually spends its original printed Karama for the original free auction card; the later Shipment source still has no future printed card or receipt. '
: 'Emperor retains its original printed Karama; Harkonnen retains both actual opening Truthtrances. ') +
'Only original array-recorded actions are continued. A later actual printed price spend is independent of delivery; its native desert tariff halves from two to one. No hand, wallet, army, phase, shipment, price grant, cost or promise is installed.',
};
}
export type TruthTurnKaramaShipmentFixture = ReturnType<typeof createTruthTurnKaramaShipmentFixture>;
export function declareTruthTurnKaramaShipment(f: TruthTurnKaramaShipmentFixture, source = f.game, copy: 0 | 1 = 0,
actions: TruthTurnKaramaShipmentStep[] = []) {
return declareTruthTurnKarama(f.base, source, copy, actions);
}
export function askTruthTurnKaramaShipment(game: Game, asker: string, target: string, mixed: TruthTurnKaramaShipmentExpression,
actions: TruthTurnKaramaShipmentStep[] = []): Game {
return actTruthTurnKaramaShipment(game, asker, { type: 'truthAsk', question: {
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
} }, actions);
}
export function answerTruthTurnKaramaShipment(game: Game, target: string, answer: 'yes' | 'no' | 'unknown',
actions: TruthTurnKaramaShipmentStep[] = []): Game {
return actTruthTurnKaramaShipment(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthTurnKaramaShipment(f: TruthTurnKaramaShipmentFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
source = f.game, copy: 0 | 1 = 0, actions: TruthTurnKaramaShipmentStep[] = []) {
const declared = declareTruthTurnKaramaShipment(f, source, copy, actions);
const asked = askTruthTurnKaramaShipment(declared.game, f.asker, f.target, mixed, actions);
return { game: answerTruthTurnKaramaShipment(asked, f.target, answer, actions), declared, asked, actions };
}
export function closeTruthTurnKaramaShipment(game: Game, f: TruthTurnKaramaShipmentFixture,
actions: TruthTurnKaramaShipmentStep[] = []): Game {
return advanceTruthTurnKaramaShipment(game, state => truthTurnKaramaShipmentClean(state) &&
!windowCardOpportunity(state, 'karamaShipment', f.source), actions, true);
}
+102 -1
View File
@@ -32,8 +32,10 @@ const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', minimum =
const leaderRevival = (leader: string): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
const finalAlliance = (partner = 'r'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
const printedCard = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
const shipmentPriceSource = (recipient = 'p', cunning?: string) => JSON.stringify(['karamaShipment', 2, recipient,
...(cunning ? ['cunning', cunning] : ['original'])]);
const windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow'
: effect === 'karamaAuction' ? 'ordinary-auction:2:2' : 'storm:2'): Expression =>
: effect === 'karamaAuction' ? 'ordinary-auction:2:2' : effect === 'karamaShipment' ? shipmentPriceSource() : 'storm:2'): Expression =>
({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } });
const planClaim = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 3 }): Expression =>
({ kind: 'action', action: { kind: 'battlePlan', battle: 'controlled-native-battle', territory: 'arrakeen', claim } });
@@ -113,6 +115,15 @@ function auctionKarama(invoice = false): GameView {
if (!invoice) { game.auction!.active = 'a'; game.active = 'a'; }
return game;
}
function shipmentKarama(recipient = 'p', cunning?: string): GameView {
const game = projected(5, { type: 'card', card: 'controlled-printed-karama', mode: 'shipment', target: recipient });
game.active = recipient; game.movementRemaining = ['p', 'a', 'r']; game.karamaShipping = null;
game.karamaShipmentWindows = game.movementRemaining.map(player => ({ effect: 'karamaShipment' as const,
turn: 2, recipient: player, source: shipmentPriceSource(player, player === recipient ? cunning : undefined) }));
game.karamaShipmentCards = ['controlled-printed-karama'];
game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('karamaShipment', shipmentPriceSource(recipient, cunning)), payment()] };
return game;
}
function nexus(next: Action = { type: 'alliance', target: 'r' }): GameView {
const game = projected(1, next);
game.nexus = true; game.spiceWindow = null; game.active = 'a';
@@ -919,6 +930,96 @@ void test('current ordinary-lot printed-role questions retain quoted owned Box r
}
});
void test('shipment-price picker names the current public beneficiary without reading card or native funding quotes', () => {
const game = shipmentKarama('a'), e = editor(physical(), false, game);
for (const player of game.players) for (const key of ['hand', 'spice', 'forces', 'leaders', 'traitors', 'reserves'])
Object.defineProperty(player, key, { get() { throw Error(`No private shipment-price authoring ${key} read`); }, configurable: true });
Object.defineProperty(game, 'karamaShipmentCards', { get() { throw Error('The public picker must not inspect the giver’s native card quote'); } });
select(e.tree(), '1', 'action', 'windowCardPlay'); assert.deepEqual(e.value, windowClaim('karamaShipment', shipmentPriceSource('a')));
assert.equal(e.error(), null); assert.match(e.html(), /Asker’s current native shipment price/);
select(e.tree(), '1', 'window-source', ''); assert.ok(e.error());
select(e.tree(), '1', 'window-source', shipmentPriceSource('p')); assert.ok(e.error(), 'An inactive entitlement is not a new public picker option.');
select(e.tree(), '1', 'window-source', shipmentPriceSource('a')); assert.equal(e.error(), null);
game.active = 'p'; assert.ok(e.error()); select(e.tree(), '1', 'window-source', shipmentPriceSource('p')); assert.equal(e.error(), null);
const cunning = shipmentPriceSource('p', 'controlled-cunning-window');
game.karamaShipmentWindows = game.karamaShipmentWindows.map(window => window.recipient === 'p' ? { ...window, source: cunning } : window);
assert.ok(e.error()); select(e.tree(), '1', 'window-source', cunning); assert.equal(e.error(), null);
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'karamaShipment', source: cunning, target: 'p' } }, game, 'p'));
const fullGame = shipmentKarama(), full = editor({ kind: 'or', terms: [windowClaim('karamaShipment'), fact()] }, false, fullGame);
for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'windowCardPlay'); }
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
const deep = editor({ kind: 'and', terms: [fact(), windowClaim('karamaShipment')] }, false, fullGame);
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
});
void test('printed shipment-price guides forward exact own or foreign-giver grants and explain the distinct actual shipment', () => {
for (const recipient of ['p', 'a']) for (const cunning of [undefined, 'controlled-cunning-window']) {
const game = shipmentKarama(recipient, cunning), sent: Action[] = [];
for (const key of ['windowCards', 'compiled', 'preparation'])
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} shipment-price read`); }, configurable: true });
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
assert.equal(sent[0].type, 'card'); assert.equal(sent[0].mode, 'shipment'); assert.equal(sent[0].target, recipient);
assert.equal(sent[0].turnActionPromiseEvent, event); assert.equal(sent[0].turnActionPromisePhase, 5);
assert.match(renderToStaticMarkup(button), /grant .* this native shipment price/);
game.turnActionGuide = null;
const html = markup(game); assert.match(html, /price grant counts separately from an actual force shipment/);
assert.match(html, /does not require the recipient to ship forces/); assert.match(html, /original shipment-price opportunity ends/);
assert.doesNotMatch(html, /windowCards|compiled|controlled-printed-karama/);
}
});
void test('saved shipment-price guides expire with the active source, target, quote, physical card and grant state', () => {
for (const recipient of ['p', 'a']) for (const change of [
(game: GameView) => { game.active = recipient === 'p' ? 'a' : 'p'; },
(game: GameView) => { game.karamaShipmentWindows = game.karamaShipmentWindows.map(window => window.recipient === recipient
? { ...window, source: shipmentPriceSource(recipient, 'later-cunning-window') } : window); },
(game: GameView) => { game.turnActionGuide!.action.target = recipient === 'p' ? 'a' : 'p'; },
(game: GameView) => { game.karamaShipmentCards = []; },
(game: GameView) => { const card = game.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card); card.kind = 'worthless'; },
(game: GameView) => { game.karamaShipping = { player: recipient, owner: 'p' }; },
(game: GameView) => { game.turnActionPromises[0].mixed = windowClaim('karamaShipment', shipmentPriceSource('r')); },
]) {
const game = shipmentKarama(recipient), saved = guide({ game, busy: false, act() { assert.fail('A stale shipment-price grant cannot send'); } });
assert.equal(saved.props.disabled, false); change(game); press(saved);
}
const game = shipmentKarama('a'), props: Props = { game, busy: false, act() { assert.fail('A replaced grant callback cannot send'); } };
const saved = guide(props); props.act = () => assert.fail('A replaced callback cannot receive an old grant'); press(saved);
});
void test('current printed shipment roles reject forged grants and inactive targets without blocking older native preparation guides', () => {
for (const extra of [{ target: 'r' }, { target: undefined }, { amount: 3 }, { leader: 'foreign-leader' }, { mode: 'purchase' }]) {
const game = shipmentKarama(); Object.assign(game.turnActionGuide!.action, extra);
const button = guide({ game, busy: false, act() { assert.fail('An unsupported shipment-price packet cannot send'); } });
assert.equal(button.props.disabled, true); press(button);
}
const converted = shipmentKarama(), card = converted.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card);
card.kind = 'worthless'; delete card.effect;
assert.equal(guide({ game: converted, busy: false, act() { assert.fail('Worthless conversion is not printed shipment-role use'); } }).props.disabled, true);
const unquoted = shipmentKarama(); unquoted.karamaShipmentCards = [];
assert.equal(guide({ game: unquoted, busy: false, act() { assert.fail('An absent native shipment card quote cannot be bypassed'); } }).props.disabled, true);
const inactive = shipmentKarama('a'); inactive.active = 'p';
assert.equal(guide({ game: inactive, busy: false, act() { assert.fail('An inactive beneficiary cannot receive this grant control'); } }).props.disabled, true);
for (const mixed of [payment(), windowClaim('karamaShipment', shipmentPriceSource('a'))]) {
const legacy = shipmentKarama(); legacy.turnActionPromises[0].mixed = mixed; legacy.karamaShipmentCards = [];
const sent: Action[] = [], button = guide({ game: legacy, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], legacy.turnActionGuide!.action);
}
});
void test('a current shipment-price question retains a quoted Box route for an owned or foreign giver', () => {
for (const recipient of ['p', 'a']) {
const game = shipmentKarama(recipient), me = game.players.find(player => player.id === 'p')!;
const box = { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special' as const, effect: 'nullentropyBox' };
me.hand = [box]; game.nullentropy = { card: box, blocked: null, search: null };
game.turnActionGuide!.action = { type: 'card', card: box.id, turnActionPromiseEvent: event, turnActionPromisePhase: 5 };
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
game.nullentropy.blocked = 'The native Box is no longer available.'; press(button); assert.equal(sent.length, 1);
}
});
void test('turn-action printed-role editor uses public names without holdings or private stages and retains the shared logical budget', () => {
const game = projected();
for (const player of game.players) for (const key of ['hand', 'spice', 'traitors', 'faceDancers', 'tanks', 'leaders', 'hajrAvailable'])
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { windowCardOpportunity } from '../game/window-card-play';
import { createTruthBidFixture, declareTruthBid, actTruthBid as act, advanceTruthBid as advance,
truthBidClean as clean, truthBidPlayer as player, truthBidInventory as inventory } from './fixture-truth-bid';
import { truthTraitorDecisionTraitors as traitors } from './fixture-truth-traitor-decision';
import { askTruthTurnKaramaShipment as ask, truthTurnKaramaShipmentGroup as group,
truthTurnKaramaShipmentPlay as play, truthTurnKaramaShipmentPayment as payment } from './fixture-truth-turn-karama-shipment';
/** Original unopened H2/A1/E1/G1 deal, genuine remaining auction/Revival turns,
* and actual pre-answer bribe. No played purse, response or promise is installed. */
function source(remaining: 2 | 4) {
const f = createTruthBidFixture({ advanced: true, extraGuild: true, targetKarama: true, window: 'active' });
const actions = structuredClone(f.sourceActions);
let game = advance(f.game, state => state.phase === 5 && state.active === f.target && clean(state), actions);
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 10 - remaining }, actions);
assert.equal(player(game, f.target).spice, remaining);
assert.equal(traitors(game).length, 20); assert.deepEqual(inventory(game), f.originalCards);
const window = windowCardOpportunity(game, 'karamaShipment'); assert.ok(window);
const declared = declareTruthBid(f, game); actions.push(...declared.actions);
game = ask(declared.game, f.asker, f.target, group('or', play(window.source), payment(f.asker)), actions);
assert.ok(viewGame(game, f.target).truthTurnActionAnswers?.includes('yes'));
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }, actions);
return { f, game, actions, window };
}
void test('an own shipment cannot spend its remaining payment alternative before a foreign Guild stop decision', () => {
const { f, game, actions } = source(2), before = structuredClone(game);
assert.throws(() => act(game, f.target, { type: 'ship', territory: 'polar_sink', sector: 0,
amount: 1, elite: 0, allyPayment: 0 }, actions), /Truthtrance|completion/);
assert.deepEqual(game, before);
let paid = act(game, f.target, { type: 'bribe', target: f.asker, amount: 2 }, actions);
assert.equal(paid.turnActionPromises![0].fulfilled, true); assert.equal(player(paid, f.target).spice, 0);
paid = act(paid, f.target, { type: 'endMovement' }, actions);
assert.equal(paid.discard.filter(card => card.id === f.karama).length, 0);
assert.deepEqual(inventory(paid), f.originalCards); assert.equal(traitors(paid).length, 20);
});
void test('a funded payment alternative survives actual unpriced delivery and Guild income before being paid', () => {
const { f, actions, game: initial } = source(4);
let game = initial;
game = act(game, f.target, { type: 'ship', territory: 'polar_sink', sector: 0,
amount: 1, elite: 0, allyPayment: 0 }, actions);
assert.equal(game.decision?.kind, 'guildShipment');
assert.equal(game.decision?.player, 'guild'); assert.equal(player(game, f.target).spice, 4);
game = advance(game, clean, actions);
assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
assert.equal(game.turnActionPromises![0].released, false);
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 2 }, actions);
assert.equal(player(game, f.target).spice, 0); assert.equal(game.turnActionPromises![0].fulfilled, true);
assert.ok(player(game, f.target).hand.some(card => card.id === f.karama));
assert.deepEqual(inventory(game), f.originalCards); assert.equal(traitors(game).length, 20);
});
@@ -0,0 +1,291 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import { truthFactAnswer } from '../game/truthtrance';
import { windowCardOpportunity } from '../game/window-card-play';
import {
actTruthTurnKaramaShipment as act, advanceTruthTurnKaramaShipment as advance,
answerTruthTurnKaramaShipment as answer, askTruthTurnKaramaShipment as ask,
bindTruthTurnKaramaShipment as bind, closeTruthTurnKaramaShipment as close,
createTruthTurnKaramaShipmentFixture as fixture, declareTruthTurnKaramaShipment as declare,
finishTruthTurnKaramaShipment as finish, nextTruthTurnKaramaShipmentNative as nativeStep,
settleTruthTurnKaramaShipment as settle, truthTurnKaramaShipmentClean as clean,
truthTurnKaramaShipmentFact as fact, truthTurnKaramaShipmentGroup as group,
truthTurnKaramaShipmentInventory as inventory, truthTurnKaramaShipmentPayment as payment,
truthTurnKaramaShipmentPlay as play, truthTurnKaramaShipmentPlayer as player,
truthTurnKaramaShipmentPromise as promise, truthTurnKaramaShipmentShip as ship,
truthTurnKaramaShipmentTraitors as traitors,
type TruthTurnKaramaShipmentFixture, type TruthTurnKaramaShipmentStep,
} from './fixture-truth-turn-karama-shipment';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function physical(game: Game, f: TruthTurnKaramaShipmentFixture, copies: 1 | 2 = 1) {
for (const truth of f.truths.slice(0, copies)) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), f.originalTraitors);
}
function receipt(game: Game, index = 0) {
const stage = game.turnActionPromises![index].windowCards?.karamaShipment; assert.ok(stage); return stage;
}
const armies = (game: Game) => game.players.map(candidate => ({ id: candidate.id, forces: candidate.forces,
reserves: candidate.reserves, tanks: candidate.tanks, elites: candidate.elites }));
void test('shipment-price sources keep original dealt Truths and Karama through actual all-pass and Movement prefixes', () => {
for (const advanced of [false, true]) for (const beneficiary of ['own', 'foreign'] as const) {
const f = fixture({ advanced, beneficiary }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.equal(f.setup.deck.length, 33); assert.equal(f.originalCards.length, 33); assert.equal(f.originalTraitors.length, 15);
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
assert.deepEqual(player(f.game, f.target).hand.map(card => card.id), [f.karama]);
assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.beneficiary); assert.equal(f.game.advanced, advanced);
assert.equal(player(f.game, f.target).spice, 10); assert.equal(player(f.game, f.beneficiary).shipped, false);
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCounts.selected, f.sourceActions.length);
assert.equal(f.sourceCounts.inherited, f.base.sourceActionCount);
assert.equal(f.sourceCounts.nativeContinuation, f.sourceActions.length - f.base.sourceActionCount);
assert.equal(f.sourceCounts.actualAuctionClose, f.auctionClose.steps.length);
assert.ok(f.sourceActions.some(step => step.actor === f.target && step.action.type === 'passBid'));
assert.deepEqual(windowCardOpportunity(f.game, 'karamaShipment'), f.opportunity);
assert.equal(f.source, JSON.stringify(['karamaShipment', 1, f.beneficiary, 'original']));
const own = viewGame(f.game, f.target); assert.deepEqual(own.karamaShipmentCards, [f.karama]);
assert.ok(own.karamaShipmentWindows.some(window => window.source === f.source && window.recipient === f.beneficiary));
assert.deepEqual(viewGame(f.game, f.asker).karamaShipmentCards, []);
assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors);
}
});
void test('actual self or foreign printed price grant fulfills independently of spice, movement and delivery', () => {
for (const beneficiary of ['own', 'foreign'] as const) {
const f = fixture({ beneficiary }); let game = bind(f, 'yes', play(f.source)).game;
const beforeArmies = structuredClone(armies(game)), purses = game.players.map(candidate => candidate.spice);
assert.deepEqual(receipt(game), { source: f.source, played: false, closed: false });
game = settle(act(game, f.target, f.grant));
assert.equal(game.karamaShipping?.owner, f.target); assert.equal(game.karamaShipping?.player, f.beneficiary);
assert.equal(game.karamaShipping?.card, f.karama); assert.equal(receipt(game).played, true);
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(armies(game), beforeArmies);
assert.deepEqual(game.players.map(candidate => candidate.spice), purses);
assert.equal(player(game, f.beneficiary).shipped, false); assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
game = act(game, f.beneficiary, f.endMovement);
assert.equal(game.karamaShipping, null); assert.deepEqual(armies(game), beforeArmies);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('real own and foreign shipments pay the native half-price bank tariff after the printed cost', () => {
for (const beneficiary of ['own', 'foreign'] as const) {
const f = fixture({ beneficiary }); let game = bind(f, 'yes', play(f.source)).game;
game = settle(act(game, f.target, f.grant)); const purse = player(game, f.beneficiary).spice;
const reserves = player(game, f.beneficiary).reserves;
game = settle(act(game, f.beneficiary, f.shipment));
assert.equal(player(game, f.beneficiary).spice, purse - 1);
assert.equal(player(game, f.beneficiary).reserves, reserves - 1);
assert.equal(player(game, f.beneficiary).forces['polar_sink:0'], 1);
assert.equal(player(game, f.beneficiary).shipped, true); assert.equal(game.karamaShipping, null);
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
physical(game, f);
}
});
void test('printed price AND actual shipment AND payment share the exact funded purse while facts freeze before payment', () => {
const f = fixture(), mixed = group('and', fact(f.spiceFact), play(f.source), ship(), payment(f.recipient, 9));
let game = bind(f, 'yes', mixed).game;
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 9 });
assert.equal(player(game, f.target).spice, 1); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
game = settle(act(game, f.target, f.grant)); assert.ok(!promise(game, f.target).fulfilled);
game = settle(act(game, f.target, f.shipment));
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, 9);
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(receipt(game).played, true);
assert.equal(player(game, f.target).forces['polar_sink:0'], 1); physical(game, f);
});
void test('printed shipment funding reserves the half-price fee from the actual beneficiary rather than a foreign card giver', () => {
const own = fixture(), unaffordable = group('and', play(own.source), ship(), payment(own.recipient, 10));
const asked = ask(declare(own).game, own.asker, own.target, unaffordable);
assert.deepEqual(viewGame(asked, own.target).truthTurnActionAnswers, ['no']);
rejected(asked, own.target, { type: 'truthAnswer', answer: 'yes' });
const foreign = fixture({ beneficiary: 'foreign' });
const mixed = group('and', play(foreign.source), payment(foreign.recipient, 10));
let game = bind(foreign, 'yes', mixed).game;
const beneficiaryPurse = player(game, foreign.beneficiary).spice;
game = act(game, foreign.target, { type: 'bribe', target: foreign.recipient, amount: 10 });
assert.equal(player(game, foreign.target).spice, 0);
assert.equal(player(game, foreign.beneficiary).spice, beneficiaryPurse, 'The actual bribe receipt remains deferred');
game = settle(act(game, foreign.target, foreign.grant));
assert.equal(promise(game, foreign.target).fulfilled, true); assert.ok(!promise(game, foreign.target).released);
game = settle(act(game, foreign.beneficiary, foreign.shipment));
assert.equal(player(game, foreign.target).spice, 0);
assert.equal(player(game, foreign.beneficiary).spice, beneficiaryPurse - 1);
assert.equal(player(game, foreign.beneficiary).forces['polar_sink:0'], 1);
assert.equal(game.discard.filter(card => card.id === foreign.karama).length, 1);
assert.equal(promise(game, foreign.target).fulfilled, true); assert.ok(!promise(game, foreign.target).released);
physical(game, foreign);
});
void test('whole price OR payment Yes preserves the manual grant route and payment route for either current beneficiary', () => {
for (const beneficiary of ['own', 'foreign'] as const) {
const f = fixture({ beneficiary }), mixed = group('or', play(f.source), payment(f.recipient));
const bound = bind(f, 'yes', mixed).game;
const granted = settle(act(bound, f.target, f.grant));
assert.equal(promise(granted, f.target).fulfilled, true); assert.equal(player(granted, f.recipient).bribes, 0);
let paid = act(bound, f.target, f.payment); paid = act(paid, f.beneficiary, f.endMovement);
assert.equal(promise(paid, f.target).fulfilled, true); assert.equal(receipt(paid).played, false);
assert.ok(player(paid, f.target).hand.some(card => card.id === f.karama)); physical(granted, f); physical(paid, f);
}
});
void test('whole price AND payment No preserves either earlier action and rejects the real completing grant or payment', () => {
const f = fixture();
for (const first of ['payment', 'grant'] as const) {
let game = bind(f, 'no').game;
if (first === 'payment') {
game = act(game, f.target, f.payment); rejected(game, f.target, f.grant);
} else {
game = settle(act(game, f.target, f.grant)); rejected(game, f.target, f.payment);
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
}
assert.ok(!promise(game, f.target).released); game = finish(game);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('whole price OR payment No keeps the actual cumulative payment cap after the beneficiary closes the source', () => {
for (const beneficiary of ['own', 'foreign'] as const) {
const f = fixture({ beneficiary }), mixed = group('or', play(f.source), payment(f.recipient));
let game = bind(f, 'no', mixed).game; rejected(game, f.target, f.grant); rejected(game, f.target, f.payment);
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); game = close(game, f);
assert.deepEqual(receipt(game), { source: f.source, played: false, closed: true });
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
assert.ok(!promise(game, f.target).released); game = finish(game);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('own shipment or Movement end cannot evade a required printed price while the original path remains feasible', () => {
const f = fixture(); let game = bind(f, 'yes', play(f.source)).game;
rejected(game, f.target, f.shipment); rejected(game, f.target, f.endMovement);
assert.equal(player(game, f.target).spice, 10); assert.equal(player(game, f.target).shipped, false);
assert.ok(player(game, f.target).hand.some(card => card.id === f.karama));
game = settle(act(game, f.target, f.grant)); game = act(game, f.target, f.endMovement);
assert.equal(player(game, f.target).forces['polar_sink:0'] ?? 0, 0);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('whole No price AND shipment checks the prospective physical shipment before closing its price source', () => {
const f = fixture(), mixed = group('and', play(f.source), ship());
let granted = bind(f, 'no', mixed).game; granted = settle(act(granted, f.target, f.grant));
rejected(granted, f.target, f.shipment); assert.equal(player(granted, f.target).spice, 10);
assert.equal(player(granted, f.target).forces['polar_sink:0'] ?? 0, 0);
granted = act(granted, f.target, f.endMovement); granted = finish(granted);
assert.equal(promise(granted, f.target).fulfilled, true); assert.equal(receipt(granted).played, true); physical(granted, f);
let shipped = bind(f, 'no', mixed).game; shipped = settle(act(shipped, f.target, f.shipment));
assert.equal(player(shipped, f.target).spice, 8); assert.equal(receipt(shipped).played, false);
assert.ok(player(shipped, f.target).hand.some(card => card.id === f.karama));
shipped = finish(shipped); assert.equal(promise(shipped, f.target).fulfilled, true); physical(shipped, f);
});
void test('foreign shipment or end stays legal and releases only that source before a second genuine own-beneficiary question', () => {
const f = fixture({ beneficiary: 'foreign' });
for (const action of [f.shipment, f.endMovement]) {
const actions: TruthTurnKaramaShipmentStep[] = [];
let game = bind(f, 'yes', play(f.source), f.game, 0, actions).game;
game = settle(act(game, f.beneficiary, action, actions), actions);
assert.equal(promise(game, f.target).released, true); assert.equal(receipt(game).played, false);
assert.equal(receipt(game).closed, true); assert.ok(player(game, f.target).hand.some(card => card.id === f.karama));
const earlier = structuredClone(receipt(game));
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), actions, true);
const own = windowCardOpportunity(game, 'karamaShipment'); assert.ok(own?.effect === 'karamaShipment');
assert.equal(own.recipient, f.target); assert.notEqual(own.source, f.source);
game = bind(f, 'yes', play(own.source), game, 1, actions).game;
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.target, f.target); assert.equal(guide.event, game.turnActionPromises![1].event);
game = settle(act(game, f.target, guide.action, actions), actions);
assert.equal(game.turnActionPromises![1].fulfilled, true); assert.equal(receipt(game, 1).played, true);
assert.deepEqual(receipt(game), earlier); assert.equal(game.turnActionPromises![0].released, true);
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1); physical(game, f, 2);
}
});
void test('a foreign closed price OR remains payable late even after the same actual Karama prices the own shipment instead', () => {
const f = fixture({ beneficiary: 'foreign' }), mixed = group('or', play(f.source), payment(f.recipient));
let game = bind(f, 'yes', mixed).game; game = act(game, f.beneficiary, f.endMovement);
assert.deepEqual(receipt(game), { source: f.source, played: false, closed: true });
assert.ok(!promise(game, f.target).released); const evidence = structuredClone(receipt(game));
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), [], true);
game = settle(act(game, f.target, f.ownGrant)); game = settle(act(game, f.target, f.shipment));
assert.equal(player(game, f.target).spice, 9); assert.deepEqual(receipt(game), evidence);
assert.ok(!promise(game, f.target).fulfilled); game = advance(game, state => state.phase === 8 && clean(state), [], true);
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, f.payment);
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(receipt(game), evidence); physical(game, f);
});
void test('a real prior auction cost never counts as future printed Karama shipment spending', () => {
const f = fixture({ priorAuctionSpend: true }); assert.ok(f.auctionCost);
assert.equal(f.sourceCounts.actualPriorAuctionCost, f.auctionCost.steps.length);
assert.ok(f.auctionCost.steps.some(step => step.actor === f.target && step.action.type === 'card' && step.action.mode === 'purchase'));
assert.equal(f.game.discard.filter(card => card.id === f.karama).length, 1);
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.base.lotCard));
const asked = ask(declare(f).game, f.asker, f.target, play(f.source));
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
let game = answer(asked, f.target, 'no'); assert.equal(receipt(game).played, false);
game = close(game, f); assert.equal(receipt(game).played, false); assert.equal(promise(game, f.target).fulfilled, true);
physical(game, f);
});
void test('printed shipment guides carry exact beneficiary source and event while cards remain viewer-owned', () => {
for (const beneficiary of ['own', 'foreign'] as const) {
const f = fixture({ beneficiary }), declared = declare(f);
for (const source of [JSON.stringify(['karamaShipment', f.game.turn + 1, f.beneficiary, 'original']),
JSON.stringify(['karamaShipment', f.game.turn, 'absent-beneficiary', 'original']),
JSON.stringify(['karamaShipment', f.game.turn, f.beneficiary, 'cunning', 'invented-event'])])
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, mixed: play(source) } });
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, mixed: { kind: 'action', action: {
kind: 'windowCardPlay', effect: 'karamaShipment', source: f.source, recipient: f.beneficiary,
} } } });
let game = bind(f).game; game = act(game, f.target, f.payment);
const own = viewGame(game, f.target), guide = own.turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.karama);
assert.equal(guide.action.mode, 'shipment'); assert.equal(guide.action.target, f.beneficiary);
assert.equal(guide.event, promise(game, f.target).event); assert.equal(guide.action.turnActionPromiseEvent, guide.event);
assert.equal(guide.action.turnActionPromisePhase, 5); assert.deepEqual(own.karamaShipmentCards, [f.karama]);
assert.deepEqual(viewGame(game, f.asker).karamaShipmentCards, []);
assert.ok(Object.keys(promise(game, f.target)).every(key => ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'].includes(key)));
rejected(game, f.asker, guide.action); rejected(game, f.target, { ...guide.action, target: f.observer });
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-price-event' });
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 3 });
const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, packet));
rejected(game, f.target, packet); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('all four minimal profiles execute native printed shipment-price guides for own and foreign beneficiaries', () => {
for (const beneficiary of ['own', 'foreign'] as const) {
const f = fixture({ beneficiary }), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthTurnKaramaShipmentStep[] = [];
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const response = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(response, difficulty);
assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions);
for (let count = 0; count < 20 && !promise(game, f.target).fulfilled; count++) {
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const guide = view.turnActionGuide;
if (guide) {
const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.event);
assert.ok(action, `${beneficiary}/${difficulty}`); assert.deepEqual(action, guide.action);
game = act(game, f.target, action, actions);
} else {
const next = nativeStep(game); assert.ok(next, `${beneficiary}/${difficulty}`);
game = act(game, next.actor, next.action, actions);
}
}
assert.equal(promise(game, f.target).fulfilled, true, `${beneficiary}/${difficulty}`);
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'bribe'));
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'card' && step.action.card === f.karama &&
step.action.mode === 'shipment' && step.action.target === f.beneficiary));
game = settle(game); physical(game, f);
}
}
});
+55 -1
View File
@@ -59,7 +59,8 @@ const call = (call = true, battle = 'battle:1'): Expression =>
const card = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expression =>
({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } });
const windowSource = (effect: WindowCardEffect) => effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
const windowSource = (effect: WindowCardEffect) => effect === 'karamaShipment'
? JSON.stringify(['karamaShipment', 1, 'recipient', 'original']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
effect === 'harvester' ? 'spice-blow:1' : 'storm:1';
const windowCard = (effect: WindowCardEffect = 'weather', source = windowSource(effect)): Expression =>
({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } });
@@ -1466,3 +1467,56 @@ void test('closing the auction role preserves a later payment alternative and fu
assert.throws(() => turnActionValue(windowed(group('or', windowCard('karamaAuction'),
windowCard('karamaAuction', 'ordinary-auction:1:2')))), TurnActionError);
});
void test('shipment-price receipt records the actual printed grant without requiring use and keeps whole transport correlation', () => {
const source = windowSource('karamaShipment');
const grantOnly = windowed(windowCard('karamaShipment'));
const joint = windowed(group('and', windowCard('karamaShipment'), travel('ship'), pay()), true, 'turn-action:2');
const granted = recordTurnActionWindowCardPlay([grantOnly, joint], 'respondent', 1, 'karamaShipment', source);
assert.equal(granted[0].fulfilled, true, 'The accepted price grant counts even if its beneficiary never ships');
assert.deepEqual(branches(granted[1]), [[signedTravel('ship'), signedPay()]]);
const shipped = recordTurnActionShipment(granted, 'respondent', 1, event());
assert.deepEqual(branches(shipped[1]), [[signedPay()]]);
assert.equal(recordTurnActionPayment(shipped, 'respondent', 1, payment())[1].fulfilled, true);
assert.equal(joint.windowCards!.karamaShipment!.played, false);
validateTurnActionPromises(granted, seats);
});
void test('shipment-price No remains attached to its original recipient while another recipient has a compatible live interval', () => {
const first = windowSource('karamaShipment');
const later = JSON.stringify(['karamaShipment', 1, 'other', 'original']);
const no = windowed(windowCard('karamaShipment', first), false);
const yes = windowed(group('or', windowCard('karamaShipment', later), pay()), true, 'turn-action:2');
const granted = recordTurnActionWindowCardPlay([no, yes], 'respondent', 1, 'karamaShipment', later);
assert.equal(granted[0], no);
assert.equal(granted[1].fulfilled, true);
assert.throws(() => recordTurnActionWindowCardPlay(granted, 'respondent', 1, 'karamaShipment', first), TurnActionError);
assert.equal(no.windowCards!.karamaShipment!.played, false);
const cunning = JSON.stringify(['karamaShipment', 1, 'recipient', 'cunning', 'cunning:1']);
assert.equal(recordTurnActionWindowCardPlay(granted, 'respondent', 1, 'karamaShipment', cunning)[0], no);
assert.equal(closeTurnActionWindowCardPlay(granted, 'respondent', 1, 'karamaShipment', later)[0], no);
const closed = closeTurnActionWindowCardPlay(granted, 'respondent', 1, 'karamaShipment', first);
assert.equal(closed[0].fulfilled, true);
assert.equal(closed[1], granted[1]);
validateTurnActionPromises(closed, seats);
});
void test('closing a shipment-price opportunity retains actual play and later whole payment alternatives through turn expiry', () => {
const source = windowSource('karamaShipment');
const yes = windowed(group('and', windowCard('karamaShipment'), pay()));
const no = windowed(group('and', windowCard('karamaShipment'), pay()), false, 'turn-action:2');
const granted = recordTurnActionWindowCardPlay([yes, no], 'respondent', 1, 'karamaShipment', source);
const closed = closeTurnActionWindowCardPlay(granted, 'respondent', 1, 'karamaShipment', source);
assert.deepEqual(closed[0].windowCards!.karamaShipment, { source, played: true, closed: true });
assert.deepEqual(branches(closed[0]), [[signedPay()]]);
assert.equal(closed[1].fulfilled, false);
assert.throws(() => recordTurnActionPayment(closed, 'respondent', 1, payment()), TurnActionError);
assert.equal(recordTurnActionPayment([closed[0]], 'respondent', 1, payment())[0].fulfilled, true);
const alternative = windowed(group('or', windowCard('karamaShipment'), pay()), true, 'turn-action:3');
const absent = closeTurnActionWindowCardPlay([alternative], 'respondent', 1, 'karamaShipment', source);
assert.deepEqual(branches(absent[0]), [[signedPay()]]);
const expired = finishTurnActionTurn([no], 'respondent', 1)[0];
assert.equal(expired.fulfilled, true);
assert.deepEqual(expired.windowCards!.karamaShipment, { source, played: false, closed: true });
validateTurnActionPromises([expired], seats);
});
+127 -3
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, type WindowCardGame } from '../game/window-card-play';
import { WINDOW_CARD_EFFECTS, karamaShipmentOpportunities, parseKaramaShipmentSource, windowCardNativeEffect, windowCardOpportunity,
type WindowCardGame } from '../game/window-card-play';
function storm(): WindowCardGame {
return { status: 'playing', phase: 0, turn: 3, shieldWallDestroyed: false };
@@ -14,10 +15,133 @@ function auction(): WindowCardGame {
function publicAuction(): WindowCardGame {
return { ...storm(), phase: 3, auction: { bid: 4, bidder: 'respondent', remaining: 2 }, bidPromiseLot: { turn: 3, lot: 1 } };
}
const shipmentSource = (recipient: string, turn = 3) => JSON.stringify(['karamaShipment', turn, recipient, 'original']);
function shipments(): WindowCardGame {
return { ...storm(), phase: 5, active: 'first', movementRemaining: ['first', 'later', 'done'],
players: [{ id: 'first', shipped: false }, { id: 'later', shipped: false }, { id: 'done', shipped: true }],
karamaShipping: null };
}
void test('window roles expose a shared exhaustive tuple and exact native printed-card mapping', () => {
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction']);
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama']);
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment']);
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama']);
});
void test('shipment-price windows retain every unused original entitlement while new questions select only the active recipient', () => {
const source = shipments(), before = structuredClone(source);
assert.deepEqual(karamaShipmentOpportunities(source), [
{ effect: 'karamaShipment', turn: 3, source: shipmentSource('first'), recipient: 'first' },
{ effect: 'karamaShipment', turn: 3, source: shipmentSource('later'), recipient: 'later' },
]);
assert.equal(windowCardOpportunity(source, 'karamaShipment')!.source, shipmentSource('first'));
assert.equal(windowCardOpportunity(source, 'karamaShipment', shipmentSource('later'))!.source, shipmentSource('later'));
assert.equal(windowCardOpportunity({ ...source, active: 'later' }, 'karamaShipment', shipmentSource('first'))!.source,
shipmentSource('first'));
assert.deepEqual(source, before);
});
void test('shipment source decoding identifies original and Cunning beneficiaries even after a grant or shipment closes them', () => {
assert.deepEqual(parseKaramaShipmentSource(shipmentSource('first')), { turn: 3, recipient: 'first', kind: 'original' });
const source = JSON.stringify(['karamaShipment', 3, 'first', 'cunning', 'cunning:1']);
assert.deepEqual(parseKaramaShipmentSource(source), { turn: 3, recipient: 'first', kind: 'cunning', event: 'cunning:1' });
for (const invalid of [undefined, null, [], '{}', `${source} `,
JSON.stringify(['karamaShipment', 0, 'first', 'original']),
JSON.stringify(['karamaShipment', 3.5, 'first', 'original']),
JSON.stringify(['karamaShipment', Number.MAX_SAFE_INTEGER + 1, 'first', 'original']),
JSON.stringify(['karamaShipment', 3, ' first', 'original']),
JSON.stringify(['karamaShipment', 3, 'first', 'cunning']),
JSON.stringify(['karamaShipment', 3, 'first', 'cunning', '']),
JSON.stringify(['karamaShipment', 3, 'first', 'original', 'extra'])])
assert.equal(parseKaramaShipmentSource(invalid), null);
assert.equal(windowCardOpportunity({ ...shipments(), karamaShipping: { player: 'first' } }, 'karamaShipment',
shipmentSource('first')), null);
assert.equal(parseKaramaShipmentSource(shipmentSource('first'))!.recipient, 'first');
});
void test('a shipment-price grant closes only its beneficiary and actual shipment or turn completion closes that original source', () => {
const source = shipments();
const granted = { ...source, karamaShipping: { player: 'first' } };
assert.equal(windowCardOpportunity(granted, 'karamaShipment'), null);
assert.deepEqual(karamaShipmentOpportunities(granted).map(window => window.recipient), ['later']);
const shipped = { ...source, players: source.players!.map(player => ({ ...player, shipped: true })) };
assert.deepEqual(karamaShipmentOpportunities(shipped), []);
const finished = { ...source, active: 'later', movementRemaining: ['later', 'done'] };
assert.equal(windowCardOpportunity(finished, 'karamaShipment', shipmentSource('first')), null);
assert.equal(windowCardOpportunity({ ...source, phase: 6 }, 'karamaShipment', shipmentSource('first')), null);
assert.equal(windowCardOpportunity({ ...source, turn: 4 }, 'karamaShipment', shipmentSource('first')), null);
});
void test('temporary native modal priority preserves original shipment entitlements without offering an inactive question', () => {
for (const modal of [{ response: {} }, { decision: {} }, { truthtrance: {} }, { phaseOpening: {} }]) {
const source = { ...shipments(), active: 'later', ...modal };
assert.equal(windowCardOpportunity(source, 'karamaShipment')!.source, shipmentSource('later'));
assert.equal(windowCardOpportunity(source, 'karamaShipment', shipmentSource('first'))!.source, shipmentSource('first'));
}
assert.equal(windowCardOpportunity(storm(), 'weather', 'storm:3')!.source, 'storm:3');
assert.equal(windowCardOpportunity(storm(), 'weather', 'storm:4'), null);
assert.equal(windowCardOpportunity(auction(), 'karamaAuction', 'ordinary-auction:3:2'), null);
});
void test('current Cunning second shipment has its own event source and never reopens an unused original shipment', () => {
const source = shipments(), receipt = { turn: 3, owner: 'first', event: 'cunning:1' };
const second = { ...source, nexusGuildCunningHistory: [{ receipt, stage: 'secondShipment' }] };
const expected = JSON.stringify(['karamaShipment', 3, 'first', 'cunning', 'cunning:1']);
assert.equal(windowCardOpportunity(second, 'karamaShipment')!.source, expected);
const projected = { ...source, players: source.players!.map(({ id }) => ({ id })),
karamaShipmentWindows: karamaShipmentOpportunities(second) };
assert.equal(windowCardOpportunity(projected, 'karamaShipment')!.source, expected);
assert.equal(windowCardOpportunity(second, 'karamaShipment', shipmentSource('first')), null);
assert.equal(windowCardOpportunity({ ...second, active: 'later' }, 'karamaShipment', expected), null);
assert.equal(windowCardOpportunity({ ...second, karamaShipping: { player: 'first' } }, 'karamaShipment', expected), null);
for (const stage of ['pending', 'extraMove', 'completed', 'canceled']) {
const closed = { ...source, nexusGuildCunningHistory: [{ receipt, stage }] };
assert.equal(windowCardOpportunity(closed, 'karamaShipment'), null);
assert.equal(windowCardOpportunity(closed, 'karamaShipment', shipmentSource('first')), null);
assert.ok(windowCardOpportunity(closed, 'karamaShipment', shipmentSource('later')));
}
const old = { ...source, nexusGuildCunningHistory: [{ receipt: { ...receipt, turn: 2 }, stage: 'completed' }] };
assert.equal(windowCardOpportunity(old, 'karamaShipment')!.source, shipmentSource('first'));
});
void test('public shipment projections require no shipped history and return copies of strictly coherent source rows', () => {
const native = shipments(), windows = karamaShipmentOpportunities(native);
const source = { ...native, players: native.players!.map(({ id }) => ({ id })), karamaShipmentWindows: windows };
const before = structuredClone(source), projected = karamaShipmentOpportunities(source);
assert.deepEqual(projected, windows);
assert.notEqual(projected, windows);
assert.notEqual(projected[0], windows[0]);
projected[0].recipient = 'changed-copy';
assert.deepEqual(source, before);
assert.equal(windowCardOpportunity(source, 'karamaShipment', shipmentSource('later'))!.source, shipmentSource('later'));
});
void test('malformed public shipment windows cannot invent a recipient turn entitlement or Cunning source', () => {
const native = shipments(), window = karamaShipmentOpportunities(native)[0];
for (const invalid of [{ ...window, recipient: 'unseated' }, { ...window, turn: 2 }, { ...window, effect: 'karamaAuction' },
{ ...window, source: '' }, { ...window, source: `${window.source} ` }, { ...window, source: shipmentSource('later') },
{ ...window, source: JSON.stringify(['karamaShipment', 3, 'first', 'cunning']) },
{ ...window, source: JSON.stringify(['karamaShipment', 3, 'first', 'cunning', '']) },
{ ...window, card: 'private-card' }])
assert.deepEqual(karamaShipmentOpportunities({ ...native, karamaShipmentWindows: [invalid] } as WindowCardGame), []);
for (const invalid of [[window, window], [window, { ...window,
source: JSON.stringify(['karamaShipment', 3, 'first', 'cunning', 'cunning:1']) }]])
assert.deepEqual(karamaShipmentOpportunities({ ...native, karamaShipmentWindows: invalid }), []);
assert.deepEqual(karamaShipmentOpportunities({ ...native, active: 'later', karamaShipmentWindows: [{ ...window,
source: JSON.stringify(['karamaShipment', 3, 'first', 'cunning', 'cunning:1']) }] }), []);
assert.deepEqual(karamaShipmentOpportunities({ ...native, movementRemaining: ['later'], active: 'later',
karamaShipmentWindows: [window] }), []);
assert.deepEqual(karamaShipmentOpportunities({ ...native, karamaShipping: { player: 'first' },
karamaShipmentWindows: [window] }), []);
});
void test('malformed native shipment metadata cannot create a price opportunity', () => {
const source = shipments();
for (const invalid of [{ players: [{ id: 'first' }] }, { players: [{ id: 'first', shipped: false }, { id: 'first', shipped: false }] },
{ movementRemaining: ['first', 'first'] }, { movementRemaining: ['missing'] }, { movementRemaining: null },
{ active: 'missing' }, { karamaShipping: { player: 'missing' } }, { status: 'setup' }, { turn: 0 },
{ nexusGuildCunningHistory: [{ receipt: { turn: 4, owner: 'first', event: 'cunning:1' }, stage: 'secondShipment' }] },
{ nexusGuildCunningHistory: [{ receipt: { turn: 3, owner: 'missing', event: 'cunning:1' }, stage: 'secondShipment' }] }])
assert.deepEqual(karamaShipmentOpportunities({ ...source, ...invalid }), []);
});
void test('ordinary Karama binds the same public current-lot source from native and projected auction shapes', () => {