Bind printed Box use to shared Truthtrance funding
This commit is contained in:
1 parent
3abde581db
commit
3e959504ef
20 files changed
+948
-57
No files matched your search
+91
-15
@@ -12,7 +12,7 @@ import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurn
|
||||
closeTurnActionRevival, settleTurnActionAlliance, recordTurnActionPlan, recordTurnActionTraitor, closeTurnActionCombat,
|
||||
recordTurnActionBattlePower, closeTurnActionBattlePower,
|
||||
recordTurnActionWindowCardPlay, closeTurnActionWindowCardPlay,
|
||||
type TurnActionPlanValue, type TurnActionCombat, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation, type TurnActionWindowCards } from './turn-action-promises';
|
||||
turnActionPromiseModeSupported, type TurnActionPlanValue, type TurnActionCombat, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation, type TurnActionWindowCards } from './turn-action-promises';
|
||||
import { TravelPromiseError, liveTravelPromises, travelBranches, recordTravelShipment, recordTravelMovement,
|
||||
finishTravelTurn, validateTravelPromises, type TravelClaim, type TravelPromise, type TravelBranchLeaf } from './travel-expression-promises';
|
||||
import { mixedTraitorRequirement, type MixedTraitorClaim } from './mixed-traitor-question';
|
||||
@@ -4959,7 +4959,9 @@ function finishNullentropy(
|
||||
}
|
||||
function beginNullentropy(g: Game, owner: Player, cardId: string) {
|
||||
const { candidates } = validateNullentropyBegin(g, owner, cardId);
|
||||
guardTurnActionBoxCost(g, owner, cardId);
|
||||
reopenTurnActionWindowReady(g, owner.id);
|
||||
recordNativePrintedCardPlay(g, owner.id, 'nullentropyBox');
|
||||
owner.spice -= 2;
|
||||
const event = crypto.randomUUID();
|
||||
g.pendingNullentropy = {
|
||||
@@ -5015,6 +5017,7 @@ function nullentropyView(g: Game, owner: Player) {
|
||||
let blocked: string | null = null;
|
||||
try {
|
||||
validateNullentropyBegin(g, owner, card.id);
|
||||
guardTurnActionBoxCost(g, owner, card.id);
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
blocked = error.message;
|
||||
@@ -5035,7 +5038,7 @@ function savedTransferResponses(g: Game) {
|
||||
}
|
||||
function transferCardBlock(g: Game, owner: Player, card: Card) {
|
||||
if ((g as TurnActionCardProbe)._turnActionUnknownCards?.includes(card.id))
|
||||
return 'An unseen future auction card cannot fund this completion.';
|
||||
return 'An unseen future acquired card cannot fund this completion.';
|
||||
if (isPortableSnooper(card) && homeworldSavedDecisions(g).some((d) => d.kind === 'homeworldDefense' && d.player === owner.id))
|
||||
return 'Resolve the Homeworld late-defense choice before transferring its Portable Snooper.';
|
||||
|
||||
@@ -18963,6 +18966,7 @@ function projectMovementBoxRecovery(source: Game, actor: string, box: string, se
|
||||
'Retain the original paid search.');
|
||||
else {
|
||||
validateNullentropyBegin(game, owner, box); owner.spice -= 2;
|
||||
recordNativePrintedCardPlay(game, actor, 'nullentropyBox');
|
||||
// The real voluntary Box play closes Advanced Hajr's immediate window.
|
||||
// Recovery cannot preserve that window across its own card action.
|
||||
if (game.advanced) delete owner.hajrWindow;
|
||||
@@ -19018,6 +19022,13 @@ 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 === 'nullentropyBox') {
|
||||
for (const card of player.hand.filter(card => card.effect === effect)) {
|
||||
try { validateNullentropyBegin(g, player, card.id); yield { type: 'card', card: card.id }; }
|
||||
catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (effect === 'hajr') {
|
||||
if (g.phase === 5 && g.active === actor && hajrAvailable(g, player))
|
||||
for (const card of player.hand.filter(card => card.effect === 'hajr' && !transferCardBlock(g, player, card)))
|
||||
@@ -19033,14 +19044,44 @@ function* nativePrintedCardActions(g: Game, actor: string, effect: PrintedCardEf
|
||||
yield* gholaForcePreparations({ cards: ghola.cards, tanks: player.tanks,
|
||||
eliteTanks: player.elites?.tanks ?? 0, maxForces: ghola.maxForces, eliteRemaining: ghola.eliteRemaining });
|
||||
}
|
||||
/** A known recovery may supply its known effect. An unpaid private choice is
|
||||
* otherwise opaque: no recovered identity can fund another promised action.
|
||||
* Pure recovery preserves pieces and costs without a hidden draw or shuffle. */
|
||||
function* turnActionBoxSeeds(source: Game, actor: string, onlyBox?: string):
|
||||
Generator<{ game: Game; actions: Action[] }> {
|
||||
if (source.pendingNullentropy) return;
|
||||
const player = getPlayer(source, actor);
|
||||
const known = knownMovementDiscards(source.movementDiscardKnowledge, actor, source.discard);
|
||||
const candidates = eligibleNullentropyCards(source.discard);
|
||||
const choices = [...known.map(card => ({ card, opaque: false })),
|
||||
...candidates.filter(card => !known.some(seen => seen.id === card.id)).slice(0, 1)
|
||||
.map(card => ({ card, opaque: true }))];
|
||||
for (const box of player.hand.filter(card => card.effect === 'nullentropyBox' && (!onlyBox || card.id === onlyBox)))
|
||||
for (const choice of choices) {
|
||||
const projected = projectMovementBoxRecovery(source, actor, box.id, choice.card.id, false);
|
||||
if (!projected) continue;
|
||||
if (choice.opaque) (projected as TurnActionCardProbe)._turnActionUnknownCards =
|
||||
[...new Set([...((projected as TurnActionCardProbe)._turnActionUnknownCards ?? []), choice.card.id])];
|
||||
yield { game: projected, actions: [{ type: 'card', card: box.id }] };
|
||||
}
|
||||
}
|
||||
/** Reject the known printed fee before paid inspection can suspend legality.
|
||||
* The native owner chooses the recovered card only after actual payment. */
|
||||
function guardTurnActionBoxCost(source: Game, owner: Player, box: string) {
|
||||
if (!liveTurnActionPromises(source.turnActionPromises ?? [], owner.id, source.turn).length) return;
|
||||
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.');
|
||||
}
|
||||
/** 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[]):
|
||||
Generator<{ game: Game; actions: Action[] }> {
|
||||
const gholaDue = requirements.some(required => required.effect === 'ghola' && required.answer &&
|
||||
nativePrintedCardCount(source, actor, 'ghola') <= required.baseline);
|
||||
yield { game: gholaDue ? structuredClone(source) : source, actions: [] };
|
||||
if (!gholaDue) return;
|
||||
const boxDue = requirements.some(required => required.effect === 'nullentropyBox' && required.answer &&
|
||||
nativePrintedCardCount(source, actor, 'nullentropyBox') <= required.baseline);
|
||||
yield { game: gholaDue || boxDue ? structuredClone(source) : source, actions: [] };
|
||||
if (!gholaDue && !boxDue) return;
|
||||
const player = getPlayer(source, actor);
|
||||
const plays = function* (game: Game, prefix: Action[]): Generator<{ game: Game; actions: Action[] }> {
|
||||
for (const action of nativePrintedCardActions(game, actor, 'ghola')) {
|
||||
@@ -19052,7 +19093,16 @@ function* turnActionCardSeeds(source: Game, actor: string, requirements: readonl
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
};
|
||||
yield* plays(source, []);
|
||||
if (boxDue) for (const boxed of turnActionBoxSeeds(source, actor)) {
|
||||
yield boxed;
|
||||
if (gholaDue) yield* plays(boxed.game, boxed.actions);
|
||||
}
|
||||
if (!gholaDue) return;
|
||||
for (const played of plays(source, [])) {
|
||||
yield played;
|
||||
if (boxDue) for (const boxed of turnActionBoxSeeds(played.game, actor))
|
||||
yield { game: boxed.game, actions: [...played.actions, ...boxed.actions] };
|
||||
}
|
||||
const paid = source.pendingNullentropy;
|
||||
if (paid?.player === actor) {
|
||||
if (!nullentropyIntegrity(source)) for (const card of eligibleNullentropyCards(source.discard).filter(card => card.effect === 'ghola')) {
|
||||
@@ -19400,7 +19450,14 @@ function guardTurnActionShipmentPriceDelivery(g: Game, shipment: PendingShipment
|
||||
guardTurnActionShipmentPriceBoundary(trial, shipment.player, original, undefined, receipt);
|
||||
}
|
||||
function turnActionTargetAvailable(g: Game, _actor: string): boolean {
|
||||
return bribeQuestionWindow(g.phaseOpening ? { ...g, phaseOpening: null } : g) && movementPromiseModeSupported(g);
|
||||
const context = g.phaseOpening ? { ...g, phaseOpening: null } : g;
|
||||
return turnActionPromiseTurn(g) && !bribeTimingBlock({ ...context, truthtrance: null,
|
||||
inflation: context.inflation?.side === 'double' ? null : context.inflation });
|
||||
}
|
||||
/** Whole native commitments retain the existing Semuta discard continuation.
|
||||
* This does not expand the separate legacy bribe or Kull/module envelopes. */
|
||||
function turnActionPromiseTurn(g: Game): boolean {
|
||||
return g.status === 'playing' && g.phase >= 0 && g.phase <= 8 && turnActionPromiseModeSupported(g);
|
||||
}
|
||||
function turnActionTransportOpen(g: Game, actor: string): boolean {
|
||||
return g.phase < 5 || g.phase === 5 && (!!g.phaseOpening?.initialize || !!g.movementRemaining?.includes(actor));
|
||||
@@ -19868,7 +19925,7 @@ 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;
|
||||
let bid = false, revival = false, alliance = false, plan = false, traitor = false, cardHajr = false, cardGhola = false, cardBox = false;
|
||||
const powers = new Set<NativeBattlePower>();
|
||||
const windowCards: TurnActionWindowCards = {};
|
||||
const visit = (node: TurnActionClaim['mixed']): void => {
|
||||
@@ -19883,6 +19940,7 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
};
|
||||
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 === 'forces' || node.action.kind === 'leader') revival = true;
|
||||
} else if (node.kind !== 'fact') node.terms.forEach(visit);
|
||||
};
|
||||
@@ -19914,8 +19972,9 @@ 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 ? { cardPlays: { hajr: cardHajr ? { played: false, closed: !open } : null,
|
||||
ghola: cardGhola ? { played: false, closed: false } : null } } : {}),
|
||||
...(cardHajr || cardGhola || cardBox ? { cardPlays: { hajr: cardHajr ? { played: false, closed: !open } : null,
|
||||
ghola: cardGhola ? { played: false, closed: false } : null,
|
||||
...(cardBox ? { nullentropyBox: { played: false, closed: false } } : {}) } } : {}),
|
||||
...(combat ? { combat } : {}),
|
||||
...(preparation ? { preparation } : {}),
|
||||
...(nexus ? { alliance: { source: nexus.source, partner: null, closed: false } } : {}),
|
||||
@@ -19930,8 +19989,17 @@ function turnActionAnswerSource(g: Game, player: string, alliance = false, windo
|
||||
const source = g.phase === 0 && window || g.phase === 1 && (alliance || window) || g.phase === 2 || g.phase === 4 && !g.revivalOrder || g.phase === 8 ||
|
||||
g.phase === 7 && bribesCloseBeforeNextMentat(g)
|
||||
? { ...g, ready: g.ready.filter(id => id !== player) } : g;
|
||||
return source.phaseOpening ? { ...source, phaseOpening: { ...source.phaseOpening,
|
||||
const opened = source.phaseOpening ? { ...source, phaseOpening: { ...source.phaseOpening,
|
||||
passed: source.phaseOpening.passed.filter(id => id !== player) } } : source;
|
||||
const consumed = g.truthtrance?.stage !== 'priority' && g.truthtrance?.queue[0];
|
||||
if (!consumed) return opened;
|
||||
const projected = structuredClone(opened), owner = getPlayer(projected, consumed.player);
|
||||
if (!owner.hand.some(card => card.id === consumed.card && card.effect === 'truthtrance')) return opened;
|
||||
// This exact already-declared public cost is discarded on either definite
|
||||
// answer. Quote it once, without playing another queued card or a reaction.
|
||||
const card = discard(projected, owner, consumed.card);
|
||||
rememberMovementDiscards(projected, [{ card, discardedBy: owner.id, publicFace: true }]);
|
||||
return projected;
|
||||
}
|
||||
function turnActionTruthAnswers(g: Game, p: Player, claim: TurnActionClaim): TruthAnswer[] {
|
||||
if (claim.turn !== g.turn || !turnActionTargetAvailable(g, p.id)) return [];
|
||||
@@ -19990,7 +20058,7 @@ function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, s
|
||||
requireRule(actor !== promise.player || !promise.answer, 'You cannot voluntarily evade your Truthtrance bribe answer.');
|
||||
if (promise.answer) promise.released = true; else promise.fulfilled = true;
|
||||
}
|
||||
if (!bribePromiseTurn(g) || g.truthtrance) return;
|
||||
if (!turnActionPromiseTurn(g) || g.truthtrance) return;
|
||||
const nexus = allianceOpportunity(g);
|
||||
if (nexus && action?.type === 'bribe' && g.turnActionPromises!.some(promise =>
|
||||
promise.player === actor && promise.turn === g.turn && promise.alliance?.source === nexus.source))
|
||||
@@ -20397,11 +20465,16 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises:
|
||||
return;
|
||||
}
|
||||
for (const required of cardPlays.filter(required => required.answer &&
|
||||
nativePrintedCardCount(state, owner, required.effect) <= required.baseline))
|
||||
nativePrintedCardCount(state, owner, required.effect) <= required.baseline)) {
|
||||
if (required.effect === 'nullentropyBox') {
|
||||
yield* turnActionBoxSeeds(state, owner);
|
||||
continue;
|
||||
}
|
||||
for (const action of nativePrintedCardActions(state, owner, required.effect)) {
|
||||
try { yield { game: groundMovementProbe(applyActionInner(state, owner, action, 'movementPreparation'), owner), actions: [action] }; }
|
||||
catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
}
|
||||
if (state.advanced && player.faction === 'beneGesserit') {
|
||||
for (const territory of quoteAdvisorPreparationWindow(state, owner).territories) {
|
||||
const action: Action = { type: 'prepareAdvisors', territory };
|
||||
@@ -28513,7 +28586,10 @@ function findLegalBattlePlan(
|
||||
const promises = options.promises ?? b.truthPromises ?? [];
|
||||
const fixed = options.prescience;
|
||||
const commitments = committedPlanElements(b, p.id);
|
||||
const unseen = (g as TurnActionCardProbe)._turnActionUnknownCards ?? [];
|
||||
const hand = p.hand.filter(card => !unseen.includes(card.id));
|
||||
const accepts = (plan: Partial<Plan>) =>
|
||||
![plan.leader, plan.weapon, plan.defense].some(card => typeof card === 'string' && unseen.includes(card)) &&
|
||||
(!options.unarmed || (!plan.weapon && !plan.defense)) &&
|
||||
commitments.every(element =>
|
||||
inspectedPlanMatches(p, element.field, element.value, plan[element.field])) &&
|
||||
@@ -28553,11 +28629,11 @@ function findLegalBattlePlan(
|
||||
...controlledLeaders(g, p)
|
||||
.filter((l) => !l.dead && (!l.usedAt || l.usedAt === b.territory))
|
||||
.map((l) => l.id),
|
||||
...p.hand.filter((c) => c.kind === 'hero').map((c) => c.id),
|
||||
...hand.filter((c) => c.kind === 'hero').map((c) => c.id),
|
||||
];
|
||||
const weapons = [
|
||||
null,
|
||||
...p.hand
|
||||
...hand
|
||||
.filter(
|
||||
(card) =>
|
||||
battleCardSlotEligible('weapon', card, { planetologistWeapon: true }),
|
||||
@@ -28566,7 +28642,7 @@ function findLegalBattlePlan(
|
||||
];
|
||||
const defenses = [
|
||||
null,
|
||||
...p.hand.filter((c) => battleCardSlotEligible('defense', c)).map((c) => c.id),
|
||||
...hand.filter((c) => battleCardSlotEligible('defense', c)).map((c) => c.id),
|
||||
];
|
||||
for (const leader of leaderIds) {
|
||||
if (!accepts({ leader })) continue;
|
||||
|
||||
@@ -119,7 +119,7 @@ export const FACTION_RULES: Record<
|
||||
'Start with five forces in Tuek’s Sietch, fifteen in reserve and five spice. One force revival is free each turn.',
|
||||
'Choose one shipment: reserves to Dune, one on-planet territory to another, or one on-planet territory back to reserves. Movement follows shipment.',
|
||||
'Pay half the normal shipment rate, rounding the total cost up. Returning forces cost one spice per two tokens, rounded up.',
|
||||
'In classic-faction games without optional modules, a Guild or allied ordinary reserve/cross-planet shipment and Guild’s own return to reserves wait for a Karama rate response before paying. Canceling the discount charges the normal one/two-spice-per-force destination tariff for the exact force group and original approved payment split; return uses one spice per force. If that split or the saved physical group cannot complete, the declaration returns unused, and a replacement this turn uses full price. The full-price return and safe withdrawal are table interpretations, not dedicated publisher examples; optional modules and separate transport-permission cancellation still need integration.',
|
||||
'In classic-faction games without optional modules, a Guild or allied ordinary reserve/cross-planet shipment and Guild’s own return to reserves wait for a Karama rate response before paying. Canceling the discount charges the normal one/two-spice-per-force destination tariff for the exact force group and original approved payment split; return uses one spice per force. If that split or the saved physical group cannot complete, the declaration returns unused, and a replacement this turn uses full price. The full-price return and safe withdrawal are table interpretations, not dedicated publisher examples; a separate native cross/return-permission response is also connected before payment or departure. Permission denial preserves the discount and another legal shipment or movement; immediate redeclaration for that original opportunity remains provisionally blocked. Optional combinations remain partial.',
|
||||
'Receive spice for other factions’ ordinary off-planet shipments and allied cross-shipments. Karama shipping and a canceled Guild payment go to the bank. Fremen’s normal reinforcements provide no income. Any spice the Guild contributes toward its ally’s shipment goes to the bank.',
|
||||
'Allies can ship from off-planet or cross-ship on Dune at half price. They cannot ship back to reserves. Allied Fremen may pay to cross-ship from southern reserves to any legal destination. Allied cross-shipments pay the Guild; any Guild-funded share goes to the bank.',
|
||||
'If the game reaches its end without another qualifying victory, Guild and its ally win through the Guild special victory.',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type PrintedCardEffect = 'hajr' | 'ghola';
|
||||
export const PRINTED_CARD_EFFECTS = ['hajr', 'ghola', 'nullentropyBox'] as const;
|
||||
export type PrintedCardEffect = typeof PRINTED_CARD_EFFECTS[number];
|
||||
export type PrintedCardPlayCounts = {
|
||||
turn: number;
|
||||
plays: { player: string; effect: PrintedCardEffect; count: number }[];
|
||||
@@ -23,7 +24,7 @@ const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||||
const positive = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
||||
const effect = (value: unknown): value is PrintedCardEffect => value === 'hajr' || value === 'ghola';
|
||||
const effect = (value: unknown): value is PrintedCardEffect => PRINTED_CARD_EFFECTS.some(candidate => candidate === value);
|
||||
|
||||
function validateCounts(counts: unknown): asserts counts is PrintedCardPlayCounts {
|
||||
check(object(counts) && keys(counts, 'plays,turn') && positive(counts.turn) && Array.isArray(counts.plays),
|
||||
@@ -40,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 or Ghola play, current turn and exact player.');
|
||||
'Choose the printed Hajr, Ghola or Nullentropy Box play, current turn and exact player.');
|
||||
}
|
||||
|
||||
/** Private native witness baseline only. Per-answer promises track their own
|
||||
|
||||
+3
-1
@@ -1036,6 +1036,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'An active Truthtrance question and exact card or transaction commitments must resolve as required. Basic and Advanced full-hand holders have a visibly provisional atomic exchange: retrieve one non-Box and discard the played Box, leaving the final hand at its current limit. The printed intermediate-capacity question remains unresolved; over-limit hands are rejected.',
|
||||
'A pending purchased-shipment cost does not block the search. Under the provisional refund composition, recovering that exact cost preserves its purchased price, creator and beneficiary but ends its later Guild special-stop refund, even if discarded again. Selecting another card leaves the cost’s existing claim status unchanged. Empty or only-Box searches are rejected before payment under the current unsupported-empty-search policy; there is no fallback deck draw.',
|
||||
'All four AI profiles start only when their own projected availability and spice reserve allow it. They do not inspect unpaid discard contents. Once paid, they choose from their entitled cards, prefer existing functional effects over unfinished Richese faces and complete a legal selection even if only unfinished effects remain. A paid search takes priority over optional actions.',
|
||||
'Whole Truthtrance questions may name a future printed Box activation. Accepted payment records that play once before private selection; it does not promise the recovered identity. Actual costs share funding with other commitments, while an unpaid unknown recovery cannot supply another effect. Native paid selection and Semuta continuation keep their priority.',
|
||||
'The paid search and continuations are integrated in development fixtures, with a connected full-hand prototype in both modes. Purchased-shipment cost recovery now uses the provisional nonduplication policy without blocking the search. The explicit Semuta preview can recover the used Box after a clean completed search without replaying payment or shuffle; other discard compositions remain separate. Full Richese starts and complete browser/mobile/full-game acceptance remain gated.',
|
||||
],
|
||||
related: [
|
||||
@@ -3228,6 +3229,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Native owners can provide their eligible ally’s power. Voice finishes before Prescience; a pending Prescience condition does not advance its turn. Original command and No-Field restrictions remain. All predicates concern the same one native choice; source expiry is absence and never becomes a synthetic Decline. Borrowed Nexus and special powers retain separate scope.',
|
||||
'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.',
|
||||
'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.',
|
||||
@@ -3247,7 +3249,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-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-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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
import { parseTravelActionClaim, type TravelClaim } from './travel-expression-promises';
|
||||
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES,
|
||||
type TurnActionClaim } from './turn-action-promises';
|
||||
turnActionPromiseModeSupported, type TurnActionClaim } from './turn-action-promises';
|
||||
import { battlePowerOf, nativeBattlePowerOpportunity } from './battle-power-question';
|
||||
import { parseKaramaShipmentSource, windowCardOpportunity } from './window-card-play';
|
||||
import { parseTraitorActionClaim, type MixedTraitorClaim } from './mixed-traitor-question';
|
||||
@@ -380,7 +380,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
}
|
||||
if (v.kind === 'turnActions') {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && v.turn === g.turn &&
|
||||
bribePromiseModeSupported(g) && g.status === 'playing' && Number.isInteger(g.phase) && g.phase >= 0 && g.phase <= 8 &&
|
||||
turnActionPromiseModeSupported(g) && g.status === 'playing' && Number.isInteger(g.phase) && g.phase >= 0 && g.phase <= 8 &&
|
||||
!g.response && !g.decision && (!g.phaseOpening || !!windowCardOpportunity(g, 'amal')) && !g.pendingTreacheryDiscard && !g.pendingKarama,
|
||||
'Supply the current turn, complete turn-action question and a supported quiet native window.');
|
||||
const respondent = g.players.find(player => player.id === v.target)!;
|
||||
@@ -752,7 +752,9 @@ 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 `you play ${action.effect === 'hajr' ? 'Hajr' : 'Tleilaxu Ghola'} in its printed role after this answer 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 === '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)`;
|
||||
|
||||
@@ -6,8 +6,9 @@ import { parseRevivalActionClaim, type RevivalActionClaim } from './mixed-reviva
|
||||
import { RevivalExpressionPromiseError, type RevivalExpressionReturn } from './revival-expression-promises';
|
||||
import { parseBattleActionClaim, type MixedBattleActionClaim } from './mixed-battle-question';
|
||||
import { PlanClaimError } from './battle-promises';
|
||||
import type { PrintedCardEffect } from './printed-card-play';
|
||||
import { PRINTED_CARD_EFFECTS, type PrintedCardEffect } from './printed-card-play';
|
||||
import { WINDOW_CARD_EFFECTS, type WindowCardEffect } from './window-card-play';
|
||||
import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope';
|
||||
import { BattlePowerQuestionError, battlePowerMatches, battlePowerOf, parseBattlePowerClaim,
|
||||
type BattlePowerClaim, type BattlePowerDeclaration, type NativeBattlePower } from './battle-power-question';
|
||||
import {
|
||||
@@ -28,6 +29,15 @@ import type { TruthFact } from './truthtrance';
|
||||
export const TURN_ACTION_MAX_LEAVES = 16;
|
||||
export const TURN_ACTION_MAX_DEPTH = 4;
|
||||
|
||||
/** Native whole answers retain Semuta's own discard continuation. Other
|
||||
* unsupported interruption/module envelopes stay separately gated. */
|
||||
export function turnActionPromiseModeSupported(game: NativePromiseScope & {
|
||||
kullPreview?: unknown; nexusKullPreview?: unknown; richeseBetrayalPreview?: unknown;
|
||||
}): boolean {
|
||||
return nativePromiseScopeSupported(game) && !game.kullPreview &&
|
||||
!game.nexusKullPreview && !game.richeseBetrayalPreview;
|
||||
}
|
||||
|
||||
export type TurnActionLeaf = TravelActionClaim | { kind: 'payment'; recipient: string; minimum: number } |
|
||||
{ kind: 'bid'; lot: number; minimum: number } | RevivalActionClaim | { kind: 'alliance'; partner: string } |
|
||||
{ kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim } |
|
||||
@@ -68,7 +78,10 @@ export type TurnActionCombat = {
|
||||
traitor: { beneficiary: string; target: string; leader: string | null; closed: boolean; call: boolean | null } | null;
|
||||
powers?: Record<NativeBattlePower, TurnActionPowerStage | null>;
|
||||
};
|
||||
export type TurnActionCardPlays = Record<PrintedCardEffect, { played: boolean; closed: boolean } | null>;
|
||||
type PrintedCardStage = { played: boolean; closed: boolean } | null;
|
||||
/** The first two keys retain the legacy schema; later printed roles are optional. */
|
||||
export type TurnActionCardPlays = Record<'hajr' | 'ghola', PrintedCardStage> &
|
||||
Partial<Record<Exclude<PrintedCardEffect, 'hajr' | 'ghola'>, PrintedCardStage>>;
|
||||
export type TurnActionWindowCards = Partial<Record<WindowCardEffect, { source: string; played: boolean; closed: boolean }>>;
|
||||
export type TurnActionPromise = TurnActionClaim & {
|
||||
event: string;
|
||||
@@ -154,9 +167,9 @@ export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
|
||||
return { kind: 'battlePower', battle: value.battle, territory: value.territory, claim: parsePowerClaim(value.claim) };
|
||||
}
|
||||
if (value.kind === 'cardPlay') {
|
||||
check(keys(value, 'effect,kind') && (value.effect === 'hajr' || value.effect === 'ghola'),
|
||||
'Choose only the printed Hajr or Tleilaxu Ghola play after this answer.');
|
||||
return { kind: 'cardPlay', effect: value.effect };
|
||||
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.');
|
||||
return { kind: 'cardPlay', effect: value.effect as PrintedCardEffect };
|
||||
}
|
||||
if (value.kind === 'battlePlan' || value.kind === 'traitorCall') {
|
||||
check(keys(value, value.kind === 'battlePlan' ? 'battle,claim,kind,territory' : 'battle,call,kind,territory') &&
|
||||
@@ -323,8 +336,10 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
}
|
||||
}
|
||||
if ('cardPlays' in promise) {
|
||||
check(object(promise.cardPlays) && keys(promise.cardPlays, 'ghola,hajr'), 'Retain only the native printed-card play stages.');
|
||||
for (const effect of ['hajr', 'ghola'] as const) {
|
||||
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.');
|
||||
for (const effect of PRINTED_CARD_EFFECTS) {
|
||||
if (effect === 'nullentropyBox' && !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.');
|
||||
@@ -685,7 +700,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(effect === 'hajr' || effect === 'ghola', 'Record an accepted printed Hajr or Tleilaxu Ghola play.');
|
||||
check(PRINTED_CARD_EFFECTS.some(candidate => candidate === effect), 'Record an accepted printed Hajr, Tleilaxu Ghola or Nullentropy Box play.');
|
||||
const updates = new Map<T, T>();
|
||||
for (const promise of liveTurnActionPromises(promises, player, turn)) {
|
||||
readPromise(promise);
|
||||
@@ -966,10 +981,10 @@ export function finishTurnActionTurn<T extends TurnActionPromise>(promises: read
|
||||
shipment: { ...promise.shipment, pending: false }, ...(promise.preparation ? { preparation: {
|
||||
bid: promise.preparation.bid ? { ...promise.preparation.bid, closed: true } : null,
|
||||
revival: promise.preparation.revival ? { ...promise.preparation.revival, closed: true } : null,
|
||||
} } : {}), ...(promise.cardPlays ? { cardPlays: {
|
||||
hajr: promise.cardPlays.hajr ? { ...promise.cardPlays.hajr, closed: true } : null,
|
||||
ghola: promise.cardPlays.ghola ? { ...promise.cardPlays.ghola, closed: true } : null,
|
||||
} } : {}), ...(promise.windowCards ? { windowCards } : {}) };
|
||||
} } : {}), ...(promise.cardPlays ? { cardPlays: Object.fromEntries(PRINTED_CARD_EFFECTS
|
||||
.filter(effect => Object.hasOwn(promise.cardPlays!, effect)).map(effect => [effect, promise.cardPlays![effect]
|
||||
? { ...promise.cardPlays![effect]!, closed: true } : null])) as TurnActionCardPlays } : {}),
|
||||
...(promise.windowCards ? { windowCards } : {}) };
|
||||
const unresolvedCombat = promise.combat && (promise.combat.plan && !promise.combat.plan.closed ||
|
||||
promise.combat.traitor && !promise.combat.traitor.closed || promise.combat.powers?.voice && !promise.combat.powers.voice.closed ||
|
||||
promise.combat.powers?.prescience && !promise.combat.powers.prescience.closed);
|
||||
@@ -994,7 +1009,7 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[
|
||||
(!promise.combat?.powers?.voice || promise.combat.powers.voice.closed) &&
|
||||
(!promise.combat?.powers?.prescience || promise.combat.powers.prescience.closed) &&
|
||||
WINDOW_CARD_EFFECTS.every(effect => promise.windowCards?.[effect]?.closed ?? true) &&
|
||||
(!promise.cardPlays?.hajr || promise.cardPlays.hajr.closed) && (!promise.cardPlays?.ghola || promise.cardPlays.ghola.closed);
|
||||
PRINTED_CARD_EFFECTS.every(effect => promise.cardPlays?.[effect]?.closed ?? true);
|
||||
const recipientsValid = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
|
||||
? node.action.kind === 'alliance' ? players.includes(node.action.partner) && node.action.partner !== promise.player :
|
||||
node.action.kind !== 'payment' || players.includes(node.action.recipient) && node.action.recipient !== promise.player
|
||||
|
||||
Reference in new issue
Block a user