Bind printed transfer and order plays to Truthtrance
This commit is contained in:
1 parent
3e959504ef
commit
4c4bebe85e
25 files changed
+1245
-57
No files matched your search
+126
-12
@@ -5156,6 +5156,8 @@ function distransBlock(g: Game, owner: Player, card: Card): string | null {
|
||||
return 'Distrans is available during an active game.';
|
||||
if (g.truthtrance)
|
||||
return 'Resolve the active Truthtrance question before changing hands.';
|
||||
const promised = printedRolePlayBlock(g, owner.id, 'distrans');
|
||||
if (promised) return promised;
|
||||
const reserved = transferCardBlock(g, owner, card);
|
||||
if (reserved) return reserved;
|
||||
return distransAuctionBlock(g);
|
||||
@@ -8586,6 +8588,11 @@ function semutaOfferSupported(
|
||||
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
entries: FreshDiscardBatch['entries'],
|
||||
) {
|
||||
// These completed public costs retain the original ordinary lot through the
|
||||
// existing signed continuation. Semuta itself has no during-bid exception;
|
||||
// native Distrans still cannot produce a transfer after that bid starts.
|
||||
const ordinaryAuctionDiscard = g.phase === 3 && !!g.auction && !g.richeseAuction && !g.currentAuctionSale &&
|
||||
(continuation.kind === 'truthtranceDiscard' || continuation.kind === 'distransDiscard');
|
||||
const richeseGiftDiscard = continuation.kind === 'karamaRicheseGiftDiscard';
|
||||
const richeseDiscard = richeseGiftDiscard || continuation.kind === 'karamaRicheseAuctionDiscard';
|
||||
const auctionAdvantageDiscard = continuation.kind === 'karamaAuctionAdvantageDiscard';
|
||||
@@ -8903,7 +8910,7 @@ function semutaOfferSupported(
|
||||
continuation.use.response.kind === 'emperorIncome'
|
||||
? !!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal'
|
||||
? !!g.auction && !g.richeseAuction : !!g.richeseAuction && !g.auction)
|
||||
: (continuation.kind === 'saphoAuctionDiscard' && continuation.cyclic === 'normal') || !g.auction)) &&
|
||||
: ordinaryAuctionDiscard || (continuation.kind === 'saphoAuctionDiscard' && continuation.cyclic === 'normal') || !g.auction)) &&
|
||||
(advisorDiscard || auctionCostDiscard || bonusDiscard || auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' ||
|
||||
continuation.kind === 'ixAllyCard' ||
|
||||
(continuation.kind === 'karamaIncomeDiscard' &&
|
||||
@@ -19009,6 +19016,14 @@ function printedCardRule<T>(operation: () => T): T {
|
||||
function nativePrintedCardCount(g: Game, player: string, effect: PrintedCardEffect): number {
|
||||
return printedCardRule(() => printedCardPlayCount(g.printedCardPlays, player, g.turn, effect));
|
||||
}
|
||||
/** Read-only immediate No constraints also protect native controls and minimal
|
||||
* policies that choose a card outside the dedicated whole-answer guide. */
|
||||
function printedRolePlayBlock(g: Game, player: string, effect: PrintedCardEffect): string | null {
|
||||
if (!g.turnActionPromises) return null;
|
||||
try { turnActionRule(() => recordTurnActionCardPlay(g.turnActionPromises!, player, g.turn, effect)); }
|
||||
catch (error) { if (error instanceof RuleError) return error.message; throw error; }
|
||||
return null;
|
||||
}
|
||||
function recordNativePrintedCardPlay(g: Game, player: string, effect: PrintedCardEffect) {
|
||||
const policy = (g as TurnActionCardProbe)._turnActionCardPolicy;
|
||||
requireRule(policy?.player !== player || !policy.forbidden.includes(effect),
|
||||
@@ -19022,6 +19037,18 @@ function turnActionCardsMatch(g: Game, actor: string, requirements: readonly Tur
|
||||
}
|
||||
function* nativePrintedCardActions(g: Game, actor: string, effect: PrintedCardEffect): Generator<Action> {
|
||||
const player = getPlayer(g, actor);
|
||||
if (effect === 'distrans') {
|
||||
const quote = distransView(g, player);
|
||||
if (quote && !quote.blocked) for (const choice of quote.choices) if (!choice.blocked)
|
||||
for (const card of choice.cards)
|
||||
yield { type: 'card', card: quote.card.id, target: choice.recipient, give: card.id };
|
||||
return;
|
||||
}
|
||||
if (effect === 'juiceOfSapho') {
|
||||
for (const option of saphoOptions(g, player))
|
||||
yield { type: 'card', card: 'richese-juice-of-sapho', scope: option.scope, event: option.event, mode: option.mode };
|
||||
return;
|
||||
}
|
||||
if (effect === 'nullentropyBox') {
|
||||
for (const card of player.hand.filter(card => card.effect === effect)) {
|
||||
try { validateNullentropyBegin(g, player, card.id); yield { type: 'card', card: card.id }; }
|
||||
@@ -19072,9 +19099,32 @@ function guardTurnActionBoxCost(source: Game, owner: Player, box: string) {
|
||||
for (const seed of turnActionBoxSeeds(source, owner.id, box)) if (turnActionCompletion(seed.game, owner.id)) return;
|
||||
throw new RuleError('The two-spice Box payment must preserve your accepted whole Truthtrance answers.');
|
||||
}
|
||||
/** Native transfer/order effects retain their physical cost and public queue.
|
||||
* No recipient consent, unseen acquisition or income is assumed. */
|
||||
function* turnActionTransferOrderSeeds(source: Game, actor: string, requirements: readonly TurnActionCardRequirement[]):
|
||||
Generator<{ game: Game; actions: Action[] }> {
|
||||
const search = function* (game: Game, actions: Action[]): Generator<{ game: Game; actions: Action[] }> {
|
||||
const pending = [...new Set(requirements.filter(required => required.answer &&
|
||||
(required.effect === 'distrans' || required.effect === 'juiceOfSapho') &&
|
||||
nativePrintedCardCount(game, actor, required.effect) <= required.baseline).map(required => required.effect))];
|
||||
for (const effect of pending) for (const action of nativePrintedCardActions(game, actor, effect)) {
|
||||
try {
|
||||
const played = applyActionInner(game, actor, action, 'movementPreparation');
|
||||
const active = played.active, extortion = played.decision?.kind === 'moritaniExtortion' ? played.decision : null;
|
||||
const projected = groundMovementProbe(played, actor);
|
||||
projected.active = active;
|
||||
if (extortion) projected.decision = extortion;
|
||||
yield* search(projected, [...actions, action]);
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
// Prefer a legal current printed use; retain deferral as an alternative.
|
||||
yield { game, actions };
|
||||
};
|
||||
yield* search(source, []);
|
||||
}
|
||||
/** Current Ghola and known owned Box routes share the original physical cost.
|
||||
* A separate Movement forecast retains the later-play alternative. */
|
||||
function* turnActionCardSeeds(source: Game, actor: string, requirements: readonly TurnActionCardRequirement[]):
|
||||
function* turnActionOwnedCardSeeds(source: Game, actor: string, requirements: readonly TurnActionCardRequirement[]):
|
||||
Generator<{ game: Game; actions: Action[] }> {
|
||||
const gholaDue = requirements.some(required => required.effect === 'ghola' && required.answer &&
|
||||
nativePrintedCardCount(source, actor, 'ghola') <= required.baseline);
|
||||
@@ -19115,6 +19165,24 @@ function* turnActionCardSeeds(source: Game, actor: string, requirements: readonl
|
||||
if (projected) yield* plays(projected, [{ type: 'card', card: box.id }]);
|
||||
}
|
||||
}
|
||||
function* turnActionCardSeeds(source: Game, actor: string, requirements: readonly TurnActionCardRequirement[]):
|
||||
Generator<{ game: Game; actions: Action[] }> {
|
||||
if (!requirements.some(required => required.answer &&
|
||||
(required.effect === 'distrans' || required.effect === 'juiceOfSapho') &&
|
||||
nativePrintedCardCount(source, actor, required.effect) <= required.baseline)) {
|
||||
yield* turnActionOwnedCardSeeds(source, actor, requirements);
|
||||
return;
|
||||
}
|
||||
for (const side of turnActionTransferOrderSeeds(source, actor, requirements))
|
||||
for (const owned of turnActionOwnedCardSeeds(side.game, actor, requirements))
|
||||
yield { game: owned.game, actions: [...side.actions, ...owned.actions] };
|
||||
// Recovery or Ghola may first provide a known physical card or native income.
|
||||
for (const owned of turnActionOwnedCardSeeds(source, actor, requirements)) {
|
||||
if (!owned.actions.length) continue;
|
||||
for (const side of turnActionTransferOrderSeeds(owned.game, actor, requirements))
|
||||
if (side.actions.length) yield { game: side.game, actions: [...owned.actions, ...side.actions] };
|
||||
}
|
||||
}
|
||||
type TurnActionWindowCardRequirement = Extract<TurnActionBranchLeaf, { kind: 'windowCardPlay' }>;
|
||||
const isWindowCardEffect = (effect: unknown): effect is WindowCardEffect =>
|
||||
WINDOW_CARD_EFFECTS.some(candidate => candidate === effect);
|
||||
@@ -19346,6 +19414,12 @@ function guardTurnActionPrintedCardCost(g: Game, player: Player, used: Card) {
|
||||
const trial = structuredClone(g), owner = getPlayer(trial, player.id);
|
||||
owner.spice += pendingBattleRevivalIncome(trial, owner);
|
||||
trial.truthtrance = null; trial.response = null; trial.pendingKarama = null;
|
||||
if (used.effect === 'juiceOfSapho' || used.effect === 'distrans') {
|
||||
// The effect is committed before its optional used-card claim. The actual
|
||||
// claim/counter choices stay at the table, without projected income.
|
||||
trial.pendingTreacheryDiscard = null;
|
||||
if (trial.decision?.kind === 'moritaniExtortion') trial.decision = null;
|
||||
}
|
||||
// The Ghola effect/cost is complete; forecast beyond its owned interruption
|
||||
// without executing or borrowing the original market's pending income.
|
||||
if (trial.pendingChoamMarketGhola?.player === player.id) trial.pendingChoamMarketGhola = null;
|
||||
@@ -19830,7 +19904,8 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
|
||||
// permanent restrictions such as Inflation Double remain authoritative.
|
||||
const ownedInvoice = trial.decision?.kind === 'auctionPayment' && trial.decision.player === actor;
|
||||
const ownedBox = trial.decision?.kind === 'nullentropy' && trial.pendingNullentropy?.player === actor;
|
||||
const paymentWindow = ownedInvoice || ownedBox ? { ...trial, decision: null,
|
||||
const extortion = trial.decision?.kind === 'moritaniExtortion';
|
||||
const paymentWindow = ownedInvoice || ownedBox || extortion ? { ...trial, decision: null,
|
||||
pendingNullentropy: ownedBox ? undefined : trial.pendingNullentropy } : trial;
|
||||
if (required.length && bribeTimingBlock(paymentWindow)) continue;
|
||||
const total = required.reduce((sum, payment) => sum + payment.amount, 0);
|
||||
@@ -19925,7 +20000,8 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
const compiled = compileMixedActionExpression(claim.mixed,
|
||||
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
|
||||
const open = turnActionTransportOpen(g, p.id);
|
||||
let bid = false, revival = false, alliance = false, plan = false, traitor = false, cardHajr = false, cardGhola = false, cardBox = false;
|
||||
let bid = false, revival = false, alliance = false, plan = false, traitor = false;
|
||||
const printedCards = new Set<PrintedCardEffect>();
|
||||
const powers = new Set<NativeBattlePower>();
|
||||
const windowCards: TurnActionWindowCards = {};
|
||||
const visit = (node: TurnActionClaim['mixed']): void => {
|
||||
@@ -19938,9 +20014,7 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
if (node.action.kind === 'windowCardPlay') windowCards[node.action.effect] = {
|
||||
source: node.action.source, played: false, closed: false,
|
||||
};
|
||||
if (node.action.kind === 'cardPlay' && node.action.effect === 'hajr') cardHajr = true;
|
||||
if (node.action.kind === 'cardPlay' && node.action.effect === 'ghola') cardGhola = true;
|
||||
if (node.action.kind === 'cardPlay' && node.action.effect === 'nullentropyBox') cardBox = true;
|
||||
if (node.action.kind === 'cardPlay') printedCards.add(node.action.effect);
|
||||
if (node.action.kind === 'forces' || node.action.kind === 'leader') revival = true;
|
||||
} else if (node.kind !== 'fact') node.terms.forEach(visit);
|
||||
};
|
||||
@@ -19972,9 +20046,10 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
closed: false, call: null } : null } : undefined;
|
||||
let promise: TurnActionPromise = { turn: claim.turn, mixed: structuredClone(claim.mixed), event, player: p.id, asker, answer, compiled,
|
||||
...(Object.keys(windowCards).length ? { windowCards } : {}),
|
||||
...(cardHajr || cardGhola || cardBox ? { cardPlays: { hajr: cardHajr ? { played: false, closed: !open } : null,
|
||||
ghola: cardGhola ? { played: false, closed: false } : null,
|
||||
...(cardBox ? { nullentropyBox: { played: false, closed: false } } : {}) } } : {}),
|
||||
...(printedCards.size ? { cardPlays: { hajr: printedCards.has('hajr') ? { played: false, closed: !open } : null,
|
||||
ghola: printedCards.has('ghola') ? { played: false, closed: false } : null,
|
||||
...Object.fromEntries([...printedCards].filter(effect => effect !== 'hajr' && effect !== 'ghola')
|
||||
.map(effect => [effect, { played: false, closed: false }])) } } : {}),
|
||||
...(combat ? { combat } : {}),
|
||||
...(preparation ? { preparation } : {}),
|
||||
...(nexus ? { alliance: { source: nexus.source, partner: null, closed: false } } : {}),
|
||||
@@ -20109,8 +20184,10 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
|
||||
const promise = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn)[0];
|
||||
const ownedBox = g.decision?.kind === 'nullentropy' && g.pendingNullentropy?.player === player.id;
|
||||
const ownedInvoice = g.decision?.kind === 'auctionPayment' && g.decision.player === player.id;
|
||||
const saphoExtortion = g.decision?.kind === 'moritaniExtortion' &&
|
||||
saphoOptions(g, player).some(option => option.scope === 'extortion');
|
||||
if (!promise || g.truthtrance || g.response || g.pendingTreacheryDiscard ||
|
||||
g.decision && !ownedBox && !ownedInvoice) return null;
|
||||
g.decision && !ownedBox && !ownedInvoice && !saphoExtortion) return null;
|
||||
const completion = turnActionCompletion(g, player.id);
|
||||
if (!completion) return null;
|
||||
if (g.phaseOpening) {
|
||||
@@ -20136,8 +20213,10 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
|
||||
const shipmentTarget = preparation?.type === 'card' && preparation.mode === 'shipment'
|
||||
? preparation.target ?? player.id : null;
|
||||
if ((preparation?.type !== 'bid' || g.auction?.active === player.id) &&
|
||||
(!preparation || turnActionCurrentPrintedPreparation(g, player, preparation)) &&
|
||||
(shipmentTarget === null || shipmentTarget === g.active)) action = preparation;
|
||||
}
|
||||
if (saphoExtortion && (action?.type !== 'card' || action.scope !== 'extortion')) return null;
|
||||
if (!action && g.phase <= 5) {
|
||||
const lot = g.phase === 3 && !g.decision ? bidOpportunity(g) : null;
|
||||
if (lot && g.auction?.active === player.id) action = completion.bid ??
|
||||
@@ -20193,7 +20272,10 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
|
||||
const windows: WindowCardEffect[] = printedWindow ? [printedWindow]
|
||||
: action.type === 'decision' && action.karama === true ? ['karamaAuction']
|
||||
: action.type === 'ready' || held?.effect === 'nullentropyBox' ? [...WINDOW_CARD_EFFECTS] : [];
|
||||
const guidePromise = windows.length ? liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(row =>
|
||||
const sideRole = held?.effect === 'distrans' || held?.effect === 'juiceOfSapho' ? held.effect : null;
|
||||
const guidePromise = sideRole ? liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(row =>
|
||||
row.cardPlays?.[sideRole] && !row.cardPlays[sideRole]!.played && !row.cardPlays[sideRole]!.closed) ?? promise
|
||||
: windows.length ? liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(row =>
|
||||
windows.some(effect => {
|
||||
const stage = row.windowCards?.[effect];
|
||||
return stage && !stage.closed && (!printedWindow || !stage.played) &&
|
||||
@@ -20203,6 +20285,15 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
|
||||
for (const key of ['bidPromiseEvent', 'revivalExpressionPromiseEvent', 'leaderRevivalPromiseEvent', 'revivalPromiseEvent']) delete packet[key];
|
||||
return { event: guidePromise.event, action: { ...packet, turnActionPromiseEvent: guidePromise.event, turnActionPromisePhase: g.phase } };
|
||||
}
|
||||
/** A future calendar quote is not a current native action. Use the same live
|
||||
* transfer/order choices as manual controls before offering a guided packet. */
|
||||
function turnActionCurrentPrintedPreparation(g: Game, player: Player, action: Action): boolean {
|
||||
if (action.type !== 'card') return true;
|
||||
const card = player.hand.find(card => card.id === action.card);
|
||||
if (card?.effect !== 'distrans' && card?.effect !== 'juiceOfSapho') return true;
|
||||
return [...nativePrintedCardActions(g, player.id, card.effect)].some(choice =>
|
||||
['type', 'card', 'target', 'give', 'scope', 'event', 'mode'].every(key => choice[key] === action[key]));
|
||||
}
|
||||
function travelRule<T>(operation: () => T): T {
|
||||
try { return operation(); }
|
||||
catch (error) { if (error instanceof TravelPromiseError) throw new RuleError(error.message); throw error; }
|
||||
@@ -20406,6 +20497,23 @@ function groundMovementCompletionNative(source: Game, actor: string,
|
||||
return null;
|
||||
}
|
||||
for (const seed of earlyShipmentCompletionSeeds(game, source, actor, cardPlays.length > 0)) {
|
||||
if (cardPlays.some(required => required.effect === 'juiceOfSapho' && required.answer &&
|
||||
nativePrintedCardCount(seed.game, actor, required.effect) <= required.baseline)) {
|
||||
// Upcoming native Movement has a public order, not a singleton owned
|
||||
// probe. Reset only its ordinary entry counters; grant no future cash,
|
||||
// cards or foreign moves, and retain earlier owned preparation once.
|
||||
const future = charityForecastContext(seed.game);
|
||||
future.phase = 5; future.movementRemaining = [...source.order];
|
||||
future.saphoMovementLast = null; future.guildTimingGranted = false; future.guildTimingLocked = false;
|
||||
for (const player of future.players) resetPlayerMovement(player);
|
||||
future.active = future.movementRemaining[0] ?? null;
|
||||
for (const prepared of turnActionTransferOrderSeeds(future, actor, cardPlays)) {
|
||||
if (!prepared.actions.length) continue;
|
||||
const completion = groundMovementCompletionAtMovement(groundMovementProbe(prepared.game, actor), actor,
|
||||
promises, expressions, cardPlays);
|
||||
if (completion) return { actions: [...seed.actions, ...prepared.actions, ...completion.actions], early: seed.early };
|
||||
}
|
||||
}
|
||||
resetGroundMovementForecast(seed.game, actor);
|
||||
const completion = groundMovementCompletionAtMovement(seed.game, actor, promises, expressions, cardPlays);
|
||||
if (completion) return { actions: [...seed.actions, ...completion.actions], early: seed.early };
|
||||
@@ -25601,6 +25709,7 @@ function saphoMovementRule<T>(calculate: () => T): T {
|
||||
/** Own custody and public finite scopes only; no opponent hand scan. */
|
||||
function saphoOptions(g: Game, p: Player): SaphoOption[] {
|
||||
if (!saphoCleanWindow(g)) return [];
|
||||
if (printedRolePlayBlock(g, p.id, 'juiceOfSapho')) return [];
|
||||
const card = p.hand.find((c) => c.id === 'richese-juice-of-sapho');
|
||||
if (
|
||||
!card ||
|
||||
@@ -25703,6 +25812,7 @@ function playSapho(g: Game, p: Player, action: Action) {
|
||||
option,
|
||||
'This Juice of Sapho opportunity is unavailable, already completed or expired.',
|
||||
);
|
||||
recordNativePrintedCardPlay(g, p.id, 'juiceOfSapho');
|
||||
if (option.scope === 'battleAggressor') {
|
||||
const b = g.battle!;
|
||||
const next = saphoAggressorRule(() => changeSaphoAggressor(b.saphoAggressor,
|
||||
@@ -38825,7 +38935,9 @@ function applyActionInner(
|
||||
return g;
|
||||
}
|
||||
if (t === 'card' && action.card === 'richese-juice-of-sapho') {
|
||||
const used = p.hand.find(card => card.id === action.card);
|
||||
playSapho(g, p, action);
|
||||
if (execution === 'live' && used) guardTurnActionPrintedCardCost(g, p, used);
|
||||
return g;
|
||||
}
|
||||
if (t === 'tupileIntelligence') {
|
||||
@@ -38955,6 +39067,8 @@ function applyActionInner(
|
||||
p.hand = result.ownerHand;
|
||||
recipient.hand = result.recipientHand;
|
||||
g.discard.push(result.discarded);
|
||||
recordNativePrintedCardPlay(g, p.id, 'distrans');
|
||||
if (execution === 'live') guardTurnActionPrintedCardCost(g, p, result.discarded);
|
||||
log(
|
||||
g,
|
||||
`${p.name} played Distrans to give ${recipient.name} one private Treachery Card, then discarded Distrans. No spice, purchase bonus or recipient confirmation is required.`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const PRINTED_CARD_EFFECTS = ['hajr', 'ghola', 'nullentropyBox'] as const;
|
||||
export const PRINTED_CARD_EFFECTS = ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho'] as const;
|
||||
export type PrintedCardEffect = typeof PRINTED_CARD_EFFECTS[number];
|
||||
export type PrintedCardPlayCounts = {
|
||||
turn: number;
|
||||
@@ -41,7 +41,7 @@ function validateCounts(counts: unknown): asserts counts is PrintedCardPlayCount
|
||||
|
||||
function validateRequest(player: unknown, turn: unknown, cardEffect: unknown): void {
|
||||
check(identifier(player) && positive(turn) && effect(cardEffect),
|
||||
'Choose the printed Hajr, Ghola or Nullentropy Box play, current turn and exact player.');
|
||||
'Choose a supported printed-card play, current turn and exact player.');
|
||||
}
|
||||
|
||||
/** Private native witness baseline only. Per-answer promises track their own
|
||||
|
||||
+3
-2
@@ -751,7 +751,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A transfer must preserve binding battle answers, compulsory discards and both sides of a pending Harkonnen hand exchange. The recipient must retain room for its mandatory incoming return. Optional proposals that become unavailable can still finish through their existing decline or no-effect path.',
|
||||
'A different legal transfer can fill or free space for a pending Richese gift. The ordinary Distrans transfer resolves first, and the pending gift rechecks capacity before moving its own card. A canceled alliance gift does not automatically prevent using this separate card effect.',
|
||||
'All four AI profiles use their own legal projected cards. They may give an allied Bene Gesserit or CHOAM a Worthless card, or share a duplicate ordinary weapon, defense or Karama to free a full hand. They do not inspect the recipient’s private cards and preserve existing decision priority.',
|
||||
'The explicitly opted-in Semuta preview now offers a neutral all-seat response to the public used Distrans after a clean completed private transfer. The given card remains private and belongs to its recipient; only the used Distrans is claimable. Responses or decisions already in progress, unresolved bid and self-transfer rulings, full combined Richese games and ordinary expansion starts remain unfinished.',
|
||||
'The explicitly opted-in Semuta preview offers a neutral all-seat response to the public used Distrans after a completed private transfer, including before an ordinary bid starts. Completed Truthtrance costs also retain a standing ordinary bid through their Semuta window. The given card remains private and belongs to its recipient; only the used Distrans is claimable. Responses or decisions already in progress, unresolved bid and self-transfer rulings, full combined Richese games and ordinary expansion starts remain unfinished.',
|
||||
],
|
||||
related: [
|
||||
'card-richese-distrans',
|
||||
@@ -3230,6 +3230,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A printed-card condition requires an actual Hajr or Tleilaxu Ghola play in its printed role after this answer. Earlier plays, discards, sales, Karama roles and recovery alone do not count; a genuine later Box recovery followed by replay can count. No hidden card is needed merely to ask the public named condition.',
|
||||
'Hajr counts even if its extra move is unused, closes at your own Movement end and must earn its actual Advanced timing. Ghola remains available through this turn’s Mentat and uses real Tanks, eligible leader or Kwisatz and typed selection; it does not spend or satisfy ordinary Revival quotas. Ordinary returns and Ghola share the actual remaining Tanks and purse.',
|
||||
'A printed Nullentropy Box clause counts its accepted two-spice search activation after answering this turn, before private selection. It does not name the recovered card; earlier use or recovery alone does not count. No lasts through Mentat. The actual fee shares the purse with payments and physical actions; an unpaid unknown recovered card cannot supply another effect. Whole native Basic Richese answers retain their real Semuta discard continuation. Current guides use native availability and paid selection keeps its own panel.',
|
||||
'Printed Distrans and Juice of Sapho clauses count actual native use after answering this turn. They do not select a transfer recipient/card or an order scope/mode/outcome. Native costs, capacity, reservations, first/last, no-op and unsplit-current-turn rules remain. Earlier Sapho answers may wait for original Movement without new income; only current exact choices become guides. These No intervals last through Mentat. Wider future ordering and action calendars remain partial.',
|
||||
'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.',
|
||||
@@ -3249,7 +3250,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ 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', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-thumper-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/fixture-truth-box-play.ts', 'tests/truth-box-play-runtime.test.ts', 'tests/truth-opaque-card-plan.test.ts', 'tests/fixture-truth-choam-income.ts', 'tests/truth-choam-income-runtime.test.ts', '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', 'tests/fixture-truth-turn-thumper.ts', 'tests/truth-turn-thumper-runtime.test.ts', 'tests/fixture-truth-turn-amal.ts', 'tests/truth-turn-amal-runtime.test.ts', 'game/ordinary-payment-plan.ts', 'tests/ordinary-payment-plan.test.ts', 'tests/fixture-truth-charity-payments.ts', 'tests/truth-charity-payments-runtime.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/fixture-truth-transfer-order-play.ts', 'tests/truth-transfer-order-play-runtime.test.ts', 'tests/fixture-distrans-prebid.ts', 'tests/fixture-sapho-started-movement.ts', 'tests/fixture-truth-box-play.ts', 'tests/truth-box-play-runtime.test.ts', 'tests/truth-opaque-card-plan.test.ts', 'tests/fixture-truth-choam-income.ts', 'tests/truth-choam-income-runtime.test.ts', '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', 'tests/fixture-truth-turn-thumper.ts', 'tests/truth-turn-thumper-runtime.test.ts', 'tests/fixture-truth-turn-amal.ts', 'tests/truth-turn-amal-runtime.test.ts', 'game/ordinary-payment-plan.ts', 'tests/ordinary-payment-plan.test.ts', 'tests/fixture-truth-charity-payments.ts', 'tests/truth-charity-payments-runtime.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+7
-3
@@ -752,9 +752,13 @@ export function truthQuestionText(
|
||||
action.effect === 'atomics' ? 'Family Atomics' : 'Harvester'} in its printed role after this answer in this original ${
|
||||
action.effect === 'harvester' ? 'fresh Spice Blow' : 'Storm window'} this turn`;
|
||||
}
|
||||
if (action.kind === 'cardPlay') return action.effect === 'nullentropyBox'
|
||||
? 'you activate Nullentropy Box in its printed role and pay its 2-spice search cost after this answer this turn'
|
||||
: `you play ${action.effect === 'hajr' ? 'Hajr' : 'Tleilaxu Ghola'} in its printed role after this answer this turn`;
|
||||
if (action.kind === 'cardPlay') {
|
||||
if (action.effect === 'nullentropyBox')
|
||||
return 'you activate Nullentropy Box in its printed role and pay its 2-spice search cost after this answer this turn';
|
||||
if (action.effect === 'distrans') return 'you complete a printed Distrans transfer and discard Distrans after this answer this turn';
|
||||
return `you play ${action.effect === 'hajr' ? 'Hajr' : action.effect === 'ghola' ? 'Tleilaxu Ghola' :
|
||||
'Juice of Sapho'} in its printed role after this answer this turn`;
|
||||
}
|
||||
if (action.kind === 'battlePower') {
|
||||
const claim = action.claim, where = `in this original battle in ${locationName(action.territory)}`;
|
||||
if (claim.kind === 'voiceCommand') return `you actually declare native Voice ${claim.must ? 'requiring' : 'forbidding'} ${claim.cardType} ${where} (later prevention does not change the declaration)`;
|
||||
|
||||
@@ -168,7 +168,7 @@ export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
|
||||
}
|
||||
if (value.kind === 'cardPlay') {
|
||||
check(keys(value, 'effect,kind') && PRINTED_CARD_EFFECTS.some(effect => effect === value.effect),
|
||||
'Choose only the printed Hajr, Tleilaxu Ghola or Nullentropy Box play after this answer.');
|
||||
'Choose only a supported printed-card play after this answer.');
|
||||
return { kind: 'cardPlay', effect: value.effect as PrintedCardEffect };
|
||||
}
|
||||
if (value.kind === 'battlePlan' || value.kind === 'traitorCall') {
|
||||
@@ -336,10 +336,11 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
}
|
||||
}
|
||||
if ('cardPlays' in promise) {
|
||||
check(object(promise.cardPlays) && (keys(promise.cardPlays, 'ghola,hajr') ||
|
||||
keys(promise.cardPlays, [...PRINTED_CARD_EFFECTS].sort().join(','))), 'Retain only the native printed-card play stages.');
|
||||
check(object(promise.cardPlays) && Object.hasOwn(promise.cardPlays, 'hajr') && Object.hasOwn(promise.cardPlays, 'ghola') &&
|
||||
Object.keys(promise.cardPlays).every(key => PRINTED_CARD_EFFECTS.some(effect => effect === key)),
|
||||
'Retain only the native printed-card play stages and original Hajr/Ghola keys.');
|
||||
for (const effect of PRINTED_CARD_EFFECTS) {
|
||||
if (effect === 'nullentropyBox' && !Object.hasOwn(promise.cardPlays, effect)) continue;
|
||||
if (!Object.hasOwn(promise.cardPlays, effect)) continue;
|
||||
const stage = promise.cardPlays[effect];
|
||||
check(stage === null || object(stage) && keys(stage, 'closed,played') && typeof stage.played === 'boolean' &&
|
||||
typeof stage.closed === 'boolean', 'Retain the actual printed-role play and native stage closure.');
|
||||
@@ -700,7 +701,7 @@ export function closeTurnActionBattlePower<T extends TurnActionPromise>(promises
|
||||
* success, disposal, recovery and real card custody; none count as this event. */
|
||||
export function recordTurnActionCardPlay<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||
turn: number, effect: PrintedCardEffect): T[] {
|
||||
check(PRINTED_CARD_EFFECTS.some(candidate => candidate === effect), 'Record an accepted printed Hajr, Tleilaxu Ghola or Nullentropy Box play.');
|
||||
check(PRINTED_CARD_EFFECTS.some(candidate => candidate === effect), 'Record an accepted supported printed-card play.');
|
||||
const updates = new Map<T, T>();
|
||||
for (const promise of liveTurnActionPromises(promises, player, turn)) {
|
||||
readPromise(promise);
|
||||
|
||||
Reference in new issue
Block a user