Bind Truthtrance Karama promises to shipment beneficiaries
This commit is contained in:
1 parent
fd0b2f0fa5
commit
fd38c51ffd
19 files changed
+1148
-41
No files matched your search
+124
-13
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user