Bind completed Semuta exchanges to Truthtrance promises
This commit is contained in:
1 parent
4c4bebe85e
commit
b02839ce2a
24 files changed
+1014
-40
No files matched your search
@@ -24,5 +24,6 @@ export function botSemutaActions(g: GameView): Action[] {
|
||||
);
|
||||
if (reaction.canCommit && !reaction.blocked && hasSemuta)
|
||||
return [{ type: 'semutaCommit', event: reaction.event }];
|
||||
if (reaction.passBlocked) return [];
|
||||
return [{ type: 'semutaPass', event: reaction.event }];
|
||||
}
|
||||
+150
-9
@@ -5036,8 +5036,8 @@ function savedTransferResponses(g: Game) {
|
||||
canceled(g.pendingRicheseGift?.resume.pendingKarama),
|
||||
];
|
||||
}
|
||||
function transferCardBlock(g: Game, owner: Player, card: Card) {
|
||||
if ((g as TurnActionCardProbe)._turnActionUnknownCards?.includes(card.id))
|
||||
function transferCardBlock(g: Game, owner: Player, card: Card, opaqueTransfer = false) {
|
||||
if (!opaqueTransfer && (g as TurnActionCardProbe)._turnActionUnknownCards?.includes(card.id))
|
||||
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.';
|
||||
@@ -5191,7 +5191,9 @@ function validateDistrans(
|
||||
error instanceof Error ? error.message : 'Invalid Distrans transfer.',
|
||||
);
|
||||
}
|
||||
const reserved = transferCardBlock(g, owner, result.transferred);
|
||||
// Distrans needs another physical card, not knowledge of that card's effect.
|
||||
// Detached opaque acquisitions may supply this generic transfer only.
|
||||
const reserved = transferCardBlock(g, owner, result.transferred, true);
|
||||
requireRule(!reserved, reserved ?? 'This card is already committed.');
|
||||
validateTransferCompletion(
|
||||
g,
|
||||
@@ -8995,7 +8997,10 @@ function semutaSpendingBlock(g: Game, owner: Player, card: Card): string | null
|
||||
: preventedGift ? {...g,...gift!.resume,pendingRicheseGift:null} : g,owner,card);
|
||||
}
|
||||
|
||||
function projectedSemutaReaction(g: Game, me: Player) {
|
||||
function projectedSemutaReaction(g: Game, me: Player): {
|
||||
event: string; stage: 'select' | 'offer'; passed: boolean; canCommit: boolean;
|
||||
blocked: string | null; candidates: Card[]; passBlocked?: string;
|
||||
} | null {
|
||||
const pending = g.pendingTreacheryDiscard;
|
||||
const reaction = pending?.reaction;
|
||||
if (!pending || !reaction) return null;
|
||||
@@ -9010,15 +9015,20 @@ function projectedSemutaReaction(g: Game, me: Player) {
|
||||
richeseCardDefinition(card)?.card.effect === 'semutaDrug');
|
||||
const held = !!semuta;
|
||||
const blocked = !held || passed ? null
|
||||
: semutaSpendingBlock(g, me, semuta!) ??
|
||||
: semutaSpendingBlock(g, me, semuta!) ?? printedRolePlayBlock(g, me.id, 'semutaDrug') ??
|
||||
(me.hand.length > handLimit(me)
|
||||
? 'Resolve your excess hand cards before using Semuta Drug.'
|
||||
: pending.batch.entries.every(entry => entry.discardedBy === me.id)
|
||||
? 'No other player discarded a card in this fresh event.'
|
||||
: null);
|
||||
: hasTurnActionSemutaStage(g, me.id) && !turnActionSemutaCompletion(g, me.id, 'use')
|
||||
? 'Retain a legal completion of your whole Truthtrance answer before committing Semuta.' : null);
|
||||
const passBlocked = !passed && hasTurnActionSemutaStage(g, me.id) &&
|
||||
!turnActionSemutaCompletion(g, me.id, 'pass')
|
||||
? 'Your whole Truthtrance answer still needs this fresh Semuta exchange.' : null;
|
||||
return {
|
||||
event: pending.batch.event, stage: 'offer' as const, passed,
|
||||
canCommit: held && !passed && !blocked, blocked, candidates: [] as Card[],
|
||||
...(passBlocked ? { passBlocked } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9086,6 +9096,11 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
g.players.map(p => p.id), owner.id, pending.batch.entries.filter(entry => entry.discardedBy !== owner.id), claim!.card));
|
||||
rememberMovementDiscards(g, pending.batch.entries.filter(entry => entry.discardedBy === owner.id)
|
||||
.map(entry => ({ ...entry, publicFace: false })));
|
||||
recordNativePrintedCardPlay(g, owner.id, 'semutaDrug');
|
||||
// Check the completed exchange before its typed suffix can draw, pay or
|
||||
// offer another player's decision. The suffix itself stays authoritative.
|
||||
const quoted = turnActionFreshDiscardTail(g, next);
|
||||
guardTurnActionPrintedCardCost(quoted, getPlayer(quoted, owner.id), result.usedCard);
|
||||
log(g, `${owner.name} played Semuta Drug and recovered one freshly discarded Treachery Card. The acquired card stays private.`,
|
||||
{ faction: owner.faction, name: 'Semuta Drug' });
|
||||
}
|
||||
@@ -9327,6 +9342,9 @@ function applySemutaReaction(g: Game, id: string, action: Action) {
|
||||
if (action.type === 'semutaPass') {
|
||||
requireRule(Object.keys(action).sort().join(',') === 'event,type',
|
||||
'Pass only the current fresh discard.');
|
||||
if (hasTurnActionSemutaStage(g, id))
|
||||
requireRule(!!turnActionSemutaCompletion(g, id, 'pass'),
|
||||
'Retain a legal completion of your whole Truthtrance answer before passing this fresh discard.');
|
||||
if (reaction.passed.length + 1 === g.players.length)
|
||||
finishTreacheryDiscard(g, undefined, id);
|
||||
else reaction.passed.push(id);
|
||||
@@ -9335,6 +9353,11 @@ function applySemutaReaction(g: Game, id: string, action: Action) {
|
||||
requireRule(action.type === 'semutaCommit' &&
|
||||
Object.keys(action).sort().join(',') === 'event,type',
|
||||
'Choose whether to commit Semuta to this discard.');
|
||||
const forbidden = printedRolePlayBlock(g, id, 'semutaDrug');
|
||||
requireRule(!forbidden, forbidden ?? 'Retain your whole Truthtrance answer.');
|
||||
if (hasTurnActionSemutaStage(g, id))
|
||||
requireRule(!!turnActionSemutaCompletion(g, id, 'use'),
|
||||
'This Semuta commitment would remove the last legal completion of your whole Truthtrance answer.');
|
||||
const candidates = semutaCandidates(g, player).map(card => card.id);
|
||||
pending.reaction = { stage: 'select', player: id, candidates };
|
||||
if (candidates.length === 1) finishTreacheryDiscard(g, { player: id, card: candidates[0] });
|
||||
@@ -9347,6 +9370,9 @@ function applySemutaReaction(g: Game, id: string, action: Action) {
|
||||
id === reaction.player && typeof action.card === 'string' &&
|
||||
reaction.candidates.includes(action.card),
|
||||
'Choose one committed Semuta card from this fresh discard.');
|
||||
if (hasTurnActionSemutaStage(g, id))
|
||||
requireRule(!!turnActionSemutaCompletion(g, id, 'use', action.card),
|
||||
'Choose a fresh card that retains a legal completion of your whole Truthtrance answer.');
|
||||
finishTreacheryDiscard(g, { player: id, card: action.card });
|
||||
}
|
||||
function place(p: Player, t: string, s: number, n: number, elite = 0) {
|
||||
@@ -19008,6 +19034,7 @@ type TurnActionCardProbe = Game & {
|
||||
_turnActionPriceCursor?: { order: string[]; index: number };
|
||||
_turnActionShipmentReceipt?: { player: string; event?: { territory: string; amount: number } | null };
|
||||
_turnActionThumperForecast?: true;
|
||||
_turnActionFreshDiscard?: { batch: FreshDiscardBatch; continuation: TruthDiscardContinuation };
|
||||
};
|
||||
function printedCardRule<T>(operation: () => T): T {
|
||||
try { return operation(); }
|
||||
@@ -19037,6 +19064,9 @@ function turnActionCardsMatch(g: Game, actor: string, requirements: readonly Tur
|
||||
}
|
||||
function* nativePrintedCardActions(g: Game, actor: string, effect: PrintedCardEffect): Generator<Action> {
|
||||
const player = getPlayer(g, actor);
|
||||
// Semuta belongs only to its exact fresh-discard window, projected before
|
||||
// ordinary payment/card preparation rather than as a generic card action.
|
||||
if (effect === 'semutaDrug') return;
|
||||
if (effect === 'distrans') {
|
||||
const quote = distransView(g, player);
|
||||
if (quote && !quote.blocked) for (const choice of quote.choices) if (!choice.blocked)
|
||||
@@ -19751,7 +19781,7 @@ function turnActionAllianceCanClose(g: Game, player: Player): boolean {
|
||||
return !!turnActionCompletion(source, player.id);
|
||||
} catch (error) { if (error instanceof RuleError) return false; throw error; }
|
||||
}
|
||||
type TurnActionCompletion = MovementCompletion & { payments: Action[]; paymentsAfterPreparations?: boolean; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[]; battlePlan?: BattleCompletion; traitor?: Action; cardPreparations?: Action[]; battlePower?: Action };
|
||||
type TurnActionCompletion = MovementCompletion & { payments: Action[]; paymentsAfterPreparations?: boolean; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[]; battlePlan?: BattleCompletion; traitor?: Action; cardPreparations?: Action[]; battlePower?: Action; freshDiscard?: Action };
|
||||
/** Forecast deterministic opening work only. Auction/deck draws and foreign
|
||||
* choices stay at the real table; fixed Mentat receipts arrive after Amal. */
|
||||
function projectTurnActionOpeningEnd(g: Game) {
|
||||
@@ -19815,6 +19845,82 @@ function guardTurnActionOpening(g: Game, actor: string, pass: boolean) {
|
||||
/** Whole-answer alternatives stay private. Only detached native rows quote a
|
||||
* route, sharing actual interval progress and the remaining physical purse. */
|
||||
function turnActionCompletion(source: Game, actor: string, unknownConsent?: { value: boolean }): TurnActionCompletion | null {
|
||||
if (hasTurnActionSemutaStage(source, actor) &&
|
||||
(source.pendingTreacheryDiscard?.reaction || (source as TurnActionCardProbe)._turnActionFreshDiscard))
|
||||
return turnActionSemutaCompletion(source, actor, 'either', undefined, unknownConsent);
|
||||
return turnActionCompletionAfterFreshDiscard(source, actor, unknownConsent);
|
||||
}
|
||||
function hasTurnActionSemutaStage(g: Game, actor: string): boolean {
|
||||
return liveTurnActionPromises(g.turnActionPromises ?? [], actor, g.turn)
|
||||
.some(promise => !!promise.cardPlays?.semutaDrug);
|
||||
}
|
||||
/** Remove only this reaction from a detached continuation quote. Native
|
||||
* suffixes, hidden draws, queued questions and opposing choices remain live. */
|
||||
function turnActionFreshDiscardTail(source: Game,
|
||||
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation']): Game {
|
||||
const game = structuredClone(source);
|
||||
game.pendingTreacheryDiscard = null;
|
||||
delete (game as TurnActionCardProbe)._turnActionFreshDiscard;
|
||||
game.truthtrance = null;
|
||||
if ('resume' in continuation) {
|
||||
game.response = structuredClone(continuation.resume.response);
|
||||
game.decision = structuredClone(continuation.resume.decision);
|
||||
game.pendingKarama = structuredClone(continuation.resume.pendingKarama);
|
||||
game.phaseOpening = structuredClone(continuation.resume.phaseOpening);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
/** Fresh use precedes every later witness. An uncommitted hidden face cannot
|
||||
* fund a specific effect: only public cards, or the entitled selector's actual
|
||||
* candidates, are known. One opaque alternative suffices for generic custody. */
|
||||
function turnActionSemutaCompletion(source: Game, actor: string, mode: 'use' | 'pass' | 'either',
|
||||
selected?: string, unknownConsent?: { value: boolean }): TurnActionCompletion | null {
|
||||
const pending = source.pendingTreacheryDiscard, forecast = (source as TurnActionCardProbe)._turnActionFreshDiscard;
|
||||
const batch = pending?.batch ?? forecast?.batch;
|
||||
const continuation = pending?.continuation ?? forecast?.continuation;
|
||||
if (!batch || !continuation) return null;
|
||||
const reaction = pending?.reaction;
|
||||
const selecting = reaction?.stage === 'select';
|
||||
if (selecting && reaction.player !== actor) return null;
|
||||
const owner = getPlayer(source, actor);
|
||||
const tail = turnActionFreshDiscardTail(source, continuation);
|
||||
if (mode !== 'pass' && (!reaction || selecting || !reaction.passed.includes(actor))) {
|
||||
const semuta = owner.hand.find(card => card.id === SEMUTA_DRUG_ID && card.effect === 'semutaDrug');
|
||||
if (semuta && owner.hand.length <= handLimit(owner) && !semutaSpendingBlock(source, owner, semuta) &&
|
||||
!printedRolePlayBlock(source, actor, 'semutaDrug')) {
|
||||
let usedOpaque = false;
|
||||
for (const entry of batch.entries) {
|
||||
if (entry.discardedBy === actor || selected !== undefined && entry.card.id !== selected ||
|
||||
selecting && !reaction.candidates.includes(entry.card.id)) continue;
|
||||
const known = selecting || entry.publicFace;
|
||||
if (!known && usedOpaque) continue;
|
||||
if (!known) usedOpaque = true;
|
||||
try {
|
||||
const result = resolveSemutaDrug({ batch, event: batch.event, turn: source.turn, phase: source.phase,
|
||||
owner: actor, ownerHand: owner.hand, discard: source.discard, semutaId: SEMUTA_DRUG_ID,
|
||||
handLimit: handLimit(owner), incomingReservedSlots: 0, reservedTargetIds: [] }, entry.card.id);
|
||||
const game = structuredClone(tail), player = getPlayer(game, actor);
|
||||
player.hand = result.ownerHand; game.discard = result.discard;
|
||||
if (!known) (game as TurnActionCardProbe)._turnActionUnknownCards = [
|
||||
...((game as TurnActionCardProbe)._turnActionUnknownCards ?? []), result.claimedCard.id,
|
||||
];
|
||||
recordNativePrintedCardPlay(game, actor, 'semutaDrug');
|
||||
setMovementDiscardKnowledge(game, recordMovementClaimKnowledge(game.movementDiscardKnowledge,
|
||||
game.players.map(p => p.id), actor, batch.entries.filter(candidate => candidate.discardedBy !== actor), entry.card.id));
|
||||
const completion = turnActionCompletionAfterFreshDiscard(game, actor, unknownConsent);
|
||||
if (completion) return { ...completion, freshDiscard: selecting
|
||||
? { type: 'semutaSelect', event: batch.event, card: entry.card.id }
|
||||
: { type: 'semutaCommit', event: batch.event } };
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mode === 'use' || selecting) return null;
|
||||
const completion = turnActionCompletionAfterFreshDiscard(tail, actor, unknownConsent);
|
||||
return completion ? { ...completion, ...(reaction?.stage === 'offer' && !reaction.passed.includes(actor)
|
||||
? { freshDiscard: { type: 'semutaPass', event: batch.event } } : {}) } : null;
|
||||
}
|
||||
function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unknownConsent?: { value: boolean }): TurnActionCompletion | null {
|
||||
const records = [
|
||||
...liveTravelPromises(source.travelPromises ?? [], actor, source.turn).map(promise =>
|
||||
() => travelRule(() => travelBranches(promise))),
|
||||
@@ -20060,7 +20166,8 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
g.turn, combat.event, combat.territory, turnActionPlanValues(g, p.id, [claim.mixed])))[0];
|
||||
return promise;
|
||||
}
|
||||
function turnActionAnswerSource(g: Game, player: string, alliance = false, window = false): Game {
|
||||
function turnActionAnswerSource(g: Game, player: string, alliance = false, window = false,
|
||||
fresh?: { claim: TurnActionClaim; answer: boolean }): Game {
|
||||
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;
|
||||
@@ -20074,6 +20181,24 @@ function turnActionAnswerSource(g: Game, player: string, alliance = false, windo
|
||||
// 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 }]);
|
||||
if (fresh) {
|
||||
const continuation: TruthDiscardContinuation = { kind: 'truthtranceDiscard', consumed,
|
||||
historyIndex: g.truthHistory?.length ?? 0,
|
||||
record: { turn: g.turn, phase: g.phase, asker: consumed.player,
|
||||
question: { kind: 'turnActions', target: player, ...fresh.claim }, answer: fresh.answer ? 'yes' : 'no' },
|
||||
remaining: g.truthtrance!.queue.length > 1 ? { stage: 'ask', queue: g.truthtrance!.queue.slice(1),
|
||||
passed: g.truthtrance!.passed, question: null } : null,
|
||||
resume: { response: projected.response, decision: projected.decision,
|
||||
pendingKarama: projected.pendingKarama, phaseOpening: projected.phaseOpening },
|
||||
promise: null, parentSignature: '' };
|
||||
const entries = [{ card, discardedBy: owner.id, publicFace: true }];
|
||||
const admission = { ...projected, truthtrance: null, response: null, decision: null,
|
||||
pendingKarama: null, phaseOpening: null };
|
||||
if (semutaOfferSupported(admission, continuation, entries))
|
||||
(projected as TurnActionCardProbe)._turnActionFreshDiscard = { continuation,
|
||||
batch: { event: 'turn-action:declared-truth-discard', turn: g.turn, phase: g.phase,
|
||||
cause: 'truthtrance', entries } };
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
function turnActionTruthAnswers(g: Game, p: Player, claim: TurnActionClaim): TruthAnswer[] {
|
||||
@@ -20084,7 +20209,7 @@ function turnActionTruthAnswers(g: Game, p: Player, claim: TurnActionClaim): Tru
|
||||
try { promise = turnActionPromise(g, p, claim, answer, 'candidate', g.players.find(other => other.id !== p.id)!.id); }
|
||||
catch (error) { if (error instanceof RuleError) continue; throw error; }
|
||||
if (promise.compiled.kind === 'constant' && promise.compiled.value !== answer) continue;
|
||||
const trial = { ...turnActionAnswerSource(g, p.id, !!promise.alliance, !!promise.windowCards),
|
||||
const trial = { ...turnActionAnswerSource(g, p.id, !!promise.alliance, !!promise.windowCards, { claim, answer }),
|
||||
turnActionPromises: [...(g.turnActionPromises ?? []), promise], travelPromiseOrder: undefined };
|
||||
if (turnActionCompletion(trial, p.id, answer && promise.alliance ? consent : undefined)) answers.push(answer ? 'yes' : 'no');
|
||||
}
|
||||
@@ -20182,6 +20307,14 @@ function projectedTurnActionPromises(g: Game, player: string) {
|
||||
}
|
||||
function projectedTurnActionGuide(g: Game, player: Player): { event: string; action: Action } | null {
|
||||
const promise = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn)[0];
|
||||
if (g.pendingTreacheryDiscard?.reaction) {
|
||||
const row = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn)
|
||||
.find(row => !!row.cardPlays?.semutaDrug);
|
||||
if (!row) return null;
|
||||
const action = turnActionCompletion(g, player.id)?.freshDiscard;
|
||||
return action ? { event: row.event, action: { ...action,
|
||||
turnActionPromiseEvent: row.event, turnActionPromisePhase: g.phase } } : null;
|
||||
}
|
||||
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' &&
|
||||
@@ -38087,6 +38220,14 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
getPlayer(state, id);
|
||||
if (state.pendingTreacheryDiscard.reaction &&
|
||||
['semutaPass', 'semutaCommit', 'semutaSelect'].includes(action?.type)) {
|
||||
if (action.turnActionPromiseEvent !== undefined || action.turnActionPromisePhase !== undefined) {
|
||||
const guide = projectedTurnActionGuide(state, getPlayer(state, id));
|
||||
requireRule(guide && action.turnActionPromisePhase === state.phase && movementGuideActionMatches(action, guide.action),
|
||||
'This turn-action guide is stale or belongs to another fresh discard.');
|
||||
action = { ...action };
|
||||
delete action.turnActionPromiseEvent;
|
||||
delete action.turnActionPromisePhase;
|
||||
}
|
||||
const g = structuredClone(state);
|
||||
applySemutaReaction(g, id, action);
|
||||
treacheryDiscardIntegrity(g);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const PRINTED_CARD_EFFECTS = ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho'] as const;
|
||||
export const PRINTED_CARD_EFFECTS = ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug'] as const;
|
||||
export type PrintedCardEffect = typeof PRINTED_CARD_EFFECTS[number];
|
||||
export type PrintedCardPlayCounts = {
|
||||
turn: number;
|
||||
@@ -55,7 +55,8 @@ export function printedCardPlayCount(counts: PrintedCardPlayCounts | undefined,
|
||||
}
|
||||
|
||||
/** Call once for each actually accepted printed-role play, never for an effect,
|
||||
* sale, discard, recovery or role conversion. Genuine reacquisition may replay. */
|
||||
* sale, discard, recovery or role conversion. Semuta requires its physical
|
||||
* exchange, not commitment or candidate inspection. Genuine reacquisition may replay. */
|
||||
export function recordPrintedCardPlay(counts: PrintedCardPlayCounts | undefined, player: string,
|
||||
turn: number, cardEffect: PrintedCardEffect): PrintedCardPlayCounts {
|
||||
validateRequest(player, turn, cardEffect);
|
||||
|
||||
+4
-3
@@ -3231,6 +3231,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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.',
|
||||
'A printed Semuta Drug clause counts the completed exchange and disposal after answering, not a multi-card inspection commitment. The selected card is not promised. Its No interval lasts through Mentat. Current fresh windows and the already-declared foreign Truth cost retain native event, reservations and physical exchange; no later foreign discard is invented. Immediate No and completion checks precede inspection, and a required Yes cannot pass its sole modelled trigger. OR can preserve a later alternative. Unknown acquisitions supply no specific effect, but can supply Distrans’s generic other-card transfer with native reservations.',
|
||||
'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.',
|
||||
@@ -3246,11 +3247,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 phase-opening Amal with native payment order, printed Thumper with known-encounter quotation, 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: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse phase-opening Amal with native payment order, printed Thumper with known-encounter quotation, Karama shipment pricing and current-lot costs, original-window plays, native power declarations, printed Hajr/Ghola/Box/Distrans/Sapho and completed Semuta exchanges, 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', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-thumper-runtime.test.ts'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed roles, exact fresh Semuta controls 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-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'] },
|
||||
{ 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-semuta-play.ts', 'tests/truth-semuta-play-runtime.test.ts', 'tests/semuta-reaction-controls.test.tsx', '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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -756,6 +756,7 @@ export function truthQuestionText(
|
||||
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';
|
||||
if (action.effect === 'semutaDrug') return 'you complete a printed Semuta Drug exchange and discard Semuta Drug 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`;
|
||||
}
|
||||
|
||||
@@ -698,7 +698,8 @@ export function closeTurnActionBattlePower<T extends TurnActionPromise>(promises
|
||||
}
|
||||
|
||||
/** Accepted printed-role play only. Native callers separately own effect
|
||||
* success, disposal, recovery and real card custody; none count as this event. */
|
||||
* success, disposal, recovery and real card custody; none alone count as this
|
||||
* event. Semuta requires its physical exchange, not commitment or inspection. */
|
||||
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 supported printed-card play.');
|
||||
|
||||
Reference in new issue
Block a user