Connect Truthtrance promises to printed Hajr and Ghola plays
This commit is contained in:
1 parent
f548039cd8
commit
c93b693137
17 files changed
+1554
-104
No files matched your search
@@ -581,7 +581,7 @@ export function Truthtrance({
|
|||||||
<option value="mixedBid">Combine current facts and ordinary bids</option>
|
<option value="mixedBid">Combine current facts and ordinary bids</option>
|
||||||
<option value="traitorCall">Bind this battle’s Traitor declaration</option>
|
<option value="traitorCall">Bind this battle’s Traitor declaration</option>
|
||||||
<option value="mixedTraitorCall">Combine current facts and this Traitor declaration</option>
|
<option value="mixedTraitorCall">Combine current facts and this Traitor declaration</option>
|
||||||
<option value="turnActions">Combine the current battle with this turn’s alliances, bids, Revival, payments, shipment and ground movements</option>
|
<option value="turnActions">Combine Hajr/Ghola plays, the current battle and this turn’s other actions</option>
|
||||||
<option value="travel">Combine this turn’s shipment and ground movements</option>
|
<option value="travel">Combine this turn’s shipment and ground movements</option>
|
||||||
<option value="shipment">
|
<option value="shipment">
|
||||||
Bind a shipment from reserves
|
Bind a shipment from reserves
|
||||||
@@ -602,7 +602,7 @@ export function Truthtrance({
|
|||||||
<>
|
<>
|
||||||
<TurnActionFields value={turnActions} game={g} target={target}
|
<TurnActionFields value={turnActions} game={g} target={target}
|
||||||
disabled={busy || !g.truthTurnActionTargets.includes(target)} onChange={setTurnActions} />
|
disabled={busy || !g.truthTurnActionTargets.includes(target)} onChange={setTurnActions} />
|
||||||
{!g.truthTurnActionTargets.includes(target) && <p className="notice">Choose a player with a supported current battle, Traitor declaration, alliance, ordinary bid, Revival, payment, shipment or ground-movement opportunity this turn.
|
{!g.truthTurnActionTargets.includes(target) && <p className="notice">Choose a player with supported printed card plays, current battle, Traitor declaration, alliance, ordinary bid, Revival, payment, shipment or ground-movement opportunities this turn.
|
||||||
Ordinary payments may remain available after their Shipment and Movement opportunity ends.</p>}
|
Ordinary payments may remain available after their Shipment and Movement opportunity ends.</p>}
|
||||||
<p className="notice" aria-label="Public turn-action question preview">{turnActionsError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
|
<p className="notice" aria-label="Public turn-action question preview">{turnActionsError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const currentBattle = (game: GameView, target: string, kind: 'battlePlan' | 'tra
|
|||||||
const config: MixedActionConfig<TurnActionLeaf> = {
|
const config: MixedActionConfig<TurnActionLeaf> = {
|
||||||
prefix: 'turn-action',
|
prefix: 'turn-action',
|
||||||
legend: 'Current battle and this-turn action conditions, with optional current facts',
|
legend: 'Current battle and this-turn action conditions, with optional current facts',
|
||||||
actionLabel: 'battle, alliance, bid, Revival, payment, shipment or ground-movement condition',
|
actionLabel: 'printed card play, battle, alliance, bid, Revival, payment, shipment or ground-movement condition',
|
||||||
actionTiming: 'Current battle or future native action · this turn',
|
actionTiming: 'Current battle or future native action · this turn',
|
||||||
actionHelp: <>Ordinary payment conditions sum future real bribes to the same recipient after the answer this turn.
|
actionHelp: <>Ordinary payment conditions sum future real bribes to the same recipient after the answer this turn.
|
||||||
Earlier payments, gifts and ally aid do not count. The sender pays immediately; the recipient collects at the next Mentat opening.
|
Earlier payments, gifts and ally aid do not count. The sender pays immediately; the recipient collects at the next Mentat opening.
|
||||||
@@ -56,6 +56,10 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
|||||||
An open plan commitment settles when both plans are revealed, not merely when your own plan is sealed.
|
An open plan commitment settles when both plans are revealed, not merely when your own plan is sealed.
|
||||||
Traitor conditions concern one original Reveal or decline declaration in the current revealed battle.
|
Traitor conditions concern one original Reveal or decline declaration in the current revealed battle.
|
||||||
The declaration counts before a later Karama or betrayal response can prevent its effect.
|
The declaration counts before a later Karama or betrayal response can prevent its effect.
|
||||||
|
Printed-card conditions ask you to play Hajr or Tleilaxu Ghola in its printed role after this answer this turn.
|
||||||
|
An earlier play, discard, sale, Karama role or recovery alone does not count; an actual later replay after Box recovery does.
|
||||||
|
Hajr counts when played even if its extra movement is unused. Tleilaxu Ghola is a card play, separate from ordinary Revival.
|
||||||
|
Hajr conditions close at your own Movement turn end; Tleilaxu Ghola conditions close at the actual turn end.
|
||||||
Shipment conditions concern the single future original reserve shipment after the answer.
|
Shipment conditions concern the single future original reserve shipment after the answer.
|
||||||
Ground-movement conditions use the maximum physical forces in one actual future move to that destination; smaller moves never add together.
|
Ground-movement conditions use the maximum physical forces in one actual future move to that destination; smaller moves never add together.
|
||||||
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
|
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
|
||||||
@@ -93,6 +97,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
|||||||
const kind = event.currentTarget.value;
|
const kind = event.currentTarget.value;
|
||||||
if (kind === value.kind) return;
|
if (kind === value.kind) return;
|
||||||
const minimum = 'minimum' in value ? value.minimum : 1;
|
const minimum = 'minimum' in value ? value.minimum : 1;
|
||||||
|
if (kind === 'cardPlay') onChange({ kind, effect: 'hajr' });
|
||||||
if (kind === 'battlePlan') onChange({ kind, battle: game.battle?.event ?? '', territory: game.battle?.territory ?? '',
|
if (kind === 'battlePlan') onChange({ kind, battle: game.battle?.event ?? '', territory: game.battle?.territory ?? '',
|
||||||
claim: { kind: 'dial', compare: 'eq', value: 0 } });
|
claim: { kind: 'dial', compare: 'eq', value: 0 } });
|
||||||
if (kind === 'traitorCall') onChange({ kind, battle: game.battle?.event ?? '', territory: game.battle?.territory ?? '', call: true });
|
if (kind === 'traitorCall') onChange({ kind, battle: game.battle?.event ?? '', territory: game.battle?.territory ?? '', call: true });
|
||||||
@@ -104,6 +109,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
|||||||
if (kind === 'ship' || kind === 'move') onChange({ kind,
|
if (kind === 'ship' || kind === 'move') onChange({ kind,
|
||||||
territory: value.kind === 'ship' || value.kind === 'move' ? value.territory : 'carthag', minimum });
|
territory: value.kind === 'ship' || value.kind === 'move' ? value.territory : 'carthag', minimum });
|
||||||
}}>
|
}}>
|
||||||
|
<option value="cardPlay">Play Hajr or Tleilaxu Ghola in its printed role</option>
|
||||||
<option value="battlePlan">Use an actual current battle-plan choice</option>
|
<option value="battlePlan">Use an actual current battle-plan choice</option>
|
||||||
<option value="traitorCall">Make the current Traitor declaration</option>
|
<option value="traitorCall">Make the current Traitor declaration</option>
|
||||||
<option value="alliance">End this Nexus allied with a named partner</option>
|
<option value="alliance">End this Nexus allied with a named partner</option>
|
||||||
@@ -114,7 +120,17 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
|||||||
<option value="ship">Ship from reserves</option>
|
<option value="ship">Ship from reserves</option>
|
||||||
<option value="move">Make ground movement</option>
|
<option value="move">Make ground movement</option>
|
||||||
</select>
|
</select>
|
||||||
{value.kind === 'battlePlan' || value.kind === 'traitorCall' ? <>
|
{value.kind === 'cardPlay' ? <>
|
||||||
|
<label htmlFor={`${id}-effect`}>Printed card role after this answer</label>
|
||||||
|
<select id={`${id}-effect`} value={value.effect} disabled={disabled}
|
||||||
|
aria-invalid={value.effect !== 'hajr' && value.effect !== 'ghola'} onChange={event => {
|
||||||
|
const effect = event.currentTarget.value;
|
||||||
|
if (effect === 'hajr' || effect === 'ghola') onChange({ kind: 'cardPlay', effect });
|
||||||
|
}}>
|
||||||
|
<option value="hajr">Play Hajr in its printed role after this answer this turn</option>
|
||||||
|
<option value="ghola">Play Tleilaxu Ghola in its printed role after this answer this turn</option>
|
||||||
|
</select>
|
||||||
|
</> : value.kind === 'battlePlan' || value.kind === 'traitorCall' ? <>
|
||||||
<label htmlFor={`${id}-battle`}>Current battle</label>
|
<label htmlFor={`${id}-battle`}>Current battle</label>
|
||||||
<select id={`${id}-battle`} value={game.battle?.event === value.battle && game.battle.territory === value.territory ? value.battle ?? '' : ''}
|
<select id={`${id}-battle`} value={game.battle?.event === value.battle && game.battle.territory === value.territory ? value.battle ?? '' : ''}
|
||||||
disabled={disabled} aria-invalid={!currentBattle(game, target, value.kind) || game.battle?.event !== value.battle || game.battle?.territory !== value.territory}
|
disabled={disabled} aria-invalid={!currentBattle(game, target, value.kind) || game.battle?.event !== value.battle || game.battle?.territory !== value.territory}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { bribeAction } from '@/game/bribe-options';
|
|||||||
import { alliancePromiseModeSupported } from '@/game/alliance-promises';
|
import { alliancePromiseModeSupported } from '@/game/alliance-promises';
|
||||||
import { nativePromiseScopeSupported } from '@/game/native-promise-scope';
|
import { nativePromiseScopeSupported } from '@/game/native-promise-scope';
|
||||||
import { quoteNexusAlliance } from '@/game/nexus-alliance';
|
import { quoteNexusAlliance } from '@/game/nexus-alliance';
|
||||||
|
import { ordinaryCardAvailability } from '@/game/card-availability';
|
||||||
|
import { gholaForcePreparations } from '@/game/ghola-force-preparation';
|
||||||
import { truthQuestionText, type TruthAnswer } from '@/game/truthtrance';
|
import { truthQuestionText, type TruthAnswer } from '@/game/truthtrance';
|
||||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
@@ -43,8 +45,37 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
|||||||
const nexus = game.alliancePromiseNexus, nexusOpen = game.nexus, nexusSpiceWindow = game.spiceWindow;
|
const nexus = game.alliancePromiseNexus, nexusOpen = game.nexus, nexusSpiceWindow = game.spiceWindow;
|
||||||
const nexusSignature = JSON.stringify({ ...publicAllianceContext(game), ready: game.ready });
|
const nexusSignature = JSON.stringify({ ...publicAllianceContext(game), ready: game.ready });
|
||||||
const box = game.nullentropy, search = box?.search;
|
const box = game.nullentropy, search = box?.search;
|
||||||
|
const cardOwner = action.type === 'card' ? game.players.find(player => player.id === actor) : undefined;
|
||||||
|
const heldCard = cardOwner?.hand?.find(card => card.id === action.card);
|
||||||
|
const cardSignature = heldCard && JSON.stringify({ id: heldCard.id, kind: heldCard.kind, effect: heldCard.effect });
|
||||||
|
const ghola = heldCard?.effect === 'ghola' ? game.ghola : null, gholaSignature = JSON.stringify(ghola);
|
||||||
|
const hajrSource = (current: GameView) => {
|
||||||
|
const player = current.players.find(player => player.id === actor);
|
||||||
|
return JSON.stringify({ moved: player?.moved, movesAllowed: player?.movesAllowed,
|
||||||
|
hajrPlays: player?.hajrPlays, available: player?.hajrAvailable });
|
||||||
|
};
|
||||||
|
const hajrSignature = heldCard?.effect === 'hajr' ? hajrSource(game) : null;
|
||||||
const canAct = () => {
|
const canAct = () => {
|
||||||
const current = props.game, me = current.players.find(player => player.id === actor);
|
const current = props.game, me = current.players.find(player => player.id === actor);
|
||||||
|
const ownCard = action.type === 'card' && !!heldCard && !!me && me === cardOwner &&
|
||||||
|
me.hand?.find(card => card.id === action.card) === heldCard &&
|
||||||
|
JSON.stringify({ id: heldCard.id, kind: heldCard.kind, effect: heldCard.effect }) === cardSignature;
|
||||||
|
const printedHajr = ownCard && !!heldCard && !!me && heldCard.kind === 'special' && heldCard.effect === 'hajr' && action.mode === undefined &&
|
||||||
|
phase === 5 && current.active === actor && me.hajrAvailable === true && hajrSource(current) === hajrSignature &&
|
||||||
|
ordinaryCardAvailability(current, heldCard.id)?.available === true;
|
||||||
|
const gholaSource = ownCard && !!heldCard && heldCard.kind === 'special' && heldCard.effect === 'ghola' && action.mode === undefined &&
|
||||||
|
!!ghola && current.ghola === ghola && JSON.stringify(current.ghola) === gholaSignature &&
|
||||||
|
ghola.available && ghola.cards.includes(heldCard.id);
|
||||||
|
const forceChoices = gholaSource && !!heldCard && !!me && !!ghola && action.leader === undefined && typeof action.amount === 'number' &&
|
||||||
|
Number.isSafeInteger(action.amount) && action.amount >= 1 && action.amount <= 5 &&
|
||||||
|
Number.isSafeInteger(ghola.maxForces) && ghola.maxForces >= 0 && ghola.maxForces <= 5
|
||||||
|
? [...gholaForcePreparations({ cards: [heldCard.id], tanks: me.tanks, eliteTanks: me.elites?.tanks ?? 0,
|
||||||
|
maxForces: ghola.maxForces, eliteRemaining: ghola.eliteRemaining })].filter(choice => choice.amount === action.amount)
|
||||||
|
: [];
|
||||||
|
const printedGhola = gholaSource && !!ghola && (action.leader !== undefined
|
||||||
|
? typeof action.leader === 'string' && (action.leader === 'kwisatz' ? ghola.kwisatz : ghola.leaders.some(leader => leader.id === action.leader))
|
||||||
|
: action.elite === undefined ? forceChoices.length === 1 : forceChoices.some(choice => choice.elite === action.elite));
|
||||||
|
const genericCard = ownCard && !!heldCard && heldCard.effect !== 'ghola' && heldCard.effect !== 'hajr';
|
||||||
const options = current.bribeOptions;
|
const options = current.bribeOptions;
|
||||||
const target = action.type === 'bribe' && typeof action.target === 'string'
|
const target = action.type === 'bribe' && typeof action.target === 'string'
|
||||||
? current.players.find(player => player.id === action.target) : undefined;
|
? current.players.find(player => player.id === action.target) : undefined;
|
||||||
@@ -88,18 +119,18 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
|||||||
JSON.stringify(publicBattleContext(current)) === battleSignature;
|
JSON.stringify(publicBattleContext(current)) === battleSignature;
|
||||||
const planWindow = !!battle && currentBattle && !battle.revealed && [battle.attacker, battle.defender].includes(actor) && !battle.submitted.includes(actor);
|
const planWindow = !!battle && currentBattle && !battle.revealed && [battle.attacker, battle.defender].includes(actor) && !battle.submitted.includes(actor);
|
||||||
const plan = action.type === 'battlePlan' && planWindow && !!fillPlan && props.fillPlan === fillPlan;
|
const plan = action.type === 'battlePlan' && planWindow && !!fillPlan && props.fillPlan === fillPlan;
|
||||||
const battlePreparation = action.type === 'card' && planWindow;
|
const battlePreparation = genericCard && planWindow;
|
||||||
const declaration = action.type === 'traitorCall' && typeof action.call === 'boolean' && !!battle && currentBattle && battle.revealed &&
|
const declaration = action.type === 'traitorCall' && typeof action.call === 'boolean' && !!battle && currentBattle && battle.revealed &&
|
||||||
battle.traitorVoters.includes(actor) && !battle.traitorSubmitted.includes(actor);
|
battle.traitorVoters.includes(actor) && !battle.traitorSubmitted.includes(actor);
|
||||||
const opportunity = payment || paidBox || plan || battlePreparation || declaration || nexusWindow && (ownedAlliance || action.type === 'ready') || (phase === 2
|
const opportunity = payment || paidBox || printedHajr || printedGhola || plan || battlePreparation || declaration || nexusWindow && (ownedAlliance || action.type === 'ready') || (phase === 2
|
||||||
? !current.ready.includes(actor) && ['charity', 'ready', 'card'].includes(action.type)
|
? !current.ready.includes(actor) && (['charity', 'ready'].includes(action.type) || genericCard)
|
||||||
: phase === 3
|
: phase === 3
|
||||||
? invoice || nativePass || requiredBid || action.type === 'passBid' && !current.richeseAuction && currentAuction && current.auction?.active === actor
|
? invoice || nativePass || requiredBid || action.type === 'passBid' && !current.richeseAuction && currentAuction && current.auction?.active === actor
|
||||||
: phase === 4
|
: phase === 4
|
||||||
? !current.ready.includes(actor) && revivalTurn && ['revive', 'reviveLeader', 'reviveKwisatz', 'acceptLeaderRevival', 'ready', 'card'].includes(action.type)
|
? !current.ready.includes(actor) && revivalTurn && (['revive', 'reviveLeader', 'reviveKwisatz', 'acceptLeaderRevival', 'ready'].includes(action.type) || genericCard)
|
||||||
: phase === 5
|
: phase === 5
|
||||||
? current.active === actor && (action.type === 'guildShip' && action.from === 'reserves' ||
|
? current.active === actor && (action.type === 'guildShip' && action.from === 'reserves' ||
|
||||||
['ship', 'move', 'endMovement', 'card', 'prepareAdvisors', 'revealNoField'].includes(action.type))
|
['ship', 'move', 'endMovement', 'prepareAdvisors', 'revealNoField'].includes(action.type) || genericCard)
|
||||||
: (phase === 7 || phase === 8) &&
|
: (phase === 7 || phase === 8) &&
|
||||||
action.type === 'ready' && !current.ready.includes(actor));
|
action.type === 'ready' && !current.ready.includes(actor));
|
||||||
return !props.busy && props.act === act && current === game && nativePromiseScopeSupported(current) && current.status === 'playing' &&
|
return !props.busy && props.act === act && current === game && nativePromiseScopeSupported(current) && current.status === 'playing' &&
|
||||||
@@ -116,8 +147,14 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
|||||||
: typeof action.amount === 'number' ? action.amount : 0;
|
: typeof action.amount === 'number' ? action.amount : 0;
|
||||||
const destination = game.combatLocations.find(location => location.id === action.territory)?.name ?? 'the quoted destination';
|
const destination = game.combatLocations.find(location => location.id === action.territory)?.name ?? 'the quoted destination';
|
||||||
const recipient = game.players.find(player => player.id === action.target)?.name ?? 'the quoted recipient';
|
const recipient = game.players.find(player => player.id === action.target)?.name ?? 'the quoted recipient';
|
||||||
|
const gholaLeader = action.leader === 'kwisatz' ? 'Kwisatz Haderach'
|
||||||
|
: ghola?.leaders.find(leader => leader.id === action.leader)?.name ?? 'the quoted leader';
|
||||||
const sector = typeof action.sector === 'number' ? `, sector ${action.sector}` : '';
|
const sector = typeof action.sector === 'number' ? `, sector ${action.sector}` : '';
|
||||||
const label = action.type === 'bribe' ? `Pay ${amount} spice to ${recipient} while honoring your whole answer`
|
const label = action.type === 'bribe' ? `Pay ${amount} spice to ${recipient} while honoring your whole answer`
|
||||||
|
: action.type === 'card' && heldCard?.effect === 'hajr' ? 'Play Hajr in its printed role while honoring your whole answer'
|
||||||
|
: action.type === 'card' && heldCard?.effect === 'ghola' ? action.leader !== undefined
|
||||||
|
? `Play Tleilaxu Ghola to return ${gholaLeader} while honoring your whole answer`
|
||||||
|
: `Play Tleilaxu Ghola to return ${amount} physical forces while honoring your whole answer`
|
||||||
: action.type === 'battlePlan' ? 'Fill the quoted battle plan for review before sealing'
|
: action.type === 'battlePlan' ? 'Fill the quoted battle plan for review before sealing'
|
||||||
: action.type === 'traitorCall' ? action.call ? 'Reveal traitor while honoring your whole answer' : 'Decline traitor while honoring your whole answer'
|
: action.type === 'traitorCall' ? action.call ? 'Reveal traitor while honoring your whole answer' : 'Decline traitor while honoring your whole answer'
|
||||||
: action.type === 'alliance' ? action.target === null
|
: action.type === 'alliance' ? action.target === null
|
||||||
@@ -188,12 +225,17 @@ export function TurnActionPromises(props: TurnActionProps) {
|
|||||||
Battle conditions describe your actual plan in the named current battle. An open plan condition settles when both plans are revealed;
|
Battle conditions describe your actual plan in the named current battle. An open plan condition settles when both plans are revealed;
|
||||||
sealing your own plan does not finish it. A plan already fixed can decide its condition at the answer.
|
sealing your own plan does not finish it. A plan already fixed can decide its condition at the answer.
|
||||||
Traitor conditions describe your original Reveal or decline declaration, before any later Karama or betrayal prevention.
|
Traitor conditions describe your original Reveal or decline declaration, before any later Karama or betrayal prevention.
|
||||||
|
Printed-card conditions count actual future Hajr or Tleilaxu Ghola plays in their printed role after the answer this turn.
|
||||||
|
Earlier plays, discards, sales, Karama roles and recovery alone do not count; an actual later Box replay does.
|
||||||
|
Hajr counts even if its extra movement is unused and closes at your own Movement turn end.
|
||||||
|
Tleilaxu Ghola is separate from ordinary Revival and remains open until the actual turn ends.
|
||||||
Shipment conditions concern one future original reserve shipment. Ground-movement conditions count the maximum physical forces in one actual future move,
|
Shipment conditions concern one future original reserve shipment. Ground-movement conditions count the maximum physical forces in one actual future move,
|
||||||
never the sum of smaller moves. Earlier shipment and movement do not count.
|
never the sum of smaller moves. Earlier shipment and movement do not count.
|
||||||
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
|
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
|
||||||
Shipment and movement close at your own Movement turn end; ordinary payments remain possible until the actual turn ends.
|
Shipment and movement close at your own Movement turn end; ordinary payments remain possible until the actual turn ends.
|
||||||
|
A known Inflation Double opening can close payments earlier without closing Ghola.
|
||||||
Original costs, timing, cards, preparation and action rights still apply. This claim grants no extra action.</p>
|
Original costs, timing, cards, preparation and action rights still apply. This claim grants no extra action.</p>
|
||||||
{live && <p>{game.phase === 8 ? 'Complete any remaining ordinary payment before the actual turn ends. Late recipients collect next turn.'
|
{live && <p>{game.phase === 8 ? 'Honor remaining Ghola or payment conditions before this turn ends. Late payment recipients collect next turn.'
|
||||||
: game.phase < 5 ? 'Your Movement turn is still ahead. Preserve the needed forces, spice and cards and complete any offered native preparation.'
|
: game.phase < 5 ? 'Your Movement turn is still ahead. Preserve the needed forces, spice and cards and complete any offered native preparation.'
|
||||||
: 'Use the current native controls to honor your whole answer. Payment conditions can remain after your Movement turn ends.'}</p>}
|
: 'Use the current native controls to honor your whole answer. Payment conditions can remain after your Movement turn ends.'}</p>}
|
||||||
{live && <TurnActionGuideButton {...props} />}
|
{live && <TurnActionGuideButton {...props} />}
|
||||||
@@ -226,8 +268,11 @@ export function TurnActionAnswers(props: TurnActionProps) {
|
|||||||
A remaining alliance condition stays open through temporary membership until that close. Neither answer promises another player’s consent.
|
A remaining alliance condition stays open through temporary membership until that close. Neither answer promises another player’s consent.
|
||||||
Current battle-plan conditions use the actual native plan; an open condition settles when both plans are revealed.
|
Current battle-plan conditions use the actual native plan; an open condition settles when both plans are revealed.
|
||||||
Traitor conditions use the original Reveal or decline declaration, before later Karama or betrayal prevention.
|
Traitor conditions use the original Reveal or decline declaration, before later Karama or betrayal prevention.
|
||||||
|
Hajr and Tleilaxu Ghola conditions require actual later printed-role plays, not earlier play, disposal or recovery alone.
|
||||||
|
Hajr closes at your own Movement turn end even if its extra move is unused; Ghola closes at the actual turn end.
|
||||||
Bids close with their lot; Revival closes with your native Revival opportunity.
|
Bids close with their lot; Revival closes with your native Revival opportunity.
|
||||||
Shipment and movement close at your own Movement turn end; payment conditions last until the actual turn ends.
|
Shipment and movement close at your own Movement turn end; payment conditions last until the actual turn ends.
|
||||||
|
A known Inflation Double opening can close payments earlier without closing Ghola.
|
||||||
Original rights, preparation, timing and costs still apply. Only your whole answer is shared.</p>
|
Original rights, preparation, timing and costs still apply. Only your whole answer is shared.</p>
|
||||||
{answers.map(answer => {
|
{answers.map(answer => {
|
||||||
const action: Action = { type: 'truthAnswer', answer };
|
const action: Action = { type: 'truthAnswer', answer };
|
||||||
|
|||||||
@@ -16,6 +16,43 @@ open mode gates.
|
|||||||
|
|
||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
**Next: remaining Basic action families and native timing.**
|
||||||
|
Continue broader card-play and owned battle-power declarations before Advanced
|
||||||
|
and wider interactions. Prototype labels and final acceptance gates remain.
|
||||||
|
|
||||||
|
**Truthtrance / printed Hajr and Ghola plays — 27 October. Prototyped / Partial.**
|
||||||
|
Whole current-turn questions now include actual future printed Hajr/Ghola use,
|
||||||
|
with existing AND/OR/No semantics and frozen facts. Hajr counts even when its
|
||||||
|
extra move is unused and closes at own Movement end; Ghola closes at turn end.
|
||||||
|
Earlier plays, disposal, alternative roles and recovery alone do not count.
|
||||||
|
Ordinary Revival and Ghola share real Tanks/purse but retain separate receipts
|
||||||
|
and quotas. A known Double opening closes payments without closing Ghola.
|
||||||
|
|
||||||
|
Runtime-first feedback fixes shared Revival resources, Advanced Box forecasts
|
||||||
|
that preserved a spent Hajr window, and guide metadata reaching strict native
|
||||||
|
card controls. The supported Advanced source47 actually moves once and plays
|
||||||
|
Hajr before the answer; Box2/recovery must precede a second real move and replay.
|
||||||
|
The Basic Richese source's Semuta composition remains excluded. No played
|
||||||
|
cards, purse, casualties, timing window or progress are installed in a source.
|
||||||
|
|
||||||
|
Evidence covers **22 files,278/278** across source-bound runs: V5 retains234
|
||||||
|
passing cases in twenty unchanged files; V6 reruns both repaired files44/44.
|
||||||
|
The only intervening changes fix two test oracles: guidance wording and the
|
||||||
|
actual one-spice Tleilaxu Ghola income. A per-file manifest verifies unchanged
|
||||||
|
production, fixtures and runtime script. Final types/lint/build pass. Thirty-six
|
||||||
|
new cases include16 native/controlled-token checks; four profiles maintain only
|
||||||
|
minimal legal participation. Seven runtime paths pass with33 GameTable SSR
|
||||||
|
renders,7 actual answer callbacks and17 exact native guide callbacks.
|
||||||
|
|
||||||
|
Proof/manifest: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-printed-proof-v6/`;
|
||||||
|
reused journeys/HTML: adjacent `20261027-truth-printed-runtime-v5/`.
|
||||||
|
Original Hajr19/Advanced23, Ghola forces53, leader102, typed-source58,
|
||||||
|
Advanced replay47 and whole-No force42 prefixes derive from actual arrays.
|
||||||
|
No HTTP/storage boundary changed; no database, reset/deletion, restart or
|
||||||
|
deployed operation is performed. Wider card/power families, Semuta compositions
|
||||||
|
and final browser/deployed/recovery/privacy/custody acceptance remain open.
|
||||||
|
[Contract and scope](TRUTHTRANCE_TURN_ACTIONS.md#printed-hajr-and-ghola-plays--27-october-2026).
|
||||||
|
|
||||||
**Ordinary-auction ten-second bidding — 27 October. Prototyped / Partial.**
|
**Ordinary-auction ten-second bidding — 27 October. Prototyped / Partial.**
|
||||||
New ordinary lots in Basic and Advanced now use trusted server time. Silence
|
New ordinary lots in Basic and Advanced now use trusted server time. Silence
|
||||||
follows the actual native pass; when a binding Truthtrance answer forbids it,
|
follows the actual native pass; when a binding Truthtrance answer forbids it,
|
||||||
|
|||||||
@@ -1,5 +1,40 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 27 October 2026 — Future printed Hajr and Ghola plays
|
||||||
|
|
||||||
|
**Truthtrance / printed Hajr and Ghola plays — 27 October. Prototyped / Partial.**
|
||||||
|
Whole current-turn questions now include actual future printed Hajr/Ghola use,
|
||||||
|
with existing AND/OR/No semantics and frozen facts. Hajr counts even when its
|
||||||
|
extra move is unused and closes at own Movement end; Ghola closes at turn end.
|
||||||
|
Earlier plays, disposal, alternative roles and recovery alone do not count.
|
||||||
|
Ordinary Revival and Ghola share real Tanks/purse but retain separate receipts
|
||||||
|
and quotas. A known Double opening closes payments without closing Ghola.
|
||||||
|
|
||||||
|
Runtime-first feedback fixes shared Revival resources, Advanced Box forecasts
|
||||||
|
that preserved a spent Hajr window, and guide metadata reaching strict native
|
||||||
|
card controls. The supported Advanced source47 actually moves once and plays
|
||||||
|
Hajr before the answer; Box2/recovery must precede a second real move and replay.
|
||||||
|
The Basic Richese source's Semuta composition remains excluded. No played
|
||||||
|
cards, purse, casualties, timing window or progress are installed in a source.
|
||||||
|
|
||||||
|
Evidence covers **22 files,278/278** across source-bound runs: V5 retains234
|
||||||
|
passing cases in twenty unchanged files; V6 reruns both repaired files44/44.
|
||||||
|
The only intervening changes fix two test oracles: guidance wording and the
|
||||||
|
actual one-spice Tleilaxu Ghola income. A per-file manifest verifies unchanged
|
||||||
|
production, fixtures and runtime script. Final types/lint/build pass. Thirty-six
|
||||||
|
new cases include16 native/controlled-token checks; four profiles maintain only
|
||||||
|
minimal legal participation. Seven runtime paths pass with33 GameTable SSR
|
||||||
|
renders,7 actual answer callbacks and17 exact native guide callbacks.
|
||||||
|
|
||||||
|
Proof/manifest: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-printed-proof-v6/`;
|
||||||
|
reused journeys/HTML: adjacent `20261027-truth-printed-runtime-v5/`.
|
||||||
|
Original Hajr19/Advanced23, Ghola forces53, leader102, typed-source58,
|
||||||
|
Advanced replay47 and whole-No force42 prefixes derive from actual arrays.
|
||||||
|
No HTTP/storage boundary changed; no database, reset/deletion, restart or
|
||||||
|
deployed operation is performed. Wider card/power families, Semuta compositions
|
||||||
|
and final browser/deployed/recovery/privacy/custody acceptance remain open.
|
||||||
|
[Contract and scope](TRUTHTRANCE_TURN_ACTIONS.md#printed-hajr-and-ghola-plays--27-october-2026).
|
||||||
|
|
||||||
## 27 October 2026 — Ordinary-auction ten-second deadline
|
## 27 October 2026 — Ordinary-auction ten-second deadline
|
||||||
|
|
||||||
**Ordinary-auction ten-second bidding — 27 October. Prototyped / Partial.**
|
**Ordinary-auction ten-second bidding — 27 October. Prototyped / Partial.**
|
||||||
|
|||||||
@@ -295,3 +295,73 @@ correlations and final rules/recovery/privacy/custody acceptance remain. Full
|
|||||||
AI strategy and calibration stay deferred. Native mode/module preview fences
|
AI strategy and calibration stay deferred. Native mode/module preview fences
|
||||||
remain; this batch performs no database/save/server/reset/restart/deployment
|
remain; this batch performs no database/save/server/reset/restart/deployment
|
||||||
operation and does not certify any complete mode.
|
operation and does not certify any complete mode.
|
||||||
|
|
||||||
|
## Printed Hajr and Ghola plays — 27 October 2026
|
||||||
|
|
||||||
|
**Prototyped / Partial.** Add one atomic named-effect leaf:
|
||||||
|
`{kind:'cardPlay',effect:'hajr'|'ghola'}`. It asks about an actual accepted use in
|
||||||
|
that printed role after this answer during the current turn. Hajr counts even
|
||||||
|
if its extra move remains unused; Ghola card use is distinct from ordinary
|
||||||
|
force or leader Revival. Earlier plays, disposals, sales, role conversions and
|
||||||
|
recovery alone do not count. A legitimate later physical replay can count.
|
||||||
|
This is an explicit composition of the existing current-turn Truth authority
|
||||||
|
and native card contracts, not a new publisher per-card promise ruling.
|
||||||
|
|
||||||
|
Per-answer optional private stages retain played/closed booleans only. Native
|
||||||
|
private counters supply detached witness baselines; earlier counts are never
|
||||||
|
installed as future answer progress. Old raw records remain valid without the
|
||||||
|
new stages. Original raw conditions and aggregate status remain the only own
|
||||||
|
history; physical card IDs and selected completion branches stay private.
|
||||||
|
|
||||||
|
Hajr's remaining opportunity closes at the owner's actual Movement end; Ghola
|
||||||
|
continues through the full current turn. Whole alternatives retain payments
|
||||||
|
and other native deadlines. A known Inflation Double opening closes payments
|
||||||
|
before Mentat without closing a remaining Ghola alternative; Ready quotes only
|
||||||
|
that payment closure. Full turn expiry still closes both families.
|
||||||
|
Basic Hajr uses its original own-turn timing;
|
||||||
|
Advanced must earn its actual immediate window through real completed movement
|
||||||
|
and required arrival decisions. Recovery supplies no extra timing privilege.
|
||||||
|
|
||||||
|
Detached native witnesses share the real purse, Tanks, card cost, leader and
|
||||||
|
movement allowance with ordinary Revival, payments, transport and current plan
|
||||||
|
constraints. No branch is stored in the real ledger. Known owned Box routes
|
||||||
|
can recover a real eligible card; unknown future draws/gifts/casualties are not
|
||||||
|
invented. Broader future preparation/funding and module combinations remain.
|
||||||
|
The current Basic Richese public replay source includes Semuta and remains
|
||||||
|
outside this whole-turn composition's existing envelope; recovery evidence
|
||||||
|
uses an original supported Advanced source instead of removing that marker.
|
||||||
|
Its actual first move and first Hajr precede the answer. The future paid Box
|
||||||
|
must precede a second real movement before Hajr can be replayed: a voluntary
|
||||||
|
Box play closes any earlier immediate Advanced window, including in detached
|
||||||
|
forecasts. Exact guide metadata is checked before passing the original native
|
||||||
|
action to strict card controls; it never becomes a Box selection field.
|
||||||
|
Native accepted printed plays, rather than their later income effects, record
|
||||||
|
receipts. Own post-cost completion is checked before foreign income/discard
|
||||||
|
responses can mask voluntary evasion. Human controls and four minimal profiles
|
||||||
|
receive the same exact native packet. All existing mode, final assurance and
|
||||||
|
AI strategy/calibration gates remain open.
|
||||||
|
|
||||||
|
The frozen source-bound proof covers22 related files and278 passing cases:
|
||||||
|
V5's234 cases in twenty unchanged files plus V6's44/44 in the two repaired
|
||||||
|
files. Six counter, ten aggregate helper, four control and sixteen native/
|
||||||
|
controlled-token cases add36 checks. Final types/lint/build pass. Seven native
|
||||||
|
journeys provide33 complete GameTable SSR renders,7 actual answer callbacks
|
||||||
|
and17 exact guide callbacks. They exercise Basic Hajr/frozen facts/payment,
|
||||||
|
Advanced actual first movement, Ghola/ordinary shared Tanks and purse, Hidar,
|
||||||
|
Advanced typed source selection, paid Advanced replay and whole No through
|
||||||
|
Mentat. The forced Sardaukar return is separately covered by the native test;
|
||||||
|
a minimal guide may choose a legal normal force instead.
|
||||||
|
|
||||||
|
The initial runtime expectation incorrectly demanded an elite guide selection;
|
||||||
|
its native packet can legally choose a normal force. The original Basic replay
|
||||||
|
source is rejected by the existing Semuta fence, so the supported Advanced
|
||||||
|
chronology is used. Targeted feedback repairs the real shared-Tanks, early
|
||||||
|
payment-closure, Box-window forecast and strict native packet issues. Type/lint
|
||||||
|
feedback is repaired. The final two test-oracle fixes change no production or
|
||||||
|
fixture behavior; the per-file manifest preserves the passing runtime evidence.
|
||||||
|
|
||||||
|
Proof/manifest: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-printed-proof-v6/`;
|
||||||
|
reused journeys/HTML: adjacent `20261027-truth-printed-runtime-v5/`.
|
||||||
|
This is local engine/SSR/control evidence. No HTTP/storage boundary changes,
|
||||||
|
server restart, reset/deletion or deployed operation occurs. Full browser,
|
||||||
|
deployed, recovery/privacy/custody and wider-combination acceptance remain.
|
||||||
+274
-76
@@ -1,8 +1,10 @@
|
|||||||
import { AuctionClockError, AUCTION_BID_MS, quoteAuctionClock, viewAuctionClock,
|
import { AuctionClockError, AUCTION_BID_MS, quoteAuctionClock, viewAuctionClock,
|
||||||
type AuctionClock, type AuctionClockView } from './auction-clock';
|
type AuctionClock, type AuctionClockView } from './auction-clock';
|
||||||
|
import { PrintedCardPlayError, printedCardPlayCount, recordPrintedCardPlay,
|
||||||
|
type PrintedCardEffect, type PrintedCardPlayCounts } from './printed-card-play';
|
||||||
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
|
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
|
||||||
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, finishTurnActionTurn,
|
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, closeTurnActionPayments, finishTurnActionTurn,
|
||||||
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival,
|
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival, recordTurnActionCardPlay,
|
||||||
closeTurnActionRevival, settleTurnActionAlliance, recordTurnActionPlan, recordTurnActionTraitor, closeTurnActionCombat,
|
closeTurnActionRevival, settleTurnActionAlliance, recordTurnActionPlan, recordTurnActionTraitor, closeTurnActionCombat,
|
||||||
type TurnActionPlanValue, type TurnActionCombat, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation } from './turn-action-promises';
|
type TurnActionPlanValue, type TurnActionCombat, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation } from './turn-action-promises';
|
||||||
import { TravelPromiseError, liveTravelPromises, travelBranches, recordTravelShipment, recordTravelMovement,
|
import { TravelPromiseError, liveTravelPromises, travelBranches, recordTravelShipment, recordTravelMovement,
|
||||||
@@ -2176,6 +2178,8 @@ export type Game = {
|
|||||||
truthHistory?: TruthRecord[];
|
truthHistory?: TruthRecord[];
|
||||||
travelPromises?: TravelPromise[];
|
travelPromises?: TravelPromise[];
|
||||||
turnActionPromises?: TurnActionPromise[];
|
turnActionPromises?: TurnActionPromise[];
|
||||||
|
/** Actual accepted printed-role uses; old games need no baseline retrofit. */
|
||||||
|
printedCardPlays?: PrintedCardPlayCounts;
|
||||||
/** Accepted order shared by phase-five travel, shipment and movement answers. */
|
/** Accepted order shared by phase-five travel, shipment and movement answers. */
|
||||||
travelPromiseOrder?: { kind: 'shipment' | 'movement' | 'movementExpression' | 'travel' | 'bribe' | 'bribeExpression' | 'turnActions'; index: number }[];
|
travelPromiseOrder?: { kind: 'shipment' | 'movement' | 'movementExpression' | 'travel' | 'bribe' | 'bribeExpression' | 'turnActions'; index: number }[];
|
||||||
mixedTraitorQuestions?: (MixedTraitorClaim & {
|
mixedTraitorQuestions?: (MixedTraitorClaim & {
|
||||||
@@ -18804,7 +18808,12 @@ function projectMovementBoxRecovery(source: Game, actor: string, box: string, se
|
|||||||
try {
|
try {
|
||||||
if (paid) requireRule(game.pendingNullentropy?.player === actor && !nullentropyIntegrity(game),
|
if (paid) requireRule(game.pendingNullentropy?.player === actor && !nullentropyIntegrity(game),
|
||||||
'Retain the original paid search.');
|
'Retain the original paid search.');
|
||||||
else { validateNullentropyBegin(game, owner, box); owner.spice -= 2; }
|
else {
|
||||||
|
validateNullentropyBegin(game, owner, box); owner.spice -= 2;
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
const context = nullentropyContext(game), player = getPlayer(context, actor);
|
const context = nullentropyContext(game), player = getPlayer(context, actor);
|
||||||
let result: ReturnType<typeof resolveNullentropyBox>;
|
let result: ReturnType<typeof resolveNullentropyBox>;
|
||||||
try { result = resolveNullentropyBox(player.hand, box, context.discard, selected,
|
try { result = resolveNullentropyBox(player.hand, box, context.discard, selected,
|
||||||
@@ -18825,6 +18834,91 @@ function turnActionRule<T>(operation: () => T): T {
|
|||||||
try { return operation(); }
|
try { return operation(); }
|
||||||
catch (error) { if (error instanceof TurnActionError) throw new RuleError(error.message); throw error; }
|
catch (error) { if (error instanceof TurnActionError) throw new RuleError(error.message); throw error; }
|
||||||
}
|
}
|
||||||
|
type TurnActionCardRequirement = { effect: PrintedCardEffect; answer: boolean; baseline: number };
|
||||||
|
/** This field exists only on detached completion clones and is never returned
|
||||||
|
* as a real game or installed as a selected whole-answer branch. */
|
||||||
|
type TurnActionCardProbe = Game & { _turnActionCardPolicy?: { player: string; forbidden: PrintedCardEffect[] } };
|
||||||
|
function printedCardRule<T>(operation: () => T): T {
|
||||||
|
try { return operation(); }
|
||||||
|
catch (error) { if (error instanceof PrintedCardPlayError) throw new RuleError(error.message); throw error; }
|
||||||
|
}
|
||||||
|
function nativePrintedCardCount(g: Game, player: string, effect: PrintedCardEffect): number {
|
||||||
|
return printedCardRule(() => printedCardPlayCount(g.printedCardPlays, player, g.turn, effect));
|
||||||
|
}
|
||||||
|
function recordNativePrintedCardPlay(g: Game, player: string, effect: PrintedCardEffect) {
|
||||||
|
const policy = (g as TurnActionCardProbe)._turnActionCardPolicy;
|
||||||
|
requireRule(policy?.player !== player || !policy.forbidden.includes(effect),
|
||||||
|
'Retain a native completion without this forbidden printed-role card play.');
|
||||||
|
g.printedCardPlays = printedCardRule(() => recordPrintedCardPlay(g.printedCardPlays, player, g.turn, effect));
|
||||||
|
if (g.turnActionPromises) g.turnActionPromises = turnActionRule(() =>
|
||||||
|
recordTurnActionCardPlay(g.turnActionPromises!, player, g.turn, effect));
|
||||||
|
}
|
||||||
|
function turnActionCardsMatch(g: Game, actor: string, requirements: readonly TurnActionCardRequirement[]): boolean {
|
||||||
|
return requirements.every(required => (nativePrintedCardCount(g, actor, required.effect) > required.baseline) === required.answer);
|
||||||
|
}
|
||||||
|
function* nativePrintedCardActions(g: Game, actor: string, effect: PrintedCardEffect): Generator<Action> {
|
||||||
|
const player = getPlayer(g, actor);
|
||||||
|
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)))
|
||||||
|
yield { type: 'card', card: card.id };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ghola = gholaOptions(g, player);
|
||||||
|
if (!ghola.available) return;
|
||||||
|
for (const card of ghola.cards) {
|
||||||
|
for (const leader of ghola.leaders) yield { type: 'card', card, leader: leader.id };
|
||||||
|
if (ghola.kwisatz) yield { type: 'card', card, leader: 'kwisatz' };
|
||||||
|
}
|
||||||
|
yield* gholaForcePreparations({ cards: ghola.cards, tanks: player.tanks,
|
||||||
|
eliteTanks: player.elites?.tanks ?? 0, maxForces: ghola.maxForces, eliteRemaining: ghola.eliteRemaining });
|
||||||
|
}
|
||||||
|
/** 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 player = getPlayer(source, actor);
|
||||||
|
const plays = function* (game: Game, prefix: Action[]): Generator<{ game: Game; actions: Action[] }> {
|
||||||
|
for (const action of nativePrintedCardActions(game, actor, 'ghola')) {
|
||||||
|
try {
|
||||||
|
const played = applyActionInner(game, actor, action, 'movementPreparation');
|
||||||
|
const owner = getPlayer(played, actor);
|
||||||
|
owner.spice += pendingBattleRevivalIncome(played, owner);
|
||||||
|
yield { game: groundMovementProbe(played, actor), actions: [...prefix, action] };
|
||||||
|
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
yield* plays(source, []);
|
||||||
|
const paid = source.pendingNullentropy;
|
||||||
|
if (paid?.player === actor) {
|
||||||
|
if (!nullentropyIntegrity(source)) for (const card of eligibleNullentropyCards(source.discard).filter(card => card.effect === 'ghola')) {
|
||||||
|
const projected = projectMovementBoxRecovery(source, actor, paid.box, card.id, true);
|
||||||
|
if (projected) yield* plays(projected, [{ type: 'decision', event: paid.event, card: card.id }]);
|
||||||
|
}
|
||||||
|
} else for (const box of player.hand.filter(card => card.effect === 'nullentropyBox'))
|
||||||
|
for (const known of knownMovementDiscards(source.movementDiscardKnowledge, actor, source.discard).filter(card => card.effect === 'ghola')) {
|
||||||
|
const projected = projectMovementBoxRecovery(source, actor, box.id, known.id, false);
|
||||||
|
if (projected) yield* plays(projected, [{ type: 'card', card: box.id }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Check the actual spent card and returned pieces before a later foreign
|
||||||
|
* income/discard response can hide an own voluntary loss of feasibility. */
|
||||||
|
function guardTurnActionPrintedCardCost(g: Game, player: Player, used: Card) {
|
||||||
|
if (!liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).length) return;
|
||||||
|
const trial = structuredClone(g), owner = getPlayer(trial, player.id);
|
||||||
|
owner.spice += pendingBattleRevivalIncome(trial, owner);
|
||||||
|
trial.truthtrance = null; trial.response = null; trial.pendingKarama = 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;
|
||||||
|
rememberMovementDiscards(trial, [{ card: used, discardedBy: player.id, publicFace: true }]);
|
||||||
|
requireRule(!!turnActionCompletion(trial, player.id),
|
||||||
|
'This printed card play would remove the last legal completion of your whole Truthtrance answer.');
|
||||||
|
}
|
||||||
function turnActionTargetAvailable(g: Game, _actor: string): boolean {
|
function turnActionTargetAvailable(g: Game, _actor: string): boolean {
|
||||||
return bribeQuestionWindow(g) && movementPromiseModeSupported(g);
|
return bribeQuestionWindow(g) && movementPromiseModeSupported(g);
|
||||||
}
|
}
|
||||||
@@ -18991,7 +19085,7 @@ function turnActionAllianceCanClose(g: Game, player: Player): boolean {
|
|||||||
return !!turnActionCompletion(source, player.id);
|
return !!turnActionCompletion(source, player.id);
|
||||||
} catch (error) { if (error instanceof RuleError) return false; throw error; }
|
} catch (error) { if (error instanceof RuleError) return false; throw error; }
|
||||||
}
|
}
|
||||||
type TurnActionCompletion = MovementCompletion & { payments: Action[]; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[]; battlePlan?: BattleCompletion; traitor?: Action };
|
type TurnActionCompletion = MovementCompletion & { payments: Action[]; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[]; battlePlan?: BattleCompletion; traitor?: Action; cardPreparations?: Action[] };
|
||||||
/** Whole-answer alternatives stay private. Only detached native rows quote a
|
/** Whole-answer alternatives stay private. Only detached native rows quote a
|
||||||
* route, sharing actual interval progress and the remaining physical purse. */
|
* route, sharing actual interval progress and the remaining physical purse. */
|
||||||
function turnActionCompletion(source: Game, actor: string, unknownConsent?: { value: boolean }): TurnActionCompletion | null {
|
function turnActionCompletion(source: Game, actor: string, unknownConsent?: { value: boolean }): TurnActionCompletion | null {
|
||||||
@@ -19015,6 +19109,11 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
|
|||||||
delete trial.travelPromises;
|
delete trial.travelPromises;
|
||||||
delete trial.travelPromiseOrder;
|
delete trial.travelPromiseOrder;
|
||||||
const owner = getPlayer(trial, actor);
|
const owner = getPlayer(trial, actor);
|
||||||
|
const cardRequirements: TurnActionCardRequirement[] = leaves.flatMap(leaf => leaf.kind === 'cardPlay'
|
||||||
|
? [{ effect: leaf.effect, answer: leaf.answer, baseline: nativePrintedCardCount(source, actor, leaf.effect) }] : []);
|
||||||
|
if (cardRequirements.length) (trial as TurnActionCardProbe)._turnActionCardPolicy = {
|
||||||
|
player: actor, forbidden: cardRequirements.filter(required => !required.answer).map(required => required.effect),
|
||||||
|
};
|
||||||
trial.bribePromises ??= [];
|
trial.bribePromises ??= [];
|
||||||
trial.shipmentPromises ??= [];
|
trial.shipmentPromises ??= [];
|
||||||
trial.movementPromises ??= [];
|
trial.movementPromises ??= [];
|
||||||
@@ -19071,70 +19170,79 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
|
|||||||
required.some(payment => payment.recipient === actor || payment.recipient === owner.ally ||
|
required.some(payment => payment.recipient === actor || payment.recipient === owner.ally ||
|
||||||
!trial.players.some(player => player.id === payment.recipient))) continue;
|
!trial.players.some(player => player.id === payment.recipient))) continue;
|
||||||
for (const alliancePlan of alliancePlans) {
|
for (const alliancePlan of alliancePlans) {
|
||||||
const funded = structuredClone(trial), payer = getPlayer(funded, actor);
|
const paid = structuredClone(trial), paidOwner = getPlayer(paid, actor);
|
||||||
try {
|
try {
|
||||||
for (const payment of required) recordBribePayment(funded, actor, payment.recipient, payment.amount);
|
for (const payment of required) recordBribePayment(paid, actor, payment.recipient, payment.amount);
|
||||||
payer.spice -= total;
|
paidOwner.spice -= total;
|
||||||
for (const action of alliancePlan?.steps ?? []) {
|
for (const action of alliancePlan?.steps ?? []) {
|
||||||
const quote = normalNexusAllianceQuote(funded, getPlayer(funded, action.player), action.target);
|
const quote = normalNexusAllianceQuote(paid, getPlayer(paid, action.player), action.target);
|
||||||
for (const player of funded.players) player.ally = quote.allies[player.id];
|
for (const player of paid.players) player.ally = quote.allies[player.id];
|
||||||
funded.allianceOffers = quote.offers;
|
paid.allianceOffers = quote.offers;
|
||||||
if (quote.formed) for (const id of quote.formed) getPlayer(funded, id).allySinceTurn = funded.turn;
|
if (quote.formed) for (const id of quote.formed) getPlayer(paid, id).allySinceTurn = paid.turn;
|
||||||
funded.ready = [];
|
paid.ready = [];
|
||||||
}
|
}
|
||||||
if (alliancePlan) {
|
if (alliancePlan) {
|
||||||
const opportunity = allianceOpportunity(funded)!;
|
const opportunity = allianceOpportunity(paid)!;
|
||||||
funded.alliancePromises = (funded.alliancePromises ?? []).map(promise => promise.player === actor &&
|
paid.alliancePromises = (paid.alliancePromises ?? []).map(promise => promise.player === actor &&
|
||||||
promise.turn === opportunity.turn && promise.source === opportunity.source && !promise.released
|
promise.turn === opportunity.turn && promise.source === opportunity.source && !promise.released
|
||||||
? { ...promise, fulfilled: true } : promise);
|
? { ...promise, fulfilled: true } : promise);
|
||||||
funded.allianceExpressionPromises = (funded.allianceExpressionPromises ?? []).map(promise => promise.player === actor &&
|
paid.allianceExpressionPromises = (paid.allianceExpressionPromises ?? []).map(promise => promise.player === actor &&
|
||||||
promise.turn === opportunity.turn && promise.source === opportunity.source && !promise.released
|
promise.turn === opportunity.turn && promise.source === opportunity.source && !promise.released
|
||||||
? { ...promise, fulfilled: true } : promise);
|
? { ...promise, fulfilled: true } : promise);
|
||||||
}
|
}
|
||||||
const voluntary = { actor, action: { type: 'bribe' } };
|
const voluntary = { actor, action: { type: 'bribe' } };
|
||||||
for (const payment of required)
|
for (const payment of required)
|
||||||
checkBureaucratContributors(funded, payment.recipient, [{ payer: actor, amount: payment.amount }]);
|
checkBureaucratContributors(paid, payment.recipient, [{ payer: actor, amount: payment.amount }]);
|
||||||
reconcileBattlePromises(funded, voluntary);
|
for (const cardSeed of turnActionCardSeeds(paid, actor, cardRequirements)) {
|
||||||
const battle = funded.battle;
|
const funded = cardSeed.game, payer = getPlayer(funded, actor);
|
||||||
const needsPlan = !!battle && !battle.revealed && [battle.attacker, battle.defender].includes(actor) &&
|
try {
|
||||||
!!battle.truthPromises?.some(promise => promise.player === actor && !promise.released);
|
reconcileBattlePromises(funded, voluntary);
|
||||||
const battlePlan = needsPlan ? findReachableBattlePlan(funded, payer) : undefined;
|
const battle = funded.battle;
|
||||||
if (needsPlan && !battlePlan) continue;
|
const needsPlan = !!battle && !battle.revealed && [battle.attacker, battle.defender].includes(actor) &&
|
||||||
const traitorContext = traitorTruthContext(funded, actor);
|
!!battle.truthPromises?.some(promise => promise.player === actor && !promise.released);
|
||||||
const needsTraitor = requestedTraitor || !!battle?.traitorDecisionPromises?.some(promise =>
|
const battlePlan = needsPlan ? findReachableBattlePlan(funded, payer, { cardPlays: cardRequirements }) : undefined;
|
||||||
promise.player === actor && !promise.fulfilled && !promise.released);
|
if (needsPlan && !battlePlan) continue;
|
||||||
let traitor: Action | undefined;
|
const traitorContext = traitorTruthContext(funded, actor);
|
||||||
if (needsTraitor) {
|
const needsTraitor = requestedTraitor || !!battle?.traitorDecisionPromises?.some(promise =>
|
||||||
if (!traitorContext || traitorContext.submitted !== undefined) continue;
|
promise.player === actor && !promise.fulfilled && !promise.released);
|
||||||
const choices = traitorDecisionRule(() => traitorDecisionChoices(traitorContext, battle?.traitorDecisionPromises ?? []));
|
let traitor: Action | undefined;
|
||||||
if (!choices.length) continue;
|
if (needsTraitor) {
|
||||||
traitor = { type: 'traitorCall', call: choices[0] };
|
if (!traitorContext || traitorContext.submitted !== undefined) continue;
|
||||||
}
|
const choices = traitorDecisionRule(() => traitorDecisionChoices(traitorContext, battle?.traitorDecisionPromises ?? []));
|
||||||
reconcileBidPromises(funded, voluntary);
|
if (!choices.length) continue;
|
||||||
reconcileRevivalPromises(funded, voluntary);
|
traitor = { type: 'traitorCall', call: choices[0] };
|
||||||
const lot = bidOpportunity(funded);
|
}
|
||||||
const needsBid = !!lot && bidRequired(funded.bidPromises ?? [], actor, lot);
|
reconcileBidPromises(funded, voluntary);
|
||||||
const bid = needsBid ? bidPromiseCompletion(funded, payer) : undefined;
|
reconcileRevivalPromises(funded, voluntary);
|
||||||
if (needsBid && !bid) continue;
|
const lot = bidOpportunity(funded);
|
||||||
const needsRevival = [
|
const needsBid = !!lot && bidRequired(funded.bidPromises ?? [], actor, lot);
|
||||||
...liveRevivalPromises(funded.revivalPromises ?? [], actor, source.turn),
|
const bid = needsBid ? bidPromiseCompletion(funded, payer) : undefined;
|
||||||
...liveLeaderRevivalPromises(funded.leaderRevivalPromises ?? [], actor, source.turn),
|
if (needsBid && !bid) continue;
|
||||||
...liveRevivalExpressionPromises(funded.revivalExpressionPromises ?? [], actor, source.turn),
|
const needsRevival = [
|
||||||
].some(promise => promise.answer);
|
...liveRevivalPromises(funded.revivalPromises ?? [], actor, source.turn),
|
||||||
const revival = needsRevival ? revivalPromisePlan(funded, payer) : undefined;
|
...liveLeaderRevivalPromises(funded.leaderRevivalPromises ?? [], actor, source.turn),
|
||||||
if (needsRevival && !revival) continue;
|
...liveRevivalExpressionPromises(funded.revivalExpressionPromises ?? [], actor, source.turn),
|
||||||
const transport = [
|
].some(promise => promise.answer);
|
||||||
...liveShipmentPromises(funded.shipmentPromises ?? [], actor, source.turn),
|
const revival = needsRevival ? revivalPromisePlan(funded, payer) : undefined;
|
||||||
...liveMovementPromises(funded.movementPromises ?? [], actor, source.turn),
|
if (needsRevival && !revival) continue;
|
||||||
...liveMovementExpressionPromises(funded.movementExpressionPromises ?? [], actor, source.turn),
|
const transport = [
|
||||||
];
|
...liveShipmentPromises(funded.shipmentPromises ?? [], actor, source.turn),
|
||||||
const completion = transport.length ? groundMovementCompletionNative(funded, actor) : { actions: [] };
|
...liveMovementPromises(funded.movementPromises ?? [], actor, source.turn),
|
||||||
if (completion) {
|
...liveMovementExpressionPromises(funded.movementExpressionPromises ?? [], actor, source.turn),
|
||||||
if (alliancePlan?.uncertain) { unknownConsent!.value = true; continue; }
|
];
|
||||||
return { ...completion, ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
|
const cardsPending = !turnActionCardsMatch(funded, actor, cardRequirements);
|
||||||
...(battlePlan ? { battlePlan } : {}), ...(traitor ? { traitor } : {}),
|
const forecastCards = cardsPending && source.phase <= 5 && turnActionTransportOpen(source, actor);
|
||||||
...(alliancePlan ? { alliance: alliancePlan.steps.map(step => ({ type: 'alliance', target: step.target })) } : {}),
|
const completion = transport.length || forecastCards
|
||||||
payments: required.map(payment => ({ type: 'bribe', target: payment.recipient, amount: payment.amount })) };
|
? groundMovementCompletionNative(funded, actor, undefined, undefined, cardRequirements)
|
||||||
|
: !cardsPending || battlePlan?.cardPlaysComplete ? { actions: [] } : null;
|
||||||
|
if (completion) {
|
||||||
|
if (alliancePlan?.uncertain) { unknownConsent!.value = true; continue; }
|
||||||
|
return { ...completion, ...(cardSeed.actions.length ? { cardPreparations: cardSeed.actions } : {}), ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
|
||||||
|
...(battlePlan ? { battlePlan } : {}), ...(traitor ? { traitor } : {}),
|
||||||
|
...(alliancePlan ? { alliance: alliancePlan.steps.map(step => ({ type: 'alliance', target: step.target })) } : {}),
|
||||||
|
payments: required.map(payment => ({ type: 'bribe', target: payment.recipient, amount: payment.amount })) };
|
||||||
|
}
|
||||||
|
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||||
}
|
}
|
||||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||||
}
|
}
|
||||||
@@ -19148,13 +19256,15 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
|||||||
const compiled = compileMixedActionExpression(claim.mixed,
|
const compiled = compileMixedActionExpression(claim.mixed,
|
||||||
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
|
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
|
||||||
const open = turnActionTransportOpen(g, p.id);
|
const open = turnActionTransportOpen(g, p.id);
|
||||||
let bid = false, revival = false, alliance = false, plan = false, traitor = false;
|
let bid = false, revival = false, alliance = false, plan = false, traitor = false, cardHajr = false, cardGhola = false;
|
||||||
const visit = (node: TurnActionClaim['mixed']): void => {
|
const visit = (node: TurnActionClaim['mixed']): void => {
|
||||||
if (node.kind === 'action') {
|
if (node.kind === 'action') {
|
||||||
if (node.action.kind === 'bid') bid = true;
|
if (node.action.kind === 'bid') bid = true;
|
||||||
if (node.action.kind === 'alliance') alliance = true;
|
if (node.action.kind === 'alliance') alliance = true;
|
||||||
if (node.action.kind === 'battlePlan') plan = true;
|
if (node.action.kind === 'battlePlan') plan = true;
|
||||||
if (node.action.kind === 'traitorCall') traitor = true;
|
if (node.action.kind === 'traitorCall') traitor = true;
|
||||||
|
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 === 'forces' || node.action.kind === 'leader') revival = true;
|
if (node.action.kind === 'forces' || node.action.kind === 'leader') revival = true;
|
||||||
} else if (node.kind !== 'fact') node.terms.forEach(visit);
|
} else if (node.kind !== 'fact') node.terms.forEach(visit);
|
||||||
};
|
};
|
||||||
@@ -19177,6 +19287,8 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
|||||||
traitor: context ? { beneficiary: context.beneficiary, target: context.target, leader: context.leader,
|
traitor: context ? { beneficiary: context.beneficiary, target: context.target, leader: context.leader,
|
||||||
closed: false, call: null } : null } : undefined;
|
closed: false, call: null } : null } : undefined;
|
||||||
let promise: TurnActionPromise = { turn: claim.turn, mixed: structuredClone(claim.mixed), event, player: p.id, asker, answer, compiled,
|
let promise: TurnActionPromise = { turn: claim.turn, mixed: structuredClone(claim.mixed), event, player: p.id, asker, answer, compiled,
|
||||||
|
...(cardHajr || cardGhola ? { cardPlays: { hajr: cardHajr ? { played: false, closed: !open } : null,
|
||||||
|
ghola: cardGhola ? { played: false, closed: false } : null } } : {}),
|
||||||
...(combat ? { combat } : {}),
|
...(combat ? { combat } : {}),
|
||||||
...(preparation ? { preparation } : {}),
|
...(preparation ? { preparation } : {}),
|
||||||
...(nexus ? { alliance: { source: nexus.source, partner: null, closed: false } } : {}),
|
...(nexus ? { alliance: { source: nexus.source, partner: null, closed: false } } : {}),
|
||||||
@@ -19303,6 +19415,7 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
|
|||||||
const completion = turnActionCompletion(g, player.id);
|
const completion = turnActionCompletion(g, player.id);
|
||||||
if (!completion) return null;
|
if (!completion) return null;
|
||||||
let action = !bribeTimingBlock(g) ? completion.payments[0] : undefined;
|
let action = !bribeTimingBlock(g) ? completion.payments[0] : undefined;
|
||||||
|
if (!action) action = completion.cardPreparations?.[0];
|
||||||
if (!action && g.phase <= 5) {
|
if (!action && g.phase <= 5) {
|
||||||
const lot = g.phase === 3 && !g.decision ? bidOpportunity(g) : null;
|
const lot = g.phase === 3 && !g.decision ? bidOpportunity(g) : null;
|
||||||
if (lot && g.auction?.active === player.id) action = completion.bid ??
|
if (lot && g.auction?.active === player.id) action = completion.bid ??
|
||||||
@@ -19526,12 +19639,13 @@ function groundMovementCompletion(source: Game, actor: string,
|
|||||||
}
|
}
|
||||||
function groundMovementCompletionNative(source: Game, actor: string,
|
function groundMovementCompletionNative(source: Game, actor: string,
|
||||||
promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn),
|
promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn),
|
||||||
expressions = liveMovementExpressionPromises(source.movementExpressionPromises ?? [], actor, source.turn)): MovementCompletion | null {
|
expressions = liveMovementExpressionPromises(source.movementExpressionPromises ?? [], actor, source.turn),
|
||||||
|
cardPlays: readonly TurnActionCardRequirement[] = []): MovementCompletion | null {
|
||||||
if (!movementPromiseModeSupported(source) || source.phase < 0 || source.phase > 5) return null;
|
if (!movementPromiseModeSupported(source) || source.phase < 0 || source.phase > 5) return null;
|
||||||
const game = groundMovementProbe(source, actor);
|
const game = groundMovementProbe(source, actor);
|
||||||
if (source.phase === 5 && source.phaseOpening?.initialize) {
|
if (source.phase === 5 && source.phaseOpening?.initialize) {
|
||||||
resetGroundMovementForecast(game, actor);
|
resetGroundMovementForecast(game, actor);
|
||||||
return groundMovementCompletionAtMovement(game, actor, promises, expressions);
|
return groundMovementCompletionAtMovement(game, actor, promises, expressions, cardPlays);
|
||||||
}
|
}
|
||||||
if (source.phase < 5) {
|
if (source.phase < 5) {
|
||||||
const paid = source.pendingNullentropy;
|
const paid = source.pendingNullentropy;
|
||||||
@@ -19539,20 +19653,22 @@ function groundMovementCompletionNative(source: Game, actor: string,
|
|||||||
if (nullentropyIntegrity(source)) return null;
|
if (nullentropyIntegrity(source)) return null;
|
||||||
for (const card of eligibleNullentropyCards(source.discard)) {
|
for (const card of eligibleNullentropyCards(source.discard)) {
|
||||||
const projected = projectMovementBoxRecovery(source, actor, paid.box, card.id, true);
|
const projected = projectMovementBoxRecovery(source, actor, paid.box, card.id, true);
|
||||||
const completion = projected && groundMovementCompletion(projected, actor, promises, expressions);
|
const completion = projected && (cardPlays.length
|
||||||
|
? groundMovementCompletionNative(projected, actor, promises, expressions, cardPlays)
|
||||||
|
: groundMovementCompletion(projected, actor, promises, expressions));
|
||||||
if (completion) return { ...completion,
|
if (completion) return { ...completion,
|
||||||
actions: [{ type: 'decision', event: paid.event, card: card.id }, ...completion.actions] };
|
actions: [{ type: 'decision', event: paid.event, card: card.id }, ...completion.actions] };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
for (const seed of earlyShipmentCompletionSeeds(game, source, actor)) {
|
for (const seed of earlyShipmentCompletionSeeds(game, source, actor, cardPlays.length > 0)) {
|
||||||
resetGroundMovementForecast(seed.game, actor);
|
resetGroundMovementForecast(seed.game, actor);
|
||||||
const completion = groundMovementCompletionAtMovement(seed.game, actor, promises, expressions);
|
const completion = groundMovementCompletionAtMovement(seed.game, actor, promises, expressions, cardPlays);
|
||||||
if (completion) return { actions: [...seed.actions, ...completion.actions], early: seed.early };
|
if (completion) return { actions: [...seed.actions, ...completion.actions], early: seed.early };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return groundMovementCompletionAtMovement(game, actor, promises, expressions);
|
return groundMovementCompletionAtMovement(game, actor, promises, expressions, cardPlays);
|
||||||
}
|
}
|
||||||
function resetGroundMovementForecast(game: Game, actor: string) {
|
function resetGroundMovementForecast(game: Game, actor: string) {
|
||||||
resetPlayerMovement(getPlayer(game, actor));
|
resetPlayerMovement(getPlayer(game, actor));
|
||||||
@@ -19561,14 +19677,35 @@ function resetGroundMovementForecast(game: Game, actor: string) {
|
|||||||
game.movementRemaining = [actor];
|
game.movementRemaining = [actor];
|
||||||
}
|
}
|
||||||
function groundMovementCompletionAtMovement(game: Game, actor: string, promises: readonly MovementPromise[],
|
function groundMovementCompletionAtMovement(game: Game, actor: string, promises: readonly MovementPromise[],
|
||||||
expressions: readonly MovementExpressionPromise[]): MovementCompletion | null {
|
expressions: readonly MovementExpressionPromise[], cardPlays: readonly TurnActionCardRequirement[] = []): MovementCompletion | null {
|
||||||
if (!movementPromiseTargetAvailable(game.pendingNullentropy?.player === actor ? { ...game, decision: null } : game, actor)) return null;
|
if (!movementPromiseTargetAvailable(game.pendingNullentropy?.player === actor ? { ...game, decision: null } : game, actor)) return null;
|
||||||
|
const owner = getPlayer(game, actor), paidBox = game.pendingNullentropy?.player === actor;
|
||||||
|
for (const required of cardPlays.filter(required => required.answer &&
|
||||||
|
nativePrintedCardCount(game, actor, required.effect) <= required.baseline)) {
|
||||||
|
const held = owner.hand.some(card => card.effect === required.effect);
|
||||||
|
const recoverable = paidBox ? eligibleNullentropyCards(game.discard)
|
||||||
|
: owner.hand.some(card => card.effect === 'nullentropyBox')
|
||||||
|
? knownMovementDiscards(game.movementDiscardKnowledge, actor, game.discard) : [];
|
||||||
|
if (!held && !recoverable.some(card => card.effect === required.effect)) return null;
|
||||||
|
if (required.effect === 'ghola') {
|
||||||
|
const options = gholaOptions(game, owner);
|
||||||
|
if (!options.maxForces && !options.leaders.length && !options.kwisatz) return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
return findMovementCompletion(game, actor, promises, {
|
return findMovementCompletion(game, actor, promises, {
|
||||||
allowance: movesAllowed,
|
allowance: movesAllowed,
|
||||||
canFinish(state, owner) {
|
canFinish(state, owner) {
|
||||||
return !state.pendingNullentropy && liveShipmentPromises(state.shipmentPromises ?? [], owner, state.turn)
|
return !state.pendingNullentropy && turnActionCardsMatch(state, owner, cardPlays) &&
|
||||||
|
liveShipmentPromises(state.shipmentPromises ?? [], owner, state.turn)
|
||||||
.every(promise => matchesShipment(promise, null) === promise.answer);
|
.every(promise => matchesShipment(promise, null) === promise.answer);
|
||||||
},
|
},
|
||||||
|
needsProgress(state, owner) {
|
||||||
|
return state.advanced && !hajrAvailable(state, getPlayer(state, owner)) && cardPlays.some(required =>
|
||||||
|
required.effect === 'hajr' && required.answer && nativePrintedCardCount(state, owner, 'hajr') <= required.baseline);
|
||||||
|
},
|
||||||
|
progressKey(state, owner) {
|
||||||
|
return cardPlays.map(required => [required.effect, nativePrintedCardCount(state, owner, required.effect) > required.baseline]);
|
||||||
|
},
|
||||||
*preparations(state, owner) {
|
*preparations(state, owner) {
|
||||||
const player = getPlayer(state, owner), paid = state.pendingNullentropy;
|
const player = getPlayer(state, owner), paid = state.pendingNullentropy;
|
||||||
const relevant = (card: Card) => card.effect === 'hajr' || card.effect === 'ornithopter' || card.effect === 'ghola' ||
|
const relevant = (card: Card) => card.effect === 'hajr' || card.effect === 'ornithopter' || card.effect === 'ghola' ||
|
||||||
@@ -19583,6 +19720,12 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises:
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
for (const required of cardPlays.filter(required => required.answer &&
|
||||||
|
nativePrintedCardCount(state, owner, required.effect) <= required.baseline))
|
||||||
|
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') {
|
if (state.advanced && player.faction === 'beneGesserit') {
|
||||||
for (const territory of quoteAdvisorPreparationWindow(state, owner).territories) {
|
for (const territory of quoteAdvisorPreparationWindow(state, owner).territories) {
|
||||||
const action: Action = { type: 'prepareAdvisors', territory };
|
const action: Action = { type: 'prepareAdvisors', territory };
|
||||||
@@ -27603,6 +27746,7 @@ function playMarketGhola(g: Game, p: Player, action: Action) {
|
|||||||
}
|
}
|
||||||
if (g.response) (g.response as ResponseWindow).intent = g.pendingChoamMarketGhola.event;
|
if (g.response) (g.response as ResponseWindow).intent = g.pendingChoamMarketGhola.event;
|
||||||
const used = discard(g, p, card.id);
|
const used = discard(g, p, card.id);
|
||||||
|
guardTurnActionPrintedCardCost(g, p, used);
|
||||||
log(g, `${p.name} played ${card.name}. The declared CHOAM sale waits for this revival and its income to finish.`,
|
log(g, `${p.name} played ${card.name}. The declared CHOAM sale waits for this revival and its income to finish.`,
|
||||||
{ faction: p.faction, name: 'Ghola revival' });
|
{ faction: p.faction, name: 'Ghola revival' });
|
||||||
stageOrdinaryCardDiscard(g, p, used);
|
stageOrdinaryCardDiscard(g, p, used);
|
||||||
@@ -27674,6 +27818,7 @@ function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
|
|||||||
}
|
}
|
||||||
techIncome(g, 'axlotl', p);
|
techIncome(g, 'axlotl', p);
|
||||||
collectRevivalIncome(g, p, 0, false, true);
|
collectRevivalIncome(g, p, 0, false, true);
|
||||||
|
recordNativePrintedCardPlay(g, p.id, 'ghola');
|
||||||
}
|
}
|
||||||
/** Search complete legal plans without learning or consulting the opponent's sealed choices. */
|
/** Search complete legal plans without learning or consulting the opponent's sealed choices. */
|
||||||
function findLegalBattlePlan(
|
function findLegalBattlePlan(
|
||||||
@@ -27796,6 +27941,7 @@ type BattleCompletion = {
|
|||||||
plan: Plan;
|
plan: Plan;
|
||||||
actions: Action[];
|
actions: Action[];
|
||||||
waitingForIncome: boolean;
|
waitingForIncome: boolean;
|
||||||
|
cardPlaysComplete?: true;
|
||||||
};
|
};
|
||||||
function gholaPreparationActions(g: Game, p: Player): Action[] {
|
function gholaPreparationActions(g: Game, p: Player): Action[] {
|
||||||
const card = p.hand.find((c) => c.effect === 'ghola');
|
const card = p.hand.find((c) => c.effect === 'ghola');
|
||||||
@@ -27848,11 +27994,12 @@ function pendingBattleRevivalIncome(g: Game, p: Player): number {
|
|||||||
function findReachableBattlePlan(
|
function findReachableBattlePlan(
|
||||||
g: Game,
|
g: Game,
|
||||||
p: Player,
|
p: Player,
|
||||||
options: Parameters<typeof findLegalBattlePlan>[2] = {},
|
options: Parameters<typeof findLegalBattlePlan>[2] & { cardPlays?: readonly TurnActionCardRequirement[] } = {},
|
||||||
): BattleCompletion | null {
|
): BattleCompletion | null {
|
||||||
|
const cardPlays = options.cardPlays ?? [];
|
||||||
const immediate = findLegalBattlePlan(g, p, options);
|
const immediate = findLegalBattlePlan(g, p, options);
|
||||||
if (immediate)
|
if (immediate && turnActionCardsMatch(g, p.id, cardPlays))
|
||||||
return { plan: immediate, actions: [], waitingForIncome: false };
|
return { plan: immediate, actions: [], waitingForIncome: false, ...(cardPlays.length ? { cardPlaysComplete: true } : {}) };
|
||||||
if (g.battle!.revealed || g.battle!.plans[p.id]) return null;
|
if (g.battle!.revealed || g.battle!.plans[p.id]) return null;
|
||||||
const initial = structuredClone(g);
|
const initial = structuredClone(g);
|
||||||
const player = getPlayer(initial, p.id);
|
const player = getPlayer(initial, p.id);
|
||||||
@@ -27875,8 +28022,8 @@ function findReachableBattlePlan(
|
|||||||
actor = getPlayer(node.state, p.id);
|
actor = getPlayer(node.state, p.id);
|
||||||
if (node.actions.length || pending) {
|
if (node.actions.length || pending) {
|
||||||
const plan = findLegalBattlePlan(node.state, actor, options);
|
const plan = findLegalBattlePlan(node.state, actor, options);
|
||||||
if (plan)
|
if (plan && turnActionCardsMatch(node.state, p.id, cardPlays))
|
||||||
return { plan, actions: node.actions, waitingForIncome: pending > 0 };
|
return { plan, actions: node.actions, waitingForIncome: pending > 0, ...(cardPlays.length ? { cardPlaysComplete: true } : {}) };
|
||||||
}
|
}
|
||||||
for (const action of [
|
for (const action of [
|
||||||
...(node.state.status === 'playing' && node.state.phase === 6 && actor.faction === 'ixians' &&
|
...(node.state.status === 'playing' && node.state.phase === 6 && actor.faction === 'ixians' &&
|
||||||
@@ -28065,7 +28212,40 @@ function earlyRevivalCompletionSeeds(initial: Game, source: Game, owner: string)
|
|||||||
return { ...seed, game: g };
|
return { ...seed, game: g };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string): ShipmentCompletionSeed[] {
|
/** Materialize required ordinary returns before a later printed-card witness
|
||||||
|
* can use the same Tanks or leader. These are the native cumulative quotes,
|
||||||
|
* not an independent optimistic Revival reservation beside the card route. */
|
||||||
|
function completeTurnActionRevivalSeed(seed: ShipmentCompletionSeed, owner: string): ShipmentCompletionSeed | null {
|
||||||
|
const game = structuredClone(seed.game), actions = [...seed.actions], early = [...seed.early];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const pending = () => [
|
||||||
|
...liveRevivalPromises(game.revivalPromises ?? [], owner, game.turn),
|
||||||
|
...liveLeaderRevivalPromises(game.leaderRevivalPromises ?? [], owner, game.turn),
|
||||||
|
...liveRevivalExpressionPromises(game.revivalExpressionPromises ?? [], owner, game.turn),
|
||||||
|
].some(promise => promise.answer);
|
||||||
|
while (pending()) {
|
||||||
|
const player = getPlayer(game, owner);
|
||||||
|
const signature = JSON.stringify([player.tanks, player.elites, player.spice, player.leaders,
|
||||||
|
player.kwisatz, player.revived, player.leaderRevived, game.revivalPromises,
|
||||||
|
game.leaderRevivalPromises, game.revivalExpressionPromises]);
|
||||||
|
if (visited.has(signature)) return null;
|
||||||
|
visited.add(signature);
|
||||||
|
const action = currentRevivalPromiseCompletion(game, player, game.revivalPromises ?? []);
|
||||||
|
if (!action || action.type === 'ready') return null;
|
||||||
|
try {
|
||||||
|
if (action.type === 'revive') stagePromisedForceReturn(game, player, normalForceRevivalQuote(game, player, action));
|
||||||
|
else if (['reviveLeader', 'reviveKwisatz', 'acceptLeaderRevival'].includes(action.type)) {
|
||||||
|
const quote = action.type === 'acceptLeaderRevival' ? negotiatedLeaderRevivalQuote(game, player)
|
||||||
|
: normalLeaderRevivalQuote(game, player, action);
|
||||||
|
stagePromisedLeaderReturn(game, player, quote);
|
||||||
|
} else return null;
|
||||||
|
actions.push(action); early.push({ phase: 4, action });
|
||||||
|
} catch (error) { if (error instanceof RuleError) return null; throw error; }
|
||||||
|
}
|
||||||
|
return { game, actions, early };
|
||||||
|
}
|
||||||
|
function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string,
|
||||||
|
completeRevival = false): ShipmentCompletionSeed[] {
|
||||||
const seeds = earlyRevivalCompletionSeeds(initial, source, owner);
|
const seeds = earlyRevivalCompletionSeeds(initial, source, owner);
|
||||||
// A real Movement quote already owns its active Karama/rate and allowances.
|
// A real Movement quote already owns its active Karama/rate and allowances.
|
||||||
// Only earlier phases may pass through the future Revival/Movement reset.
|
// Only earlier phases may pass through the future Revival/Movement reset.
|
||||||
@@ -28085,7 +28265,11 @@ function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string
|
|||||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return revivalSeeds.map(seed => {
|
const completed = completeRevival ? revivalSeeds.flatMap(seed => {
|
||||||
|
const next = completeTurnActionRevivalSeed(seed, owner);
|
||||||
|
return next ? [next] : [];
|
||||||
|
}) : revivalSeeds;
|
||||||
|
return completed.map(seed => {
|
||||||
seed.game.phase = 5; seed.game.ready = []; seed.game.karamaShipping = null;
|
seed.game.phase = 5; seed.game.ready = []; seed.game.karamaShipping = null;
|
||||||
delete seed.game.revivalOrder;
|
delete seed.game.revivalOrder;
|
||||||
const p = getPlayer(seed.game, owner); p.shipped = false; p.moved = 0;
|
const p = getPlayer(seed.game, owner); p.shipped = false; p.moved = 0;
|
||||||
@@ -37079,6 +37263,12 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
|||||||
const guide = projectedTurnActionGuide(state, getPlayer(state, id));
|
const guide = projectedTurnActionGuide(state, getPlayer(state, id));
|
||||||
requireRule(guide && action.turnActionPromisePhase === state.phase && movementGuideActionMatches(action, guide.action),
|
requireRule(guide && action.turnActionPromisePhase === state.phase && movementGuideActionMatches(action, guide.action),
|
||||||
'This turn-action guide is stale or belongs to another opportunity.');
|
'This turn-action guide is stale or belongs to another opportunity.');
|
||||||
|
// Validate the exact guide envelope before the native dispatcher receives
|
||||||
|
// its original action. Strict card controls such as Box accept no extra
|
||||||
|
// selection fields; promise metadata is not a native card selection.
|
||||||
|
action = { ...action };
|
||||||
|
delete action.turnActionPromiseEvent;
|
||||||
|
delete action.turnActionPromisePhase;
|
||||||
}
|
}
|
||||||
if (action.travelPromiseEvent !== undefined || action.travelPromisePhase !== undefined) {
|
if (action.travelPromiseEvent !== undefined || action.travelPromisePhase !== undefined) {
|
||||||
const guide = projectedTravelGuide(state, getPlayer(state, id));
|
const guide = projectedTravelGuide(state, getPlayer(state, id));
|
||||||
@@ -41426,6 +41616,7 @@ function applyActionInner(
|
|||||||
throw new RuleError(availability.reason);
|
throw new RuleError(availability.reason);
|
||||||
if (c.effect === 'hajr') {
|
if (c.effect === 'hajr') {
|
||||||
p.hajrPlays = hajrPlayCount(g,p) + 1;
|
p.hajrPlays = hajrPlayCount(g,p) + 1;
|
||||||
|
recordNativePrintedCardPlay(g, p.id, 'hajr');
|
||||||
if (!g.hajr.includes(id)) g.hajr.push(id);
|
if (!g.hajr.includes(id)) g.hajr.push(id);
|
||||||
delete p.hajrWindow;
|
delete p.hajrWindow;
|
||||||
const cunning = currentGuildCunning(g,id);
|
const cunning = currentGuildCunning(g,id);
|
||||||
@@ -41465,6 +41656,8 @@ function applyActionInner(
|
|||||||
'This card’s timing and effect are still being implemented.',
|
'This card’s timing and effect are still being implemented.',
|
||||||
);
|
);
|
||||||
const used = discard(g, p, c.id);
|
const used = discard(g, p, c.id);
|
||||||
|
if (execution === 'live' && (c.effect === 'hajr' || c.effect === 'ghola'))
|
||||||
|
guardTurnActionPrintedCardCost(g, p, used);
|
||||||
log(
|
log(
|
||||||
g,
|
g,
|
||||||
`${p.name} played ${c.name}.`,
|
`${p.name} played ${c.name}.`,
|
||||||
@@ -41481,10 +41674,15 @@ function applyActionInner(
|
|||||||
if (allianceQuestionWindow(g)) guardAlliancePromiseReady(g, p);
|
if (allianceQuestionWindow(g)) guardAlliancePromiseReady(g, p);
|
||||||
const bribeWindowCloses = g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g);
|
const bribeWindowCloses = g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g);
|
||||||
if (bribeWindowCloses && g.turnActionPromises) {
|
if (bribeWindowCloses && g.turnActionPromises) {
|
||||||
const closed = turnActionRule(() => finishTurnActionTurn(g.turnActionPromises!, p.id, g.turn));
|
// Known Double closes payments at the next opening, while Ghola keeps
|
||||||
|
// its actual Mentat opportunity. Only full turn expiry closes both.
|
||||||
|
const closed = turnActionRule(() => g.phase === 8
|
||||||
|
? finishTurnActionTurn(g.turnActionPromises!, p.id, g.turn)
|
||||||
|
: closeTurnActionPayments(g.turnActionPromises!, p.id, g.turn));
|
||||||
requireRule(!g.turnActionPromises.some((promise, index) => promise.player === p.id && promise.turn === g.turn &&
|
requireRule(!g.turnActionPromises.some((promise, index) => promise.player === p.id && promise.turn === g.turn &&
|
||||||
!promise.fulfilled && !promise.released && closed[index].released),
|
!promise.fulfilled && !promise.released && closed[index].released) &&
|
||||||
'Complete your whole Truthtrance payment answer before closing this turn’s payments.');
|
(g.phase === 8 || !!turnActionCompletion({ ...g, turnActionPromises: closed }, p.id)),
|
||||||
|
'Complete your whole Truthtrance answer or retain a legal alternative before closing its opportunity.');
|
||||||
}
|
}
|
||||||
if (bribeWindowCloses) requireRule(!hasBribePromises(g, p.id, true),
|
if (bribeWindowCloses) requireRule(!hasBribePromises(g, p.id, true),
|
||||||
g.phase === 8 ? 'Complete your Truthtrance bribe payments before finishing Mentat Pause.'
|
g.phase === 8 ? 'Complete your Truthtrance bribe payments before finishing Mentat Pause.'
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { evaluateMovementExpression, liveMovementExpressionPromises, movementExp
|
|||||||
export type MovementPromiseAdapter = {
|
export type MovementPromiseAdapter = {
|
||||||
allowance: (game: Game, player: Player) => number;
|
allowance: (game: Game, player: Player) => number;
|
||||||
canFinish: (game: Game, player: string) => boolean;
|
canFinish: (game: Game, player: string) => boolean;
|
||||||
|
/** A required preparation may itself need a real first move, such as the
|
||||||
|
* native Advanced Hajr window, even without a positive destination claim. */
|
||||||
|
needsProgress?: (game: Game, player: string) => boolean;
|
||||||
|
progressKey?: (game: Game, player: string) => unknown;
|
||||||
preparations: (game: Game, player: string) => Iterable<{ game: Game; actions: Action[] }>;
|
preparations: (game: Game, player: string) => Iterable<{ game: Game; actions: Action[] }>;
|
||||||
reachable: (game: Game, player: Player, origin: string, destination: string, sector: number) => boolean;
|
reachable: (game: Game, player: Player, origin: string, destination: string, sector: number) => boolean;
|
||||||
project: (game: Game, player: string, action: Action) => { game: Game; event: MovementEvent } | null;
|
project: (game: Game, player: string, action: Action) => { game: Game; event: MovementEvent } | null;
|
||||||
@@ -75,14 +79,14 @@ export function findMovementCompletion(source: Game, actor: string,
|
|||||||
game.discard.map(card => card.id), game.movementDiscardKnowledge?.[actor], game.pendingNullentropy,
|
game.discard.map(card => card.id), game.movementDiscardKnowledge?.[actor], game.pendingNullentropy,
|
||||||
game.choamWorthlessBlocked, game.hajr, game.ornithopter ? [game.ornithopter.mode, game.ornithopter.startingMove, game.ornithopter.completed, game.ornithopter.cohort] : null,
|
game.choamWorthlessBlocked, game.hajr, game.ornithopter ? [game.ornithopter.mode, game.ornithopter.startingMove, game.ornithopter.completed, game.ornithopter.cohort] : null,
|
||||||
live.map(promise => [promise.territory, promise.minimum, promise.answer]),
|
live.map(promise => [promise.territory, promise.minimum, promise.answer]),
|
||||||
liveWhole.map(promise => [promise.claim, promise.answer, promise.progress])]);
|
liveWhole.map(promise => [promise.claim, promise.answer, promise.progress]), adapter.progressKey?.(game, actor)]);
|
||||||
if (failed.has(key)) return null;
|
if (failed.has(key)) return null;
|
||||||
const destinations = gameTerritories(game).filter(destination => !destination.id.startsWith('homeworld:'));
|
const destinations = gameTerritories(game).filter(destination => !destination.id.startsWith('homeworld:'));
|
||||||
const wanted = new Set([...live.filter(promise => promise.answer).map(promise => promise.territory),
|
const wanted = new Set([...live.filter(promise => promise.answer).map(promise => promise.territory),
|
||||||
...positiveWhole.flatMap(promise => movementExpressionTerritories(promise.claim))]);
|
...positiveWhole.flatMap(promise => movementExpressionTerritories(promise.claim))]);
|
||||||
destinations.sort((a, b) => Number(wanted.has(b.id)) - Number(wanted.has(a.id)) ||
|
destinations.sort((a, b) => Number(wanted.has(b.id)) - Number(wanted.has(a.id)) ||
|
||||||
(marker ? Number(['arrakeen', 'carthag'].includes(b.id)) - Number(['arrakeen', 'carthag'].includes(a.id)) : 0));
|
(marker ? Number(['arrakeen', 'carthag'].includes(b.id)) - Number(['arrakeen', 'carthag'].includes(a.id)) : 0));
|
||||||
if (needsMove && (marker || !live.some(promise => promise.answer && promise.minimum > physical)) &&
|
if ((needsMove || adapter.needsProgress?.(game, actor)) && (marker || !live.some(promise => promise.answer && promise.minimum > physical)) &&
|
||||||
!game.pendingNullentropy && player.moved < adapter.allowance(game, player)) {
|
!game.pendingNullentropy && player.moved < adapter.allowance(game, player)) {
|
||||||
const origins = [...new Set([...Object.keys(player.forces).filter(key => player.forces[key] > 0)
|
const origins = [...new Set([...Object.keys(player.forces).filter(key => player.forces[key] > 0)
|
||||||
.map(key => splitLocation(key).territory), ...(marker ? [marker.location.territory] : [])])];
|
.map(key => splitLocation(key).territory), ...(marker ? [marker.location.territory] : [])])];
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
export type PrintedCardEffect = 'hajr' | 'ghola';
|
||||||
|
export type PrintedCardPlayCounts = {
|
||||||
|
turn: number;
|
||||||
|
plays: { player: string; effect: PrintedCardEffect; count: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PrintedCardPlayError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'PrintedCardPlayError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function check(value: unknown, message: string): asserts value {
|
||||||
|
if (!value) throw new PrintedCardPlayError(message);
|
||||||
|
}
|
||||||
|
function object(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
const keys = (value: Record<string, unknown>, expected: string): boolean =>
|
||||||
|
Object.keys(value).sort().join(',') === expected;
|
||||||
|
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';
|
||||||
|
|
||||||
|
function validateCounts(counts: unknown): asserts counts is PrintedCardPlayCounts {
|
||||||
|
check(object(counts) && keys(counts, 'plays,turn') && positive(counts.turn) && Array.isArray(counts.plays),
|
||||||
|
'Retain the original turn and accepted printed-card play counts.');
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const play of counts.plays) {
|
||||||
|
check(object(play) && keys(play, 'count,effect,player') && identifier(play.player) &&
|
||||||
|
effect(play.effect) && positive(play.count), 'Retain each player’s safe positive printed-role play count.');
|
||||||
|
const key = JSON.stringify([play.player, play.effect]);
|
||||||
|
check(!seen.has(key), 'Retain one printed-card count per player and effect.');
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Private native witness baseline only. Per-answer promises track their own
|
||||||
|
* future interval, rather than treating these earlier accepted plays as new. */
|
||||||
|
export function printedCardPlayCount(counts: PrintedCardPlayCounts | undefined, player: string,
|
||||||
|
turn: number, cardEffect: PrintedCardEffect): number {
|
||||||
|
validateRequest(player, turn, cardEffect);
|
||||||
|
if (counts === undefined) return 0;
|
||||||
|
validateCounts(counts);
|
||||||
|
return counts.turn === turn ? counts.plays.find(play => play.player === player && play.effect === cardEffect)?.count ?? 0 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call once for each actually accepted printed-role play, never for an effect,
|
||||||
|
* sale, discard, recovery or role conversion. Genuine reacquisition may replay. */
|
||||||
|
export function recordPrintedCardPlay(counts: PrintedCardPlayCounts | undefined, player: string,
|
||||||
|
turn: number, cardEffect: PrintedCardEffect): PrintedCardPlayCounts {
|
||||||
|
validateRequest(player, turn, cardEffect);
|
||||||
|
if (counts !== undefined) {
|
||||||
|
validateCounts(counts);
|
||||||
|
check(turn >= counts.turn, 'An accepted printed-card play cannot be recorded in a past turn.');
|
||||||
|
}
|
||||||
|
const plays = counts?.turn === turn ? counts.plays.map(play => ({ ...play })) : [];
|
||||||
|
const prior = plays.find(play => play.player === player && play.effect === cardEffect);
|
||||||
|
if (prior) {
|
||||||
|
check(Number.isSafeInteger(prior.count + 1), 'The printed-card play count exceeds the safe integer range.');
|
||||||
|
prior.count++;
|
||||||
|
} else plays.push({ player, effect: cardEffect, count: 1 });
|
||||||
|
return { turn, plays };
|
||||||
|
}
|
||||||
+9
-7
@@ -3210,11 +3210,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'truthtrance-turn-actions',
|
id: 'truthtrance-turn-actions',
|
||||||
title: 'Truthtrance: combined alliances, bids, Revival, payments and transport',
|
title: 'Truthtrance: combined card plays, battle and turn actions',
|
||||||
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
|
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
|
||||||
summary: 'Combine current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
|
summary: 'Combine future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
|
||||||
steps: [
|
steps: [
|
||||||
'Choose Combine the current battle with this turn’s alliances, bids, Revival, payments, shipment and ground movements. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of native battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
|
'Choose Combine Hajr/Ghola plays, the current battle and this turn’s other actions. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of printed Hajr/Ghola use, native battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
|
||||||
'A final-alliance clause names another current participant during this original quiet native Nexus. Existing invitations and reciprocal formation remain authoritative. Offers, temporary membership and your own Ready do not settle the clause; final actual membership is fixed only when the Nexus closes.',
|
'A final-alliance clause names another current participant during this original quiet native Nexus. Existing invitations and reciprocal formation remain authoritative. Offers, temporary membership and your own Ready do not settle the clause; final actual membership is fixed only when the Nexus closes.',
|
||||||
'Whole No can permit a temporary paid pair and later require a break. Own Ready must preserve a completion after final membership is fixed; remaining payment or physical-action alternatives keep their later deadlines. Definite answers, including constants, uniformly reopen own Nexus readiness; later ordinary payments require confirmation again.',
|
'Whole No can permit a temporary paid pair and later require a break. Own Ready must preserve a completion after final membership is fixed; remaining payment or physical-action alternatives keep their later deadlines. Definite answers, including constants, uniformly reopen own Nexus readiness; later ordinary payments require confirmation again.',
|
||||||
'Missing foreign consent can offer I don’t know and save or change the question; it never invents a definite Yes. Unknown requires an otherwise funded native completion, so a known unaffordable branch stays No. Foreign withdrawal can release impossibility without vetoing the other player’s rights.',
|
'Missing foreign consent can offer I don’t know and save or change the question; it never invents a definite Yes. Unknown requires an otherwise funded native completion, so a known unaffordable branch stays No. Foreign withdrawal can release impossibility without vetoing the other player’s rights.',
|
||||||
@@ -3224,18 +3224,20 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'Basic Revival No lasts to phase close; ordered Advanced finishes at the respondent’s actual own turn end. Own Ready must retain a whole completion, including a funded later payment alternative. An owned Karama invoice guide appears only at the actual payment decision, never while another bidder is active.',
|
'Basic Revival No lasts to phase close; ordered Advanced finishes at the respondent’s actual own turn end. Own Ready must retain a whole completion, including a funded later payment alternative. An owned Karama invoice guide appears only at the actual payment decision, never while another bidder is active.',
|
||||||
'A battle-plan condition names an atomic part of your actual plan in this current battle. An open condition settles when both original plans reveal; a plan already sealed fixes its value at the answer. The human guide fills your existing plan for review before sealing. Native leaders, physical cards, Voice, costs and ordered preparation still apply.',
|
'A battle-plan condition names an atomic part of your actual plan in this current battle. An open condition settles when both original plans reveal; a plan already sealed fixes its value at the answer. The human guide fills your existing plan for review before sealing. Native leaders, physical cards, Voice, costs and ordered preparation still apply.',
|
||||||
'A Traitor condition describes the original eligible voter’s Reveal or Decline declaration in this revealed battle, before any later Karama prevention. A prevented Reveal stays a declared Reveal. No missing battle or declaration is invented as a zero plan or Decline, and an absent matching Traitor never creates one.',
|
'A Traitor condition describes the original eligible voter’s Reveal or Decline declaration in this revealed battle, before any later Karama prevention. A prevented Reveal stays a declared Reveal. No missing battle or declaration is invented as a zero plan or Decline, and an absent matching Traitor never creates one.',
|
||||||
|
'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.',
|
||||||
'Shipment clauses describe the same one future original reserve shipment. Each ground-movement minimum requires one actual physical group, never the sum of smaller moves. Existing allowances, cards, pieces and preparation remain authoritative.',
|
'Shipment clauses describe the same one future original reserve shipment. Each ground-movement minimum requires one actual physical group, never the sum of smaller moves. Existing allowances, cards, pieces and preparation remain authoritative.',
|
||||||
'Yes requires the whole expression and fulfills on qualifying actual events. OR preserves legal alternatives; No negates the whole expression. Current facts freeze at answer. A private native quotation shares funding with earlier commitments without storing or publishing a selected branch.',
|
'Yes requires the whole expression and fulfills on qualifying actual events. OR preserves legal alternatives; No negates the whole expression. Current facts freeze at answer. A private native quotation shares funding with earlier commitments without storing or publishing a selected branch.',
|
||||||
'Transport closes at your actual Movement end. A payment alternative may remain after that close, through the full current turn including Mentat. Complete a required payment before finishing the turn. No payment caps remain until their native opportunity closes.',
|
'Transport and Hajr close at your actual Movement end. Ghola or a payment alternative may remain after that close, through the full current turn including Mentat. A known Inflation Double opening closes payments earlier without closing Ghola. Complete a required payment before its actual opportunity closes; No payment caps remain binding until then.',
|
||||||
'Review the persistent own history and current source-bound native guide, or choose another legal manual route. Every definite answer uniformly reopens applicable concurrent preparation or final payment readiness. Four minimal AI profiles participate; complete strategy and final rules/deployed acceptance remain unfinished.',
|
'Review the persistent own history and current source-bound native guide, or choose another legal manual route. Every definite answer uniformly reopens applicable concurrent preparation or final payment readiness. Four minimal AI profiles participate; complete strategy and final rules/deployed acceptance remain unfinished.',
|
||||||
],
|
],
|
||||||
related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-travel', 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-bribe-expression', 'implementation-checklist'],
|
related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-travel', 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-bribe-expression', 'implementation-checklist'],
|
||||||
checklist: [
|
checklist: [
|
||||||
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse native battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals and deadlines.', evidence: ['game/turn-action-promises.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
|
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse printed Hajr/Ghola, native battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['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: '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; 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'] },
|
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed Hajr/Ghola; 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'] },
|
||||||
{ 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: '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; broader composition and final acceptance remain.', evidence: ['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'] },
|
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays and shared Revival resources; broader composition and final acceptance remain.', evidence: ['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'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -729,6 +729,7 @@ export function truthQuestionText(
|
|||||||
};
|
};
|
||||||
if (q.kind === 'turnActions') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
|
if (q.kind === 'turnActions') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
|
||||||
action => {
|
action => {
|
||||||
|
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 === 'payment') return `you ${bribeClaimText({ turn: q.turn, ...action }, playerName)}`;
|
if (action.kind === 'payment') return `you ${bribeClaimText({ turn: q.turn, ...action }, playerName)}`;
|
||||||
if (action.kind === 'bid') return `you ${bidClaimText({ turn: q.turn, ...action })}`;
|
if (action.kind === 'bid') return `you ${bidClaimText({ turn: q.turn, ...action })}`;
|
||||||
if (action.kind === 'battlePlan') return `in your own native plan in this original battle in ${locationName(action.territory)}, ${planClaimText(action.claim, leaderName)}`;
|
if (action.kind === 'battlePlan') return `in your own native plan in this original battle in ${locationName(action.territory)}, ${planClaimText(action.claim, leaderName)}`;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { parseRevivalActionClaim, type RevivalActionClaim } from './mixed-reviva
|
|||||||
import { RevivalExpressionPromiseError, type RevivalExpressionReturn } from './revival-expression-promises';
|
import { RevivalExpressionPromiseError, type RevivalExpressionReturn } from './revival-expression-promises';
|
||||||
import { parseBattleActionClaim, type MixedBattleActionClaim } from './mixed-battle-question';
|
import { parseBattleActionClaim, type MixedBattleActionClaim } from './mixed-battle-question';
|
||||||
import { PlanClaimError } from './battle-promises';
|
import { PlanClaimError } from './battle-promises';
|
||||||
|
import type { PrintedCardEffect } from './printed-card-play';
|
||||||
import {
|
import {
|
||||||
MixedActionQuestionError,
|
MixedActionQuestionError,
|
||||||
parseMixedActionExpression,
|
parseMixedActionExpression,
|
||||||
@@ -27,7 +28,8 @@ export const TURN_ACTION_MAX_DEPTH = 4;
|
|||||||
export type TurnActionLeaf = TravelActionClaim | { kind: 'payment'; recipient: string; minimum: number } |
|
export type TurnActionLeaf = TravelActionClaim | { kind: 'payment'; recipient: string; minimum: number } |
|
||||||
{ kind: 'bid'; lot: number; minimum: number } | RevivalActionClaim | { kind: 'alliance'; partner: string } |
|
{ kind: 'bid'; lot: number; minimum: number } | RevivalActionClaim | { kind: 'alliance'; partner: string } |
|
||||||
{ kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim } |
|
{ kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim } |
|
||||||
{ kind: 'traitorCall'; battle: string; territory: string; call: boolean };
|
{ kind: 'traitorCall'; battle: string; territory: string; call: boolean } |
|
||||||
|
{ kind: 'cardPlay'; effect: PrintedCardEffect };
|
||||||
export type TurnActionClaim = { turn: number; mixed: MixedActionExpression<TurnActionLeaf> };
|
export type TurnActionClaim = { turn: number; mixed: MixedActionExpression<TurnActionLeaf> };
|
||||||
export type TurnActionPayment = { recipient: string; amount: number };
|
export type TurnActionPayment = { recipient: string; amount: number };
|
||||||
export type TurnActionBranchLeaf = TravelBranchLeaf |
|
export type TurnActionBranchLeaf = TravelBranchLeaf |
|
||||||
@@ -38,7 +40,8 @@ export type TurnActionBranchLeaf = TravelBranchLeaf |
|
|||||||
{ kind: 'alliance'; partner: string; source: string; answer: boolean } |
|
{ kind: 'alliance'; partner: string; source: string; answer: boolean } |
|
||||||
{ kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim; answer: boolean } |
|
{ kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim; answer: boolean } |
|
||||||
{ kind: 'traitorCall'; battle: string; territory: string; call: boolean; answer: boolean;
|
{ kind: 'traitorCall'; battle: string; territory: string; call: boolean; answer: boolean;
|
||||||
beneficiary: string; target: string; leader: string | null };
|
beneficiary: string; target: string; leader: string | null } |
|
||||||
|
{ kind: 'cardPlay'; effect: PrintedCardEffect; answer: boolean };
|
||||||
export type TurnActionPreparation = {
|
export type TurnActionPreparation = {
|
||||||
bid: { lot: number; source: string; maximum: number; closed: boolean } | null;
|
bid: { lot: number; source: string; maximum: number; closed: boolean } | null;
|
||||||
revival: { total: number; normal: number; elite: number; leaders: string[]; closed: boolean } | null;
|
revival: { total: number; normal: number; elite: number; leaders: string[]; closed: boolean } | null;
|
||||||
@@ -52,6 +55,7 @@ export type TurnActionCombat = {
|
|||||||
plan: { closed: boolean; values: TurnActionPlanValue[] | null } | null;
|
plan: { closed: boolean; values: TurnActionPlanValue[] | null } | null;
|
||||||
traitor: { beneficiary: string; target: string; leader: string | null; closed: boolean; call: boolean | null } | null;
|
traitor: { beneficiary: string; target: string; leader: string | null; closed: boolean; call: boolean | null } | null;
|
||||||
};
|
};
|
||||||
|
export type TurnActionCardPlays = Record<PrintedCardEffect, { played: boolean; closed: boolean } | null>;
|
||||||
export type TurnActionPromise = TurnActionClaim & {
|
export type TurnActionPromise = TurnActionClaim & {
|
||||||
event: string;
|
event: string;
|
||||||
player: string;
|
player: string;
|
||||||
@@ -73,6 +77,8 @@ export type TurnActionPromise = TurnActionClaim & {
|
|||||||
/** Final membership is recorded only by the original Nexus closing event. */
|
/** Final membership is recorded only by the original Nexus closing event. */
|
||||||
alliance?: TurnActionAlliance;
|
alliance?: TurnActionAlliance;
|
||||||
combat?: TurnActionCombat;
|
combat?: TurnActionCombat;
|
||||||
|
/** Accepted printed-role plays after this answer only, never earlier counts. */
|
||||||
|
cardPlays?: TurnActionCardPlays;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class TurnActionError extends Error {
|
export class TurnActionError extends Error {
|
||||||
@@ -110,8 +116,13 @@ const planKey = (claim: MixedBattleActionClaim): string => JSON.stringify(parseC
|
|||||||
|
|
||||||
/** Public leaves carry only their native destination/recipient and minimum. */
|
/** Public leaves carry only their native destination/recipient and minimum. */
|
||||||
export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
|
export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
|
||||||
check(object(value), 'Choose one future ordinary bid, Revival, final Nexus alliance, reserve shipment, ground movement or bribe payment.');
|
check(object(value), 'Choose one future native action, printed-card play or ordinary bribe payment.');
|
||||||
try {
|
try {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
if (value.kind === 'battlePlan' || value.kind === 'traitorCall') {
|
if (value.kind === 'battlePlan' || value.kind === 'traitorCall') {
|
||||||
check(keys(value, value.kind === 'battlePlan' ? 'battle,claim,kind,territory' : 'battle,call,kind,territory') &&
|
check(keys(value, value.kind === 'battlePlan' ? 'battle,claim,kind,territory' : 'battle,call,kind,territory') &&
|
||||||
identifier(value.battle) && identifier(value.territory), 'Retain the exact original current battle and native location.');
|
identifier(value.battle) && identifier(value.territory), 'Retain the exact original current battle and native location.');
|
||||||
@@ -158,6 +169,7 @@ function storedFact(value: unknown, depth = 0): TruthFact {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function actionKey(action: TurnActionLeaf): string {
|
function actionKey(action: TurnActionLeaf): string {
|
||||||
|
if (action.kind === 'cardPlay') return JSON.stringify([action.kind, action.effect]);
|
||||||
if (action.kind === 'battlePlan') return JSON.stringify([action.kind, action.battle, action.territory, planKey(action.claim)]);
|
if (action.kind === 'battlePlan') return JSON.stringify([action.kind, action.battle, action.territory, planKey(action.claim)]);
|
||||||
if (action.kind === 'traitorCall') return JSON.stringify([action.kind, action.battle, action.territory, action.call]);
|
if (action.kind === 'traitorCall') return JSON.stringify([action.kind, action.battle, action.territory, action.call]);
|
||||||
if (action.kind === 'alliance') return JSON.stringify([action.kind, action.partner]);
|
if (action.kind === 'alliance') return JSON.stringify([action.kind, action.partner]);
|
||||||
@@ -205,6 +217,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
|||||||
if ('preparation' in promise) fields.push('preparation');
|
if ('preparation' in promise) fields.push('preparation');
|
||||||
if ('alliance' in promise) fields.push('alliance');
|
if ('alliance' in promise) fields.push('alliance');
|
||||||
if ('combat' in promise) fields.push('combat');
|
if ('combat' in promise) fields.push('combat');
|
||||||
|
if ('cardPlays' in promise) fields.push('cardPlays');
|
||||||
check(keys(promise, fields.sort().join(',')) &&
|
check(keys(promise, fields.sort().join(',')) &&
|
||||||
positive(promise.turn) && identifier(promise.event) && identifier(promise.player) && identifier(promise.asker),
|
positive(promise.turn) && identifier(promise.event) && identifier(promise.player) && identifier(promise.asker),
|
||||||
'Retain the complete original turn-action commitment and its metadata.');
|
'Retain the complete original turn-action commitment and its metadata.');
|
||||||
@@ -226,6 +239,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
|||||||
const combatSources = new Set<string>();
|
const combatSources = new Set<string>();
|
||||||
const planClaims = new Set<string>();
|
const planClaims = new Set<string>();
|
||||||
let hasTraitor = false;
|
let hasTraitor = false;
|
||||||
|
const cardEffects = new Set<PrintedCardEffect>();
|
||||||
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
|
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
|
||||||
if (node.kind === 'action') {
|
if (node.kind === 'action') {
|
||||||
originals.add(actionKey(node.action));
|
originals.add(actionKey(node.action));
|
||||||
@@ -238,6 +252,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
|||||||
combatSources.add(JSON.stringify([node.action.battle, node.action.territory]));
|
combatSources.add(JSON.stringify([node.action.battle, node.action.territory]));
|
||||||
if (node.action.kind === 'battlePlan') planClaims.add(planKey(node.action.claim));
|
if (node.action.kind === 'battlePlan') planClaims.add(planKey(node.action.claim));
|
||||||
if (node.action.kind === 'traitorCall') hasTraitor = true;
|
if (node.action.kind === 'traitorCall') hasTraitor = true;
|
||||||
|
if (node.action.kind === 'cardPlay') cardEffects.add(node.action.effect);
|
||||||
} else if (node.kind !== 'fact') node.terms.forEach(visit);
|
} else if (node.kind !== 'fact') node.terms.forEach(visit);
|
||||||
};
|
};
|
||||||
visit(mixed);
|
visit(mixed);
|
||||||
@@ -246,6 +261,15 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
|||||||
check(!hasAlliance || !!promise.alliance, 'Retain the original private Nexus binding for every final-alliance leaf.');
|
check(!hasAlliance || !!promise.alliance, 'Retain the original private Nexus binding for every final-alliance leaf.');
|
||||||
check(!planClaims.size || !!promise.combat?.plan, 'Retain the original private plan stage for every battle-plan clause.');
|
check(!planClaims.size || !!promise.combat?.plan, 'Retain the original private plan stage for every battle-plan clause.');
|
||||||
check(!hasTraitor || !!promise.combat?.traitor, 'Retain the original private Traitor declaration stage.');
|
check(!hasTraitor || !!promise.combat?.traitor, 'Retain the original private Traitor declaration stage.');
|
||||||
|
for (const effect of cardEffects) check(!!promise.cardPlays?.[effect], 'Retain the original future interval for each printed-card play clause.');
|
||||||
|
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) {
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
if ('combat' in promise) {
|
if ('combat' in promise) {
|
||||||
const combat = promise.combat;
|
const combat = promise.combat;
|
||||||
check(object(combat) && keys(combat, 'event,plan,territory,traitor') && identifier(combat.event) &&
|
check(object(combat) && keys(combat, 'event,plan,territory,traitor') && identifier(combat.event) &&
|
||||||
@@ -330,6 +354,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
|||||||
}
|
}
|
||||||
|
|
||||||
function actionValue(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
|
function actionValue(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
|
||||||
|
if (action.kind === 'cardPlay') return promise.cardPlays?.[action.effect]?.played ?? false;
|
||||||
if (action.kind === 'battlePlan') return promise.combat?.plan?.values?.find(entry =>
|
if (action.kind === 'battlePlan') return promise.combat?.plan?.values?.find(entry =>
|
||||||
planKey(entry.claim) === planKey(action.claim))?.value ?? false;
|
planKey(entry.claim) === planKey(action.claim))?.value ?? false;
|
||||||
if (action.kind === 'traitorCall') return !!promise.combat?.traitor?.closed &&
|
if (action.kind === 'traitorCall') return !!promise.combat?.traitor?.closed &&
|
||||||
@@ -353,6 +378,7 @@ function evaluate(node: CompiledMixedAction<TurnActionLeaf>, promise: TurnAction
|
|||||||
}
|
}
|
||||||
|
|
||||||
function actionClosed(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
|
function actionClosed(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
|
||||||
|
if (action.kind === 'cardPlay') return promise.cardPlays![action.effect]!.closed;
|
||||||
if (action.kind === 'battlePlan') return promise.combat!.plan!.closed;
|
if (action.kind === 'battlePlan') return promise.combat!.plan!.closed;
|
||||||
if (action.kind === 'traitorCall') return promise.combat!.traitor!.closed;
|
if (action.kind === 'traitorCall') return promise.combat!.traitor!.closed;
|
||||||
if (action.kind === 'alliance') return promise.alliance!.closed;
|
if (action.kind === 'alliance') return promise.alliance!.closed;
|
||||||
@@ -392,6 +418,7 @@ export function liveTurnActionPromises<T extends TurnActionPromise>(promises: re
|
|||||||
|
|
||||||
function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): boolean {
|
function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): boolean {
|
||||||
if (left.kind !== right.kind) return false;
|
if (left.kind !== right.kind) return false;
|
||||||
|
if (left.kind === 'cardPlay') return right.kind === 'cardPlay' && left.effect === right.effect;
|
||||||
if (left.kind === 'leader') return right.kind === 'leader' && left.leader === right.leader;
|
if (left.kind === 'leader') return right.kind === 'leader' && left.leader === right.leader;
|
||||||
if (left.kind === 'alliance') return right.kind === 'alliance' && left.partner === right.partner && left.source === right.source;
|
if (left.kind === 'alliance') return right.kind === 'alliance' && left.partner === right.partner && left.source === right.source;
|
||||||
if (left.kind === 'battlePlan') return right.kind === 'battlePlan' && left.battle === right.battle &&
|
if (left.kind === 'battlePlan') return right.kind === 'battlePlan' && left.battle === right.battle &&
|
||||||
@@ -416,7 +443,7 @@ function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf
|
|||||||
if (!prior) merged.push({ ...leaf });
|
if (!prior) merged.push({ ...leaf });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (leaf.kind === 'leader' || leaf.kind === 'alliance' || leaf.kind === 'battlePlan') {
|
if (leaf.kind === 'leader' || leaf.kind === 'alliance' || leaf.kind === 'battlePlan' || leaf.kind === 'cardPlay') {
|
||||||
const prior = merged.find(other => sameAction(other, leaf));
|
const prior = merged.find(other => sameAction(other, leaf));
|
||||||
if (prior && prior.answer !== leaf.answer) return null;
|
if (prior && prior.answer !== leaf.answer) return null;
|
||||||
if (leaf.kind === 'alliance' && leaf.answer && merged.some(other => other.kind === 'alliance' &&
|
if (leaf.kind === 'alliance' && leaf.answer && merged.some(other => other.kind === 'alliance' &&
|
||||||
@@ -485,6 +512,22 @@ function settle<T extends TurnActionPromise>(promise: T, update: T): T {
|
|||||||
? { ...update, fulfilled: true } : update;
|
? { ...update, fulfilled: true } : update;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Accepted printed-role play only. Native callers separately own effect
|
||||||
|
* 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.');
|
||||||
|
const updates = new Map<T, T>();
|
||||||
|
for (const promise of liveTurnActionPromises(promises, player, turn)) {
|
||||||
|
readPromise(promise);
|
||||||
|
const stage = promise.cardPlays?.[effect];
|
||||||
|
if (!stage || stage.closed || stage.played) continue;
|
||||||
|
updates.set(promise, settle(promise, { ...promise, cardPlays: { ...promise.cardPlays!,
|
||||||
|
[effect]: { ...stage, played: true } } }));
|
||||||
|
}
|
||||||
|
return promises.map(promise => updates.get(promise) ?? promise);
|
||||||
|
}
|
||||||
|
|
||||||
/** A union receipt may serve several answers; each stored row retains exactly
|
/** A union receipt may serve several answers; each stored row retains exactly
|
||||||
* its original distinct atomic predicates and no unrelated native plan values. */
|
* its original distinct atomic predicates and no unrelated native plan values. */
|
||||||
export function recordTurnActionPlan<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
export function recordTurnActionPlan<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||||
@@ -704,7 +747,20 @@ export function recordTurnActionPayment<T extends TurnActionPromise>(promises: r
|
|||||||
return promises.map(promise => updates.get(promise) ?? promise);
|
return promises.map(promise => updates.get(promise) ?? promise);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close transport only; a later ordinary payment can still complete the tree.
|
/** A known payment ban can end ordinary payments before the full turn ends.
|
||||||
|
* Other native opportunities, including later Ghola, retain their own stages. */
|
||||||
|
export function closeTurnActionPayments<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||||
|
turn: number): T[] {
|
||||||
|
const live = new Set(liveTurnActionPromises(promises, player, turn));
|
||||||
|
return promises.map(promise => {
|
||||||
|
if (!live.has(promise)) return promise;
|
||||||
|
readPromise(promise);
|
||||||
|
if (promise.paymentClosed) return promise;
|
||||||
|
return settle(promise, { ...promise, paymentClosed: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close transport and its native Hajr stage; later Ghola or payment can still complete the tree.
|
||||||
* The native joint solver, rather than this ledger, rejects own illegal closure. */
|
* The native joint solver, rather than this ledger, rejects own illegal closure. */
|
||||||
export function closeTurnActionTransport<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
export function closeTurnActionTransport<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||||
turn: number): T[] {
|
turn: number): T[] {
|
||||||
@@ -713,7 +769,9 @@ export function closeTurnActionTransport<T extends TurnActionPromise>(promises:
|
|||||||
if (!live.has(promise)) return promise;
|
if (!live.has(promise)) return promise;
|
||||||
readPromise(promise);
|
readPromise(promise);
|
||||||
return settle(promise, { ...promise, transportClosed: true,
|
return settle(promise, { ...promise, transportClosed: true,
|
||||||
shipment: { ...promise.shipment, pending: false } });
|
shipment: { ...promise.shipment, pending: false }, ...(promise.cardPlays ? { cardPlays: {
|
||||||
|
...promise.cardPlays, hajr: promise.cardPlays.hajr ? { ...promise.cardPlays.hajr, closed: true } : null,
|
||||||
|
} } : {}) });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,6 +787,9 @@ export function finishTurnActionTurn<T extends TurnActionPromise>(promises: read
|
|||||||
shipment: { ...promise.shipment, pending: false }, ...(promise.preparation ? { preparation: {
|
shipment: { ...promise.shipment, pending: false }, ...(promise.preparation ? { preparation: {
|
||||||
bid: promise.preparation.bid ? { ...promise.preparation.bid, closed: true } : null,
|
bid: promise.preparation.bid ? { ...promise.preparation.bid, closed: true } : null,
|
||||||
revival: promise.preparation.revival ? { ...promise.preparation.revival, 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,
|
||||||
} } : {}) };
|
} } : {}) };
|
||||||
const unresolvedCombat = promise.combat && (promise.combat.plan && !promise.combat.plan.closed ||
|
const unresolvedCombat = promise.combat && (promise.combat.plan && !promise.combat.plan.closed ||
|
||||||
promise.combat.traitor && !promise.combat.traitor.closed);
|
promise.combat.traitor && !promise.combat.traitor.closed);
|
||||||
@@ -749,7 +810,8 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[
|
|||||||
(!promise.preparation?.bid || promise.preparation.bid.closed) &&
|
(!promise.preparation?.bid || promise.preparation.bid.closed) &&
|
||||||
(!promise.preparation?.revival || promise.preparation.revival.closed) &&
|
(!promise.preparation?.revival || promise.preparation.revival.closed) &&
|
||||||
(!promise.alliance || promise.alliance.closed) && (!promise.combat?.plan || promise.combat.plan.closed) &&
|
(!promise.alliance || promise.alliance.closed) && (!promise.combat?.plan || promise.combat.plan.closed) &&
|
||||||
(!promise.combat?.traitor || promise.combat.traitor.closed);
|
(!promise.combat?.traitor || promise.combat.traitor.closed) &&
|
||||||
|
(!promise.cardPlays?.hajr || promise.cardPlays.hajr.closed) && (!promise.cardPlays?.ghola || promise.cardPlays.ghola.closed);
|
||||||
const recipientsValid = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
|
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 === '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
|
node.action.kind !== 'payment' || players.includes(node.action.recipient) && node.action.recipient !== promise.player
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||||
|
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
|
||||||
|
import { createTruthTravelFixture } from './fixture-truth-travel';
|
||||||
|
import { createTruthMixedMovementFixture } from './fixture-truth-mixed-movement';
|
||||||
|
import { createTruthMovementGholaFixture } from './fixture-truth-movement-ghola';
|
||||||
|
import { createTruthRevivalFixture } from './fixture-truth-revival';
|
||||||
|
import { createTruthLeaderRevivalFixture } from './fixture-truth-leader-revival';
|
||||||
|
import { location } from '../game/board';
|
||||||
|
import {
|
||||||
|
actHajrReplay, advanceHajrReplay, createHajrReplayFixture, hajrReplayClean, hajrReplayMove,
|
||||||
|
} from './fixture-hajr-replay';
|
||||||
|
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||||
|
import { actTruthMovement, truthMovementInventory, truthMovementPlayer, type TruthMovementStep } from './fixture-truth-movement';
|
||||||
|
import type { TruthTurnCombatLeaf } from './fixture-truth-turn-combat';
|
||||||
|
|
||||||
|
export type TruthTurnCardPlayLeaf = TruthTurnCombatLeaf | { kind: 'cardPlay'; effect: 'hajr' | 'ghola' };
|
||||||
|
export type TruthTurnCardPlayExpression = MixedActionExpression<TruthTurnCardPlayLeaf>;
|
||||||
|
export type TruthTurnCardPlayStep = TruthMovementStep;
|
||||||
|
export const actTruthTurnCardPlay = actTruthMovement;
|
||||||
|
export const truthTurnCardPlayInventory = truthMovementInventory;
|
||||||
|
export const truthTurnCardPlayPlayer = truthMovementPlayer;
|
||||||
|
export const truthTurnCardPlayClean = (game: Game) => !game.truthtrance && !game.response && !game.decision &&
|
||||||
|
!game.phaseOpening && !game.pendingTreacheryDiscard && !game.pendingShipment && !game.pendingRevival;
|
||||||
|
export const truthTurnCardPlayFact = (fact: TruthFact): TruthTurnCardPlayExpression => ({ kind: 'fact', fact });
|
||||||
|
export const truthTurnCardPlayUse = (effect: 'hajr' | 'ghola'): TruthTurnCardPlayExpression =>
|
||||||
|
({ kind: 'action', action: { kind: 'cardPlay', effect } });
|
||||||
|
export const truthTurnCardPlayPayment = (recipient: string, minimum = 2): TruthTurnCardPlayExpression =>
|
||||||
|
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
|
||||||
|
export const truthTurnCardPlayShip = (territory = 'polar_sink', minimum = 1): TruthTurnCardPlayExpression =>
|
||||||
|
({ kind: 'action', action: { kind: 'ship', territory, minimum } });
|
||||||
|
export const truthTurnCardPlayForce = (minimum = 2, counter: 'total' | 'normal' | 'elite' = 'total'):
|
||||||
|
TruthTurnCardPlayExpression => ({ kind: 'action', action: { kind: 'forces', minimum, counter } });
|
||||||
|
export const truthTurnCardPlayGroup = (kind: 'and' | 'or', ...terms: TruthTurnCardPlayExpression[]): TruthTurnCardPlayExpression =>
|
||||||
|
({ kind, terms });
|
||||||
|
|
||||||
|
/** The supported Advanced original avoids Basic Richese's Semuta preview.
|
||||||
|
* Stop after move1 then its actual Hajr, preserving one native movement for
|
||||||
|
* after the future paid Box recovery. The Box itself supplies no Hajr timing. */
|
||||||
|
function createSupportedHajrReplaySource() {
|
||||||
|
const base = createHajrReplayFixture({ advanced: true, truthForGuild: true });
|
||||||
|
const actions = structuredClone(base.actions), inherited = actions.length;
|
||||||
|
let game = structuredClone(base.game);
|
||||||
|
const firstMove = hajrReplayMove(game, base.source);
|
||||||
|
game = actHajrReplay(game, base.owner, firstMove, actions);
|
||||||
|
game = advanceHajrReplay(game, hajrReplayClean, actions);
|
||||||
|
game = actHajrReplay(game, base.owner, { type: 'card', card: base.hajr }, actions);
|
||||||
|
game = advanceHajrReplay(game, hajrReplayClean, actions);
|
||||||
|
const truth = base.guildTruth; assert.ok(truth);
|
||||||
|
const originalCards = truthMovementInventory(base.setup);
|
||||||
|
assert.equal(game.advanced, true); assert.ok(!game.semutaPreview);
|
||||||
|
assert.equal(truthMovementPlayer(game, base.owner).moved, 1);
|
||||||
|
assert.equal(truthMovementPlayer(game, base.owner).hajrPlays, 1);
|
||||||
|
assert.equal(truthMovementPlayer(game, base.owner).spice, 2);
|
||||||
|
assert.ok(truthMovementPlayer(game, base.owner).hand.some(card => card.id === base.box));
|
||||||
|
assert.equal(game.discard.filter(card => card.id === base.hajr).length, 1);
|
||||||
|
assert.deepEqual(truthMovementInventory(game), originalCards);
|
||||||
|
return { ...base, game, actions, asker: 'guild', target: base.owner, truth, originalCards,
|
||||||
|
firstMove, secondMoveFrom: location(String(firstMove.territory), Number(firstMove.sector)),
|
||||||
|
sourceActionCount: actions.length,
|
||||||
|
sourceCounts: { inherited, nativeContinuation: actions.length - inherited, selected: actions.length },
|
||||||
|
sourceDisclosure: `${base.staging.join(' ')} This is the original Advanced preview with Guild's unopened Truthtrance deal. ` +
|
||||||
|
'A real first typed movement is followed immediately by the first actual Hajr, which is now physically discarded. ' +
|
||||||
|
'The source stops at moved1/max2 with the owned Box and its exact two-spice fee. After a future answer, Box recovery must precede ' +
|
||||||
|
'the second real movement that earns new Advanced timing for replay. No Basic Semuta composition or installed timing window is claimed.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every option selects an existing genuine original source or its exact
|
||||||
|
* earlier action prefix. Native deals, casualties, purchases and already used
|
||||||
|
* Hajr all remain physical chronology; no played state is installed. */
|
||||||
|
export function createTruthTurnCardPlayFixture(options: {
|
||||||
|
source?: 'hajr' | 'gholaForces' | 'gholaRevival' | 'gholaLeader' | 'hajrReplay';
|
||||||
|
advanced?: boolean; early?: boolean; mixed?: TruthTurnCardPlayExpression;
|
||||||
|
} = {}) {
|
||||||
|
const sourceKind = options.source ?? 'hajr', requestedAdvanced = options.advanced ?? false;
|
||||||
|
assert.ok(!requestedAdvanced || sourceKind !== 'gholaForces',
|
||||||
|
'Use the genuine typed Revival source for Advanced Ghola');
|
||||||
|
const basicHajr = sourceKind === 'hajr' && !requestedAdvanced ? createTruthTravelFixture({ window: options.early ? 'charity' : 'active' }) : null;
|
||||||
|
const advancedHajr = sourceKind === 'hajr' && requestedAdvanced ? createTruthMixedMovementFixture({ advanced: true, window: 'active' }) : null;
|
||||||
|
const gholaForces = sourceKind === 'gholaForces' ? createTruthMovementGholaFixture() : null;
|
||||||
|
const gholaRevival = sourceKind === 'gholaRevival' ? createTruthRevivalFixture({ targetGhola: true, advanced: requestedAdvanced }) : null;
|
||||||
|
const gholaLeader = sourceKind === 'gholaLeader' ? createTruthLeaderRevivalFixture({ ghola: true, advanced: requestedAdvanced }) : null;
|
||||||
|
const hajrReplay = sourceKind === 'hajrReplay' ? createSupportedHajrReplaySource() : null;
|
||||||
|
const original = basicHajr ?? advancedHajr ?? gholaForces ?? gholaRevival ?? gholaLeader ?? hajrReplay!;
|
||||||
|
const game = structuredClone(original.game), actions = structuredClone(original.actions), advanced = game.advanced;
|
||||||
|
const asker = original.asker, target = original.target, recipient = asker;
|
||||||
|
const effect: 'hajr' | 'ghola' = sourceKind === 'hajr' || sourceKind === 'hajrReplay' ? 'hajr' : 'ghola';
|
||||||
|
const hajr = basicHajr?.hajr ?? advancedHajr?.hajr ?? hajrReplay?.hajr ?? null;
|
||||||
|
const ghola = gholaForces?.ghola ?? gholaRevival?.ghola ?? gholaLeader?.ghola ?? null;
|
||||||
|
const card = effect === 'hajr' ? hajr : ghola; assert.ok(card);
|
||||||
|
const truths = structuredClone(gholaRevival?.truths ?? gholaLeader?.truths ?? [original.truth]);
|
||||||
|
const mixed = structuredClone(options.mixed ?? (hajrReplay ? truthTurnCardPlayUse(effect)
|
||||||
|
: truthTurnCardPlayGroup('and', truthTurnCardPlayUse(effect), truthTurnCardPlayPayment(recipient))));
|
||||||
|
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
|
||||||
|
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthMovementPlayer(game, target).spice };
|
||||||
|
const firstMove: Action = basicHajr?.firstMove ?? advancedHajr?.firstMove ??
|
||||||
|
{ type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 1 };
|
||||||
|
const cardAction: Action = effect === 'hajr' ? { type: 'card', card } : gholaLeader
|
||||||
|
? { type: 'card', card, leader: gholaLeader.leader }
|
||||||
|
: { type: 'card', card, amount: 1, elite: advanced ? 1 : 0 };
|
||||||
|
const sourceDisclosure = 'sourceDisclosure' in original ? original.sourceDisclosure : original.staging.join(' ');
|
||||||
|
assert.ok(truthTurnCardPlayClean(game)); assert.equal(game.turnActionPromises?.length ?? 0, 0);
|
||||||
|
assert.ok(truths.every(truth => truthMovementPlayer(game, asker).hand.some(held => held.id === truth)));
|
||||||
|
assert.deepEqual(truthMovementInventory(game), original.originalCards);
|
||||||
|
return {
|
||||||
|
game, source: structuredClone(game), sourceKind, effect, advanced, early: options.early ?? false,
|
||||||
|
basicHajr, advancedHajr, gholaForces, gholaRevival, gholaLeader, hajrReplay,
|
||||||
|
asker, target, recipient, truth: truths[0], truths, card, hajr, ghola, mixed, question, spiceFact,
|
||||||
|
setup: original.setup, afterSetup: original.afterSetup, beforeStartingDeal: original.beforeStartingDeal,
|
||||||
|
originalCards: original.originalCards, actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||||
|
sourceCounts: { selected: actions.length, inherited: original.actions.length },
|
||||||
|
cardAction, firstMove,
|
||||||
|
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
|
||||||
|
shipment: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
|
||||||
|
end: { type: 'endMovement' } satisfies Action,
|
||||||
|
ordinaryForces: { type: 'revive', amount: 2, elite: 0 } satisfies Action,
|
||||||
|
leader: gholaLeader?.leader ?? null, box: hajrReplay?.box ?? null,
|
||||||
|
sourceDisclosure: `${sourceDisclosure} This adapter changes only the raw turn-action question. Future accepted printed-role play ` +
|
||||||
|
'must occur after the actual answer; earlier use, recovery, an ordinary return or another role of the same card is not a new Hajr/Ghola play. ' +
|
||||||
|
'All selected source counts derive from recorded arrays. No played hand, purse, army, casualty, clock, card, answer or receipt is installed.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type TruthTurnCardPlayFixture = ReturnType<typeof createTruthTurnCardPlayFixture>;
|
||||||
|
|
||||||
|
export function declareTruthTurnCardPlay(f: TruthTurnCardPlayFixture, source = f.game, copy: 0 | 1 = 0,
|
||||||
|
actions: TruthTurnCardPlayStep[] = []) {
|
||||||
|
const before = structuredClone(source), from = actions.length;
|
||||||
|
let game = actTruthTurnCardPlay(source, f.asker, { type: 'card', card: f.truths[copy] }, actions);
|
||||||
|
while (game.truthtrance?.stage === 'priority') {
|
||||||
|
const actor = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id)); assert.ok(actor);
|
||||||
|
game = actTruthTurnCardPlay(game, actor.id, { type: 'truthPass' }, actions);
|
||||||
|
}
|
||||||
|
assert.equal(game.truthtrance?.stage, 'ask'); return { game, before, actions, actionCount: actions.length - from };
|
||||||
|
}
|
||||||
|
export function askTruthTurnCardPlay(game: Game, asker: string, target: string, mixed: TruthTurnCardPlayExpression,
|
||||||
|
actions: TruthTurnCardPlayStep[] = []): Game {
|
||||||
|
return actTruthTurnCardPlay(game, asker, { type: 'truthAsk', question: {
|
||||||
|
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
|
||||||
|
} }, actions);
|
||||||
|
}
|
||||||
|
export function answerTruthTurnCardPlay(game: Game, target: string, answer: 'yes' | 'no',
|
||||||
|
actions: TruthTurnCardPlayStep[] = []): Game {
|
||||||
|
return actTruthTurnCardPlay(game, target, { type: 'truthAnswer', answer }, actions);
|
||||||
|
}
|
||||||
|
export function bindTruthTurnCardPlay(f: TruthTurnCardPlayFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
|
||||||
|
source = f.game, copy: 0 | 1 = 0, actions: TruthTurnCardPlayStep[] = []) {
|
||||||
|
const from = actions.length, declared = declareTruthTurnCardPlay(f, source, copy, actions);
|
||||||
|
const asked = askTruthTurnCardPlay(declared.game, f.asker, f.target, mixed, actions);
|
||||||
|
const game = answerTruthTurnCardPlay(asked, f.target, answer, actions);
|
||||||
|
return { game, declared, asked, actions, actionCount: actions.length - from };
|
||||||
|
}
|
||||||
|
export function truthTurnCardPlayPromise(game: Game, target = game.turnActionPromises?.[0]?.player ?? game.players[0].id) {
|
||||||
|
const row = viewGame(game, target).turnActionPromises.findLast(promise => promise.player === target); assert.ok(row); return row;
|
||||||
|
}
|
||||||
|
export function nextTruthTurnCardPlayNative(game: Game): TruthTurnCardPlayStep | null {
|
||||||
|
if (game.truthtrance || game.pendingNullentropy) return null;
|
||||||
|
return nextRicheseStrongholdsNativeStep(game);
|
||||||
|
}
|
||||||
|
export function nextTruthTurnCardPlay(game: Game): TruthTurnCardPlayStep | null {
|
||||||
|
if (game.truthtrance) return null;
|
||||||
|
for (const player of game.players) {
|
||||||
|
const guide = viewGame(game, player.id).turnActionGuide;
|
||||||
|
if (guide) return { actor: player.id, action: guide.action };
|
||||||
|
}
|
||||||
|
return nextTruthTurnCardPlayNative(game);
|
||||||
|
}
|
||||||
|
export function advanceTruthTurnCardPlay(game: Game, until: (state: Game) => boolean,
|
||||||
|
actions: TruthTurnCardPlayStep[] = [], nativeOnly = false): Game {
|
||||||
|
for (let count = 0; count < 1800; count++) {
|
||||||
|
if (until(game)) return game;
|
||||||
|
assert.notEqual(game.status, 'finished', 'The original card-play source ended before its requested native boundary');
|
||||||
|
const next = nativeOnly ? nextTruthTurnCardPlayNative(game) : nextTruthTurnCardPlay(game);
|
||||||
|
assert.ok(next, 'Supply the original Truthtrance, paid private selection or unsealed native battle');
|
||||||
|
game = actTruthTurnCardPlay(game, next.actor, next.action, actions);
|
||||||
|
}
|
||||||
|
throw Error('The genuine card-play source did not reach its finite boundary');
|
||||||
|
}
|
||||||
|
export function settleTruthTurnCardPlay(game: Game, actions: TruthTurnCardPlayStep[] = []): Game {
|
||||||
|
return advanceTruthTurnCardPlay(game, truthTurnCardPlayClean, actions);
|
||||||
|
}
|
||||||
|
export function finishTruthTurnCardPlay(game: Game, actions: TruthTurnCardPlayStep[] = []): Game {
|
||||||
|
const turn = game.turn;
|
||||||
|
return advanceTruthTurnCardPlay(game, state => state.turn === turn + 1 && state.phase === 0 && truthTurnCardPlayClean(state), actions);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
PrintedCardPlayError,
|
||||||
|
printedCardPlayCount,
|
||||||
|
recordPrintedCardPlay,
|
||||||
|
type PrintedCardPlayCounts,
|
||||||
|
} from '../game/printed-card-play';
|
||||||
|
|
||||||
|
void test('missing legacy counts and other turn/player/effect baselines read as zero', () => {
|
||||||
|
assert.equal(printedCardPlayCount(undefined, 'atreides', 1, 'hajr'), 0);
|
||||||
|
const counts = recordPrintedCardPlay(undefined, 'atreides', 1, 'hajr');
|
||||||
|
assert.equal(printedCardPlayCount(counts, 'atreides', 1, 'hajr'), 1);
|
||||||
|
assert.equal(printedCardPlayCount(counts, 'atreides', 2, 'hajr'), 0);
|
||||||
|
assert.equal(printedCardPlayCount(counts, 'emperor', 1, 'hajr'), 0);
|
||||||
|
assert.equal(printedCardPlayCount(counts, 'atreides', 1, 'ghola'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('accepted printed-role plays count once per call and genuine replay increments without physical IDs', () => {
|
||||||
|
const first = recordPrintedCardPlay(undefined, 'atreides', 3, 'hajr');
|
||||||
|
const second = recordPrintedCardPlay(first, 'atreides', 3, 'hajr');
|
||||||
|
const ghola = recordPrintedCardPlay(second, 'atreides', 3, 'ghola');
|
||||||
|
const other = recordPrintedCardPlay(ghola, 'emperor', 3, 'hajr');
|
||||||
|
assert.deepEqual(other, { turn: 3, plays: [
|
||||||
|
{ player: 'atreides', effect: 'hajr', count: 2 }, { player: 'atreides', effect: 'ghola', count: 1 },
|
||||||
|
{ player: 'emperor', effect: 'hajr', count: 1 },
|
||||||
|
] });
|
||||||
|
assert.deepEqual(first, { turn: 3, plays: [{ player: 'atreides', effect: 'hajr', count: 1 }] });
|
||||||
|
assert.notEqual(second, first);
|
||||||
|
assert.notEqual(second.plays, first.plays);
|
||||||
|
assert.notEqual(second.plays[0], first.plays[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a newer turn resets the list while a past-turn write rejects without changing the baseline', () => {
|
||||||
|
const old = recordPrintedCardPlay(undefined, 'atreides', 3, 'hajr');
|
||||||
|
const next = recordPrintedCardPlay(old, 'emperor', 4, 'ghola');
|
||||||
|
assert.deepEqual(next, { turn: 4, plays: [{ player: 'emperor', effect: 'ghola', count: 1 }] });
|
||||||
|
assert.equal(printedCardPlayCount(next, 'atreides', 3, 'hajr'), 0);
|
||||||
|
const before = structuredClone(next);
|
||||||
|
assert.throws(() => recordPrintedCardPlay(next, 'atreides', 3, 'hajr'), PrintedCardPlayError);
|
||||||
|
assert.deepEqual(next, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('safe integer counts reject overflow immutably', () => {
|
||||||
|
const full: PrintedCardPlayCounts = { turn: 1, plays: [{ player: 'atreides', effect: 'hajr', count: Number.MAX_SAFE_INTEGER }] };
|
||||||
|
const before = structuredClone(full);
|
||||||
|
assert.equal(printedCardPlayCount(full, 'atreides', 1, 'hajr'), Number.MAX_SAFE_INTEGER);
|
||||||
|
assert.throws(() => recordPrintedCardPlay(full, 'atreides', 1, 'hajr'), PrintedCardPlayError);
|
||||||
|
assert.deepEqual(full, before);
|
||||||
|
assert.equal(recordPrintedCardPlay(full, 'atreides', 1, 'ghola').plays[1].count, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('strict stored counts reject malformed state even when another turn would reset or miss it', () => {
|
||||||
|
const play = { player: 'atreides', effect: 'hajr', count: 1 };
|
||||||
|
const invalid: unknown[] = [null, [], {}, { turn: 0, plays: [] }, { turn: 1, plays: null },
|
||||||
|
{ turn: 1, plays: [], source: 'card-id' }, { turn: 1, plays: [play, play] },
|
||||||
|
...[0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '1'].map(count => ({ turn: 1, plays: [{ ...play, count }] })),
|
||||||
|
{ turn: 1, plays: [{ ...play, player: ' atreides' }] }, { turn: 1, plays: [{ ...play, effect: 'karama' }] },
|
||||||
|
{ turn: 1, plays: [{ ...play, card: 'hajr' }] }, { turn: 1, plays: [{ ...play, printed: true }] }];
|
||||||
|
for (const value of invalid) {
|
||||||
|
assert.throws(() => printedCardPlayCount(value as PrintedCardPlayCounts, 'atreides', 2, 'hajr'), PrintedCardPlayError);
|
||||||
|
assert.throws(() => recordPrintedCardPlay(value as PrintedCardPlayCounts, 'atreides', 2, 'hajr'), PrintedCardPlayError);
|
||||||
|
}
|
||||||
|
assert.doesNotThrow(() => printedCardPlayCount({ turn: 1, plays: [] }, 'atreides', 1, 'hajr'));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('request metadata remains exact, safe and uncoerced for both lookup and recording', () => {
|
||||||
|
for (const [player, turn, effect] of [['', 1, 'hajr'], ['atreides ', 1, 'ghola'], ['atreides', 0, 'hajr'],
|
||||||
|
['atreides', 1.5, 'hajr'], ['atreides', Number.MAX_SAFE_INTEGER + 1, 'hajr'], ['atreides', '1', 'hajr'],
|
||||||
|
['atreides', 1, 'Ghola'], ['atreides', 1, 'karama']]) {
|
||||||
|
assert.throws(() => printedCardPlayCount(undefined, player as string, turn as number, effect as never), PrintedCardPlayError);
|
||||||
|
assert.throws(() => recordPrintedCardPlay(undefined, player as string, turn as number, effect as never), PrintedCardPlayError);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { TurnActionFields, turnActionInputError } from '../components/turn-actio
|
|||||||
import { TurnActionAnswers, TurnActionGuideButton, TurnActionPromises } from '../components/turn-action-promises';
|
import { TurnActionAnswers, TurnActionGuideButton, TurnActionPromises } from '../components/turn-action-promises';
|
||||||
import { TruthFactFields } from '../components/mixed-shipment-fields';
|
import { TruthFactFields } from '../components/mixed-shipment-fields';
|
||||||
import { BattleClaimFields } from '../components/battle-promises';
|
import { BattleClaimFields } from '../components/battle-promises';
|
||||||
|
import { baseDeck } from '../game/cards';
|
||||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView, type Plan } from '../game/engine';
|
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView, type Plan } from '../game/engine';
|
||||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||||
import type { MixedBattleActionClaim } from '../game/mixed-battle-question';
|
import type { MixedBattleActionClaim } from '../game/mixed-battle-question';
|
||||||
@@ -28,6 +29,7 @@ const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', minimum =
|
|||||||
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
|
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
|
||||||
const leaderRevival = (leader: string): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
|
const leaderRevival = (leader: string): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
|
||||||
const finalAlliance = (partner = 'r'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
|
const finalAlliance = (partner = 'r'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
|
||||||
|
const printedCard = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
|
||||||
const planClaim = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 3 }): Expression =>
|
const planClaim = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 3 }): Expression =>
|
||||||
({ kind: 'action', action: { kind: 'battlePlan', battle: 'controlled-native-battle', territory: 'arrakeen', claim } });
|
({ kind: 'action', action: { kind: 'battlePlan', battle: 'controlled-native-battle', territory: 'arrakeen', claim } });
|
||||||
const traitorClaim = (call = true): Expression =>
|
const traitorClaim = (call = true): Expression =>
|
||||||
@@ -73,6 +75,17 @@ function projected(phase = 5, next: Action = { type: 'move', forces: { 'arrakeen
|
|||||||
game.turnActionPromises = [{ event, player: 'p', asker: 'a', turn: 2, mixed: claim(), answer: true, fulfilled: false, released: false }];
|
game.turnActionPromises = [{ event, player: 'p', asker: 'a', turn: 2, mixed: claim(), answer: true, fulfilled: false, released: false }];
|
||||||
game.turnActionGuide = { event, action: { ...next, turnActionPromiseEvent: event, turnActionPromisePhase: phase } };
|
game.turnActionGuide = { event, action: { ...next, turnActionPromiseEvent: event, turnActionPromisePhase: phase } };
|
||||||
game.truthTurnActionAnswers = null;
|
game.truthTurnActionAnswers = null;
|
||||||
|
if (next.type === 'card' && typeof next.card === 'string') {
|
||||||
|
const effect = next.card.includes('hajr') ? 'hajr' : next.card.includes('ghola') ? 'ghola' : 'karama';
|
||||||
|
const face = baseDeck().find(card => card.effect === effect); assert.ok(face);
|
||||||
|
const me = game.players.find(player => player.id === 'p')!;
|
||||||
|
me.hand = [{ ...face, id: next.card }]; me.hajrAvailable = effect === 'hajr';
|
||||||
|
if (effect === 'ghola') {
|
||||||
|
me.tanks = 5; me.elites = { reserves: 4, tanks: 1, forces: {}, revived: 0 };
|
||||||
|
game.ghola = { cards: [next.card], leaders: [{ id: 'controlled-dead-leader', name: 'Returned Commander', strength: 3 }],
|
||||||
|
kwisatz: false, maxForces: 5, eliteRemaining: 1, eliteBlock: null, available: true, reason: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
return game;
|
return game;
|
||||||
}
|
}
|
||||||
function auction(next: Action = { type: 'passBid' }): GameView {
|
function auction(next: Action = { type: 'passBid' }): GameView {
|
||||||
@@ -479,6 +492,117 @@ void test('turn-action combat callbacks retain current battle ownership and raw
|
|||||||
assert.match(html, /You answered Yes/); assert.doesNotMatch(html, /controlled-native-battle|Fill the quoted battle plan|compiled/);
|
assert.match(html, /You answered Yes/); assert.doesNotMatch(html, /controlled-native-battle|Fill the quoted battle plan|compiled/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('turn-action printed-role editor uses public names without holdings or private stages and retains the shared logical budget', () => {
|
||||||
|
const game = projected();
|
||||||
|
for (const player of game.players) for (const key of ['hand', 'spice', 'traitors', 'faceDancers', 'tanks', 'leaders', 'hajrAvailable'])
|
||||||
|
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} authoring inspection`); }, configurable: true });
|
||||||
|
Object.defineProperty(game, 'ghola', { get() { throw Error('No private Ghola authoring inspection'); }, configurable: true });
|
||||||
|
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private card-play stage'); } });
|
||||||
|
const e = editor(physical(), false, game); select(e.tree(), '1', 'action', 'cardPlay');
|
||||||
|
assert.deepEqual(e.value, printedCard()); assert.equal(e.error(), null);
|
||||||
|
const choices = elements(find(e.tree(), 'id', 'truth-turn-action-1-effect')).filter(element => element.type === 'option');
|
||||||
|
assert.deepEqual(choices.map(choice => choice.props.value), ['hajr', 'ghola']);
|
||||||
|
select(e.tree(), '1', 'effect', 'ghola'); assert.deepEqual(e.value, printedCard('ghola')); assert.equal(e.error(), null);
|
||||||
|
select(e.tree(), '1', 'effect', 'karama'); assert.deepEqual(e.value, printedCard('ghola'));
|
||||||
|
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect: 'ghola', card: 'private-card-id' } }, game, 'p'));
|
||||||
|
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect: 'karama' } }, game, 'p'));
|
||||||
|
const html = e.html(); assert.match(html, /Play Hajr in its printed role after this answer this turn/);
|
||||||
|
assert.match(html, /Play Tleilaxu Ghola in its printed role after this answer this turn/);
|
||||||
|
assert.match(html, /Hajr counts when played even if its extra movement is unused/);
|
||||||
|
assert.match(html, /an actual later replay after Box recovery does/); assert.doesNotMatch(html, /private-card-id|controlled-ghola|controlled-hajr/);
|
||||||
|
const full = editor({ kind: 'or', terms: [printedCard(), printedCard('ghola')] }, false, game);
|
||||||
|
for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'cardPlay'); }
|
||||||
|
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
|
||||||
|
assert.equal(control(full.tree(), 'add-action-1').props.disabled, true);
|
||||||
|
const deep = editor({ kind: 'and', terms: [fact(), printedCard('ghola')] }, false, game);
|
||||||
|
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
|
||||||
|
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
|
||||||
|
const frozen = editor(printedCard(), true, game); select(frozen.tree(), '1', 'effect', 'ghola');
|
||||||
|
select(frozen.tree(), '1', 'action', 'payment'); assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, printedCard());
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('printed Ghola guides forward exact native force, leader and KH choices out of turn and reject unavailable or forged choices', () => {
|
||||||
|
for (let phase = 0; phase <= 8; phase++) {
|
||||||
|
const game = projected(phase, { type: 'card', card: 'controlled-ghola', amount: 3, elite: 1 }); game.active = 'a'; game.advanced = true;
|
||||||
|
game.turnActionPromises[0].mixed = printedCard('ghola');
|
||||||
|
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Ghola stage inspection'); } });
|
||||||
|
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||||||
|
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||||||
|
assert.match(markup(game), /Play Tleilaxu Ghola to return 3 physical forces/);
|
||||||
|
}
|
||||||
|
for (const leader of ['controlled-dead-leader', 'kwisatz']) {
|
||||||
|
const game = projected(7, { type: 'card', card: 'controlled-ghola', leader }); game.active = 'a';
|
||||||
|
if (leader === 'kwisatz') { game.advanced = true; game.players.find(player => player.id === 'p')!.faction = 'atreides'; game.ghola.kwisatz = true; }
|
||||||
|
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||||||
|
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||||||
|
assert.match(markup(game), leader === 'kwisatz' ? /return Kwisatz Haderach/ : /return Returned Commander/);
|
||||||
|
if (leader === 'kwisatz') game.ghola.kwisatz = false; else game.ghola.leaders = [];
|
||||||
|
press(button); assert.equal(sent.length, 1);
|
||||||
|
}
|
||||||
|
for (const change of [
|
||||||
|
(game: GameView) => { game.ghola = { ...game.ghola }; }, (game: GameView) => { game.ghola.available = false; },
|
||||||
|
(game: GameView) => { game.ghola.cards = []; }, (game: GameView) => { game.ghola.maxForces = 0; },
|
||||||
|
(game: GameView) => { game.ghola.eliteRemaining = 0; }, (game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
|
||||||
|
(game: GameView) => { game.phase = 7; }, (game: GameView) => { game.truthtrance = answering(2).truthtrance; },
|
||||||
|
]) {
|
||||||
|
const game = projected(2, { type: 'card', card: 'controlled-ghola', amount: 3, elite: 1 }), sent: Action[] = [];
|
||||||
|
const button = guide({ game, busy: false, act(action) { sent.push(action); } }); assert.equal(button.props.disabled, false);
|
||||||
|
change(game); press(button); assert.deepEqual(sent, []);
|
||||||
|
}
|
||||||
|
for (const malformed of [
|
||||||
|
{ card: 'missing-ghola' }, { amount: 0 }, { amount: 6 }, { amount: 1.5 }, { amount: 3, elite: 2 }, { amount: 3, elite: -1 },
|
||||||
|
{ amount: 5, elite: 0 }, { leader: 'foreign-living-leader' }, { mode: 'shipment' },
|
||||||
|
]) {
|
||||||
|
const game = projected(2, { type: 'card', card: 'controlled-ghola', amount: 3, elite: 1 });
|
||||||
|
Object.assign(game.turnActionGuide!.action, malformed);
|
||||||
|
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||||||
|
assert.equal(button.props.disabled, true); press(button); assert.deepEqual(sent, []);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('printed Hajr guides require the actual held card and current own immediate Movement opportunity', () => {
|
||||||
|
for (const advanced of [false, true]) {
|
||||||
|
const game = projected(5, { type: 'card', card: 'controlled-hajr' }); game.advanced = advanced;
|
||||||
|
game.turnActionPromises[0].mixed = printedCard();
|
||||||
|
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Hajr stage inspection'); } });
|
||||||
|
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||||||
|
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||||||
|
assert.match(markup(game), /Play Hajr in its printed role while honoring your whole answer/);
|
||||||
|
}
|
||||||
|
for (const change of [
|
||||||
|
(game: GameView) => { game.players.find(player => player.id === 'p')!.hajrAvailable = false; },
|
||||||
|
(game: GameView) => { const player = game.players.find(player => player.id === 'p')!; player.moved = (player.moved ?? 0) + 1; },
|
||||||
|
(game: GameView) => { game.players.find(player => player.id === 'p')!.hajrPlays = 1; },
|
||||||
|
(game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
|
||||||
|
(game: GameView) => { game.active = 'a'; }, (game: GameView) => { game.phase = 4; },
|
||||||
|
(game: GameView) => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||||||
|
]) {
|
||||||
|
const game = projected(5, { type: 'card', card: 'controlled-hajr' }), sent: Action[] = [];
|
||||||
|
const button = guide({ game, busy: false, act(action) { sent.push(action); } }); assert.equal(button.props.disabled, false);
|
||||||
|
change(game); press(button); assert.deepEqual(sent, []);
|
||||||
|
}
|
||||||
|
for (const phase of [0, 2, 4, 6, 8]) {
|
||||||
|
const game = projected(phase, { type: 'card', card: 'controlled-hajr' }), sent: Action[] = [];
|
||||||
|
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||||||
|
assert.equal(button.props.disabled, true); press(button); assert.deepEqual(sent, []);
|
||||||
|
}
|
||||||
|
const missing = projected(5, { type: 'card', card: 'controlled-hajr' }); missing.turnActionGuide!.action.card = 'missing-hajr';
|
||||||
|
assert.equal(guide({ game: missing, busy: false, act() { assert.fail('A missing Hajr cannot dispatch'); } }).props.disabled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('printed-card history retains only raw aggregate conditions and their distinct native deadlines', () => {
|
||||||
|
const game = projected(8); game.turnActionGuide = null;
|
||||||
|
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard(), { kind: 'and', terms: [fact(), printedCard('ghola')] }] };
|
||||||
|
for (const key of ['cardPlays', 'compiled', 'combat', 'preparation'])
|
||||||
|
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||||||
|
const html = markup(game);
|
||||||
|
assert.match(html, /Hajr/); assert.match(html, /Tleilaxu Ghola/); assert.match(html, /You answered Yes/);
|
||||||
|
assert.match(html, /Hajr counts even if its extra movement is unused/);
|
||||||
|
assert.match(html, /Earlier plays, discards, sales, Karama roles and recovery alone do not count/);
|
||||||
|
assert.match(html, /actual later Box replay does/); assert.match(html, /Ghola is separate from ordinary Revival/);
|
||||||
|
assert.match(html, /remains open until the actual turn ends/); assert.doesNotMatch(html, /cardPlays|compiled|controlled-hajr|controlled-ghola/);
|
||||||
|
});
|
||||||
|
|
||||||
void test('turn-action history retains original own whole questions and terminal status without private compiled branches', () => {
|
void test('turn-action history retains original own whole questions and terminal status without private compiled branches', () => {
|
||||||
const game = projected(); game.turnActionGuide = null;
|
const game = projected(); game.turnActionGuide = null;
|
||||||
game.turnActionPromises.push(
|
game.turnActionPromises.push(
|
||||||
@@ -534,7 +658,7 @@ void test('turn-action ordinary-payment guide retains native out-of-turn timing
|
|||||||
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||||||
assert.match(markup(game), /Pay 3 spice to Recipient while honoring your whole answer/);
|
assert.match(markup(game), /Pay 3 spice to Recipient while honoring your whole answer/);
|
||||||
if (phase === 8) assert.match(markup(game), /Late recipients collect next turn/);
|
if (phase === 8) assert.match(markup(game), /Late payment recipients collect next turn/);
|
||||||
}
|
}
|
||||||
const game = projected(8, { type: 'ready' }); game.active = null;
|
const game = projected(8, { type: 'ready' }); game.active = null;
|
||||||
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||||
@@ -570,7 +694,7 @@ void test('turn-action current early native opportunities retain exact preparati
|
|||||||
auction({ type: 'decision', karama: false }), auction({ type: 'decision', karama: true, card: 'controlled-owned-karama' }),
|
auction({ type: 'decision', karama: false }), auction({ type: 'decision', karama: true, card: 'controlled-owned-karama' }),
|
||||||
revival(), revival({ type: 'ready' }), revival({ type: 'reviveLeader', leader: 'controlled-leader', early: true, allyPayment: 2 }),
|
revival(), revival({ type: 'ready' }), revival({ type: 'reviveLeader', leader: 'controlled-leader', early: true, allyPayment: 2 }),
|
||||||
revival({ type: 'acceptLeaderRevival', leaderRevivalPrice: 3 }), revival({ type: 'reviveKwisatz' }),
|
revival({ type: 'acceptLeaderRevival', leaderRevivalPrice: 3 }), revival({ type: 'reviveKwisatz' }),
|
||||||
revival({ type: 'card', card: 'controlled-ghola' }), richese('silent'), richese('onceAround'),
|
revival({ type: 'card', card: 'controlled-ghola', amount: 1, elite: 0 }), richese('silent'), richese('onceAround'),
|
||||||
];
|
];
|
||||||
for (const game of cases) {
|
for (const game of cases) {
|
||||||
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||||
|
import { viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { truthFactAnswer } from '../game/truthtrance';
|
||||||
|
import {
|
||||||
|
actTruthTurnCardPlay as act, advanceTruthTurnCardPlay as advance, answerTruthTurnCardPlay as answer,
|
||||||
|
askTruthTurnCardPlay as ask, bindTruthTurnCardPlay as bind, createTruthTurnCardPlayFixture as fixture,
|
||||||
|
declareTruthTurnCardPlay as declare, finishTruthTurnCardPlay as finish, nextTruthTurnCardPlayNative as nativeStep,
|
||||||
|
settleTruthTurnCardPlay as settle, truthTurnCardPlayClean as clean, truthTurnCardPlayFact as fact,
|
||||||
|
truthTurnCardPlayForce as force, truthTurnCardPlayGroup as group, truthTurnCardPlayInventory as inventory,
|
||||||
|
truthTurnCardPlayPayment as payment, truthTurnCardPlayPlayer as player, truthTurnCardPlayPromise as promise,
|
||||||
|
truthTurnCardPlayShip as ship, truthTurnCardPlayUse as use,
|
||||||
|
type TruthTurnCardPlayFixture, type TruthTurnCardPlayStep,
|
||||||
|
} from './fixture-truth-turn-card-play';
|
||||||
|
|
||||||
|
function rejected(game: Game, actor: string, action: Action) {
|
||||||
|
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||||
|
}
|
||||||
|
function physical(game: Game, f: TruthTurnCardPlayFixture) {
|
||||||
|
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
|
||||||
|
assert.deepEqual(inventory(game), f.originalCards);
|
||||||
|
}
|
||||||
|
const quotas = (game: Game, target: string) => {
|
||||||
|
const owner = player(game, target);
|
||||||
|
return [owner.revived, owner.freeForcesRevived ?? 0, owner.leaderRevived];
|
||||||
|
};
|
||||||
|
|
||||||
|
void test('native printed-card adapters retain original deals, actual casualties and counted action prefixes', () => {
|
||||||
|
for (const options of [
|
||||||
|
{ source: 'hajr', early: true }, { source: 'hajr' }, { source: 'hajr', advanced: true },
|
||||||
|
{ source: 'gholaForces' }, { source: 'gholaRevival' }, { source: 'gholaLeader' }, { source: 'hajrReplay' },
|
||||||
|
] as const) {
|
||||||
|
const f = fixture(options); assert.equal(f.setup.status, 'setup');
|
||||||
|
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
|
||||||
|
assert.ok(player(f.game, f.asker).hand.some(card => card.id === f.truth));
|
||||||
|
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceActionCount, f.actions.length);
|
||||||
|
assert.equal(f.sourceCounts.selected, f.sourceActionCount); assert.equal(f.sourceCounts.inherited, f.sourceActionCount);
|
||||||
|
assert.equal(f.game.turnActionPromises?.length ?? 0, 0); assert.deepEqual(inventory(f.game), f.originalCards);
|
||||||
|
if (f.sourceKind === 'hajr') {
|
||||||
|
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.card));
|
||||||
|
assert.equal(f.sourceActionCount, f.early ? 13 : f.advanced ? 23 : 19);
|
||||||
|
} else if (f.sourceKind === 'hajrReplay') {
|
||||||
|
assert.equal(f.advanced, true); assert.equal(f.game.advanced, true); assert.ok(!f.game.semutaPreview);
|
||||||
|
assert.equal(player(f.game, f.target).hajrPlays, 1); assert.equal(player(f.game, f.target).moved, 1);
|
||||||
|
assert.equal(player(f.game, f.target).spice, 2); assert.equal(f.game.discard.filter(card => card.id === f.card).length, 1);
|
||||||
|
assert.equal(f.hajrReplay!.sourceCounts.inherited + f.hajrReplay!.sourceCounts.nativeContinuation, f.sourceActionCount);
|
||||||
|
} else {
|
||||||
|
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.card));
|
||||||
|
assert.equal(f.game.turn, 2); assert.ok(player(f.game, f.target).tanks > 0);
|
||||||
|
if (f.sourceKind === 'gholaLeader') assert.equal(player(f.game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native Basic Hajr AND payment records the actual printed play without requiring use of its extra movement', () => {
|
||||||
|
const f = fixture(), mixed = group('and', fact(f.spiceFact), use('hajr'), payment(f.recipient));
|
||||||
|
const initial = structuredClone(f.game); let game = bind(f, 'yes', mixed).game;
|
||||||
|
assert.deepEqual(f.game, initial); rejected(game, f.target, f.end);
|
||||||
|
game = act(game, f.target, f.payment); assert.equal(player(game, f.target).spice, 8);
|
||||||
|
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
|
||||||
|
assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false);
|
||||||
|
game = settle(act(game, f.target, f.cardAction));
|
||||||
|
assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, true);
|
||||||
|
assert.equal(player(game, f.target).hajrPlays, 1); assert.equal(player(game, f.target).moved, 0);
|
||||||
|
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(promise(game, f.target).mixed, mixed);
|
||||||
|
const receipt = structuredClone(game.turnActionPromises![0].cardPlays);
|
||||||
|
game = settle(act(game, f.target, f.end));
|
||||||
|
assert.equal(player(game, f.target).moved, 0); assert.deepEqual(game.turnActionPromises![0].cardPlays, receipt);
|
||||||
|
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native Advanced Hajr guide first earns its real completed-move timing and leaves the extra movement optional', () => {
|
||||||
|
const f = fixture({ advanced: true }); let game = bind(f).game;
|
||||||
|
game = act(game, f.target, f.payment); rejected(game, f.target, f.cardAction);
|
||||||
|
assert.equal(player(game, f.target).moved, 0); assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false);
|
||||||
|
const preparation = viewGame(game, f.target).turnActionGuide; assert.ok(preparation);
|
||||||
|
assert.equal(preparation.action.type, 'move');
|
||||||
|
game = settle(act(game, f.target, f.firstMove)); assert.equal(player(game, f.target).moved, 1);
|
||||||
|
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||||
|
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.card);
|
||||||
|
game = settle(act(game, f.target, guide.action)); assert.equal(promise(game, f.target).fulfilled, true);
|
||||||
|
game = settle(act(game, f.target, f.end)); assert.equal(player(game, f.target).moved, 1);
|
||||||
|
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native whole Hajr AND payment No preserves either component and rejects only actual whole completion atomically', () => {
|
||||||
|
const f = fixture();
|
||||||
|
for (const first of ['payment', 'card'] as const) {
|
||||||
|
let game = bind(f, 'no').game;
|
||||||
|
if (first === 'payment') {
|
||||||
|
game = act(game, f.target, f.payment); rejected(game, f.target, f.cardAction);
|
||||||
|
assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false);
|
||||||
|
} else {
|
||||||
|
game = settle(act(game, f.target, f.cardAction)); rejected(game, f.target, f.payment);
|
||||||
|
game = act(game, f.target, { ...f.payment, amount: 1 });
|
||||||
|
rejected(game, f.target, { ...f.payment, amount: 1 });
|
||||||
|
}
|
||||||
|
game = settle(act(game, f.target, f.end)); assert.ok(!promise(game, f.target).released);
|
||||||
|
game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native Hajr OR payment retains direct play and own-Movement-close then late-payment routes', () => {
|
||||||
|
const f = fixture(), mixed = group('or', use('hajr'), payment(f.recipient)), bound = bind(f, 'yes', mixed).game;
|
||||||
|
const played = settle(act(bound, f.target, f.cardAction));
|
||||||
|
assert.equal(promise(played, f.target).fulfilled, true); assert.equal(player(played, f.recipient).bribes, 0);
|
||||||
|
let paid = settle(act(bound, f.target, f.end));
|
||||||
|
assert.equal(paid.turnActionPromises![0].cardPlays?.hajr?.closed, true);
|
||||||
|
assert.equal(paid.turnActionPromises![0].cardPlays?.hajr?.played, false); assert.ok(!promise(paid, f.target).fulfilled);
|
||||||
|
paid = advance(paid, state => state.phase === 8 && clean(state), [], true);
|
||||||
|
rejected(paid, f.target, { type: 'ready' }); paid = act(paid, f.target, f.payment);
|
||||||
|
assert.equal(promise(paid, f.target).fulfilled, true);
|
||||||
|
assert.ok(player(paid, f.target).hand.some(card => card.id === f.card));
|
||||||
|
for (const state of [played, paid]) physical(state, f);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native held Ghola force play AND payment returns the actual Storm casualty before a separately paid shipment', () => {
|
||||||
|
const f = fixture({ source: 'gholaForces' }), mixed = group('and', use('ghola'), payment(f.recipient), ship());
|
||||||
|
let game = bind(f, 'yes', mixed).game; const ordinary = quotas(game, f.target);
|
||||||
|
assert.equal(player(game, f.target).tanks, 1); assert.equal(player(game, f.target).reserves, 9);
|
||||||
|
game = act(game, f.target, f.payment); game = settle(act(game, f.target, f.cardAction));
|
||||||
|
assert.equal(player(game, f.target).spice, 8); assert.equal(player(game, f.target).tanks, 0);
|
||||||
|
assert.equal(player(game, f.target).reserves, 10); assert.deepEqual(quotas(game, f.target), ordinary);
|
||||||
|
assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.played, true); assert.ok(!promise(game, f.target).fulfilled);
|
||||||
|
rejected(game, f.target, f.end); game = settle(act(game, f.target, f.shipment));
|
||||||
|
assert.equal(player(game, f.target).spice, 6); assert.equal(player(game, f.target).reserves, 9);
|
||||||
|
assert.equal(player(game, f.target).forces['polar_sink:0'], 1); assert.equal(promise(game, f.target).fulfilled, true);
|
||||||
|
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native Ghola OR payment preserves either actual late Mentat play or payment after own Movement has ended', () => {
|
||||||
|
const f = fixture({ source: 'gholaForces' }), mixed = group('or', use('ghola'), payment(f.recipient));
|
||||||
|
let late = bind(f, 'yes', mixed).game; late = settle(act(late, f.target, f.end));
|
||||||
|
assert.equal(late.turnActionPromises![0].cardPlays?.ghola?.closed, false);
|
||||||
|
late = advance(late, state => state.phase === 8 && clean(state), [], true);
|
||||||
|
rejected(late, f.target, { type: 'ready' });
|
||||||
|
for (const action of [f.cardAction, f.payment]) {
|
||||||
|
const game = settle(act(late, f.target, action)); assert.equal(promise(game, f.target).fulfilled, true);
|
||||||
|
assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.played, action.type === 'card');
|
||||||
|
assert.equal(player(game, f.target).tanks, action.type === 'card' ? 0 : 1); physical(game, f);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('controlled known Double payment expiry retains the actual late Ghola alternative', () => {
|
||||||
|
const f = fixture({ source: 'gholaForces' });
|
||||||
|
// Preserve the actual played source, held card and casualty. Only the public
|
||||||
|
// token is controlled; this does not assert an original CHOAM source journey.
|
||||||
|
const controlled = structuredClone(f.game);
|
||||||
|
controlled.inflation = { side: 'cancel', placedTurn: 0, updatedTurn: 0, flipped: false };
|
||||||
|
for (const mode of ['or', 'and'] as const) {
|
||||||
|
const declared = declare(f, controlled).game;
|
||||||
|
const asked = ask(declared, f.asker, f.target, group(mode, use('ghola'), payment(f.recipient)));
|
||||||
|
let game = answer(asked, f.target, 'yes');
|
||||||
|
game = settle(act(game, f.target, f.end));
|
||||||
|
game = advance(game, state => state.phase === 7 && clean(state), [], true);
|
||||||
|
assert.equal(game.inflation?.side, 'cancel');
|
||||||
|
if (mode === 'and') {
|
||||||
|
rejected(game, f.target, { type: 'ready' });
|
||||||
|
game = act(game, f.target, f.payment);
|
||||||
|
}
|
||||||
|
game = act(game, f.target, { type: 'ready' });
|
||||||
|
assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.closed, false);
|
||||||
|
assert.ok(!promise(game, f.target).fulfilled);
|
||||||
|
game = advance(game, state => state.phase === 8 && clean(state), [], true);
|
||||||
|
assert.equal(game.inflation?.side, 'double');
|
||||||
|
rejected(game, f.target, f.payment);
|
||||||
|
rejected(game, f.target, { type: 'ready' });
|
||||||
|
game = settle(act(game, f.target, f.cardAction));
|
||||||
|
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native whole Ghola OR payment No forbids printed use and cumulative payment through actual turn end', () => {
|
||||||
|
const f = fixture({ source: 'gholaForces' }), mixed = group('or', use('ghola'), payment(f.recipient));
|
||||||
|
let game = bind(f, 'no', mixed).game; rejected(game, f.target, f.cardAction); rejected(game, f.target, f.payment);
|
||||||
|
game = act(game, f.target, { ...f.payment, amount: 1 }); game = settle(act(game, f.target, f.end));
|
||||||
|
game = advance(game, state => state.phase === 8 && clean(state), [], true);
|
||||||
|
rejected(game, f.target, f.cardAction); rejected(game, f.target, { ...f.payment, amount: 1 });
|
||||||
|
assert.ok(!promise(game, f.target).fulfilled); game = finish(game);
|
||||||
|
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.played, false);
|
||||||
|
assert.ok(player(game, f.target).hand.some(card => card.id === f.card)); physical(game, f);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native held Ghola and ordinary returns share the original Tanks and purse without double reservation or event substitution', () => {
|
||||||
|
const f = fixture({ source: 'gholaRevival' }); assert.equal(player(f.game, f.target).tanks, 3);
|
||||||
|
const overbooked = group('and', use('ghola'), force(3));
|
||||||
|
const asked = ask(declare(f).game, f.asker, f.target, overbooked);
|
||||||
|
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
|
||||||
|
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||||
|
const mixed = group('and', use('ghola'), force(2), payment(f.recipient));
|
||||||
|
let game = bind(f, 'yes', mixed).game; game = settle(act(game, f.target, f.ordinaryForces));
|
||||||
|
assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).tanks, 1);
|
||||||
|
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 2);
|
||||||
|
assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.played, false);
|
||||||
|
game = act(game, f.target, f.payment); assert.equal(player(game, f.target).spice, 0);
|
||||||
|
assert.ok(!promise(game, f.target).fulfilled); const ordinary = quotas(game, f.target);
|
||||||
|
game = settle(act(game, f.target, f.cardAction));
|
||||||
|
assert.equal(player(game, f.target).reserves, 20); assert.equal(player(game, f.target).tanks, 0);
|
||||||
|
assert.deepEqual(quotas(game, f.target), ordinary);
|
||||||
|
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 2);
|
||||||
|
assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.played, true);
|
||||||
|
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||||
|
// A separate original branch actually uses all three ordinary returns before
|
||||||
|
// asking: an ordinary return never becomes an earlier printed Ghola use.
|
||||||
|
const returned = settle(act(f.game, f.target, { type: 'revive', amount: 3, elite: 0 }));
|
||||||
|
assert.equal(player(returned, f.target).tanks, 0); assert.equal(player(returned, f.target).spice, 0);
|
||||||
|
const unavailable = ask(declare(f, returned).game, f.asker, f.target, use('ghola'));
|
||||||
|
assert.deepEqual(viewGame(unavailable, f.target).truthTurnActionAnswers, ['no']);
|
||||||
|
assert.ok(player(unavailable, f.target).hand.some(card => card.id === f.card));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native personally dead Hidar returns by the actual held Ghola alongside payment without spending ordinary leader revival', () => {
|
||||||
|
const f = fixture({ source: 'gholaLeader' }); assert.ok(f.leader);
|
||||||
|
let game = bind(f).game; const ordinary = quotas(game, f.target), dead = structuredClone(player(game, f.target).leaders.find(disc => disc.id === f.leader)!);
|
||||||
|
assert.equal(dead.dead, true); assert.equal(dead.deaths, 1); game = act(game, f.target, f.payment);
|
||||||
|
game = settle(act(game, f.target, f.cardAction));
|
||||||
|
const returned = player(game, f.target).leaders.find(disc => disc.id === f.leader)!;
|
||||||
|
assert.equal(returned.dead, false); assert.equal(returned.deaths, 1); assert.equal(returned.usedAt, undefined);
|
||||||
|
// The original four spice pay two, then native Ghola's allowed Tleilaxu
|
||||||
|
// income response pays the printed one spice; it is not ordinary Revival.
|
||||||
|
assert.equal(player(game, f.target).spice, 3); assert.deepEqual(quotas(game, f.target), ordinary);
|
||||||
|
assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.played, true);
|
||||||
|
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||||
|
physical(game, f);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native Advanced printed Ghola returns an original Storm-killed Sardaukar while retaining typed ordinary quotas', () => {
|
||||||
|
const f = fixture({ source: 'gholaRevival', advanced: true });
|
||||||
|
let game = bind(f).game; const ordinary = quotas(game, f.target), reserveElite = player(game, f.target).elites!.reserves;
|
||||||
|
assert.equal(player(game, f.target).elites!.tanks, 1);
|
||||||
|
game = act(game, f.target, f.payment); game = settle(act(game, f.target, f.cardAction));
|
||||||
|
assert.equal(player(game, f.target).elites!.tanks, 0); assert.equal(player(game, f.target).elites!.reserves, reserveElite + 1);
|
||||||
|
assert.equal(player(game, f.target).tanks, 2); assert.deepEqual(quotas(game, f.target), ordinary);
|
||||||
|
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native earlier Hajr use does not satisfy a new interval but an owned paid Box can recover and replay the same physical card', () => {
|
||||||
|
const f = fixture({ source: 'hajrReplay' }); assert.ok(f.box);
|
||||||
|
let game = bind(f).game; assert.equal(player(game, f.target).hajrPlays, 1);
|
||||||
|
assert.equal(player(game, f.target).moved, 1); assert.equal(game.advanced, true); assert.ok(!game.semutaPreview);
|
||||||
|
assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false); assert.ok(!promise(game, f.target).fulfilled);
|
||||||
|
const actions: TruthTurnCardPlayStep[] = [];
|
||||||
|
for (let count = 0; count < 6 && !promise(game, f.target).fulfilled; count++) {
|
||||||
|
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||||
|
if (count === 0) {
|
||||||
|
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.box);
|
||||||
|
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'old-box-guide' });
|
||||||
|
rejected(game, f.target, { ...guide.action, selected: f.card });
|
||||||
|
}
|
||||||
|
const originalPacket = structuredClone(guide.action);
|
||||||
|
game = settle(act(game, f.target, guide.action, actions), actions);
|
||||||
|
assert.deepEqual(guide.action, originalPacket);
|
||||||
|
}
|
||||||
|
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.target).spice, 0);
|
||||||
|
assert.equal(player(game, f.target).moved, 2); assert.equal(player(game, f.target).hajrPlays, 2);
|
||||||
|
assert.ok(actions.some(step => step.action.type === 'card' && step.action.card === f.box));
|
||||||
|
assert.ok(actions.some(step => step.action.type === 'decision' && step.action.card === f.card));
|
||||||
|
const recoveredAt = actions.findIndex(step => step.action.type === 'decision' && step.action.card === f.card);
|
||||||
|
const moveAt = actions.findIndex(step => step.action.type === 'move');
|
||||||
|
const replayAt = actions.findIndex(step => step.action.type === 'card' && step.action.card === f.card);
|
||||||
|
assert.ok(recoveredAt >= 0 && moveAt > recoveredAt && replayAt > moveAt,
|
||||||
|
'The actual second move must earn timing after paid recovery and before replay');
|
||||||
|
assert.equal(actions.filter(step => step.action.type === 'move').length, 1);
|
||||||
|
assert.ok(actions.some(step => step.action.type === 'card' && step.action.card === f.card));
|
||||||
|
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native earlier printed Hajr without a held recovery route leaves only No for another future play', () => {
|
||||||
|
const f = fixture(); const played = settle(act(f.game, f.target, f.cardAction));
|
||||||
|
assert.equal(player(played, f.target).hajrPlays, 1);
|
||||||
|
const asked = ask(declare(f, played).game, f.asker, f.target, use('hajr'));
|
||||||
|
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
|
||||||
|
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||||
|
let game = answer(asked, f.target, 'no'); assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false);
|
||||||
|
game = settle(act(game, f.target, f.end)); assert.equal(promise(game, f.target).fulfilled, true);
|
||||||
|
assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false); physical(game, f);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native printed-card guides retain exact event phase card and Ghola typed packets with only raw own history projected', () => {
|
||||||
|
for (const source of ['hajr', 'gholaForces'] as const) {
|
||||||
|
const f = fixture({ source }); let game = bind(f).game; game = act(game, f.target, f.payment);
|
||||||
|
const row = promise(game, f.target), allowed = ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'];
|
||||||
|
assert.ok(Object.keys(row).every(key => allowed.includes(key)));
|
||||||
|
assert.deepEqual(viewGame(game, f.target).truthHistory.at(-1)?.question, f.question);
|
||||||
|
assert.deepEqual(viewGame(game, f.asker).turnActionPromises, []); assert.equal(viewGame(game, f.asker).turnActionGuide, null);
|
||||||
|
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||||
|
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.card);
|
||||||
|
assert.equal(guide.event, row.event); assert.equal(guide.action.turnActionPromiseEvent, row.event);
|
||||||
|
assert.equal(guide.action.turnActionPromisePhase, game.phase);
|
||||||
|
if (source === 'gholaForces') { assert.equal(guide.action.amount, 1); assert.equal(guide.action.elite, 0); }
|
||||||
|
rejected(game, f.asker, guide.action);
|
||||||
|
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-card-play-event' });
|
||||||
|
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 4 });
|
||||||
|
rejected(game, f.target, { ...guide.action, card: f.truth });
|
||||||
|
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, wire));
|
||||||
|
assert.equal(promise(game, f.target).fulfilled, true); rejected(game, f.target, wire); physical(game, f);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('all four minimal profiles actually complete early Basic Hajr and native Ghola force or leader guides', () => {
|
||||||
|
for (const options of [{ source: 'hajr', early: true }, { source: 'gholaForces' }, { source: 'gholaLeader' }] as const) {
|
||||||
|
const f = fixture(options), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
|
||||||
|
for (const difficulty of DIFFICULTIES) {
|
||||||
|
let game = structuredClone(asked); const actions: TruthTurnCardPlayStep[] = [];
|
||||||
|
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||||
|
const response = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(response, difficulty);
|
||||||
|
assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions);
|
||||||
|
for (let count = 0; count < 100 && !promise(game, f.target).fulfilled; count++) {
|
||||||
|
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||||
|
const guide = view.turnActionGuide;
|
||||||
|
if (guide) {
|
||||||
|
const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.event);
|
||||||
|
assert.ok(action, `${f.sourceKind}/${difficulty}`); assert.deepEqual(action, guide.action);
|
||||||
|
game = settle(act(game, f.target, action, actions), actions);
|
||||||
|
} else {
|
||||||
|
const next = nativeStep(game); assert.ok(next, `${f.sourceKind}/${difficulty}`);
|
||||||
|
game = act(game, next.actor, next.action, actions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(promise(game, f.target).fulfilled, true, `${f.sourceKind}/${difficulty}`);
|
||||||
|
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'bribe'));
|
||||||
|
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'card' && step.action.card === f.card));
|
||||||
|
assert.equal(game.turnActionPromises![0].cardPlays?.[f.effect]?.played, true); physical(game, f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -10,12 +10,14 @@ import {
|
|||||||
TurnActionError,
|
TurnActionError,
|
||||||
closeTurnActionBidding,
|
closeTurnActionBidding,
|
||||||
closeTurnActionCombat,
|
closeTurnActionCombat,
|
||||||
|
closeTurnActionPayments,
|
||||||
closeTurnActionRevival,
|
closeTurnActionRevival,
|
||||||
closeTurnActionTransport,
|
closeTurnActionTransport,
|
||||||
finishTurnActionTurn,
|
finishTurnActionTurn,
|
||||||
liveTurnActionPromises,
|
liveTurnActionPromises,
|
||||||
parseTurnActionLeaf,
|
parseTurnActionLeaf,
|
||||||
recordTurnActionBid,
|
recordTurnActionBid,
|
||||||
|
recordTurnActionCardPlay,
|
||||||
recordTurnActionPlan,
|
recordTurnActionPlan,
|
||||||
recordTurnActionTraitor,
|
recordTurnActionTraitor,
|
||||||
recordTurnActionMovement,
|
recordTurnActionMovement,
|
||||||
@@ -48,6 +50,7 @@ const plan = (claim: TurnActionPlanValue['claim'] = { kind: 'dial', compare: 'gt
|
|||||||
({ kind: 'action', action: { kind: 'battlePlan', battle, territory: 'carthag', claim } });
|
({ kind: 'action', action: { kind: 'battlePlan', battle, territory: 'carthag', claim } });
|
||||||
const call = (call = true, battle = 'battle:1'): Expression =>
|
const call = (call = true, battle = 'battle:1'): Expression =>
|
||||||
({ kind: 'action', action: { kind: 'traitorCall', battle, territory: 'carthag', call } });
|
({ kind: 'action', action: { kind: 'traitorCall', battle, territory: 'carthag', call } });
|
||||||
|
const card = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
|
||||||
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
|
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
|
||||||
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
|
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
|
||||||
const signedTravel = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3,
|
const signedTravel = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3,
|
||||||
@@ -80,6 +83,11 @@ function combat(mixed: Expression, answer = true, id = 'turn-action:1'): TurnAct
|
|||||||
leader: 'other-0', closed: false, call: null } } };
|
leader: 'other-0', closed: false, call: null } } };
|
||||||
}
|
}
|
||||||
const dial = (value = true): TurnActionPlanValue => ({ claim: { kind: 'dial', compare: 'gte', value: 2 }, value });
|
const dial = (value = true): TurnActionPlanValue => ({ claim: { kind: 'dial', compare: 'gte', value: 2 }, value });
|
||||||
|
function cards(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise {
|
||||||
|
return { ...promise(mixed, answer, id), cardPlays: {
|
||||||
|
hajr: { played: false, closed: false }, ghola: { played: false, closed: false },
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
|
||||||
void test('turn-action leaves retain exact native fields, safe payment minima and no payment twenty-force cap', () => {
|
void test('turn-action leaves retain exact native fields, safe payment minima and no payment twenty-force cap', () => {
|
||||||
for (const kind of ['ship', 'move'] as const) {
|
for (const kind of ['ship', 'move'] as const) {
|
||||||
@@ -872,3 +880,191 @@ void test('native off-board combat locations retain exact event and location bin
|
|||||||
for (const kind of ['ship', 'move'])
|
for (const kind of ['ship', 'move'])
|
||||||
assert.throws(() => parseTurnActionLeaf({ kind, territory, minimum: 1 }), TurnActionError);
|
assert.throws(() => parseTurnActionLeaf({ kind, territory, minimum: 1 }), TurnActionError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('printed card-play leaves contain only the printed effect and every raw clause retains its own stage', () => {
|
||||||
|
for (const effect of ['hajr', 'ghola'] as const) {
|
||||||
|
const original = { kind: 'cardPlay', effect };
|
||||||
|
assert.deepEqual(parseTurnActionLeaf(original), original);
|
||||||
|
assert.notEqual(parseTurnActionLeaf(original), original);
|
||||||
|
for (const invalid of [{ ...original, card: 'physical-card' }, { ...original, played: true },
|
||||||
|
{ ...original, minimum: 1 }, { ...original, printed: true }, { ...original, answer: true }])
|
||||||
|
assert.throws(() => parseTurnActionLeaf(invalid), TurnActionError);
|
||||||
|
assert.throws(() => turnActionValue(promise(card(effect))), TurnActionError);
|
||||||
|
assert.throws(() => turnActionValue(promise(group('or', fact(), card(effect)))), TurnActionError,
|
||||||
|
'Compiler folding never removes the original raw interval requirement');
|
||||||
|
}
|
||||||
|
for (const effect of ['', 'Hajr', 'karama', 'thumper', null])
|
||||||
|
assert.throws(() => parseTurnActionLeaf({ kind: 'cardPlay', effect }), TurnActionError);
|
||||||
|
validateTurnActionPromises([cards(group('and', card(), card('ghola')))], seats);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('whole printed-card AND and OR retain dynamic alternatives and settle only actual future printed-role plays', () => {
|
||||||
|
const hajr = { kind: 'cardPlay', effect: 'hajr', answer: true };
|
||||||
|
const ghola = { kind: 'cardPlay', effect: 'ghola', answer: true };
|
||||||
|
const and = cards(group('and', card(), card('ghola'), pay()));
|
||||||
|
const or = cards(group('or', card(), card('ghola')), true, 'turn-action:2');
|
||||||
|
assert.deepEqual(branches(and), [[hajr, ghola, signedPay()]]);
|
||||||
|
assert.deepEqual(branches(or), [[hajr], [ghola]]);
|
||||||
|
const played = recordTurnActionCardPlay([and, or], 'respondent', 1, 'hajr');
|
||||||
|
assert.equal(played[0].fulfilled, false);
|
||||||
|
assert.equal(played[1].fulfilled, true);
|
||||||
|
assert.deepEqual(branches(played[0]), [[ghola, signedPay()]]);
|
||||||
|
const gholaPlayed = recordTurnActionCardPlay(played, 'respondent', 1, 'ghola');
|
||||||
|
assert.equal(gholaPlayed[0].fulfilled, false);
|
||||||
|
assert.deepEqual(branches(gholaPlayed[0]), [[signedPay()]]);
|
||||||
|
assert.equal(recordTurnActionPayment(gholaPlayed, 'respondent', 1, payment())[0].fulfilled, true);
|
||||||
|
assert.equal(gholaPlayed[1], played[1]);
|
||||||
|
assert.equal(and.cardPlays!.hajr!.played, false);
|
||||||
|
assert.equal(recordTurnActionCardPlay([or], 'respondent', 1, 'ghola')[0].fulfilled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('whole card-play No applies De Morgan and rejects the actual accepted play completing its aggregate', () => {
|
||||||
|
const andNo = cards(group('and', card(), card('ghola')), false);
|
||||||
|
assert.deepEqual(branches(andNo), [[{ kind: 'cardPlay', effect: 'hajr', answer: false }],
|
||||||
|
[{ kind: 'cardPlay', effect: 'ghola', answer: false }]]);
|
||||||
|
const one = recordTurnActionCardPlay([andNo], 'respondent', 1, 'hajr');
|
||||||
|
assert.deepEqual(branches(one[0]), [[{ kind: 'cardPlay', effect: 'ghola', answer: false }]]);
|
||||||
|
const before = structuredClone(one);
|
||||||
|
assert.throws(() => recordTurnActionCardPlay(one, 'respondent', 1, 'ghola'), TurnActionError);
|
||||||
|
assert.deepEqual(one, before);
|
||||||
|
const orNo = cards(group('or', card(), card('ghola')), false);
|
||||||
|
assert.deepEqual(branches(orNo), [[{ kind: 'cardPlay', effect: 'hajr', answer: false },
|
||||||
|
{ kind: 'cardPlay', effect: 'ghola', answer: false }]]);
|
||||||
|
for (const effect of ['hajr', 'ghola'] as const)
|
||||||
|
assert.throws(() => recordTurnActionCardPlay([orNo], 'respondent', 1, effect), TurnActionError);
|
||||||
|
assert.deepEqual(branches(cards(group('and', card(), card()))), [[{ kind: 'cardPlay', effect: 'hajr', answer: true }]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('earlier card receipts do not satisfy a later answer and genuine replay updates only matching live intervals', () => {
|
||||||
|
const mixed = group('and', card(), pay());
|
||||||
|
const old = cards(mixed);
|
||||||
|
const played = recordTurnActionCardPlay([old], 'respondent', 1, 'hajr')[0];
|
||||||
|
const fresh = cards(mixed, true, 'turn-action:2');
|
||||||
|
const other = { ...cards(mixed, true, 'turn-action:3'), player: 'other' };
|
||||||
|
const laterTurn = { ...cards(mixed, true, 'turn-action:4'), turn: 2 };
|
||||||
|
assert.equal(turnActionValue(fresh), false);
|
||||||
|
assert.equal(fresh.cardPlays!.hajr!.played, false);
|
||||||
|
const replay = recordTurnActionCardPlay([played, fresh, other, laterTurn], 'respondent', 1, 'hajr');
|
||||||
|
assert.equal(replay[0], played, 'One boolean receipt never counts or duplicates a later replay');
|
||||||
|
assert.equal(replay[1].cardPlays!.hajr!.played, true);
|
||||||
|
assert.equal(replay[2], other);
|
||||||
|
assert.equal(replay[3], laterTurn);
|
||||||
|
assert.equal(old.cardPlays!.hajr!.played, false);
|
||||||
|
assert.equal(fresh.cardPlays!.hajr!.played, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('actual transport closure closes Hajr while Ghola and ordinary payment alternatives retain their own deadline', () => {
|
||||||
|
const original = cards(group('or', card(), card('ghola'), pay()));
|
||||||
|
const closed = closeTurnActionTransport([original], 'respondent', 1);
|
||||||
|
assert.deepEqual(closed[0].cardPlays, { hajr: { played: false, closed: true }, ghola: { played: false, closed: false } });
|
||||||
|
assert.deepEqual(branches(closed[0]), [[{ kind: 'cardPlay', effect: 'ghola', answer: true }], [signedPay()]]);
|
||||||
|
assert.equal(recordTurnActionCardPlay(closed, 'respondent', 1, 'hajr')[0], closed[0]);
|
||||||
|
assert.equal(recordTurnActionCardPlay(closed, 'respondent', 1, 'ghola')[0].fulfilled, true);
|
||||||
|
assert.equal(recordTurnActionPayment(closed, 'respondent', 1, payment())[0].fulfilled, true);
|
||||||
|
const no = cards(group('and', card(), card('ghola')), false);
|
||||||
|
const noClosed = closeTurnActionTransport([no], 'respondent', 1);
|
||||||
|
assert.equal(noClosed[0].fulfilled, false, 'Whole No waits for its remaining relevant native family');
|
||||||
|
const gholaPlayed = recordTurnActionCardPlay(noClosed, 'respondent', 1, 'ghola');
|
||||||
|
assert.equal(gholaPlayed[0].fulfilled, false);
|
||||||
|
assert.equal(finishTurnActionTurn(gholaPlayed, 'respondent', 1)[0].fulfilled, true);
|
||||||
|
assert.equal(closeTurnActionTransport([cards(card(), false)], 'respondent', 1)[0].fulfilled, true);
|
||||||
|
validateTurnActionPromises(closed, seats);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('full-turn closure settles absence, releases unmet Yes and leaves terminal card receipts immutable', () => {
|
||||||
|
const no = cards(card('ghola'), false);
|
||||||
|
const yes = cards(card('ghola'), true, 'turn-action:2');
|
||||||
|
const fulfilled = recordTurnActionCardPlay([cards(card(), true, 'turn-action:3')], 'respondent', 1, 'hajr')[0];
|
||||||
|
const finished = finishTurnActionTurn([no, yes, fulfilled], 'respondent', 1);
|
||||||
|
assert.equal(finished[0].fulfilled, true);
|
||||||
|
assert.equal(finished[1].released, true);
|
||||||
|
assert.equal(finished[2], fulfilled);
|
||||||
|
assert.deepEqual(finished[0].cardPlays, { hajr: { played: false, closed: true }, ghola: { played: false, closed: true } });
|
||||||
|
for (const transform of [
|
||||||
|
(rows: TurnActionPromise[]) => recordTurnActionCardPlay(rows, 'respondent', 1, 'hajr'),
|
||||||
|
(rows: TurnActionPromise[]) => recordTurnActionCardPlay(rows, 'respondent', 1, 'ghola'),
|
||||||
|
(rows: TurnActionPromise[]) => closeTurnActionTransport(rows, 'respondent', 1),
|
||||||
|
(rows: TurnActionPromise[]) => finishTurnActionTurn(rows, 'respondent', 1),
|
||||||
|
]) assert.ok(transform(finished).every((row, index) => row === finished[index]));
|
||||||
|
validateTurnActionPromises(finished, seats);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('strict printed-card stages preserve old rows and reject metadata, missing effects and foreign compiled clauses', () => {
|
||||||
|
const original = cards(group('and', card(), card('ghola')));
|
||||||
|
for (const cardPlays of [undefined, null, {}, { hajr: null, ghola: null },
|
||||||
|
{ hajr: { played: false, closed: false } },
|
||||||
|
{ ...original.cardPlays, card: 'physical-card' },
|
||||||
|
{ ...original.cardPlays, hajr: { played: 1, closed: false } },
|
||||||
|
{ ...original.cardPlays, ghola: { played: false, closed: 'yes' } },
|
||||||
|
{ ...original.cardPlays, hajr: { played: false, closed: false, count: 1 } }])
|
||||||
|
assert.throws(() => turnActionValue({ ...original, cardPlays } as TurnActionPromise), TurnActionError);
|
||||||
|
const old = promise(pay());
|
||||||
|
assert.equal('cardPlays' in recordTurnActionCardPlay([old], 'respondent', 1, 'hajr')[0], false);
|
||||||
|
assert.equal('cardPlays' in closeTurnActionTransport([old], 'respondent', 1)[0], false);
|
||||||
|
assert.equal('cardPlays' in finishTurnActionTurn([old], 'respondent', 1)[0], false);
|
||||||
|
const optional = { ...prepared(group('and', bid(), forces())), cardPlays: { hajr: null, ghola: null } };
|
||||||
|
validateTurnActionPromises([optional], seats);
|
||||||
|
const foreign = { ...cards(card()), compiled: { kind: 'action', action: { kind: 'cardPlay', effect: 'ghola' } } };
|
||||||
|
assert.throws(() => turnActionValue(foreign as TurnActionPromise), TurnActionError);
|
||||||
|
assert.throws(() => recordTurnActionCardPlay([original], 'respondent', 1, 'karama' as never), TurnActionError);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('card-play events reject all affected No violations atomically and share the outer logical budget', () => {
|
||||||
|
const yes = cards(group('and', card(), pay()));
|
||||||
|
const no = cards(card(), false, 'turn-action:2');
|
||||||
|
const before = structuredClone([yes, no]);
|
||||||
|
assert.throws(() => recordTurnActionCardPlay([yes, no], 'respondent', 1, 'hajr'), TurnActionError);
|
||||||
|
assert.deepEqual([yes, no], before);
|
||||||
|
assert.doesNotThrow(() => turnActionValue(cards(group('or', ...Array.from({ length: 16 }, () => card())))));
|
||||||
|
assert.throws(() => turnActionValue(cards(group('or', ...Array.from({ length: 17 }, () => card())))), TurnActionError);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('payment expiry preserves a later Ghola OR alternative while an AND still requires its missing payment', () => {
|
||||||
|
const or = cards(group('or', card('ghola'), pay()));
|
||||||
|
const and = cards(group('and', card('ghola'), pay()), true, 'turn-action:2');
|
||||||
|
const closed = closeTurnActionPayments([or, and], 'respondent', 1);
|
||||||
|
for (const row of closed) {
|
||||||
|
assert.equal(row.paymentClosed, true);
|
||||||
|
assert.equal(row.transportClosed, false);
|
||||||
|
assert.deepEqual(row.shipment, { pending: true, event: null });
|
||||||
|
assert.deepEqual(row.cardPlays, or.cardPlays);
|
||||||
|
assert.equal(row.cardPlays!.ghola!.closed, false);
|
||||||
|
assert.equal(row.fulfilled, false);
|
||||||
|
assert.equal(row.released, false);
|
||||||
|
}
|
||||||
|
assert.deepEqual(branches(closed[0]), [[{ kind: 'cardPlay', effect: 'ghola', answer: true }]]);
|
||||||
|
assert.deepEqual(branches(closed[1]), []);
|
||||||
|
assert.ok(recordTurnActionPayment(closed, 'respondent', 1, payment()).every((row, index) => row === closed[index]));
|
||||||
|
const laterGhola = recordTurnActionCardPlay(closed, 'respondent', 1, 'ghola');
|
||||||
|
assert.equal(laterGhola[0].fulfilled, true);
|
||||||
|
assert.equal(laterGhola[1].fulfilled, false);
|
||||||
|
assert.equal(finishTurnActionTurn(laterGhola, 'respondent', 1)[1].released, true);
|
||||||
|
const expiredTooEarly = finishTurnActionTurn([or], 'respondent', 1)[0];
|
||||||
|
assert.equal(expiredTooEarly.released, true);
|
||||||
|
assert.equal(expiredTooEarly.cardPlays!.ghola!.closed, true);
|
||||||
|
assert.equal(closeTurnActionPayments(laterGhola, 'respondent', 1)[0], laterGhola[0], 'Terminal rows remain identical');
|
||||||
|
assert.equal(closeTurnActionPayments(closed, 'respondent', 1)[1], closed[1], 'Repeated payment closure retains the same row');
|
||||||
|
assert.equal(or.paymentClosed, false);
|
||||||
|
validateTurnActionPromises(closed, seats);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('payment-only closure keeps whole No binding through later Ghola and validates its untouched native stage', () => {
|
||||||
|
const orNo = cards(group('or', card('ghola'), pay()), false);
|
||||||
|
const andNo = cards(group('and', card('ghola'), pay()), false, 'turn-action:2');
|
||||||
|
const closed = closeTurnActionPayments([orNo, andNo], 'respondent', 1);
|
||||||
|
assert.equal(closed[0].fulfilled, false);
|
||||||
|
assert.equal(closed[1].fulfilled, false);
|
||||||
|
assert.deepEqual(branches(closed[0]), [[{ kind: 'cardPlay', effect: 'ghola', answer: false }]]);
|
||||||
|
assert.throws(() => recordTurnActionCardPlay([closed[0]], 'respondent', 1, 'ghola'), TurnActionError);
|
||||||
|
const played = recordTurnActionCardPlay([closed[1]], 'respondent', 1, 'ghola');
|
||||||
|
assert.equal(played[0].fulfilled, false);
|
||||||
|
assert.equal(finishTurnActionTurn(played, 'respondent', 1)[0].fulfilled, true);
|
||||||
|
const paymentOnlyNo = closeTurnActionPayments([promise(pay(), false)], 'respondent', 1)[0];
|
||||||
|
assert.equal(paymentOnlyNo.fulfilled, true);
|
||||||
|
const other = { ...orNo, player: 'other' }, nextTurn = { ...orNo, turn: 2 };
|
||||||
|
assert.equal(closeTurnActionPayments([other], 'respondent', 1)[0], other);
|
||||||
|
assert.equal(closeTurnActionPayments([nextTurn], 'respondent', 1)[0], nextTurn);
|
||||||
|
const malformed = { ...orNo, cardPlays: { hajr: null, ghola: { played: false, closed: 'yes' } } };
|
||||||
|
assert.throws(() => closeTurnActionPayments([malformed as unknown as TurnActionPromise], 'respondent', 1), TurnActionError);
|
||||||
|
validateTurnActionPromises(closed, seats);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user