Bind Truthtrance answers to native battle power declarations
This commit is contained in:
1 parent
c93b693137
commit
09a81a9b65
17 files changed
+1598
-28
No files matched your search
@@ -581,7 +581,7 @@ export function Truthtrance({
|
||||
<option value="mixedBid">Combine current facts and ordinary bids</option>
|
||||
<option value="traitorCall">Bind this battle’s Traitor declaration</option>
|
||||
<option value="mixedTraitorCall">Combine current facts and this Traitor declaration</option>
|
||||
<option value="turnActions">Combine Hajr/Ghola plays, the current battle and this turn’s other actions</option>
|
||||
<option value="turnActions">Combine native battle powers, Hajr/Ghola plays and this turn’s other actions</option>
|
||||
<option value="travel">Combine this turn’s shipment and ground movements</option>
|
||||
<option value="shipment">
|
||||
Bind a shipment from reserves
|
||||
@@ -602,7 +602,7 @@ export function Truthtrance({
|
||||
<>
|
||||
<TurnActionFields value={turnActions} game={g} target={target}
|
||||
disabled={busy || !g.truthTurnActionTargets.includes(target)} onChange={setTurnActions} />
|
||||
{!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.
|
||||
{!g.truthTurnActionTargets.includes(target) && <p className="notice">Choose a player with supported native Voice or Prescience, 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>}
|
||||
<p className="notice" aria-label="Public turn-action question preview">{turnActionsError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { TERRITORIES } from '@/game/board';
|
||||
import type { GameView } from '@/game/engine';
|
||||
import type { MixedActionExpression } from '@/game/mixed-action-question';
|
||||
import { parseBattleActionClaim } from '@/game/mixed-battle-question';
|
||||
import { battlePowerOf, nativeBattlePowerOpportunity, type BattlePowerClaim } from '@/game/battle-power-question';
|
||||
import { battleCardLabel, VOICE_KINDS } from '@/game/battle-cards';
|
||||
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES, type TurnActionLeaf } from '@/game/turn-action-promises';
|
||||
import { BattleClaimFields } from './battle-promises';
|
||||
import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields';
|
||||
@@ -36,10 +38,18 @@ const currentBattle = (game: GameView, target: string, kind: 'battlePlan' | 'tra
|
||||
return battle.revealed && game.truthMixedTraitorTargets.includes(target) && battle.traitorVoters.includes(target) &&
|
||||
!battle.traitorSubmitted.includes(target) ? battle : null;
|
||||
};
|
||||
const powerOpportunity = (game: GameView, target: string, claim: BattlePowerClaim) => {
|
||||
try { return nativeBattlePowerOpportunity(game, target, battlePowerOf(claim)); }
|
||||
catch { return null; }
|
||||
};
|
||||
const newPowerClaim = (game: GameView, target: string): BattlePowerClaim =>
|
||||
nativeBattlePowerOpportunity(game, target, 'voice') ? { kind: 'voice', use: true }
|
||||
: nativeBattlePowerOpportunity(game, target, 'prescience') ? { kind: 'prescience', use: true }
|
||||
: { kind: 'voice', use: true };
|
||||
const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
prefix: 'turn-action',
|
||||
legend: 'Current battle and this-turn action conditions, with optional current facts',
|
||||
actionLabel: 'printed card play, battle, alliance, bid, Revival, payment, shipment or ground-movement condition',
|
||||
actionLabel: 'native battle power, printed card play, battle, alliance, bid, Revival, payment, shipment or ground-movement condition',
|
||||
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.
|
||||
Earlier payments, gifts and ally aid do not count. The sender pays immediately; the recipient collects at the next Mentat opening.
|
||||
@@ -56,6 +66,10 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
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.
|
||||
The declaration counts before a later Karama or betrayal response can prevent its effect.
|
||||
Native Voice and Prescience conditions concern your original use or explicit decline, exact Voice command,
|
||||
or requested Prescience field in this battle. They count before later Karama prevention.
|
||||
A requested field is not the opponent's answer or inspected value. Borrowed Nexus and special powers do not count.
|
||||
These conditions close at the actual native choice or when its original source expires.
|
||||
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.
|
||||
@@ -87,10 +101,16 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
throw new Error('Choose this player’s current native battle or unsubmitted Traitor declaration.');
|
||||
if (action.kind === 'battlePlan') parseBattleActionClaim(action.claim, game.allLeaders.map(leader => leader.id));
|
||||
}
|
||||
if (action.kind === 'battlePower') {
|
||||
const opportunity = powerOpportunity(game, target, action.claim);
|
||||
if (!opportunity || action.battle !== opportunity.event || action.territory !== opportunity.territory)
|
||||
throw new Error('Choose this player’s original native Voice or Prescience opportunity in the current battle.');
|
||||
}
|
||||
return action;
|
||||
},
|
||||
renderAction: ({ value, id, game, target, disabled, onChange }) => {
|
||||
const choices = recipients(game, target), leaderChoices = leaders(game, target), lot = currentLot(game), partnerChoices = partners(game, target);
|
||||
const currentPower = value.kind === 'battlePower' ? powerOpportunity(game, target, value.claim) : null;
|
||||
return <>
|
||||
<label htmlFor={`${id}-action`}>Future action</label>
|
||||
<select id={`${id}-action`} value={value.kind} disabled={disabled} onChange={event => {
|
||||
@@ -98,6 +118,10 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
if (kind === value.kind) return;
|
||||
const minimum = 'minimum' in value ? value.minimum : 1;
|
||||
if (kind === 'cardPlay') onChange({ kind, effect: 'hajr' });
|
||||
if (kind === 'battlePower') {
|
||||
const claim = newPowerClaim(game, target), opportunity = powerOpportunity(game, target, claim);
|
||||
onChange({ kind, battle: opportunity?.event ?? '', territory: opportunity?.territory ?? '', claim });
|
||||
}
|
||||
if (kind === 'battlePlan') onChange({ kind, battle: game.battle?.event ?? '', territory: game.battle?.territory ?? '',
|
||||
claim: { kind: 'dial', compare: 'eq', value: 0 } });
|
||||
if (kind === 'traitorCall') onChange({ kind, battle: game.battle?.event ?? '', territory: game.battle?.territory ?? '', call: true });
|
||||
@@ -110,6 +134,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
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="battlePower">Declare native Voice or Prescience in this battle</option>
|
||||
<option value="battlePlan">Use an actual current battle-plan choice</option>
|
||||
<option value="traitorCall">Make the current Traitor declaration</option>
|
||||
<option value="alliance">End this Nexus allied with a named partner</option>
|
||||
@@ -130,6 +155,75 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
<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 === 'battlePower' ? <>
|
||||
<label htmlFor={`${id}-power-claim`}>Original native declaration</label>
|
||||
<select id={`${id}-power-claim`} value={value.claim.kind} disabled={disabled} onChange={event => {
|
||||
const kind = event.currentTarget.value;
|
||||
const claim: BattlePowerClaim | null = kind === 'voice' || kind === 'prescience' ? { kind, use: true }
|
||||
: kind === 'voiceCommand' ? { kind, cardType: 'poison', must: false }
|
||||
: kind === 'prescienceField' ? { kind, field: 'leader' } : null;
|
||||
if (claim) {
|
||||
const opportunity = powerOpportunity(game, target, claim);
|
||||
onChange({ ...value, claim, battle: opportunity?.event ?? '', territory: opportunity?.territory ?? '' });
|
||||
}
|
||||
}}>
|
||||
<option value="voice">Use or explicitly decline native Voice</option>
|
||||
<option value="voiceCommand">Declare an exact native Voice command</option>
|
||||
<option value="prescience">Use or explicitly decline native Prescience</option>
|
||||
<option value="prescienceField">Request an exact native Prescience field</option>
|
||||
</select>
|
||||
<label htmlFor={`${id}-power-battle`}>Original current battle opportunity</label>
|
||||
<select id={`${id}-power-battle`} value={currentPower?.event === value.battle && currentPower.territory === value.territory ? value.battle : ''}
|
||||
disabled={disabled} aria-invalid={!currentPower || currentPower.event !== value.battle || currentPower.territory !== value.territory} onChange={event => {
|
||||
const opportunity = powerOpportunity(game, target, value.claim);
|
||||
if (event.currentTarget.value === '') onChange({ ...value, battle: '', territory: '' });
|
||||
else if (opportunity?.event === event.currentTarget.value)
|
||||
onChange({ ...value, battle: opportunity.event, territory: opportunity.territory });
|
||||
}}>
|
||||
<option value="">Choose an owned native opportunity</option>
|
||||
{currentPower && <option value={currentPower.event}>
|
||||
{game.combatLocations.find(location => location.id === currentPower.territory)?.name ?? currentPower.territory}
|
||||
</option>}
|
||||
</select>
|
||||
{value.claim.kind === 'voice' || value.claim.kind === 'prescience' ? <>
|
||||
<label htmlFor={`${id}-power-use`}>Original choice</label>
|
||||
<select id={`${id}-power-use`} value={value.claim.use ? 'use' : 'decline'} disabled={disabled}
|
||||
aria-invalid={typeof value.claim.use !== 'boolean'} onChange={event => {
|
||||
if (event.currentTarget.value === 'use' || event.currentTarget.value === 'decline')
|
||||
onChange({ ...value, claim: { kind: value.claim.kind as 'voice' | 'prescience', use: event.currentTarget.value === 'use' } });
|
||||
}}>
|
||||
<option value="use">Use this native power</option><option value="decline">Explicitly decline this native power</option>
|
||||
</select>
|
||||
</> : value.claim.kind === 'voiceCommand' ? <>
|
||||
<label htmlFor={`${id}-power-card-type`}>Voice card type</label>
|
||||
<select id={`${id}-power-card-type`} value={VOICE_KINDS.includes(value.claim.cardType) ? value.claim.cardType : ''}
|
||||
disabled={disabled} aria-invalid={!VOICE_KINDS.includes(value.claim.cardType)} onChange={event => {
|
||||
if (value.claim.kind === 'voiceCommand' && (event.currentTarget.value === '' || VOICE_KINDS.includes(event.currentTarget.value)))
|
||||
onChange({ ...value, claim: { ...value.claim, cardType: event.currentTarget.value } });
|
||||
}}>
|
||||
<option value="">Choose a native Voice card type</option>
|
||||
{VOICE_KINDS.map(kind => <option key={kind} value={kind}>{battleCardLabel(kind)}</option>)}
|
||||
</select>
|
||||
<label htmlFor={`${id}-power-must`}>Exact Voice command</label>
|
||||
<select id={`${id}-power-must`} value={value.claim.must ? 'must' : 'cannot'} disabled={disabled}
|
||||
aria-invalid={typeof value.claim.must !== 'boolean'} onChange={event => {
|
||||
if (value.claim.kind === 'voiceCommand' && (event.currentTarget.value === 'must' || event.currentTarget.value === 'cannot'))
|
||||
onChange({ ...value, claim: { ...value.claim, must: event.currentTarget.value === 'must' } });
|
||||
}}>
|
||||
<option value="cannot">Opponent cannot play this type</option><option value="must">Opponent must play this type</option>
|
||||
</select>
|
||||
</> : value.claim.kind === 'prescienceField' ? <>
|
||||
<label htmlFor={`${id}-power-field`}>Requested Prescience field</label>
|
||||
<select id={`${id}-power-field`} value={value.claim.field} disabled={disabled} onChange={event => {
|
||||
const field = event.currentTarget.value;
|
||||
if (field === 'leader' || field === 'weapon' || field === 'defense' || field === 'dial')
|
||||
onChange({ ...value, claim: { kind: 'prescienceField', field } });
|
||||
}}>
|
||||
<option value="leader">Leader</option><option value="weapon">Weapon</option>
|
||||
<option value="defense">Defense</option><option value="dial">Number dialed</option>
|
||||
</select>
|
||||
</> : null}
|
||||
{!currentPower && <p className="fine">This respondent has no original native opportunity for this power in the current battle.</p>}
|
||||
</> : value.kind === 'battlePlan' || value.kind === 'traitorCall' ? <>
|
||||
<label htmlFor={`${id}-battle`}>Current battle</label>
|
||||
<select id={`${id}-battle`} value={game.battle?.event === value.battle && game.battle.territory === value.territory ? value.battle ?? '' : ''}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { nativePromiseScopeSupported } from '@/game/native-promise-scope';
|
||||
import { quoteNexusAlliance } from '@/game/nexus-alliance';
|
||||
import { ordinaryCardAvailability } from '@/game/card-availability';
|
||||
import { gholaForcePreparations } from '@/game/ghola-force-preparation';
|
||||
import { nativeBattlePowerOpportunity, type NativeBattlePower } from '@/game/battle-power-question';
|
||||
import { battleCardLabel, VOICE_KINDS } from '@/game/battle-cards';
|
||||
import { truthQuestionText, type TruthAnswer } from '@/game/truthtrance';
|
||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||
import { Button } from './ui/button';
|
||||
@@ -18,6 +20,7 @@ const publicAllianceContext = (game: GameView) => ({
|
||||
const publicBattleContext = (game: GameView) => game.battle && ({
|
||||
event: game.battle.event, territory: game.battle.territory, attacker: game.battle.attacker, defender: game.battle.defender,
|
||||
revealed: game.battle.revealed, submitted: game.battle.submitted,
|
||||
preparation: game.battle.preparation,
|
||||
traitorVoters: game.battle.traitorVoters, traitorSubmitted: game.battle.traitorSubmitted,
|
||||
});
|
||||
|
||||
@@ -28,6 +31,11 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
const actor = game.me, turn = game.turn, phase = game.phase, event = guide.event, action = guide.action;
|
||||
const act = props.act, fillPlan = props.fillPlan, battle = game.battle;
|
||||
const battleSignature = JSON.stringify(publicBattleContext(game));
|
||||
const power: NativeBattlePower | null = action.type === 'voice' ? 'voice' : action.type === 'prescience' ? 'prescience'
|
||||
: action.type === 'declineBattlePower' && (battle?.preparation?.kind === 'voice' || battle?.preparation?.kind === 'prescience')
|
||||
? battle.preparation.kind : null;
|
||||
const powerSource = power ? nativeBattlePowerOpportunity(game, actor, power) : null;
|
||||
const powerSignature = JSON.stringify(powerSource), powerPreparation = battle?.preparation;
|
||||
const signature = JSON.stringify(action), auction = game.auction, decision = game.decision;
|
||||
const native = game.richeseAuction, order = game.revivalOrder, revival = game.revival;
|
||||
const auctionSource = (current: GameView) => current.auction && JSON.stringify({
|
||||
@@ -117,12 +125,22 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
}
|
||||
const currentBattle = phase === 6 && !!battle && current.battle === battle &&
|
||||
JSON.stringify(publicBattleContext(current)) === battleSignature;
|
||||
const powerPacket = Object.keys(action).every(key => ['type', 'turnActionPromiseEvent', 'turnActionPromisePhase',
|
||||
...(action.type === 'voice' ? ['kind', 'must'] : action.type === 'prescience' ? ['field'] : [])].includes(key)) &&
|
||||
(action.type === 'voice' ? typeof action.kind === 'string' && VOICE_KINDS.includes(action.kind) && typeof action.must === 'boolean'
|
||||
: action.type === 'prescience' ? ['leader', 'weapon', 'defense', 'dial'].includes(String(action.field))
|
||||
: action.type === 'declineBattlePower');
|
||||
const ownedPower = currentBattle && !!power && !!powerSource && powerSource.ready && powerSource.owner === actor &&
|
||||
powerSource.event === battle?.event && powerSource.territory === battle?.territory &&
|
||||
current.battle?.preparation === powerPreparation && powerPreparation?.kind === power && powerPacket &&
|
||||
(action.type === 'declineBattlePower' || !powerPreparation.blocked) &&
|
||||
JSON.stringify(nativeBattlePowerOpportunity(current, actor, power)) === powerSignature;
|
||||
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 battlePreparation = genericCard && planWindow;
|
||||
const declaration = action.type === 'traitorCall' && typeof action.call === 'boolean' && !!battle && currentBattle && battle.revealed &&
|
||||
battle.traitorVoters.includes(actor) && !battle.traitorSubmitted.includes(actor);
|
||||
const opportunity = payment || paidBox || printedHajr || printedGhola || plan || battlePreparation || declaration || nexusWindow && (ownedAlliance || action.type === 'ready') || (phase === 2
|
||||
const opportunity = ownedPower || payment || paidBox || printedHajr || printedGhola || plan || battlePreparation || declaration || nexusWindow && (ownedAlliance || action.type === 'ready') || (phase === 2
|
||||
? !current.ready.includes(actor) && (['charity', 'ready'].includes(action.type) || genericCard)
|
||||
: phase === 3
|
||||
? invoice || nativePass || requiredBid || action.type === 'passBid' && !current.richeseAuction && currentAuction && current.auction?.active === actor
|
||||
@@ -156,6 +174,9 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
? `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 === 'voice' ? `Declare native Voice: opponent ${action.must ? 'must' : 'cannot'} play ${battleCardLabel(String(action.kind))}`
|
||||
: action.type === 'prescience' ? `Request the ${String(action.field)} field with native Prescience while honoring your whole answer`
|
||||
: action.type === 'declineBattlePower' ? `Explicitly decline native ${power === 'voice' ? 'Voice' : 'Prescience'} 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
|
||||
? game.players.find(player => player.id === actor)?.ally ? 'Break your current alliance while preserving your whole answer'
|
||||
@@ -225,6 +246,9 @@ 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;
|
||||
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.
|
||||
Native Voice and Prescience conditions describe your original use or explicit decline, exact Voice command,
|
||||
or requested Prescience field, before later Karama prevention. The requested field is not the inspected answer or value.
|
||||
Borrowed Nexus and special powers do not count. These conditions close at the actual native choice or when its original source expires.
|
||||
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.
|
||||
|
||||
+38
-3
@@ -16,9 +16,44 @@ open mode gates.
|
||||
|
||||
|
||||
## 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.
|
||||
**Next: remaining Basic card/action declarations and native timing.**
|
||||
Continue remaining Basic functions before Advanced and wider interactions.
|
||||
Prototype labels and final acceptance gates remain.
|
||||
|
||||
**Truthtrance / native Voice and Prescience — 27 October. Prototyped / Partial.**
|
||||
Whole turn answers now include original native power use/explicit Decline,
|
||||
exact Voice command or requested Prescience field. All predicates concern the
|
||||
same one choice. Native owner and reciprocal allied beneficiary remain; a
|
||||
pending Prescience clause waits for the original foreign Voice decision.
|
||||
Requests do not describe hidden answers. Declaration receipts close before
|
||||
later printed Karama prevention; source expiry never invents Decline.
|
||||
|
||||
Own declarations retain a completion before opening the next foreign response
|
||||
or inspection. Focused feedback catches that early handoff and fixes it at the
|
||||
actual declaration. An OR payment already fulfilled stays immutable when a
|
||||
later optional power choice occurs; its test oracle is corrected. Genuine-source
|
||||
inspection also catches the prepared-marker interpretation and sanctuary source
|
||||
before verification. Player-view nullability and lint feedback are repaired.
|
||||
|
||||
The frozen batch passes **18 files,281/281**, types/lint/build. Thirty-eight new
|
||||
cases comprise8 public quotation/predicate,10 aggregate,6 control and14 native
|
||||
cases. Eight runtime journeys pass with33 GameTable SSR renders,8 actual answer
|
||||
callbacks and13 exact native guide callbacks. They cover use/Decline, both
|
||||
printed Karama cancellations, whole No, cleanup/late Mentat payment, two real
|
||||
remote allies and waiting for foreign Voice. Four profiles maintain only
|
||||
minimal legal participation; strategic work remains deferred.
|
||||
|
||||
Original Basic A/BG/E sources actually deal Truth and buy Karama1. Own Voice44
|
||||
and Prescience45 prefixes use BG's paid1 Arrakeen reserve against originalA10.
|
||||
Remote Voice73 uses a real BG–Emperor Nexus alliance; remote Prescience84 uses
|
||||
Atreides–Emperor consent and BG/Emperor paid2 reserves in Imperial Basin.
|
||||
All source counts derive from actual arrays; no played state is installed.
|
||||
Proof/manifest: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-power-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261027-truth-power-runtime-v3/`.
|
||||
No HTTP/storage boundary, database, reset/deletion, server restart or deployed
|
||||
operation changes. Borrowed Nexus/special powers, broader card/action families
|
||||
and final browser/deployed/recovery/privacy/custody acceptance remain open.
|
||||
[Contract and scope](TRUTHTRANCE_TURN_ACTIONS.md#native-voice-and-prescience-declarations--27-october-2026).
|
||||
|
||||
**Truthtrance / printed Hajr and Ghola plays — 27 October. Prototyped / Partial.**
|
||||
Whole current-turn questions now include actual future printed Hajr/Ghola use,
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 27 October 2026 — Native Voice and Prescience declarations
|
||||
|
||||
**Truthtrance / native Voice and Prescience — 27 October. Prototyped / Partial.**
|
||||
Whole turn answers now include original native power use/explicit Decline,
|
||||
exact Voice command or requested Prescience field. All predicates concern the
|
||||
same one choice. Native owner and reciprocal allied beneficiary remain; a
|
||||
pending Prescience clause waits for the original foreign Voice decision.
|
||||
Requests do not describe hidden answers. Declaration receipts close before
|
||||
later printed Karama prevention; source expiry never invents Decline.
|
||||
|
||||
Own declarations retain a completion before opening the next foreign response
|
||||
or inspection. Focused feedback catches that early handoff and fixes it at the
|
||||
actual declaration. An OR payment already fulfilled stays immutable when a
|
||||
later optional power choice occurs; its test oracle is corrected. Genuine-source
|
||||
inspection also catches the prepared-marker interpretation and sanctuary source
|
||||
before verification. Player-view nullability and lint feedback are repaired.
|
||||
|
||||
The frozen batch passes **18 files,281/281**, types/lint/build. Thirty-eight new
|
||||
cases comprise8 public quotation/predicate,10 aggregate,6 control and14 native
|
||||
cases. Eight runtime journeys pass with33 GameTable SSR renders,8 actual answer
|
||||
callbacks and13 exact native guide callbacks. They cover use/Decline, both
|
||||
printed Karama cancellations, whole No, cleanup/late Mentat payment, two real
|
||||
remote allies and waiting for foreign Voice. Four profiles maintain only
|
||||
minimal legal participation; strategic work remains deferred.
|
||||
|
||||
Original Basic A/BG/E sources actually deal Truth and buy Karama1. Own Voice44
|
||||
and Prescience45 prefixes use BG's paid1 Arrakeen reserve against originalA10.
|
||||
Remote Voice73 uses a real BG–Emperor Nexus alliance; remote Prescience84 uses
|
||||
Atreides–Emperor consent and BG/Emperor paid2 reserves in Imperial Basin.
|
||||
All source counts derive from actual arrays; no played state is installed.
|
||||
Proof/manifest: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-power-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261027-truth-power-runtime-v3/`.
|
||||
No HTTP/storage boundary, database, reset/deletion, server restart or deployed
|
||||
operation changes. Borrowed Nexus/special powers, broader card/action families
|
||||
and final browser/deployed/recovery/privacy/custody acceptance remain open.
|
||||
[Contract and scope](TRUTHTRANCE_TURN_ACTIONS.md#native-voice-and-prescience-declarations--27-october-2026).
|
||||
|
||||
## 27 October 2026 — Future printed Hajr and Ghola plays
|
||||
|
||||
**Truthtrance / printed Hajr and Ghola plays — 27 October. Prototyped / Partial.**
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Rule decision index
|
||||
|
||||
## Truthtrance native battle-power declarations — 27 October 2026
|
||||
|
||||
Compose the existing FAQp8/current-turn AND/OR authority and supplied Advanced
|
||||
physical39 compliance/release with original Voice/Prescience decisions. Count
|
||||
actual use or explicit Decline, exact Voice command or requested Prescience
|
||||
field after the answer in that original battle, before later Karama prevention.
|
||||
A requested field is not the inspected answer. Source expiry is absence and
|
||||
does not invent Decline. Native owner, reciprocal allied beneficiary, Voice-
|
||||
before-Prescience order, command restrictions and costs remain authoritative.
|
||||
The current native envelope excludes borrowed Nexus and special powers; this
|
||||
is a provisional online composition, not a dedicated publisher power-promise
|
||||
ruling. Wider combinations and full-mode acceptance remain open.
|
||||
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#native-voice-and-prescience-declarations--27-october-2026).
|
||||
|
||||
## Ordinary-auction ten-second deadline — 26 October 2026
|
||||
|
||||
Publisher Basic printed8 and supplied Advanced physical8/printed6 require a
|
||||
|
||||
@@ -365,3 +365,63 @@ 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.
|
||||
|
||||
## Native Voice and Prescience declarations — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** Add a source-bound action leaf:
|
||||
`{kind:'battlePower',battle,territory,claim}`. Its four predicates concern native
|
||||
Voice use/explicit Decline, an exact Voice category plus require/forbid command,
|
||||
native Prescience use/explicit Decline, or its requested plan field. They never
|
||||
describe the inspected answer or promise that the later effect survives Karama.
|
||||
This composes the existing general current-turn Truth authority with original
|
||||
native power timing; it is not a new publisher-specific power-promise ruling.
|
||||
|
||||
The original respondent must own that native power opportunity, personally or
|
||||
as an eligible reciprocal ally's provider. Prescience can be pending behind
|
||||
the actual original Voice decision, but cannot execute before that stage ends.
|
||||
Native command quotations, No-Field restrictions and response ownership remain.
|
||||
Borrowed Nexus and special powers keep their existing separate envelopes.
|
||||
|
||||
Whole AND/OR/No treats every predicate about this one choice as correlated.
|
||||
An actual declaration closes its stage and fixes only the raw named predicate
|
||||
values, before a later prevention response. An expired source with no native
|
||||
declaration records absence, never an invented Decline. Own manual choices
|
||||
must preserve the whole answer or a legal alternative, including later payment.
|
||||
No selected route is stored. Native declaration quotations spend no spice or
|
||||
card and do not invent the other player's future inspected answer.
|
||||
|
||||
Optional private power stages extend original combat metadata; older rows keep
|
||||
their existing shape. Public own history remains the original raw question and
|
||||
aggregate status. The source-bound guide forwards the real native declaration
|
||||
packet; human battle plans still fill for review before separate sealing.
|
||||
Four profiles retain only minimal legal participation. Full rules,
|
||||
browser/deployed, assurance and AI strategy gates remain open.
|
||||
|
||||
The frozen batch passes18 related files and281 tests, types/lint/build. Eight
|
||||
public opportunity/predicate, ten aggregate, six control and fourteen native
|
||||
cases add38 checks. Eight original native journeys provide33 full GameTable
|
||||
SSR renders,8 actual answer callbacks and13 exact native guide callbacks.
|
||||
Original own Voice44, Prescience45, remote Voice73 and remote Prescience84
|
||||
prefixes include genuine setup/deal, paid Karama1, paid reserve shipments and,
|
||||
for remote providers, ordinary Worm/Nexus consent. The remote Prescience battle
|
||||
uses two real paid2 Imperial Basin shipments, never the Polar Sink sanctuary.
|
||||
Arrays derive every count; original physical cards and Traitors remain.
|
||||
|
||||
Runtime paths cover both power declarations followed by actual printed Karama,
|
||||
explicit Decline, a declined power followed by late Mentat payment after battle
|
||||
cleanup, both allied providers, waiting for the foreign Voice stage and whole
|
||||
No through actual turn end. Native tests also cover incompatible same-choice
|
||||
predicates, manual OR alternatives, exact event/phase/owner/stale guides,
|
||||
requested fields without disclosed values and all four minimal profiles.
|
||||
|
||||
Focused feedback repairs own evasion through a newly opened foreign response:
|
||||
record and check the actual declaration before giving away control. A later
|
||||
native choice cannot rewrite an already fulfilled OR answer; that test oracle
|
||||
is corrected. Genuine-source work rejects the prepared-marker and sanctuary
|
||||
assumptions. Player-view nullability and lint feedback are repaired. These are
|
||||
local engine/SSR/control checks, not browser/hydration/deployed acceptance.
|
||||
Proof/manifest: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-power-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261027-truth-power-runtime-v3/`.
|
||||
No HTTP/storage boundary, database, reset/deletion, server restart or deployed
|
||||
operation changes. Borrowed Nexus and special power composition, broader
|
||||
future actions and all final assurance/release gates remain open.
|
||||
@@ -0,0 +1,132 @@
|
||||
import { VOICE_KINDS } from './battle-cards';
|
||||
|
||||
export type NativeBattlePower = 'voice' | 'prescience';
|
||||
export type BattlePowerClaim =
|
||||
| { kind: 'voice'; use: boolean }
|
||||
| { kind: 'voiceCommand'; cardType: string; must: boolean }
|
||||
| { kind: 'prescience'; use: boolean }
|
||||
| { kind: 'prescienceField'; field: 'leader' | 'weapon' | 'defense' | 'dial' };
|
||||
export type BattlePowerDeclaration =
|
||||
| { power: 'voice'; use: false }
|
||||
| { power: 'voice'; use: true; cardType: string; must: boolean }
|
||||
| { power: 'prescience'; use: false }
|
||||
| { power: 'prescience'; use: true; field: 'leader' | 'weapon' | 'defense' | 'dial' };
|
||||
export type NativeBattlePowerOpportunity = {
|
||||
event: string; territory: string; power: NativeBattlePower; owner: string;
|
||||
beneficiary: string; target: string; ready: boolean;
|
||||
};
|
||||
export type NativeBattlePowerGame = {
|
||||
status: string;
|
||||
phase: number;
|
||||
players: readonly { id: string; faction: string; ally?: string | null }[];
|
||||
battle?: {
|
||||
event?: string | null;
|
||||
territory: string;
|
||||
attacker: string;
|
||||
defender: string;
|
||||
revealed: boolean;
|
||||
prepared?: boolean;
|
||||
preparation?: { kind: string; owner: string; beneficiary: string; blocked?: string } | null;
|
||||
} | null;
|
||||
response?: unknown;
|
||||
decision?: unknown;
|
||||
phaseOpening?: unknown;
|
||||
truthtrance?: unknown;
|
||||
pendingTreacheryDiscard?: unknown;
|
||||
};
|
||||
|
||||
export class BattlePowerQuestionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'BattlePowerQuestionError';
|
||||
}
|
||||
}
|
||||
function check(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new BattlePowerQuestionError(message);
|
||||
}
|
||||
function object(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||||
const keys = (value: Record<string, unknown>, expected: string): boolean =>
|
||||
Object.keys(value).sort().join(',') === expected;
|
||||
const cardType = (value: unknown): value is string => typeof value === 'string' && VOICE_KINDS.includes(value);
|
||||
const field = (value: unknown): value is 'leader' | 'weapon' | 'defense' | 'dial' =>
|
||||
value === 'leader' || value === 'weapon' || value === 'defense' || value === 'dial';
|
||||
|
||||
/** Declaration/request predicates only; never a successful effect or inspected value. */
|
||||
export function parseBattlePowerClaim(value: unknown): BattlePowerClaim {
|
||||
check(object(value), 'Choose one native Voice or Prescience declaration predicate.');
|
||||
if (value.kind === 'voice' || value.kind === 'prescience') {
|
||||
check(keys(value, 'kind,use') && typeof value.use === 'boolean', 'Choose actual use or explicit Decline of this native power.');
|
||||
return { kind: value.kind, use: value.use };
|
||||
}
|
||||
if (value.kind === 'voiceCommand') {
|
||||
check(keys(value, 'cardType,kind,must') && cardType(value.cardType) && typeof value.must === 'boolean',
|
||||
'Choose one native Voice category and require or forbid command.');
|
||||
return { kind: 'voiceCommand', cardType: value.cardType, must: value.must };
|
||||
}
|
||||
check(value.kind === 'prescienceField' && keys(value, 'field,kind') && field(value.field),
|
||||
'Choose only the native Prescience field requested, without an answer value.');
|
||||
return { kind: 'prescienceField', field: value.field };
|
||||
}
|
||||
|
||||
export function battlePowerOf(claim: BattlePowerClaim): NativeBattlePower {
|
||||
const parsed = parseBattlePowerClaim(claim);
|
||||
return parsed.kind === 'voice' || parsed.kind === 'voiceCommand' ? 'voice' : 'prescience';
|
||||
}
|
||||
|
||||
function validateDeclaration(value: unknown): asserts value is BattlePowerDeclaration {
|
||||
check(object(value) && (value.power === 'voice' || value.power === 'prescience') && typeof value.use === 'boolean',
|
||||
'Retain an actual native battle-power declaration.');
|
||||
check(!value.use ? keys(value, 'power,use') : value.power === 'voice'
|
||||
? keys(value, 'cardType,must,power,use') && cardType(value.cardType) && typeof value.must === 'boolean'
|
||||
: keys(value, 'field,power,use') && field(value.field),
|
||||
'Retain only the declared native command or requested field, or explicit Decline.');
|
||||
}
|
||||
|
||||
export function battlePowerMatches(claim: BattlePowerClaim, declaration: BattlePowerDeclaration): boolean {
|
||||
const parsed = parseBattlePowerClaim(claim);
|
||||
validateDeclaration(declaration);
|
||||
if (battlePowerOf(parsed) !== declaration.power) return false;
|
||||
if (parsed.kind === 'voice' || parsed.kind === 'prescience') return parsed.use === declaration.use;
|
||||
if (!declaration.use) return false;
|
||||
return parsed.kind === 'voiceCommand' ? declaration.power === 'voice' &&
|
||||
parsed.cardType === declaration.cardType && parsed.must === declaration.must :
|
||||
declaration.power === 'prescience' && parsed.field === declaration.field;
|
||||
}
|
||||
|
||||
/** Public native owner/side/order only. Command legality, No-Field restrictions
|
||||
* and inspected hidden values remain with the real engine quotation. */
|
||||
export function nativeBattlePowerOpportunity(game: NativeBattlePowerGame, actor: string,
|
||||
power: NativeBattlePower): NativeBattlePowerOpportunity | null {
|
||||
if (!identifier(actor) || (power !== 'voice' && power !== 'prescience') || game.status !== 'playing' || game.phase !== 6) return null;
|
||||
const battle = game.battle, preparation = battle?.preparation;
|
||||
if (!battle || battle.revealed || !identifier(battle.event) || !identifier(battle.territory) ||
|
||||
!preparation || battle.attacker === battle.defender || !Array.isArray(game.players) ||
|
||||
game.players.some(player => !identifier(player.id)) || new Set(game.players.map(player => player.id)).size !== game.players.length ||
|
||||
!game.players.some(player => player.id === battle.attacker) || !game.players.some(player => player.id === battle.defender)) return null;
|
||||
const side = (kind: NativeBattlePower) => {
|
||||
const owners = game.players.filter(player => player.faction === (kind === 'voice' ? 'beneGesserit' : 'atreides'));
|
||||
if (owners.length !== 1) return null;
|
||||
const owner = owners[0], combatants = [battle.attacker, battle.defender];
|
||||
const beneficiary = combatants.includes(owner.id) ? owner.id : owner.ally && combatants.includes(owner.ally) &&
|
||||
game.players.some(player => player.id === owner.ally && player.ally === owner.id) ? owner.ally : null;
|
||||
return beneficiary ? { owner: owner.id, beneficiary,
|
||||
target: battle.attacker === beneficiary ? battle.defender : battle.attacker } : null;
|
||||
};
|
||||
const native = side(power);
|
||||
if (!native || native.owner !== actor) return null;
|
||||
const current = preparation.kind === power;
|
||||
if (current) {
|
||||
if (preparation.owner !== native.owner || preparation.beneficiary !== native.beneficiary) return null;
|
||||
} else {
|
||||
if (power !== 'prescience' || preparation.kind !== 'voice') return null;
|
||||
const voice = side('voice');
|
||||
if (!voice || preparation.owner !== voice.owner || preparation.beneficiary !== voice.beneficiary) return null;
|
||||
}
|
||||
return { event: battle.event, territory: battle.territory, power, ...native,
|
||||
ready: current && !game.response && !game.decision && !game.phaseOpening &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard };
|
||||
}
|
||||
+82
-6
@@ -2,10 +2,13 @@ import { AuctionClockError, AUCTION_BID_MS, quoteAuctionClock, viewAuctionClock,
|
||||
type AuctionClock, type AuctionClockView } from './auction-clock';
|
||||
import { PrintedCardPlayError, printedCardPlayCount, recordPrintedCardPlay,
|
||||
type PrintedCardEffect, type PrintedCardPlayCounts } from './printed-card-play';
|
||||
import { battlePowerOf, battlePowerMatches, nativeBattlePowerOpportunity,
|
||||
type NativeBattlePower, type BattlePowerDeclaration } from './battle-power-question';
|
||||
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
|
||||
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, closeTurnActionPayments, finishTurnActionTurn,
|
||||
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival, recordTurnActionCardPlay,
|
||||
closeTurnActionRevival, settleTurnActionAlliance, recordTurnActionPlan, recordTurnActionTraitor, closeTurnActionCombat,
|
||||
recordTurnActionBattlePower, closeTurnActionBattlePower,
|
||||
type TurnActionPlanValue, type TurnActionCombat, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation } from './turn-action-promises';
|
||||
import { TravelPromiseError, liveTravelPromises, travelBranches, recordTravelShipment, recordTravelMovement,
|
||||
finishTravelTurn, validateTravelPromises, type TravelClaim, type TravelPromise, type TravelBranchLeaf } from './travel-expression-promises';
|
||||
@@ -18959,6 +18962,51 @@ function recordTurnActionRevealedPlans(g: Game) {
|
||||
g.turn, battle.event!, battle.territory, values));
|
||||
}
|
||||
}
|
||||
type TurnActionPowerRequirement = Extract<TurnActionBranchLeaf, { kind: 'battlePower' }>;
|
||||
/** These declarations cost no resource and affect the other combatant's plan,
|
||||
* never the provider's own plan. Quote the original choice, not a speculative
|
||||
* inspected answer or an invented foreign Voice/Prescience continuation. */
|
||||
function turnActionPowerCompletion(g: Game, actor: string, requirements: readonly TurnActionPowerRequirement[]):
|
||||
{ action?: Action } | null {
|
||||
let action: Action | undefined;
|
||||
for (const power of ['voice', 'prescience'] as const) {
|
||||
const required = requirements.filter(leaf => battlePowerOf(leaf.claim) === power);
|
||||
if (!required.length) continue;
|
||||
const source = nativeBattlePowerOpportunity(g, actor, power);
|
||||
if (!source || required.some(leaf => leaf.battle !== source.event || leaf.territory !== source.territory ||
|
||||
leaf.owner !== source.owner || leaf.beneficiary !== source.beneficiary || leaf.target !== source.target)) return null;
|
||||
const choices: { declaration: BattlePowerDeclaration; action: Action }[] = [
|
||||
{ declaration: { power, use: false }, action: { type: 'declineBattlePower' } },
|
||||
];
|
||||
if (power === 'voice') {
|
||||
for (const kind of ['poison', ...VOICE_KINDS.filter(kind => kind !== 'poison')]) for (const must of [false, true]) {
|
||||
try {
|
||||
quoteVoiceCommand(g, source.target, kind, must);
|
||||
choices.push({ declaration: { power, use: true, cardType: kind, must }, action: { type: 'voice', kind, must } });
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
} else for (const field of ['leader', 'weapon', 'defense', 'dial'] as PlanField[])
|
||||
if (field !== 'dial' || !battleNoFieldPlan(g, source.target))
|
||||
choices.push({ declaration: { power, use: true, field }, action: { type: 'prescience', field } });
|
||||
const choice = choices.find(choice => required.every(leaf => battlePowerMatches(leaf.claim, choice.declaration) === leaf.answer));
|
||||
if (!choice) return null;
|
||||
if (source.ready) action ??= choice.action;
|
||||
}
|
||||
return action ? { action } : {};
|
||||
}
|
||||
function recordNativeTurnActionPower(g: Game, actor: string, declaration: BattlePowerDeclaration) {
|
||||
const battle = g.battle;
|
||||
if (!g.turnActionPromises || !battle?.event) return;
|
||||
const previous = g.turnActionPromises;
|
||||
const recorded = turnActionRule(() => recordTurnActionBattlePower(previous, actor,
|
||||
g.turn, battle.event!, battle.territory, declaration));
|
||||
// Check this accepted declaration before its next foreign response or
|
||||
// inspection can temporarily suspend the ordinary joint reconciler.
|
||||
if (recorded.some((promise, index) => promise !== previous[index]))
|
||||
requireRule(!!turnActionCompletion({ ...g, turnActionPromises: recorded }, actor),
|
||||
'This native declaration would remove the last legal completion of your whole Truthtrance answer.');
|
||||
g.turnActionPromises = recorded;
|
||||
}
|
||||
/** A disappeared original opportunity is absence, never a synthetic zero plan
|
||||
* or Decline. Declaration effects can later change; only the native hook
|
||||
* records a declaration receipt. */
|
||||
@@ -18975,8 +19023,18 @@ function reconcileTurnActionCombat(g: Game) {
|
||||
const lostTraitor = combat.traitor && !combat.traitor.closed && (!context ||
|
||||
context.submitted !== undefined || context.beneficiary !== combat.traitor.beneficiary ||
|
||||
context.target !== combat.traitor.target || context.leader !== combat.traitor.leader);
|
||||
return !current || lostTraitor
|
||||
? turnActionRule(() => closeTurnActionCombat([promise], promise.turn, combat.event))[0] : promise;
|
||||
if (!current || lostTraitor) return turnActionRule(() => closeTurnActionCombat([promise], promise.turn, combat.event))[0];
|
||||
let updated = promise;
|
||||
for (const power of ['voice', 'prescience'] as const) {
|
||||
const stage = updated.combat?.powers?.[power];
|
||||
if (!stage || stage.closed) continue;
|
||||
const source = nativeBattlePowerOpportunity(g, promise.player, power);
|
||||
if (!source || source.event !== combat.event || source.territory !== combat.territory ||
|
||||
source.beneficiary !== stage.beneficiary || source.target !== stage.target)
|
||||
updated = turnActionRule(() => closeTurnActionBattlePower([updated], promise.player,
|
||||
promise.turn, combat.event, combat.territory, power))[0];
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
function turnActionTraitorChoices(g: Game, actor: string, choices: boolean[]): boolean[] {
|
||||
@@ -19085,7 +19143,7 @@ function turnActionAllianceCanClose(g: Game, player: Player): boolean {
|
||||
return !!turnActionCompletion(source, player.id);
|
||||
} catch (error) { if (error instanceof RuleError) return false; throw error; }
|
||||
}
|
||||
type TurnActionCompletion = MovementCompletion & { payments: Action[]; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[]; battlePlan?: BattleCompletion; traitor?: Action; cardPreparations?: Action[] };
|
||||
type TurnActionCompletion = MovementCompletion & { payments: Action[]; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[]; battlePlan?: BattleCompletion; traitor?: Action; cardPreparations?: Action[]; battlePower?: Action };
|
||||
/** Whole-answer alternatives stay private. Only detached native rows quote a
|
||||
* route, sharing actual interval progress and the remaining physical purse. */
|
||||
function turnActionCompletion(source: Game, actor: string, unknownConsent?: { value: boolean }): TurnActionCompletion | null {
|
||||
@@ -19155,6 +19213,9 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
|
||||
}
|
||||
});
|
||||
if (invalidCombat) return null;
|
||||
const power = turnActionPowerCompletion(trial, actor,
|
||||
leaves.filter((leaf): leaf is TurnActionPowerRequirement => leaf.kind === 'battlePower'));
|
||||
if (!power) return null;
|
||||
const alliancePlans = turnActionAlliancePlans(trial, actor, !!unknownConsent);
|
||||
for (const required of bribePromiseRule(() => bribeCompletionCandidates(
|
||||
trial.bribePromises!, trial.bribeExpressionPromises ?? [], actor, source.turn))) {
|
||||
@@ -19239,6 +19300,7 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
|
||||
if (alliancePlan?.uncertain) { unknownConsent!.value = true; continue; }
|
||||
return { ...completion, ...(cardSeed.actions.length ? { cardPreparations: cardSeed.actions } : {}), ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
|
||||
...(battlePlan ? { battlePlan } : {}), ...(traitor ? { traitor } : {}),
|
||||
...(power.action ? { battlePower: power.action } : {}),
|
||||
...(alliancePlan ? { alliance: alliancePlan.steps.map(step => ({ type: 'alliance', target: step.target })) } : {}),
|
||||
payments: required.map(payment => ({ type: 'bribe', target: payment.recipient, amount: payment.amount })) };
|
||||
}
|
||||
@@ -19257,12 +19319,14 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
|
||||
const open = turnActionTransportOpen(g, p.id);
|
||||
let bid = false, revival = false, alliance = false, plan = false, traitor = false, cardHajr = false, cardGhola = false;
|
||||
const powers = new Set<NativeBattlePower>();
|
||||
const visit = (node: TurnActionClaim['mixed']): void => {
|
||||
if (node.kind === 'action') {
|
||||
if (node.action.kind === 'bid') bid = true;
|
||||
if (node.action.kind === 'alliance') alliance = true;
|
||||
if (node.action.kind === 'battlePlan') plan = true;
|
||||
if (node.action.kind === 'traitorCall') traitor = true;
|
||||
if (node.action.kind === 'battlePower') powers.add(battlePowerOf(node.action.claim));
|
||||
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;
|
||||
@@ -19278,12 +19342,20 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
revival: revival ? { total: 0, normal: 0, elite: 0, leaders: [], closed: g.phase > 4 ||
|
||||
g.phase === 4 && !!g.revivalOrder && !g.revivalOrder.remaining.includes(p.id) } : null,
|
||||
} : undefined;
|
||||
const battle = plan || traitor ? g.battle : null;
|
||||
requireRule(!plan && !traitor || !!battle?.event, 'Retain the original current battle.');
|
||||
const battle = plan || traitor || powers.size > 0 ? g.battle : null;
|
||||
requireRule(!plan && !traitor && powers.size === 0 || !!battle?.event, 'Retain the original current battle.');
|
||||
const context = traitor ? traitorTruthContext(g, p.id) : null;
|
||||
requireRule(!traitor || context && context.submitted === undefined, 'Retain the original unsubmitted Traitor decision.');
|
||||
const powerStage = (power: NativeBattlePower) => {
|
||||
if (!powers.has(power)) return null;
|
||||
const source = nativeBattlePowerOpportunity(g, p.id, power);
|
||||
requireRule(source && battle && source.event === battle.event && source.territory === battle.territory,
|
||||
'Retain your original owned Voice or Prescience opportunity.');
|
||||
return { beneficiary: source.beneficiary, target: source.target, closed: false, values: null };
|
||||
};
|
||||
const combat: TurnActionCombat | undefined = battle?.event ? { event: battle.event, territory: battle.territory,
|
||||
plan: plan ? { closed: false, values: null } : null,
|
||||
...(powers.size ? { powers: { voice: powerStage('voice'), prescience: powerStage('prescience') } } : {}),
|
||||
traitor: context ? { beneficiary: context.beneficiary, target: context.target, leader: context.leader,
|
||||
closed: false, call: null } : null } : undefined;
|
||||
let promise: TurnActionPromise = { turn: claim.turn, mixed: structuredClone(claim.mixed), event, player: p.id, asker, answer, compiled,
|
||||
@@ -19444,7 +19516,8 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
|
||||
}
|
||||
if (!action && g.phase === 6 && g.battle) {
|
||||
const battle = g.battle;
|
||||
if (completion.battlePlan && !completion.battlePlan.waitingForIncome && !battle.revealed && !battle.plans[player.id] &&
|
||||
if (completion.battlePower) action = completion.battlePower;
|
||||
if (!action && completion.battlePlan && !completion.battlePlan.waitingForIncome && !battle.revealed && !battle.plans[player.id] &&
|
||||
(!battle.preLeader || battle.preLeader.closed) && !battle.preparation &&
|
||||
(!battle.fullPlan || battle.plans[battle.fullPlan.target] || battle.fullPlan.target === player.id))
|
||||
action = completion.battlePlan.actions[0] ?? { type: 'battlePlan', ...completion.battlePlan.plan };
|
||||
@@ -41317,6 +41390,7 @@ function applyActionInner(
|
||||
'You do not own this battle power decision.',
|
||||
);
|
||||
const kind = b.preparation.kind;
|
||||
recordNativeTurnActionPower(g, id, { power: kind === 'voice' ? 'voice' : 'prescience', use: false });
|
||||
if (kind === 'voice') battlePreparation(g, 'prescience');
|
||||
else finishBattlePreparation(g);
|
||||
log(
|
||||
@@ -41341,6 +41415,7 @@ function applyActionInner(
|
||||
action.field !== 'dial' || !battleNoFieldPlan(g, target),
|
||||
'Atreides may not inspect the number dialed in a No-Field battle.',
|
||||
);
|
||||
recordNativeTurnActionPower(g, id, { power: 'prescience', use: true, field: action.field as PlanField });
|
||||
b.prescience = { player: beneficiary, field: action.field as PlanField };
|
||||
b.preparation = {
|
||||
kind: 'prescienceAnswer',
|
||||
@@ -41409,6 +41484,7 @@ function applyActionInner(
|
||||
);
|
||||
const target = b.attacker === b.preparation.beneficiary ? b.defender : b.attacker;
|
||||
b.voice = quoteVoiceCommand(g, target, action.kind, action.must);
|
||||
recordNativeTurnActionPower(g, id, { power: 'voice', use: true, cardType: b.voice.kind, must: b.voice.must });
|
||||
battlePreparation(g, 'prescience');
|
||||
g.response = { kind: 'voice', owner: id, passed: [] };
|
||||
return g;
|
||||
|
||||
+7
-5
@@ -3212,9 +3212,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
id: 'truthtrance-turn-actions',
|
||||
title: 'Truthtrance: combined card plays, battle and turn actions',
|
||||
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
|
||||
summary: 'Combine future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
|
||||
summary: 'Combine native Voice/Prescience declarations, future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
|
||||
steps: [
|
||||
'Choose Combine 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.',
|
||||
'Choose Combine native battle powers, Hajr/Ghola plays 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 native power declarations, printed Hajr/Ghola use, current battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
|
||||
'A final-alliance clause names another current participant during this original quiet native Nexus. Existing invitations and reciprocal formation remain authoritative. Offers, temporary membership and your own Ready do not settle the clause; final actual membership is fixed only when the Nexus closes.',
|
||||
'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.',
|
||||
@@ -3224,6 +3224,8 @@ 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.',
|
||||
'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 native-power condition names your original Voice or Prescience opportunity in this battle. Ask about actual use or explicit Decline, an exact Voice category and require/forbid command, or the requested Prescience field. A requested field is not the inspected answer. The declaration counts before later Karama prevention.',
|
||||
'Native owners can provide their eligible ally’s power. Voice finishes before Prescience; a pending Prescience condition does not advance its turn. Original command and No-Field restrictions remain. All predicates concern the same one native choice; source expiry is absence and never becomes a synthetic Decline. Borrowed Nexus and special powers retain separate scope.',
|
||||
'A printed-card condition requires an actual Hajr or Tleilaxu Ghola play in its printed role after this answer. Earlier plays, discards, sales, Karama roles and recovery alone do not count; a genuine later Box recovery followed by replay can count. No hidden card is needed merely to ask the public named condition.',
|
||||
'Hajr counts even if its extra move is unused, closes at your own Movement end and must earn its actual Advanced timing. Ghola remains available through this turn’s Mentat and uses real Tanks, eligible leader or Kwisatz and typed selection; it does not spend or satisfy ordinary Revival quotas. Ordinary returns and Ghola share the actual remaining Tanks and purse.',
|
||||
'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.',
|
||||
@@ -3233,11 +3235,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-travel', 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-bribe-expression', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse 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: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse native power declarations, printed Hajr/Ghola, battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['game/battle-power-question.ts', 'game/turn-action-promises.ts', 'game/printed-card-play.ts', 'game/movement-promise-search.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Full editor, raw question, private answer, persistent own history and exact current-phase native guide.', evidence: ['components/turn-action-fields.tsx', 'components/turn-action-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-turn-action-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed Hajr/Ghola; 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: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed Hajr/Ghola and original power declarations; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/truth-turn-power-runtime.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Current funding, physical event meaning, private alternatives, separate deadlines and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TURN_ACTIONS.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays 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'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, shared Revival resources and original power declarations; broader composition and final acceptance remain.', evidence: ['tests/battle-power-question.test.ts', 'tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/fixture-truth-turn-power.ts', 'tests/truth-turn-power-runtime.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+13
-2
@@ -1,6 +1,7 @@
|
||||
import { parseTravelActionClaim, type TravelClaim } from './travel-expression-promises';
|
||||
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES,
|
||||
type TurnActionClaim } from './turn-action-promises';
|
||||
import { battlePowerOf, nativeBattlePowerOpportunity } from './battle-power-question';
|
||||
import { parseTraitorActionClaim, type MixedTraitorClaim } from './mixed-traitor-question';
|
||||
import { parseBattleActionClaim, MIXED_BATTLE_MAX_NODES, MIXED_BATTLE_MAX_TERMS, MIXED_BATTLE_MAX_DEPTH,
|
||||
MIXED_BATTLE_MAX_LEAVES, type MixedBattleClaim } from './mixed-battle-question';
|
||||
@@ -384,7 +385,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
const respondent = g.players.find(player => player.id === v.target)!;
|
||||
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), input => {
|
||||
const action = parseTurnActionLeaf(input);
|
||||
if (action.kind === 'battlePlan' || action.kind === 'traitorCall') {
|
||||
if (action.kind === 'battlePlan' || action.kind === 'traitorCall' || action.kind === 'battlePower') {
|
||||
const battle = g.battle;
|
||||
check(g.phase === 6 && battle && typeof battle.event === 'string' && battle.event.length > 0 &&
|
||||
action.battle === battle.event && action.territory === battle.territory,
|
||||
@@ -392,7 +393,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
if (action.kind === 'battlePlan') {
|
||||
check([battle.attacker, battle.defender].includes(respondent.id), 'Ask an actual combatant about their own current native plan.');
|
||||
action.claim = parseBattleActionClaim(action.claim, battleLeaderIdentities(g).map(leader => leader.id));
|
||||
} else {
|
||||
} else if (action.kind === 'traitorCall') {
|
||||
const voters = [battle.attacker, battle.defender];
|
||||
if (battle.ecazOccupy?.lead) for (const id of [battle.ecazOccupy.ecaz, battle.ecazOccupy.ally])
|
||||
if (!voters.includes(id)) voters.push(id);
|
||||
@@ -400,6 +401,10 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
if (harkonnen?.ally && voters.includes(harkonnen.ally) && !voters.includes(harkonnen.id)) voters.push(harkonnen.id);
|
||||
check(battle.revealed && voters.includes(respondent.id) && battle.traitorCalls[respondent.id] === undefined,
|
||||
'Choose an actual current revealed, unsubmitted native Traitor voter.');
|
||||
} else {
|
||||
const opportunity = nativeBattlePowerOpportunity(g, respondent.id, battlePowerOf(action.claim));
|
||||
check(opportunity && opportunity.event === action.battle && opportunity.territory === action.territory,
|
||||
'Choose the original owned native Voice or Prescience declaration opportunity.');
|
||||
}
|
||||
}
|
||||
if (action.kind === 'payment') check(action.recipient !== respondent.id && action.recipient !== respondent.ally &&
|
||||
@@ -730,6 +735,12 @@ export function truthQuestionText(
|
||||
if (q.kind === 'turnActions') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
|
||||
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 === 'battlePower') {
|
||||
const claim = action.claim, where = `in this original battle in ${locationName(action.territory)}`;
|
||||
if (claim.kind === 'voiceCommand') return `you actually declare native Voice ${claim.must ? 'requiring' : 'forbidding'} ${claim.cardType} ${where} (later prevention does not change the declaration)`;
|
||||
if (claim.kind === 'prescienceField') return `you actually request the ${claim.field} field through native Prescience ${where} (this describes the requested field, not its answer value)`;
|
||||
return `you actually ${claim.use ? 'declare use of' : 'explicitly decline'} native ${claim.kind === 'voice' ? 'Voice' : 'Prescience'} ${where}`;
|
||||
}
|
||||
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 === 'battlePlan') return `in your own native plan in this original battle in ${locationName(action.territory)}, ${planClaimText(action.claim, leaderName)}`;
|
||||
|
||||
@@ -7,6 +7,8 @@ import { RevivalExpressionPromiseError, type RevivalExpressionReturn } from './r
|
||||
import { parseBattleActionClaim, type MixedBattleActionClaim } from './mixed-battle-question';
|
||||
import { PlanClaimError } from './battle-promises';
|
||||
import type { PrintedCardEffect } from './printed-card-play';
|
||||
import { BattlePowerQuestionError, battlePowerMatches, battlePowerOf, parseBattlePowerClaim,
|
||||
type BattlePowerClaim, type BattlePowerDeclaration, type NativeBattlePower } from './battle-power-question';
|
||||
import {
|
||||
MixedActionQuestionError,
|
||||
parseMixedActionExpression,
|
||||
@@ -29,7 +31,8 @@ export type TurnActionLeaf = TravelActionClaim | { kind: 'payment'; recipient: s
|
||||
{ kind: 'bid'; lot: number; minimum: number } | RevivalActionClaim | { kind: 'alliance'; partner: string } |
|
||||
{ kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim } |
|
||||
{ kind: 'traitorCall'; battle: string; territory: string; call: boolean } |
|
||||
{ kind: 'cardPlay'; effect: PrintedCardEffect };
|
||||
{ kind: 'cardPlay'; effect: PrintedCardEffect } |
|
||||
{ kind: 'battlePower'; battle: string; territory: string; claim: BattlePowerClaim };
|
||||
export type TurnActionClaim = { turn: number; mixed: MixedActionExpression<TurnActionLeaf> };
|
||||
export type TurnActionPayment = { recipient: string; amount: number };
|
||||
export type TurnActionBranchLeaf = TravelBranchLeaf |
|
||||
@@ -41,7 +44,9 @@ export type TurnActionBranchLeaf = TravelBranchLeaf |
|
||||
{ kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim; answer: boolean } |
|
||||
{ kind: 'traitorCall'; battle: string; territory: string; call: boolean; answer: boolean;
|
||||
beneficiary: string; target: string; leader: string | null } |
|
||||
{ kind: 'cardPlay'; effect: PrintedCardEffect; answer: boolean };
|
||||
{ kind: 'cardPlay'; effect: PrintedCardEffect; answer: boolean } |
|
||||
{ kind: 'battlePower'; battle: string; territory: string; claim: BattlePowerClaim;
|
||||
owner: string; beneficiary: string; target: string; answer: boolean };
|
||||
export type TurnActionPreparation = {
|
||||
bid: { lot: number; source: string; maximum: number; closed: boolean } | null;
|
||||
revival: { total: number; normal: number; elite: number; leaders: string[]; closed: boolean } | null;
|
||||
@@ -49,11 +54,16 @@ export type TurnActionPreparation = {
|
||||
export type TurnActionBid = { lot: number; source: string; amount: number };
|
||||
export type TurnActionAlliance = { source: string; partner: string | null; closed: boolean };
|
||||
export type TurnActionPlanValue = { claim: MixedBattleActionClaim; value: boolean };
|
||||
export type TurnActionPowerStage = {
|
||||
beneficiary: string; target: string; closed: boolean;
|
||||
values: { claim: BattlePowerClaim; value: boolean }[] | null;
|
||||
};
|
||||
export type TurnActionCombat = {
|
||||
event: string;
|
||||
territory: string;
|
||||
plan: { closed: boolean; values: TurnActionPlanValue[] | null } | null;
|
||||
traitor: { beneficiary: string; target: string; leader: string | null; closed: boolean; call: boolean | null } | null;
|
||||
powers?: Record<NativeBattlePower, TurnActionPowerStage | null>;
|
||||
};
|
||||
export type TurnActionCardPlays = Record<PrintedCardEffect, { played: boolean; closed: boolean } | null>;
|
||||
export type TurnActionPromise = TurnActionClaim & {
|
||||
@@ -113,11 +123,25 @@ function parseCombatClaim(value: unknown): MixedBattleActionClaim {
|
||||
} catch (error) { if (error instanceof PlanClaimError) throw new TurnActionError(error.message); throw error; }
|
||||
}
|
||||
const planKey = (claim: MixedBattleActionClaim): string => JSON.stringify(parseCombatClaim(claim));
|
||||
function parsePowerClaim(value: unknown): BattlePowerClaim {
|
||||
try { return parseBattlePowerClaim(value); }
|
||||
catch (error) { if (error instanceof BattlePowerQuestionError) throw new TurnActionError(error.message); throw error; }
|
||||
}
|
||||
const powerKey = (claim: BattlePowerClaim): string => JSON.stringify(parsePowerClaim(claim));
|
||||
function powerMatches(claim: BattlePowerClaim, declaration: BattlePowerDeclaration): boolean {
|
||||
try { return battlePowerMatches(claim, declaration); }
|
||||
catch (error) { if (error instanceof BattlePowerQuestionError) throw new TurnActionError(error.message); throw error; }
|
||||
}
|
||||
|
||||
/** Public leaves carry only their native destination/recipient and minimum. */
|
||||
export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
|
||||
check(object(value), 'Choose one future native action, printed-card play or ordinary bribe payment.');
|
||||
try {
|
||||
if (value.kind === 'battlePower') {
|
||||
check(keys(value, 'battle,claim,kind,territory') && identifier(value.battle) && identifier(value.territory),
|
||||
'Retain the original battle and native Voice or Prescience declaration clause.');
|
||||
return { kind: 'battlePower', battle: value.battle, territory: value.territory, claim: parsePowerClaim(value.claim) };
|
||||
}
|
||||
if (value.kind === 'cardPlay') {
|
||||
check(keys(value, 'effect,kind') && (value.effect === 'hajr' || value.effect === 'ghola'),
|
||||
'Choose only the printed Hajr or Tleilaxu Ghola play after this answer.');
|
||||
@@ -169,6 +193,7 @@ function storedFact(value: unknown, depth = 0): TruthFact {
|
||||
}
|
||||
|
||||
function actionKey(action: TurnActionLeaf): string {
|
||||
if (action.kind === 'battlePower') return JSON.stringify([action.kind, action.battle, action.territory, powerKey(action.claim)]);
|
||||
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 === 'traitorCall') return JSON.stringify([action.kind, action.battle, action.territory, action.call]);
|
||||
@@ -238,6 +263,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
let hasAlliance = false;
|
||||
const combatSources = new Set<string>();
|
||||
const planClaims = new Set<string>();
|
||||
const powerClaims: Record<NativeBattlePower, Set<string>> = { voice: new Set(), prescience: new Set() };
|
||||
let hasTraitor = false;
|
||||
const cardEffects = new Set<PrintedCardEffect>();
|
||||
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
|
||||
@@ -248,11 +274,12 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
if (node.action.kind === 'bid') lots.add(node.action.lot);
|
||||
if (node.action.kind === 'forces' || node.action.kind === 'leader') hasRevival = true;
|
||||
if (node.action.kind === 'alliance') hasAlliance = true;
|
||||
if (node.action.kind === 'battlePlan' || node.action.kind === 'traitorCall')
|
||||
if (node.action.kind === 'battlePlan' || node.action.kind === 'traitorCall' || node.action.kind === 'battlePower')
|
||||
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 === 'traitorCall') hasTraitor = true;
|
||||
if (node.action.kind === 'cardPlay') cardEffects.add(node.action.effect);
|
||||
if (node.action.kind === 'battlePower') powerClaims[battlePowerOf(node.action.claim)].add(powerKey(node.action.claim));
|
||||
} else if (node.kind !== 'fact') node.terms.forEach(visit);
|
||||
};
|
||||
visit(mixed);
|
||||
@@ -261,6 +288,8 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
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(!hasTraitor || !!promise.combat?.traitor, 'Retain the original private Traitor declaration stage.');
|
||||
for (const power of ['voice', 'prescience'] as const) check(!powerClaims[power].size || !!promise.combat?.powers?.[power],
|
||||
'Retain the original private native battle-power stage, including folded original clauses.');
|
||||
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.');
|
||||
@@ -272,9 +301,30 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
}
|
||||
if ('combat' in promise) {
|
||||
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') || keys(combat, 'event,plan,powers,territory,traitor')) && identifier(combat.event) &&
|
||||
identifier(combat.territory) && [...combatSources].every(source => source === JSON.stringify([combat.event, combat.territory])),
|
||||
'Retain one original current combat event and territory.');
|
||||
if ('powers' in combat) {
|
||||
check(object(combat.powers) && keys(combat.powers, 'prescience,voice'), 'Retain only the two native battle-power stages.');
|
||||
for (const power of ['voice', 'prescience'] as const) {
|
||||
const stage = combat.powers[power];
|
||||
if (stage === null) continue;
|
||||
check(object(stage) && keys(stage, 'beneficiary,closed,target,values') && identifier(stage.beneficiary) &&
|
||||
identifier(stage.target) && stage.beneficiary !== stage.target && typeof stage.closed === 'boolean' &&
|
||||
(stage.closed || stage.values === null), 'Retain the public native power side and actual closed declaration receipt.');
|
||||
if (stage.values === null) continue;
|
||||
check(Array.isArray(stage.values) && stage.values.length === powerClaims[power].size,
|
||||
'Retain every distinct original predicate for this native power exactly once.');
|
||||
const seen = new Set<string>();
|
||||
for (const entry of stage.values) {
|
||||
check(object(entry) && keys(entry, 'claim,value') && typeof entry.value === 'boolean', 'Retain definite actual battle-power predicate values.');
|
||||
const claim = parsePowerClaim(entry.claim), key = powerKey(claim);
|
||||
check(battlePowerOf(claim) === power && powerClaims[power].has(key) && !seen.has(key),
|
||||
'Retain only unique original predicates of this native power in the actual receipt.');
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (combat.plan !== null) {
|
||||
check(object(combat.plan) && keys(combat.plan, 'closed,values') && typeof combat.plan.closed === 'boolean' &&
|
||||
(combat.plan.closed || combat.plan.values === null), 'Retain an open plan or an actual closed plan receipt.');
|
||||
@@ -354,6 +404,8 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
}
|
||||
|
||||
function actionValue(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
|
||||
if (action.kind === 'battlePower') return promise.combat?.powers?.[battlePowerOf(action.claim)]?.values?.find(entry =>
|
||||
powerKey(entry.claim) === powerKey(action.claim))?.value ?? false;
|
||||
if (action.kind === 'cardPlay') return promise.cardPlays?.[action.effect]?.played ?? false;
|
||||
if (action.kind === 'battlePlan') return promise.combat?.plan?.values?.find(entry =>
|
||||
planKey(entry.claim) === planKey(action.claim))?.value ?? false;
|
||||
@@ -378,6 +430,7 @@ function evaluate(node: CompiledMixedAction<TurnActionLeaf>, promise: TurnAction
|
||||
}
|
||||
|
||||
function actionClosed(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
|
||||
if (action.kind === 'battlePower') return promise.combat!.powers![battlePowerOf(action.claim)]!.closed;
|
||||
if (action.kind === 'cardPlay') return promise.cardPlays![action.effect]!.closed;
|
||||
if (action.kind === 'battlePlan') return promise.combat!.plan!.closed;
|
||||
if (action.kind === 'traitorCall') return promise.combat!.traitor!.closed;
|
||||
@@ -418,6 +471,9 @@ export function liveTurnActionPromises<T extends TurnActionPromise>(promises: re
|
||||
|
||||
function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): boolean {
|
||||
if (left.kind !== right.kind) return false;
|
||||
if (left.kind === 'battlePower') return right.kind === 'battlePower' && left.battle === right.battle &&
|
||||
left.territory === right.territory && left.owner === right.owner && left.beneficiary === right.beneficiary &&
|
||||
left.target === right.target && powerKey(left.claim) === powerKey(right.claim);
|
||||
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 === 'alliance') return right.kind === 'alliance' && left.partner === right.partner && left.source === right.source;
|
||||
@@ -443,7 +499,7 @@ function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf
|
||||
if (!prior) merged.push({ ...leaf });
|
||||
continue;
|
||||
}
|
||||
if (leaf.kind === 'leader' || leaf.kind === 'alliance' || leaf.kind === 'battlePlan' || leaf.kind === 'cardPlay') {
|
||||
if (leaf.kind === 'leader' || leaf.kind === 'alliance' || leaf.kind === 'battlePlan' || leaf.kind === 'cardPlay' || leaf.kind === 'battlePower') {
|
||||
const prior = merged.find(other => sameAction(other, leaf));
|
||||
if (prior && prior.answer !== leaf.answer) return null;
|
||||
if (leaf.kind === 'alliance' && leaf.answer && merged.some(other => other.kind === 'alliance' &&
|
||||
@@ -476,7 +532,10 @@ export function* turnActionBranches(promise: TurnActionPromise): Generator<TurnA
|
||||
const achieved = actionValue(action, promise);
|
||||
if (achieved || actionClosed(action, promise)) { if (achieved === answer) yield []; return; }
|
||||
if (action.kind === 'bid') yield [{ ...action, source: promise.preparation!.bid!.source, answer }];
|
||||
else if (action.kind === 'traitorCall') {
|
||||
else if (action.kind === 'battlePower') {
|
||||
const stage = promise.combat!.powers![battlePowerOf(action.claim)]!;
|
||||
yield [{ ...action, owner: promise.player, beneficiary: stage.beneficiary, target: stage.target, answer }];
|
||||
} else if (action.kind === 'traitorCall') {
|
||||
const { beneficiary, target, leader } = promise.combat!.traitor!;
|
||||
yield [{ ...action, beneficiary, target, leader, answer }];
|
||||
} else if (action.kind === 'alliance') yield [{ ...action, source: promise.alliance!.source, answer }];
|
||||
@@ -512,6 +571,50 @@ function settle<T extends TurnActionPromise>(promise: T, update: T): T {
|
||||
? { ...update, fulfilled: true } : update;
|
||||
}
|
||||
|
||||
/** Record the actual owned declaration before any later Karama prevention.
|
||||
* Its exact predicates stay true/false even if the power's effect is denied. */
|
||||
export function recordTurnActionBattlePower<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||
turn: number, event: string, territory: string, declaration: BattlePowerDeclaration): T[] {
|
||||
check(identifier(event) && identifier(territory) && object(declaration) &&
|
||||
(declaration.power === 'voice' || declaration.power === 'prescience'), 'Retain the actual original native battle-power declaration.');
|
||||
powerMatches({ kind: declaration.power, use: false }, declaration);
|
||||
const power = declaration.power;
|
||||
const updates = new Map<T, T>();
|
||||
for (const promise of liveTurnActionPromises(promises, player, turn)) {
|
||||
readPromise(promise);
|
||||
const combat = promise.combat, stage = combat?.powers?.[power];
|
||||
if (!combat || !stage || stage.closed || combat.event !== event || combat.territory !== territory) continue;
|
||||
const claims = new Map<string, BattlePowerClaim>();
|
||||
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
|
||||
if (node.kind === 'action' && node.action.kind === 'battlePower' && battlePowerOf(node.action.claim) === power)
|
||||
claims.set(powerKey(node.action.claim), parsePowerClaim(node.action.claim));
|
||||
else if (node.kind !== 'fact' && node.kind !== 'action') node.terms.forEach(visit);
|
||||
};
|
||||
visit(promise.mixed);
|
||||
const values = [...claims.values()].map(claim => ({ claim, value: powerMatches(claim, declaration) }));
|
||||
updates.set(promise, settle(promise, { ...promise, combat: { ...combat,
|
||||
powers: { ...combat.powers!, [power]: { ...stage, closed: true, values } } } }));
|
||||
}
|
||||
return promises.map(promise => updates.get(promise) ?? promise);
|
||||
}
|
||||
|
||||
/** Losing only this native opportunity records absence, never Decline, and
|
||||
* preserves the plan, other power and every already closed receipt. */
|
||||
export function closeTurnActionBattlePower<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||
turn: number, event: string, territory: string, power: NativeBattlePower): T[] {
|
||||
check(identifier(event) && identifier(territory) && (power === 'voice' || power === 'prescience'),
|
||||
'Choose the original native battle-power opportunity.');
|
||||
const updates = new Map<T, T>();
|
||||
for (const promise of liveTurnActionPromises(promises, player, turn)) {
|
||||
readPromise(promise);
|
||||
const combat = promise.combat, stage = combat?.powers?.[power];
|
||||
if (!combat || !stage || stage.closed || combat.event !== event || combat.territory !== territory) continue;
|
||||
updates.set(promise, settle(promise, { ...promise, combat: { ...combat,
|
||||
powers: { ...combat.powers!, [power]: { ...stage, closed: true, values: null } } } }));
|
||||
}
|
||||
return promises.map(promise => updates.get(promise) ?? promise);
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -587,10 +690,15 @@ export function closeTurnActionCombat<T extends TurnActionPromise>(promises: rea
|
||||
if (promise.turn !== turn || promise.fulfilled || promise.released) return promise;
|
||||
readPromise(promise);
|
||||
const combat = promise.combat;
|
||||
if (!combat || combat.event !== event || (!combat.plan || combat.plan.closed) && (!combat.traitor || combat.traitor.closed)) return promise;
|
||||
if (!combat || combat.event !== event || (!combat.plan || combat.plan.closed) && (!combat.traitor || combat.traitor.closed) &&
|
||||
(!combat.powers?.voice || combat.powers.voice.closed) && (!combat.powers?.prescience || combat.powers.prescience.closed)) return promise;
|
||||
return settle(promise, { ...promise, combat: { ...combat,
|
||||
plan: combat.plan && !combat.plan.closed ? { closed: true, values: null } : combat.plan,
|
||||
traitor: combat.traitor && !combat.traitor.closed ? { ...combat.traitor, closed: true, call: null } : combat.traitor,
|
||||
...(combat.powers ? { powers: {
|
||||
voice: combat.powers.voice && !combat.powers.voice.closed ? { ...combat.powers.voice, closed: true, values: null } : combat.powers.voice,
|
||||
prescience: combat.powers.prescience && !combat.powers.prescience.closed ? { ...combat.powers.prescience, closed: true, values: null } : combat.powers.prescience,
|
||||
} } : {}),
|
||||
} });
|
||||
});
|
||||
}
|
||||
@@ -792,7 +900,8 @@ export function finishTurnActionTurn<T extends TurnActionPromise>(promises: read
|
||||
ghola: promise.cardPlays.ghola ? { ...promise.cardPlays.ghola, closed: true } : null,
|
||||
} } : {}) };
|
||||
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 || promise.combat.powers?.voice && !promise.combat.powers.voice.closed ||
|
||||
promise.combat.powers?.prescience && !promise.combat.powers.prescience.closed);
|
||||
const fulfilled = !(promise.alliance && !promise.alliance.closed) && !unresolvedCombat && turnActionValue(closed) === promise.answer;
|
||||
return { ...closed, fulfilled, released: !fulfilled };
|
||||
});
|
||||
@@ -811,6 +920,8 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[
|
||||
(!promise.preparation?.revival || promise.preparation.revival.closed) &&
|
||||
(!promise.alliance || promise.alliance.closed) && (!promise.combat?.plan || promise.combat.plan.closed) &&
|
||||
(!promise.combat?.traitor || promise.combat.traitor.closed) &&
|
||||
(!promise.combat?.powers?.voice || promise.combat.powers.voice.closed) &&
|
||||
(!promise.combat?.powers?.prescience || promise.combat.powers.prescience.closed) &&
|
||||
(!promise.cardPlays?.hajr || promise.cardPlays.hajr.closed) && (!promise.cardPlays?.ghola || promise.cardPlays.ghola.closed);
|
||||
const recipientsValid = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
|
||||
? node.action.kind === 'alliance' ? players.includes(node.action.partner) && node.action.partner !== promise.player :
|
||||
@@ -821,6 +932,10 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[
|
||||
(!promise.alliance || promise.alliance.partner === null || players.includes(promise.alliance.partner)) &&
|
||||
(!promise.combat?.traitor || players.includes(promise.combat.traitor.beneficiary) &&
|
||||
players.includes(promise.combat.traitor.target) && promise.combat.traitor.target !== promise.player) &&
|
||||
(['voice', 'prescience'] as const).every(power => {
|
||||
const stage = promise.combat?.powers?.[power];
|
||||
return !stage || players.includes(stage.beneficiary) && players.includes(stage.target) && stage.target !== promise.player;
|
||||
}) &&
|
||||
(promise.released || (promise.answer || !satisfied) &&
|
||||
(!promise.answer || promise.fulfilled === satisfied) &&
|
||||
(!promise.fulfilled || satisfied === promise.answer && (promise.answer || relevantClosed(compiled, promise))) &&
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { VOICE_KINDS } from '../game/battle-cards';
|
||||
import {
|
||||
BattlePowerQuestionError,
|
||||
battlePowerMatches,
|
||||
battlePowerOf,
|
||||
nativeBattlePowerOpportunity,
|
||||
parseBattlePowerClaim,
|
||||
type BattlePowerClaim,
|
||||
type BattlePowerDeclaration,
|
||||
type NativeBattlePowerGame,
|
||||
} from '../game/battle-power-question';
|
||||
|
||||
function game(): NativeBattlePowerGame {
|
||||
return { status: 'playing', phase: 6, players: [
|
||||
{ id: 'bg', faction: 'beneGesserit', ally: null }, { id: 'a', faction: 'atreides', ally: null },
|
||||
{ id: 'e', faction: 'emperor', ally: null },
|
||||
], battle: { event: 'battle:1', territory: 'carthag', attacker: 'bg', defender: 'a', revealed: false,
|
||||
prepared: true, preparation: { kind: 'voice', owner: 'bg', beneficiary: 'bg' } } };
|
||||
}
|
||||
|
||||
void test('native battle-power claims parse exact declaration predicates without hidden values or groups', () => {
|
||||
const claims: BattlePowerClaim[] = [{ kind: 'voice', use: true }, { kind: 'voice', use: false },
|
||||
{ kind: 'prescience', use: true }, { kind: 'prescience', use: false },
|
||||
...VOICE_KINDS.map(cardType => ({ kind: 'voiceCommand' as const, cardType, must: false })),
|
||||
...(['leader', 'weapon', 'defense', 'dial'] as const).map(field => ({ kind: 'prescienceField' as const, field }))];
|
||||
for (const claim of claims) {
|
||||
assert.deepEqual(parseBattlePowerClaim(claim), claim);
|
||||
assert.notEqual(parseBattlePowerClaim(claim), claim);
|
||||
assert.equal(battlePowerOf(claim), claim.kind === 'voice' || claim.kind === 'voiceCommand' ? 'voice' : 'prescience');
|
||||
}
|
||||
for (const claim of [null, [], {}, { kind: 'voice', use: 'yes' }, { kind: 'voice', use: true, source: 'battle:1' },
|
||||
{ kind: 'voiceCommand', cardType: 'unknown', must: true }, { kind: 'voiceCommand', cardType: 'shield', must: 1 },
|
||||
{ kind: 'prescienceField', field: 'support' }, { kind: 'prescienceField', field: 'weapon', value: 'Lasgun' },
|
||||
{ kind: 'and', terms: [{ kind: 'voice', use: true }, { kind: 'voice', use: false }] }])
|
||||
assert.throws(() => parseBattlePowerClaim(claim), BattlePowerQuestionError);
|
||||
});
|
||||
|
||||
void test('actual Voice use, exact command and explicit Decline match independently of later effect success', () => {
|
||||
const declaration: BattlePowerDeclaration = { power: 'voice', use: true, cardType: 'projectile', must: true };
|
||||
assert.equal(battlePowerMatches({ kind: 'voice', use: true }, declaration), true);
|
||||
assert.equal(battlePowerMatches({ kind: 'voice', use: false }, declaration), false);
|
||||
assert.equal(battlePowerMatches({ kind: 'voiceCommand', cardType: 'projectile', must: true }, declaration), true);
|
||||
assert.equal(battlePowerMatches({ kind: 'voiceCommand', cardType: 'projectile', must: false }, declaration), false);
|
||||
assert.equal(battlePowerMatches({ kind: 'voiceCommand', cardType: 'poison', must: true }, declaration), false);
|
||||
const decline: BattlePowerDeclaration = { power: 'voice', use: false };
|
||||
assert.equal(battlePowerMatches({ kind: 'voice', use: false }, decline), true);
|
||||
assert.equal(battlePowerMatches({ kind: 'voiceCommand', cardType: 'projectile', must: true }, decline), false);
|
||||
assert.equal(battlePowerMatches({ kind: 'prescience', use: false }, decline), false);
|
||||
});
|
||||
|
||||
void test('Prescience predicates refer to use or requested field, never the inspected answer', () => {
|
||||
for (const field of ['leader', 'weapon', 'defense', 'dial'] as const) {
|
||||
const declaration: BattlePowerDeclaration = { power: 'prescience', use: true, field };
|
||||
assert.equal(battlePowerMatches({ kind: 'prescience', use: true }, declaration), true);
|
||||
assert.equal(battlePowerMatches({ kind: 'prescienceField', field }, declaration), true);
|
||||
assert.equal(battlePowerMatches({ kind: 'voice', use: true }, declaration), false);
|
||||
assert.equal(battlePowerMatches({ kind: 'prescience', use: false }, declaration), false);
|
||||
}
|
||||
assert.equal(battlePowerMatches({ kind: 'prescienceField', field: 'weapon' },
|
||||
{ power: 'prescience', use: true, field: 'dial' }), false);
|
||||
assert.equal(battlePowerMatches({ kind: 'prescience', use: false }, { power: 'prescience', use: false }), true);
|
||||
for (const declaration of [null, {}, { power: 'voice', use: false, cardType: 'shield' },
|
||||
{ power: 'voice', use: true, cardType: 'unknown', must: false }, { power: 'prescience', use: true, field: 'support' },
|
||||
{ power: 'prescience', use: true, field: 'weapon', value: 'Lasgun' }, { power: 'voice', use: true, cardType: 'shield', must: true, prevented: true }])
|
||||
assert.throws(() => battlePowerMatches({ kind: 'voice', use: true }, declaration as never), BattlePowerQuestionError);
|
||||
});
|
||||
|
||||
void test('actual prepared native Voice and ordered future Prescience retain distinct owners, sides and readiness', () => {
|
||||
const source = game(), before = structuredClone(source);
|
||||
assert.equal(source.battle!.prepared, true);
|
||||
assert.deepEqual(nativeBattlePowerOpportunity(source, 'bg', 'voice'), { event: 'battle:1', territory: 'carthag',
|
||||
power: 'voice', owner: 'bg', beneficiary: 'bg', target: 'a', ready: true });
|
||||
assert.deepEqual(nativeBattlePowerOpportunity(source, 'a', 'prescience'), { event: 'battle:1', territory: 'carthag',
|
||||
power: 'prescience', owner: 'a', beneficiary: 'a', target: 'bg', ready: false });
|
||||
source.battle!.preparation = { kind: 'prescience', owner: 'a', beneficiary: 'a' };
|
||||
assert.equal(nativeBattlePowerOpportunity(source, 'a', 'prescience')!.ready, true);
|
||||
assert.equal(nativeBattlePowerOpportunity(source, 'bg', 'voice'), null);
|
||||
assert.deepEqual(before.battle!.preparation, { kind: 'voice', owner: 'bg', beneficiary: 'bg' });
|
||||
});
|
||||
|
||||
void test('native power quotation supports actual reciprocal allied beneficiaries without borrowing their faction identity', () => {
|
||||
const source = game();
|
||||
source.players = source.players.map(player => ({ ...player, ally: player.id === 'bg' ? 'e' : player.id === 'e' ? 'bg' : null }));
|
||||
source.battle!.attacker = 'e';
|
||||
source.battle!.preparation = { kind: 'voice', owner: 'bg', beneficiary: 'e' };
|
||||
assert.deepEqual(nativeBattlePowerOpportunity(source, 'bg', 'voice'), { event: 'battle:1', territory: 'carthag',
|
||||
power: 'voice', owner: 'bg', beneficiary: 'e', target: 'a', ready: true });
|
||||
assert.equal(nativeBattlePowerOpportunity(source, 'e', 'voice'), null);
|
||||
source.players = source.players.map(player => player.id === 'e' ? { ...player, ally: null } : player);
|
||||
assert.equal(nativeBattlePowerOpportunity(source, 'bg', 'voice'), null);
|
||||
});
|
||||
|
||||
void test('modal and Truth priority retain the original source with readiness suspended while blocked use still permits Decline', () => {
|
||||
for (const blocker of ['response', 'decision', 'phaseOpening', 'truthtrance', 'pendingTreacheryDiscard'] as const) {
|
||||
const source = { ...game(), [blocker]: {} };
|
||||
const quote = nativeBattlePowerOpportunity(source, 'bg', 'voice');
|
||||
assert.equal(quote!.event, 'battle:1');
|
||||
assert.equal(quote!.ready, false);
|
||||
}
|
||||
const blocked = game();
|
||||
blocked.battle!.preparation!.blocked = 'This Voice use is blocked.';
|
||||
assert.equal(nativeBattlePowerOpportunity(blocked, 'bg', 'voice')!.ready, true,
|
||||
'Decline remains an executable owned choice; use legality is a separate engine quote');
|
||||
});
|
||||
|
||||
void test('borrowed powers, completed declarations, foreign owners and crossed public sources create no native opportunity', () => {
|
||||
for (const kind of ['nexusVoice', 'prescienceAnswer', 'nexusPrescienceAnswer']) {
|
||||
const source = game(); source.battle!.preparation!.kind = kind;
|
||||
assert.equal(nativeBattlePowerOpportunity(source, 'bg', 'voice'), null);
|
||||
assert.equal(nativeBattlePowerOpportunity(source, 'a', 'prescience'), null);
|
||||
}
|
||||
for (const mutate of [
|
||||
(source: NativeBattlePowerGame) => { source.status = 'finished'; },
|
||||
(source: NativeBattlePowerGame) => { source.phase = 5; },
|
||||
(source: NativeBattlePowerGame) => { source.battle!.revealed = true; },
|
||||
(source: NativeBattlePowerGame) => { source.battle!.event = ''; },
|
||||
(source: NativeBattlePowerGame) => { source.battle!.preparation!.owner = 'e'; },
|
||||
(source: NativeBattlePowerGame) => { source.battle!.preparation!.beneficiary = 'a'; },
|
||||
(source: NativeBattlePowerGame) => { source.battle!.defender = 'missing'; },
|
||||
]) {
|
||||
const source = game(); mutate(source);
|
||||
assert.equal(nativeBattlePowerOpportunity(source, 'bg', 'voice'), null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native event/location quotation is pure and accepts original off-board battle locations', () => {
|
||||
const source = game(); source.battle!.territory = 'homeworld:guild';
|
||||
const before = structuredClone(source);
|
||||
assert.equal(nativeBattlePowerOpportunity(source, 'bg', 'voice')!.territory, 'homeworld:guild');
|
||||
assert.deepEqual(source, before);
|
||||
assert.equal(nativeBattlePowerOpportunity(source, 'other', 'voice'), null);
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { BattlePowerClaim, NativeBattlePower } from '../game/battle-power-question';
|
||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
|
||||
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { truthTraitorDecisionTraitors } from './fixture-truth-traitor-decision';
|
||||
import { actTruthTurnAction, truthTurnActionInventory, truthTurnActionPlayer,
|
||||
type TruthTurnActionStep } from './fixture-truth-turn-action';
|
||||
import type { TruthTurnCombatLeaf } from './fixture-truth-turn-combat';
|
||||
|
||||
export type TruthTurnPowerLeaf = TruthTurnCombatLeaf |
|
||||
{ kind: 'battlePower'; battle: string; territory: string; claim: BattlePowerClaim };
|
||||
export type TruthTurnPowerExpression = MixedActionExpression<TruthTurnPowerLeaf>;
|
||||
export type TruthTurnPowerStep = TruthTurnActionStep;
|
||||
export const actTruthTurnPower = actTruthTurnAction;
|
||||
export const truthTurnPowerPlayer = truthTurnActionPlayer;
|
||||
export const truthTurnPowerInventory = truthTurnActionInventory;
|
||||
export const truthTurnPowerTraitors = truthTraitorDecisionTraitors;
|
||||
export const truthTurnPowerClean = (game: Game) => !game.truthtrance && !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.pendingTreacheryDiscard && !game.pendingShipment && !game.pendingRevival;
|
||||
export const truthTurnPowerFact = (fact: TruthFact): TruthTurnPowerExpression => ({ kind: 'fact', fact });
|
||||
export const truthTurnPowerPayment = (recipient: string, minimum = 2): TruthTurnPowerExpression =>
|
||||
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
|
||||
export const truthTurnPowerAction = (battle: string, territory: string, claim: BattlePowerClaim): TruthTurnPowerExpression =>
|
||||
({ kind: 'action', action: { kind: 'battlePower', battle, territory, claim: structuredClone(claim) } });
|
||||
export const truthTurnPowerGroup = (kind: 'and' | 'or', ...terms: TruthTurnPowerExpression[]): TruthTurnPowerExpression =>
|
||||
({ kind, terms });
|
||||
|
||||
/** Ordinary native continuation. A requested field is answered with an actual
|
||||
* legal original unarmed zero-dial plan element; caller-owned private plans
|
||||
* and Truthtrance questions are never submitted by this helper. */
|
||||
export function nextTruthTurnPowerNative(game: Game): TruthTurnPowerStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
if (game.status === 'setup' && game.setupStage === 'prediction')
|
||||
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'atreides', turn: 10 } };
|
||||
if (truthTurnPowerClean(game) && game.battle?.preparation?.kind === 'prescienceAnswer') {
|
||||
const owner = game.battle.preparation.owner, field = game.battle.prescience!.field;
|
||||
const leader = truthTurnPowerPlayer(game, owner).leaders.find(disc => !disc.dead && !disc.usedAt); assert.ok(leader);
|
||||
return { actor: owner, action: { type: 'prescienceAnswer', value: field === 'dial' ? 0 : field === 'leader' ? leader.id : null } };
|
||||
}
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function nextTruthTurnPower(game: Game): TruthTurnPowerStep | 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 nextTruthTurnPowerNative(game);
|
||||
}
|
||||
export function advanceTruthTurnPower(game: Game, until: (state: Game) => boolean,
|
||||
actions: TruthTurnPowerStep[] = [], nativeOnly = false): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The genuine power source ended before its native boundary');
|
||||
const next = nativeOnly ? nextTruthTurnPowerNative(game) : nextTruthTurnPower(game);
|
||||
assert.ok(next, 'Supply the actual private plan or Truthtrance control');
|
||||
game = actTruthTurnPower(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original native power chronology did not reach its finite boundary');
|
||||
}
|
||||
export function settleTruthTurnPower(game: Game, actions: TruthTurnPowerStep[] = []): Game {
|
||||
return advanceTruthTurnPower(game, truthTurnPowerClean, actions, true);
|
||||
}
|
||||
|
||||
/** Original public Basic A/BG/E base33. Select unopened deal/lot/Spice
|
||||
* positions and scalar lotteries only. All setup, auction, Nexus consent,
|
||||
* paid shipment and battle preparation are actual engine actions. */
|
||||
export function createTruthTurnPowerFixture(options: {
|
||||
power?: NativeBattlePower; remote?: boolean; window?: NativeBattlePower;
|
||||
} = {}) {
|
||||
const power = options.power ?? 'voice', remote = options.remote ?? false;
|
||||
const atreides = 'atreides', bg = 'beneGesserit', emperor = 'emperor';
|
||||
const target = power === 'voice' ? bg : atreides;
|
||||
const asker = remote ? (power === 'voice' ? atreides : bg) : emperor;
|
||||
const recipient = asker, beneficiary = remote ? emperor : target;
|
||||
let game = createGame('TRUTHTURNPOWER', newPlayer(atreides, 'Atreides', 'atreides'), false);
|
||||
joinGame(game, newPlayer(bg, 'Bene Gesserit', 'beneGesserit'));
|
||||
joinGame(game, newPlayer(emperor, 'Emperor', 'emperor'));
|
||||
const initial = structuredClone(game), actions: TruthTurnPowerStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actTruthTurnPower(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceTruthTurnPower(game, until, actions, true); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.equal(game.deck.length, 33);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
act(bg, { type: 'predict', faction: atreides, turn: 10 });
|
||||
const originalCards = truthTurnPowerInventory(game), originalTraitors = truthTurnPowerTraitors(game);
|
||||
assert.equal(originalTraitors.length, 15);
|
||||
const truth = game.deck.find(card => card.effect === 'truthtrance');
|
||||
const shield = game.deck.find(card => card.kind === 'shield');
|
||||
const projectile = game.deck.find(card => card.kind === 'projectile');
|
||||
const karama = game.deck.find(card => card.effect === 'karama');
|
||||
const poison = game.deck.find(card => card.kind === 'poison');
|
||||
const snooper = game.deck.find(card => card.kind === 'snooper');
|
||||
assert.ok(truth && shield && projectile && karama && poison && snooper);
|
||||
const ordinary = [shield, projectile], deal = game.players.map(player =>
|
||||
player.id === asker ? truth.id : ordinary.shift()!.id);
|
||||
const dealOrder = [...deal, karama.id, poison.id, snooper.id].map((id, position) => {
|
||||
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game); reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(truthTurnPowerPlayer(game, asker).hand.map(card => card.id), [truth.id]);
|
||||
assert.deepEqual(truthTurnPowerPlayer(game, bg).forces, { 'polar_sink:0': 1 });
|
||||
assert.deepEqual(truthTurnPowerPlayer(game, atreides).forces, { 'arrakeen:10': 10 });
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
reach(beforeIxSpiceDraw); const spiceOrder = orderIxSpiceCards(game, ['land', 'land']);
|
||||
reach(state => state.phase === 3 && truthTurnPowerClean(state) && !!state.auction);
|
||||
assert.equal(game.auction!.cards[0].id, karama.id);
|
||||
reach(state => state.phase === 3 && truthTurnPowerClean(state) && state.auction?.active === asker);
|
||||
const beforePurchase = structuredClone(game), purchaseAt = actions.length;
|
||||
act(asker, { type: 'bid', amount: 1 });
|
||||
reach(state => truthTurnPowerClean(state) && truthTurnPowerPlayer(state, asker).hand.some(card => card.id === karama.id));
|
||||
const purchase = { before: beforePurchase, steps: structuredClone(actions.slice(purchaseAt)), after: structuredClone(game) };
|
||||
assert.ok(truthTurnPowerPlayer(game, asker).hand.some(card => card.id === karama.id));
|
||||
let beforeNexus: Game | null = null, nexusActions: TruthTurnPowerStep[] = [];
|
||||
let nexusSpiceOrder: ReturnType<typeof orderIxSpiceCards> = [];
|
||||
if (remote) {
|
||||
reach(state => state.turn === 2 && beforeIxSpiceDraw(state));
|
||||
nexusSpiceOrder = orderIxSpiceCards(game, ['worm', 'land']);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && !!state.nexus && !state.spiceWindow &&
|
||||
!state.spiceResolution && truthTurnPowerClean(state));
|
||||
beforeNexus = structuredClone(game); const from = actions.length;
|
||||
act(target, { type: 'alliance', target: emperor }); act(emperor, { type: 'alliance', target });
|
||||
assert.equal(truthTurnPowerPlayer(game, target).ally, emperor);
|
||||
assert.equal(truthTurnPowerPlayer(game, emperor).ally, target);
|
||||
reach(state => !state.nexus && truthTurnPowerClean(state));
|
||||
nexusActions = structuredClone(actions.slice(from));
|
||||
}
|
||||
const shipper = remote ? emperor : bg, territory = remote && power === 'prescience' ? 'imperial_basin' : 'arrakeen';
|
||||
const sector = 10;
|
||||
const shipmentAction: Action = { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0 };
|
||||
type Shipment = { before: Game; steps: TruthTurnPowerStep[]; after: Game };
|
||||
let shipment: Shipment | null = null, opponentShipment: Shipment | null = null;
|
||||
reach(state => state.turn === (remote ? 2 : 1) && state.phase === 5 && truthTurnPowerClean(state));
|
||||
const shippers = game.order.filter(actor => actor === shipper || (remote && power === 'prescience' && actor === bg));
|
||||
for (const actor of shippers) {
|
||||
reach(state => state.phase === 5 && state.active === actor && truthTurnPowerClean(state));
|
||||
const before = structuredClone(game), from = actions.length;
|
||||
act(actor, shipmentAction); reach(truthTurnPowerClean);
|
||||
const transition = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
|
||||
assert.equal(truthTurnPowerPlayer(before, actor).spice - truthTurnPowerPlayer(game, actor).spice,
|
||||
territory === 'imperial_basin' ? 2 : 1);
|
||||
if (actor === shipper) shipment = transition; else opponentShipment = transition;
|
||||
act(actor, { type: 'endMovement' });
|
||||
}
|
||||
assert.ok(shipment); const beforeShipment = shipment.before;
|
||||
reach(state => state.phase === 6 && !!state.active && !state.battle && truthTurnPowerClean(state));
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(candidate => candidate.territory === territory &&
|
||||
[candidate.attacker, candidate.defender].includes(beneficiary)); assert.ok(choice);
|
||||
act(choice.chooser, { type: 'chooseBattle', territory,
|
||||
target: choice.chooser === choice.attacker ? choice.defender : choice.attacker });
|
||||
reach(state => !!state.battle?.preparation && truthTurnPowerClean(state));
|
||||
const beforeVoice = structuredClone(game), beforeVoiceActions = structuredClone(actions);
|
||||
const window = options.window ?? power;
|
||||
if (window === 'prescience') reach(state => state.battle?.preparation?.kind === 'prescience' && truthTurnPowerClean(state));
|
||||
assert.ok(game.battle?.event); const event = game.battle.event;
|
||||
assert.equal(game.battle.preparation?.kind, window);
|
||||
assert.ok(truthTurnPowerPlayer(game, asker).hand.some(card => card.id === truth.id));
|
||||
assert.equal(truthTurnPowerPlayer(game, target).ally === recipient, false);
|
||||
assert.ok(!game.nexusCards && !game.homeworlds && !game.leaderSkills && !game.techTokens && !game.strongholdCards);
|
||||
assert.deepEqual(truthTurnPowerInventory(game), originalCards);
|
||||
assert.deepEqual(truthTurnPowerTraitors(game), originalTraitors);
|
||||
const claim: BattlePowerClaim = power === 'voice' ? { kind: 'voiceCommand', cardType: 'shield', must: false }
|
||||
: { kind: 'prescienceField', field: 'dial' };
|
||||
const nativeAction: Action = power === 'voice' ? { type: 'voice', kind: 'shield', must: false }
|
||||
: { type: 'prescience', field: 'dial' };
|
||||
const payment: Action = { type: 'bribe', target: recipient, amount: 2 };
|
||||
const mixed = truthTurnPowerGroup('and', truthTurnPowerAction(event, territory, claim), truthTurnPowerPayment(recipient));
|
||||
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
|
||||
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthTurnPowerPlayer(game, target).spice };
|
||||
return { game, source: structuredClone(game), initial, lobby, setup, beforeStartingDeal, afterSetup,
|
||||
asker, target, recipient, power, remote, window, beneficiary, event, battle: event, territory,
|
||||
truth: truth.id, karama: karama.id, claim, nativeAction, payment, mixed, question, spiceFact,
|
||||
atreides, bg, emperor, shipper, beforeShipment, shipmentAction, shipment, opponentShipment, beforeNexus, nexusActions,
|
||||
beforeVoice, beforeVoiceActions, purchase, dealOrder, spiceOrder, nexusSpiceOrder,
|
||||
originalCards, originalTraitors, originalSpice, actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { selected: actions.length, beforeVoice: beforeVoiceActions.length,
|
||||
nativeKaramaPurchase: purchase.steps.length, nativeNexusConsentAndClose: nexusActions.length,
|
||||
nativeShipment: shipment.steps.length, nativeOpponentShipment: opponentShipment?.steps.length ?? 0 },
|
||||
sourceDisclosure: `Actual public Basic A/BG/E base33 setup, original prediction/Traitor choices and native first Storm. ` +
|
||||
'Select only original unopened starting deal, ordinary Karama lot and Spice positions plus scalar lotteries. The asker really buys its original Karama for one spice. ' +
|
||||
(remote ? 'Actually finish turn1, open the turn2 ordinary Worm Nexus, submit reciprocal native alliance invitations and close the Nexus. ' +
|
||||
(power === 'voice' ? 'Emperor pays one spice for an original reserve in original Atreides Arrakeen. '
|
||||
: 'BG and Emperor each pay two spice to ship one original reserve into Imperial Basin; the sanctuary Polar Sink never supplies a battle. ')
|
||||
: 'BG pays one spice to ship one original reserve into Atreides original Arrakeen10. ') +
|
||||
'Choose the resulting original battle and stop at native Voice or ordered Prescience. Counts derive from the actual action arrays. No hand, wallet, force, alliance, battle, declaration, power, clock, receipt or promise is installed.',
|
||||
};
|
||||
}
|
||||
export type TruthTurnPowerFixture = ReturnType<typeof createTruthTurnPowerFixture>;
|
||||
|
||||
export function declareTruthTurnPower(f: TruthTurnPowerFixture, source = f.game, actions: TruthTurnPowerStep[] = []) {
|
||||
let game = actTruthTurnPower(source, f.asker, { type: 'card', card: f.truth }, actions);
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actTruthTurnPower(game, actor.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask'); return { game, actions };
|
||||
}
|
||||
export function askTruthTurnPower(game: Game, asker: string, target: string, mixed: TruthTurnPowerExpression,
|
||||
actions: TruthTurnPowerStep[] = []): Game {
|
||||
return actTruthTurnPower(game, asker, { type: 'truthAsk', question: {
|
||||
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
|
||||
} }, actions);
|
||||
}
|
||||
export function answerTruthTurnPower(game: Game, target: string, answer: 'yes' | 'no' | 'unknown',
|
||||
actions: TruthTurnPowerStep[] = []): Game {
|
||||
return actTruthTurnPower(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
export function bindTruthTurnPower(f: TruthTurnPowerFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
|
||||
source = f.game, actions: TruthTurnPowerStep[] = []) {
|
||||
const declared = declareTruthTurnPower(f, source, actions);
|
||||
const asked = askTruthTurnPower(declared.game, f.asker, f.target, mixed, actions);
|
||||
return { game: answerTruthTurnPower(asked, f.target, answer, actions), asked, declared, actions };
|
||||
}
|
||||
export function truthTurnPowerPromise(game: Game, target = game.turnActionPromises![0].player) {
|
||||
const row = viewGame(game, target).turnActionPromises.find(candidate => candidate.player === target); assert.ok(row); return row;
|
||||
}
|
||||
export function finishTruthTurnPowerBattle(game: Game, f: TruthTurnPowerFixture,
|
||||
actions: TruthTurnPowerStep[] = []): Game {
|
||||
game = advanceTruthTurnPower(game, state => !state.battle || (!state.battle.preparation && truthTurnPowerClean(state)), actions, true);
|
||||
if (!game.battle) return game;
|
||||
for (const actor of [game.battle.attacker, game.battle.defender]) {
|
||||
if (game.battle?.plans[actor]) continue;
|
||||
const inspection = game.battle?.prescience;
|
||||
const inspected = inspection && inspection.player !== actor;
|
||||
const leader = truthTurnPowerPlayer(game, actor).leaders.find(disc => !disc.dead && !disc.usedAt); assert.ok(leader);
|
||||
game = actTruthTurnPower(game, actor, { type: 'battlePlan',
|
||||
leader: inspected && inspection.field === 'leader' ? inspection.value : leader.id,
|
||||
dial: inspected && inspection.field === 'dial' ? inspection.value : 0, support: 0,
|
||||
weapon: inspected && inspection.field === 'weapon' ? inspection.value : null,
|
||||
defense: inspected && inspection.field === 'defense' ? inspection.value : null }, actions);
|
||||
}
|
||||
assert.ok(game.battle?.revealed);
|
||||
return advanceTruthTurnPower(game, state => !state.battle || state.battle.event !== f.event, actions, true);
|
||||
}
|
||||
export function finishTruthTurnPower(game: Game, actions: TruthTurnPowerStep[] = []): Game {
|
||||
const turn = game.turn;
|
||||
return advanceTruthTurnPower(game, state => state.turn === turn + 1 && state.phase === 0 && truthTurnPowerClean(state), actions);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { baseDeck } from '../game/cards';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView, type Plan } from '../game/engine';
|
||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||
import type { MixedBattleActionClaim } from '../game/mixed-battle-question';
|
||||
import type { BattlePowerClaim } from '../game/battle-power-question';
|
||||
import { createRevivalOrder } from '../game/revival-order';
|
||||
import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction';
|
||||
import type { TurnActionLeaf } from '../game/turn-action-promises';
|
||||
@@ -34,6 +35,8 @@ const planClaim = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'eq'
|
||||
({ kind: 'action', action: { kind: 'battlePlan', battle: 'controlled-native-battle', territory: 'arrakeen', claim } });
|
||||
const traitorClaim = (call = true): Expression =>
|
||||
({ kind: 'action', action: { kind: 'traitorCall', battle: 'controlled-native-battle', territory: 'arrakeen', call } });
|
||||
const powerClaim = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expression =>
|
||||
({ kind: 'action', action: { kind: 'battlePower', battle: 'controlled-native-battle', territory: 'arrakeen', claim } });
|
||||
const claim = (): Expression => ({ kind: 'or', terms: [payment(), { kind: 'and', terms: [fact(), physical('ship', 3), physical('move', 4, 'arrakeen')] }] });
|
||||
|
||||
function elements(node: ReactNode): Element[] {
|
||||
@@ -114,6 +117,16 @@ function combat(revealed = false, next: Action = { type: 'battlePlan', dial: 3,
|
||||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [revealed ? traitorClaim() : planClaim(), payment()] };
|
||||
return game;
|
||||
}
|
||||
function battlePower(power: 'voice' | 'prescience' = 'voice', next?: Action, allied = false): GameView {
|
||||
const game = combat(false, next ?? (power === 'voice' ? { type: 'voice', kind: 'poison', must: false } : { type: 'prescience', field: 'weapon' }));
|
||||
const me = game.players.find(player => player.id === 'p')!, other = game.players.find(player => player.id === 'a')!;
|
||||
me.faction = power === 'voice' ? 'beneGesserit' : 'atreides'; other.faction = 'emperor';
|
||||
game.battle!.attacker = allied ? 'a' : 'p'; game.battle!.defender = 'r';
|
||||
if (allied) { me.ally = 'a'; other.ally = 'p'; }
|
||||
game.battle!.preparation = { kind: power, owner: 'p', beneficiary: allied ? 'a' : 'p' };
|
||||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [powerClaim({ kind: power, use: true }), payment()] };
|
||||
return game;
|
||||
}
|
||||
function revival(next: Action = { type: 'revive', amount: 3, elite: 1 }): GameView {
|
||||
const game = projected(4, next);
|
||||
game.revivalOrder = createRevivalOrder({ turn: 2, order: ['p', 'a', 'r'], ready: [] });
|
||||
@@ -492,6 +505,135 @@ 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/);
|
||||
});
|
||||
|
||||
void test('native battle-power editor uses public owned opportunities and repairs all four strict declaration forms', () => {
|
||||
const game = battlePower(), e = editor(physical(), false, game);
|
||||
for (const player of game.players) for (const key of ['hand', 'leaders', 'traitors', 'spice', 'tanks', 'forces'])
|
||||
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} power authoring read`); }, configurable: true });
|
||||
for (const key of ['plans', 'prescience', 'voice', 'compliantPlan'])
|
||||
Object.defineProperty(game.battle!, key, { get() { throw Error(`No hidden battle ${key} read`); }, configurable: true });
|
||||
for (const key of ['combat', 'compiled', 'battlePowers'])
|
||||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} read`); }, configurable: true });
|
||||
select(e.tree(), '1', 'action', 'battlePower'); assert.deepEqual(e.value, powerClaim()); assert.equal(e.error(), null);
|
||||
select(e.tree(), '1', 'power-use', 'decline'); assert.deepEqual(e.value, powerClaim({ kind: 'voice', use: false }));
|
||||
select(e.tree(), '1', 'power-claim', 'voiceCommand'); select(e.tree(), '1', 'power-card-type', 'shield');
|
||||
select(e.tree(), '1', 'power-must', 'must'); assert.deepEqual(e.value, powerClaim({ kind: 'voiceCommand', cardType: 'shield', must: true }));
|
||||
select(e.tree(), '1', 'power-card-type', ''); assert.ok(e.error()); assert.match(e.html(), /aria-invalid="true"/);
|
||||
select(e.tree(), '1', 'power-card-type', 'projectile'); assert.equal(e.error(), null);
|
||||
game.battle!.event = 'new-native-battle'; assert.ok(e.error()); assert.match(e.html(), /Choose an owned native opportunity/);
|
||||
select(e.tree(), '1', 'power-battle', 'new-native-battle'); assert.equal(e.error(), null);
|
||||
select(e.tree(), '1', 'power-claim', 'prescience'); assert.ok(e.error(), 'Voice ownership grants no Prescience entitlement.');
|
||||
game.players.find(player => player.id === 'p')!.faction = 'atreides';
|
||||
game.battle!.preparation = { kind: 'prescience', owner: 'p', beneficiary: 'p' };
|
||||
select(e.tree(), '1', 'power-battle', 'new-native-battle'); assert.equal(e.error(), null);
|
||||
select(e.tree(), '1', 'power-use', 'decline');
|
||||
select(e.tree(), '1', 'power-claim', 'prescienceField'); select(e.tree(), '1', 'power-field', 'dial'); assert.equal(e.error(), null);
|
||||
assert.match(e.html(), /requested field is not the opponent/);
|
||||
for (const claim of [{ kind: 'voice', use: 'yes' }, { kind: 'voiceCommand', cardType: 'invented', must: true },
|
||||
{ kind: 'prescienceField', field: 'weapon', value: 'private-card' }, { kind: 'prescienceField', field: 'support' }])
|
||||
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'battlePower', battle: 'new-native-battle', territory: 'arrakeen', claim } }, game, 'p'));
|
||||
});
|
||||
|
||||
void test('native power defaults follow actual owned ordering and share the full sixteen-leaf four-level editor budget', () => {
|
||||
const game = battlePower('prescience'), e = editor(physical(), false, game);
|
||||
select(e.tree(), '1', 'action', 'battlePower'); assert.deepEqual(e.value, powerClaim({ kind: 'prescience', use: true }));
|
||||
const full = editor({ kind: 'or', terms: [powerClaim({ kind: 'prescience', use: true }), fact()] }, false, game);
|
||||
for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'battlePower'); }
|
||||
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(), powerClaim({ kind: 'prescienceField', field: 'leader' })] }, 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(powerClaim({ kind: 'prescienceField', field: 'leader' }), true, game);
|
||||
select(frozen.tree(), '1', 'power-claim', 'voice'); select(frozen.tree(), '1', 'power-field', 'dial');
|
||||
select(frozen.tree(), '1', 'power-battle', ''); assert.equal(frozen.changes, 0);
|
||||
const future = battlePower('prescience'); future.players.find(player => player.id === 'a')!.faction = 'beneGesserit';
|
||||
future.players.find(player => player.id === 'a')!.ally = 'r'; future.players.find(player => player.id === 'r')!.ally = 'a';
|
||||
future.battle!.preparation = { kind: 'voice', owner: 'a', beneficiary: 'r' };
|
||||
const futureEditor = editor(physical(), false, future); select(futureEditor.tree(), '1', 'action', 'battlePower');
|
||||
assert.deepEqual(futureEditor.value, powerClaim({ kind: 'prescience', use: true })); assert.equal(futureEditor.error(), null);
|
||||
});
|
||||
|
||||
void test('native power guides forward exact original allied-owner declarations and explicit declines without inspecting answers', () => {
|
||||
for (const power of ['voice', 'prescience'] as const) for (const decline of [false, true]) {
|
||||
const game = battlePower(power, decline ? { type: 'declineBattlePower' } : undefined, true), sent: Action[] = [];
|
||||
for (const key of ['combat', 'compiled', 'battlePowers'])
|
||||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} guide read`); }, configurable: true });
|
||||
for (const key of ['plans', 'prescience', 'voice', 'insight'])
|
||||
Object.defineProperty(game.battle!, key, { get() { throw Error(`No inspected ${key} guide read`); }, configurable: true });
|
||||
const 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.equal(sent[0].turnActionPromiseEvent, event);
|
||||
assert.equal(sent[0].turnActionPromisePhase, 6);
|
||||
if (decline) assert.match(renderToStaticMarkup(button), /Explicitly decline native/);
|
||||
else if (power === 'voice') { assert.equal(sent[0].kind, 'poison'); assert.equal(sent[0].must, false); }
|
||||
else { assert.equal(sent[0].field, 'weapon'); assert.match(renderToStaticMarkup(button), /Request the weapon field/); }
|
||||
}
|
||||
});
|
||||
|
||||
void test('saved native power callbacks reject changed battle source, preparation, beneficiary, owner and modal priority', () => {
|
||||
for (const power of ['voice', 'prescience'] as const) for (const change of [
|
||||
(game: GameView) => { game.battle!.event = 'later-native-battle'; },
|
||||
(game: GameView) => { game.battle!.territory = 'carthag'; },
|
||||
(game: GameView) => { game.battle!.preparation = { ...game.battle!.preparation! }; },
|
||||
(game: GameView) => { game.battle!.preparation!.owner = 'a'; },
|
||||
(game: GameView) => { game.battle!.preparation!.beneficiary = 'r'; },
|
||||
(game: GameView) => { game.battle!.preparation!.kind = 'prescienceAnswer'; },
|
||||
(game: GameView) => { game.players.find(player => player.id === 'p')!.ally = null; },
|
||||
(game: GameView) => { game.battle!.defender = 'p'; },
|
||||
(game: GameView) => { game.battle!.revealed = true; },
|
||||
(game: GameView) => { game.response = { kind: 'voice', owner: 'p', passed: [] }; },
|
||||
(game: GameView) => { game.truthtrance = answering(6).truthtrance; },
|
||||
(game: GameView) => { game.phase = 5; },
|
||||
(game: GameView) => { game.turnActionGuide!.action.field = 'dial'; },
|
||||
]) {
|
||||
const game = battlePower(power, undefined, true), button = guide({ game, busy: false, act() { assert.fail('A stale native declaration cannot send'); } });
|
||||
assert.equal(button.props.disabled, false); change(game); press(button);
|
||||
}
|
||||
const game = battlePower(), props: Props = { game, busy: false, act() { assert.fail('A stale callback cannot send'); } };
|
||||
const button = guide(props); props.act = () => assert.fail('A replaced action callback cannot send'); press(button);
|
||||
const busy = battlePower(), busyProps = { game: busy, busy: false, act() { assert.fail('A busy power cannot send'); } };
|
||||
const busyButton = guide(busyProps); busyProps.busy = true; press(busyButton);
|
||||
});
|
||||
|
||||
void test('native power guides refuse future stages, borrowed powers, inspected answers and forged packet fields', () => {
|
||||
for (const [power, action, kind] of [
|
||||
['voice', { type: 'voice', kind: 'invented', must: true }, 'voice'],
|
||||
['voice', { type: 'voice', kind: 'poison', must: 'yes' }, 'voice'],
|
||||
['voice', { type: 'voice', kind: 'poison', must: true, value: 'private-answer' }, 'voice'],
|
||||
['voice', { type: 'declineBattlePower', field: 'weapon' }, 'voice'],
|
||||
['voice', { type: 'voice', kind: 'poison', must: true }, 'nexusVoice'],
|
||||
['prescience', { type: 'prescience', field: 'support' }, 'prescience'],
|
||||
['prescience', { type: 'prescience', field: 'weapon', value: 'private-card' }, 'prescience'],
|
||||
['prescience', { type: 'prescience', field: 'weapon' }, 'prescienceAnswer'],
|
||||
['prescience', { type: 'declineBattlePower' }, 'nexusPrescienceAnswer'],
|
||||
] as const) {
|
||||
const game = battlePower(power, action); game.battle!.preparation!.kind = kind;
|
||||
const button = guide({ game, busy: false, act() { assert.fail('An invalid power packet cannot send'); } });
|
||||
assert.equal(button.props.disabled, true); press(button);
|
||||
}
|
||||
const future = battlePower('prescience'); future.players.find(player => player.id === 'a')!.faction = 'beneGesserit';
|
||||
future.players.find(player => player.id === 'a')!.ally = 'r'; future.players.find(player => player.id === 'r')!.ally = 'a';
|
||||
future.battle!.preparation = { kind: 'voice', owner: 'a', beneficiary: 'r' };
|
||||
const button = guide({ game: future, busy: false, act() { assert.fail('Ordered future Prescience cannot send yet'); } });
|
||||
assert.equal(button.props.disabled, true); press(button);
|
||||
for (const power of ['voice', 'prescience'] as const) for (const decline of [false, true]) {
|
||||
const game = battlePower(power, decline ? { type: 'declineBattlePower' } : undefined), sent: Action[] = [];
|
||||
game.battle!.preparation!.blocked = 'Native use is currently blocked; explicit Decline remains available.';
|
||||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, !decline); press(button); assert.equal(sent.length, decline ? 1 : 0);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native power history keeps raw declarations distinct from effects and inspected values', () => {
|
||||
const game = battlePower(); game.turnActionGuide = null; game.battle = null;
|
||||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [powerClaim({ kind: 'voiceCommand', cardType: 'shield', must: true }),
|
||||
powerClaim({ kind: 'prescienceField', field: 'weapon' }), fact()] };
|
||||
for (const key of ['combat', 'compiled', 'battlePowers', 'declarations', 'preparation'])
|
||||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private history ${key} read`); }, configurable: true });
|
||||
const html = markup(game); assert.match(html, /Voice/); assert.match(html, /Prescience/); assert.match(html, /You answered Yes/);
|
||||
assert.match(html, /before later Karama prevention/); assert.match(html, /requested field is not the inspected answer or value/);
|
||||
assert.match(html, /close at the actual native choice or when its original source expires/);
|
||||
assert.doesNotMatch(html, /controlled-native-battle|battlePowers|declarations|private-card/);
|
||||
});
|
||||
|
||||
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'])
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import type { BattlePowerClaim } from '../game/battle-power-question';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { truthFactAnswer } from '../game/truthtrance';
|
||||
import {
|
||||
actTruthTurnPower as act, advanceTruthTurnPower as advance,
|
||||
askTruthTurnPower as ask, bindTruthTurnPower as bind, createTruthTurnPowerFixture as fixture,
|
||||
declareTruthTurnPower as declare, finishTruthTurnPower as finish, finishTruthTurnPowerBattle as cleanup,
|
||||
nextTruthTurnPowerNative as nativeStep, settleTruthTurnPower as settle,
|
||||
truthTurnPowerAction as power, truthTurnPowerClean as clean, truthTurnPowerFact as fact,
|
||||
truthTurnPowerGroup as group, truthTurnPowerInventory as inventory, truthTurnPowerPayment as payment,
|
||||
truthTurnPowerPlayer as player, truthTurnPowerPromise as promise, truthTurnPowerTraitors as traitors,
|
||||
type TruthTurnPowerFixture, type TruthTurnPowerStep,
|
||||
} from './fixture-truth-turn-power';
|
||||
|
||||
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: TruthTurnPowerFixture) {
|
||||
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), f.originalTraitors);
|
||||
}
|
||||
function receipt(game: Game, f: TruthTurnPowerFixture) {
|
||||
const stage = game.turnActionPromises![0].combat?.powers?.[f.power]; assert.ok(stage); return stage;
|
||||
}
|
||||
const use = (f: TruthTurnPowerFixture, selected = true) => power(f.event, f.territory,
|
||||
{ kind: f.power, use: selected });
|
||||
|
||||
void test('native own and remote power sources retain genuine original cards, native cash and action-array chronology', () => {
|
||||
for (const remote of [false, true]) for (const selected of ['voice', 'prescience'] as const) {
|
||||
const f = fixture({ power: selected, remote });
|
||||
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
|
||||
assert.equal(f.setup.deck.length, 33); assert.equal(f.originalCards.length, 33);
|
||||
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
|
||||
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCounts.selected, f.actions.length);
|
||||
assert.equal(f.sourceCounts.beforeVoice, f.beforeVoiceActions.length);
|
||||
assert.equal(f.sourceCounts.nativeKaramaPurchase, f.purchase.steps.length);
|
||||
assert.ok(f.purchase.steps.some(step => step.actor === f.asker && step.action.type === 'bid' && step.action.amount === 1));
|
||||
assert.ok(player(f.game, f.asker).hand.some(card => card.id === f.truth));
|
||||
assert.ok(player(f.game, f.asker).hand.some(card => card.id === f.karama));
|
||||
assert.equal(f.game.advanced, false); assert.equal(f.game.nexusCards, undefined);
|
||||
assert.equal(f.game.turn, remote ? 2 : 1); assert.equal(f.game.phase, 6);
|
||||
assert.equal(f.game.battle?.event, f.event); assert.equal(f.game.battle?.preparation?.owner, f.target);
|
||||
assert.equal(f.game.battle?.preparation?.beneficiary, f.beneficiary);
|
||||
assert.equal(f.game.battle?.preparation?.kind, selected);
|
||||
assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
|
||||
if (remote) {
|
||||
assert.ok(f.beforeNexus?.nexus); assert.equal(f.nexusActions.filter(step => step.action.type === 'alliance').length, 2);
|
||||
assert.equal(player(f.game, f.target).ally, f.emperor); assert.notEqual(f.target, f.beneficiary);
|
||||
} else assert.equal(player(f.game, f.bg).spice, 4, 'The native BG Arrakeen reserve costs one real spice');
|
||||
assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native exact Voice AND payment freezes the original spice fact and records actual declaration before its response', () => {
|
||||
const f = fixture(), mixed = group('and', fact(f.spiceFact), power(f.event, f.territory, f.claim), payment(f.recipient));
|
||||
let game = bind(f, 'yes', mixed).game;
|
||||
assert.equal(receipt(game, f).closed, false); assert.equal(receipt(game, f).values, null);
|
||||
game = act(game, f.target, f.payment); assert.equal(player(game, f.target).spice, 2);
|
||||
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
|
||||
rejected(game, f.target, { type: 'declineBattlePower' });
|
||||
rejected(game, f.target, { type: 'voice', kind: 'shield', must: true });
|
||||
game = act(game, f.target, f.nativeAction);
|
||||
assert.equal(game.response?.kind, 'voice'); assert.equal(receipt(game, f).closed, true);
|
||||
assert.deepEqual(receipt(game, f).values, [{ claim: f.claim, value: true }]);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.recipient).bribes, 2);
|
||||
game = cleanup(game, f); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native requested Prescience field AND payment fulfills at the request without requiring or exposing its hidden answer', () => {
|
||||
const f = fixture({ power: 'prescience' });
|
||||
let game = bind(f).game; game = act(game, f.target, f.payment);
|
||||
rejected(game, f.target, { type: 'declineBattlePower' });
|
||||
rejected(game, f.target, { type: 'prescience', field: 'weapon' });
|
||||
game = act(game, f.target, f.nativeAction);
|
||||
assert.equal(game.response?.kind, 'prescience'); assert.equal(game.battle?.preparation?.kind, 'prescienceAnswer');
|
||||
assert.equal(Object.hasOwn(game.battle!.prescience!, 'value'), false);
|
||||
assert.deepEqual(receipt(game, f).values, [{ claim: f.claim, value: true }]);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.target).spice, 8);
|
||||
game = cleanup(game, f); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native Voice OR payment preserves both manual routes without choosing a fixed branch', () => {
|
||||
const f = fixture(), mixed = group('or', power(f.event, f.territory, f.claim), payment(f.recipient));
|
||||
const bound = bind(f, 'yes', mixed).game;
|
||||
let voiced = act(bound, f.target, f.nativeAction);
|
||||
assert.equal(promise(voiced, f.target).fulfilled, true); assert.equal(player(voiced, f.recipient).bribes, 0);
|
||||
let paid = act(bound, f.target, f.payment);
|
||||
const terminal = structuredClone(receipt(paid, f));
|
||||
paid = act(paid, f.target, { type: 'declineBattlePower' });
|
||||
assert.deepEqual(receipt(paid, f), terminal, 'A later native choice does not rewrite an already fulfilled answer');
|
||||
assert.equal(promise(paid, f.target).fulfilled, true);
|
||||
voiced = cleanup(voiced, f); paid = cleanup(paid, f);
|
||||
physical(voiced, f); physical(paid, f);
|
||||
});
|
||||
|
||||
void test('whole native use AND payment No permits either earlier action and caps only actual completion', () => {
|
||||
const f = fixture(), mixed = group('and', use(f), payment(f.recipient));
|
||||
for (const first of ['payment', 'voice'] as const) {
|
||||
let game = bind(f, 'no', mixed).game;
|
||||
if (first === 'payment') {
|
||||
game = act(game, f.target, f.payment); rejected(game, f.target, f.nativeAction);
|
||||
game = act(game, f.target, { type: 'declineBattlePower' });
|
||||
} else {
|
||||
game = settle(act(game, f.target, f.nativeAction)); rejected(game, f.target, f.payment);
|
||||
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
|
||||
assert.equal(player(game, f.recipient).bribes, 1);
|
||||
}
|
||||
assert.ok(!promise(game, f.target).released); game = cleanup(game, f); game = finish(game);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('same-decision Voice use and command are correlated while contradictory use and explicit Decline have no Yes', () => {
|
||||
const f = fixture(), command = power(f.event, f.territory, f.claim);
|
||||
const impossible = group('and', use(f), use(f, false));
|
||||
const asked = ask(declare(f).game, f.asker, f.target, impossible);
|
||||
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
|
||||
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const mixed = group('and', use(f), command, payment(f.recipient));
|
||||
let game = bind(f, 'yes', mixed).game; game = act(game, f.target, f.payment);
|
||||
game = act(game, f.target, f.nativeAction);
|
||||
const values = receipt(game, f).values; assert.ok(values);
|
||||
assert.equal(values.length, 2); assert.ok(values.every(entry => entry.value));
|
||||
assert.equal(promise(game, f.target).fulfilled, true); game = cleanup(game, f); physical(game, f);
|
||||
});
|
||||
|
||||
void test('one native Prescience request cannot satisfy two different fields but either exact OR field remains manual', () => {
|
||||
const f = fixture({ power: 'prescience' });
|
||||
const dial = power(f.event, f.territory, { kind: 'prescienceField', field: 'dial' });
|
||||
const weapon = power(f.event, f.territory, { kind: 'prescienceField', field: 'weapon' });
|
||||
const asked = ask(declare(f).game, f.asker, f.target, group('and', dial, weapon));
|
||||
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
|
||||
const mixed = group('and', group('or', dial, weapon), payment(f.recipient));
|
||||
for (const field of ['dial', 'weapon'] as const) {
|
||||
let game = bind(f, 'yes', mixed).game; game = act(game, f.target, f.payment);
|
||||
game = act(game, f.target, { type: 'prescience', field });
|
||||
const values = receipt(game, f).values; assert.ok(values);
|
||||
assert.equal(values.filter(entry => entry.value).length, 1);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); game = cleanup(game, f); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native explicit Decline is a real future declaration for either power rather than an absent effect', () => {
|
||||
for (const selected of ['voice', 'prescience'] as const) {
|
||||
const f = fixture({ power: selected }), claim: BattlePowerClaim = { kind: selected, use: false };
|
||||
const mixed = group('and', power(f.event, f.territory, claim), payment(f.recipient));
|
||||
let game = bind(f, 'yes', mixed).game; game = act(game, f.target, f.payment);
|
||||
rejected(game, f.target, f.nativeAction); game = act(game, f.target, { type: 'declineBattlePower' });
|
||||
assert.deepEqual(receipt(game, f).values, [{ claim, value: true }]);
|
||||
assert.equal(receipt(game, f).closed, true); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
game = cleanup(game, f); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual Voice and Prescience receipts survive later printed Karama prevention without counting a successful effect', () => {
|
||||
for (const selected of ['voice', 'prescience'] as const) {
|
||||
const f = fixture({ power: selected }); let game = bind(f).game;
|
||||
game = act(game, f.target, f.payment); game = act(game, f.target, f.nativeAction);
|
||||
const evidence = structuredClone(receipt(game, f)); assert.equal(game.response?.kind, selected);
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
game = act(game, f.asker, { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
game = settle(game); assert.equal(game.battle?.[selected], undefined);
|
||||
assert.deepEqual(receipt(game, f), evidence); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
game = cleanup(game, f); assert.deepEqual(receipt(game, f), evidence); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a declined native power leaves its payment OR alive after battle cleanup through late Mentat', () => {
|
||||
const f = fixture(), mixed = group('or', use(f), payment(f.recipient));
|
||||
let game = bind(f, 'yes', mixed).game; game = act(game, f.target, { type: 'declineBattlePower' });
|
||||
assert.deepEqual(receipt(game, f).values, [{ claim: { kind: 'voice', use: true }, value: false }]);
|
||||
assert.ok(!promise(game, f.target).fulfilled); const evidence = structuredClone(receipt(game, f));
|
||||
game = cleanup(game, f); assert.equal(game.battle, null); assert.deepEqual(receipt(game, f), evidence);
|
||||
game = advance(game, state => state.phase === 8 && clean(state), [], true);
|
||||
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, f.payment);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(receipt(game, f), evidence); physical(game, f);
|
||||
});
|
||||
|
||||
void test('remote ordinary allies declare their own Voice or Prescience for the original allied beneficiary', () => {
|
||||
for (const selected of ['voice', 'prescience'] as const) {
|
||||
const f = fixture({ power: selected, remote: true }); let game = bind(f).game;
|
||||
assert.equal(receipt(game, f).beneficiary, f.emperor); assert.notEqual(f.target, f.emperor);
|
||||
assert.equal(receipt(game, f).target, f.asker); game = act(game, f.target, f.payment);
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, selected);
|
||||
rejected(game, f.beneficiary, guide.action); game = act(game, f.target, guide.action);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.recipient).bribes, 2);
|
||||
game = cleanup(game, f); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('ordered native Prescience waits for the foreign Voice choice before exposing an executable field guide', () => {
|
||||
const f = fixture({ power: 'prescience', window: 'voice' });
|
||||
assert.equal(f.game.battle?.preparation?.owner, f.bg);
|
||||
let game = bind(f).game; game = act(game, f.target, f.payment);
|
||||
assert.equal(viewGame(game, f.target).turnActionGuide, null);
|
||||
rejected(game, f.target, f.nativeAction);
|
||||
game = act(game, f.bg, { type: 'declineBattlePower' });
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'prescience'); assert.equal(guide.action.field, 'dial');
|
||||
game = act(game, f.target, guide.action); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
game = cleanup(game, f); physical(game, f);
|
||||
});
|
||||
|
||||
void test('raw native power ownership and event markers reject stale questions, prior declarations and stale executable guides', () => {
|
||||
for (const selected of ['voice', 'prescience'] as const) {
|
||||
const f = fixture({ power: selected }), declared = declare(f);
|
||||
for (const mixed of [power('other-battle', f.territory, f.claim), power(f.event, 'carthag', f.claim)])
|
||||
rejected(declared.game, f.asker, { type: 'truthAsk', question: { kind: 'turnActions', target: f.target, turn: f.game.turn, mixed } });
|
||||
rejected(declared.game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'turnActions', target: selected === 'voice' ? f.atreides : f.bg,
|
||||
turn: f.game.turn, mixed: power(f.event, f.territory, f.claim),
|
||||
} });
|
||||
rejected(declared.game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'turnActions', target: f.target, turn: f.game.turn, mixed: { kind: 'action', action: {
|
||||
kind: 'battlePower', battle: f.event, territory: f.territory, claim: { ...f.claim, value: 0 },
|
||||
} },
|
||||
} });
|
||||
let prior = settle(act(f.game, f.target, f.nativeAction)); prior = declare(f, prior).game;
|
||||
rejected(prior, f.asker, { type: 'truthAsk', question: f.question });
|
||||
let game = bind(f).game; game = act(game, f.target, f.payment);
|
||||
const row = promise(game, f.target), guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.event, row.event); assert.equal(guide.action.turnActionPromiseEvent, row.event);
|
||||
assert.equal(guide.action.turnActionPromisePhase, 6);
|
||||
assert.ok(Object.keys(row).every(key => ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'].includes(key)));
|
||||
assert.deepEqual(viewGame(game, f.asker).turnActionPromises, []); assert.equal(viewGame(game, f.asker).turnActionGuide, null);
|
||||
rejected(game, f.asker, guide.action);
|
||||
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-event' });
|
||||
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 5 });
|
||||
const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, packet);
|
||||
rejected(game, f.target, packet); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
game = cleanup(game, f); rejected(game, f.target, packet); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles answer and submit exact native Voice or Prescience guide packets through fulfillment', () => {
|
||||
for (const selected of ['voice', 'prescience'] as const) {
|
||||
const f = fixture({ power: selected }), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = structuredClone(asked); const actions: TruthTurnPowerStep[] = [];
|
||||
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 < 24 && !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, `${selected}/${difficulty}`); assert.deepEqual(action, guide.action);
|
||||
game = act(game, f.target, action, actions);
|
||||
} else {
|
||||
const next = nativeStep(game); assert.ok(next, `${selected}/${difficulty}`);
|
||||
game = act(game, next.actor, next.action, actions);
|
||||
}
|
||||
}
|
||||
assert.equal(promise(game, f.target).fulfilled, true, `${selected}/${difficulty}`);
|
||||
assert.ok(actions.some(step => step.actor === f.target && step.action.type === selected));
|
||||
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'bribe'));
|
||||
game = cleanup(game, f); physical(game, f);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { battlePowerOf, type BattlePowerClaim, type BattlePowerDeclaration } from '../game/battle-power-question';
|
||||
import {
|
||||
compileMixedActionExpression,
|
||||
type MixedActionExpression,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
TurnActionError,
|
||||
closeTurnActionBidding,
|
||||
closeTurnActionCombat,
|
||||
closeTurnActionBattlePower,
|
||||
closeTurnActionPayments,
|
||||
closeTurnActionRevival,
|
||||
closeTurnActionTransport,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
parseTurnActionLeaf,
|
||||
recordTurnActionBid,
|
||||
recordTurnActionCardPlay,
|
||||
recordTurnActionBattlePower,
|
||||
recordTurnActionPlan,
|
||||
recordTurnActionTraitor,
|
||||
recordTurnActionMovement,
|
||||
@@ -51,6 +54,8 @@ const plan = (claim: TurnActionPlanValue['claim'] = { kind: 'dial', compare: 'gt
|
||||
const call = (call = true, battle = 'battle:1'): Expression =>
|
||||
({ kind: 'action', action: { kind: 'traitorCall', battle, territory: 'carthag', call } });
|
||||
const card = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
|
||||
const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expression =>
|
||||
({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } });
|
||||
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
|
||||
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
|
||||
const signedTravel = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3,
|
||||
@@ -88,6 +93,17 @@ function cards(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActi
|
||||
hajr: { played: false, closed: false }, ghola: { played: false, closed: false },
|
||||
} };
|
||||
}
|
||||
function powers(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise {
|
||||
const row: TurnActionPromise = { ...promise(mixed, answer, id), combat: { event: 'battle:1', territory: 'carthag',
|
||||
plan: null, traitor: null, powers: { voice: null, prescience: null } } };
|
||||
const visit = (node: Expression): void => {
|
||||
if (node.kind === 'action' && node.action.kind === 'battlePower')
|
||||
row.combat!.powers![battlePowerOf(node.action.claim)] = { beneficiary: 'respondent', target: 'other', closed: false, values: null };
|
||||
else if (node.kind !== 'action' && node.kind !== 'fact') node.terms.forEach(visit);
|
||||
};
|
||||
visit(mixed);
|
||||
return row;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -1068,3 +1084,170 @@ void test('payment-only closure keeps whole No binding through later Ghola and v
|
||||
assert.throws(() => closeTurnActionPayments([malformed as unknown as TurnActionPromise], 'respondent', 1), TurnActionError);
|
||||
validateTurnActionPromises(closed, seats);
|
||||
});
|
||||
|
||||
void test('battle-power raw atoms and folded alternatives require exact original per-power stages', () => {
|
||||
const original = { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim: { kind: 'voice', use: true } };
|
||||
assert.deepEqual(parseTurnActionLeaf(original), original);
|
||||
for (const value of [{ ...original, owner: 'respondent' }, { ...original, answer: true }, { ...original, battle: '' },
|
||||
{ ...original, claim: { kind: 'prescienceField', field: 'dial', value: 4 } }])
|
||||
assert.throws(() => parseTurnActionLeaf(value), TurnActionError);
|
||||
assert.throws(() => turnActionValue(promise(power())), TurnActionError);
|
||||
assert.throws(() => turnActionValue(promise(group('or', fact(), power()))), TurnActionError);
|
||||
const old = combat(plan());
|
||||
validateTurnActionPromises([old], seats);
|
||||
assert.equal('powers' in closeTurnActionCombat([old], 1, 'battle:1')[0].combat!, false);
|
||||
});
|
||||
|
||||
void test('whole power AND keeps exact native source context and one declaration evaluates all original predicates', () => {
|
||||
const command: BattlePowerClaim = { kind: 'voiceCommand', cardType: 'projectile', must: true };
|
||||
const row = powers(group('and', power(), power(command), pay()));
|
||||
assert.deepEqual(branches(row), [[
|
||||
{ kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim: { kind: 'voice', use: true },
|
||||
owner: 'respondent', beneficiary: 'respondent', target: 'other', answer: true },
|
||||
{ kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim: command,
|
||||
owner: 'respondent', beneficiary: 'respondent', target: 'other', answer: true }, signedPay(),
|
||||
]]);
|
||||
const declared = recordTurnActionBattlePower([row], 'respondent', 1, 'battle:1', 'carthag',
|
||||
{ power: 'voice', use: true, cardType: 'projectile', must: true });
|
||||
assert.deepEqual(declared[0].combat!.powers!.voice!.values, [
|
||||
{ claim: { kind: 'voice', use: true }, value: true }, { claim: command, value: true },
|
||||
]);
|
||||
assert.deepEqual(branches(declared[0]), [[signedPay()]]);
|
||||
assert.equal(declared[0].fulfilled, false);
|
||||
assert.equal(recordTurnActionPayment(declared, 'respondent', 1, payment())[0].fulfilled, true);
|
||||
assert.equal(row.combat!.powers!.voice!.values, null);
|
||||
});
|
||||
|
||||
void test('whole OR retains actual Voice and Prescience alternatives without storing a selected route', () => {
|
||||
const mixed = group('or', power({ kind: 'voiceCommand', cardType: 'poison', must: false }),
|
||||
power({ kind: 'prescienceField', field: 'weapon' }));
|
||||
const row = powers(mixed);
|
||||
assert.equal(branches(row).length, 2);
|
||||
const voice = recordTurnActionBattlePower([row], 'respondent', 1, 'battle:1', 'carthag',
|
||||
{ power: 'voice', use: true, cardType: 'poison', must: false });
|
||||
assert.equal(voice[0].fulfilled, true);
|
||||
const prescience = recordTurnActionBattlePower([row], 'respondent', 1, 'battle:1', 'carthag',
|
||||
{ power: 'prescience', use: true, field: 'weapon' });
|
||||
assert.equal(prescience[0].fulfilled, true);
|
||||
assert.deepEqual(prescience[0].mixed, mixed);
|
||||
const otherVoice = recordTurnActionBattlePower([row], 'respondent', 1, 'battle:1', 'carthag',
|
||||
{ power: 'voice', use: true, cardType: 'projectile', must: true });
|
||||
assert.equal(otherVoice[0].fulfilled, false);
|
||||
assert.equal(branches(otherVoice[0]).length, 1);
|
||||
});
|
||||
|
||||
void test('explicit power Decline differs from absent opportunity and preserves requested-field semantics', () => {
|
||||
const mixed = group('or', power({ kind: 'prescience', use: false }), pay());
|
||||
const row = powers(mixed);
|
||||
const declined = recordTurnActionBattlePower([row], 'respondent', 1, 'battle:1', 'carthag', { power: 'prescience', use: false });
|
||||
assert.equal(declined[0].fulfilled, true);
|
||||
assert.deepEqual(declined[0].combat!.powers!.prescience!.values,
|
||||
[{ claim: { kind: 'prescience', use: false }, value: true }]);
|
||||
const absent = closeTurnActionBattlePower([row], 'respondent', 1, 'battle:1', 'carthag', 'prescience');
|
||||
assert.equal(absent[0].fulfilled, false);
|
||||
assert.equal(absent[0].combat!.powers!.prescience!.values, null);
|
||||
assert.deepEqual(branches(absent[0]), [[signedPay()]]);
|
||||
const field = powers(group('and', power({ kind: 'prescienceField', field: 'dial' }), pay()));
|
||||
const requested = recordTurnActionBattlePower([field], 'respondent', 1, 'battle:1', 'carthag', { power: 'prescience', use: true, field: 'dial' });
|
||||
assert.deepEqual(branches(requested[0]), [[signedPay()]]);
|
||||
});
|
||||
|
||||
void test('whole power No applies De Morgan and actual declarations or later payments reject aggregate violations atomically', () => {
|
||||
const no = powers(group('and', power(), pay()), false);
|
||||
assert.equal(branches(no).length, 2);
|
||||
const declaration: BattlePowerDeclaration = { power: 'voice', use: true, cardType: 'shield', must: false };
|
||||
const declared = recordTurnActionBattlePower([no], 'respondent', 1, 'battle:1', 'carthag', declaration);
|
||||
assert.deepEqual(branches(declared[0]), [[signedPay('recipient', 3, 0, false)]]);
|
||||
const before = structuredClone(declared);
|
||||
assert.throws(() => recordTurnActionPayment(declared, 'respondent', 1, payment()), TurnActionError);
|
||||
assert.deepEqual(declared, before);
|
||||
const paid = recordTurnActionPayment([no], 'respondent', 1, payment());
|
||||
assert.throws(() => recordTurnActionBattlePower(paid, 'respondent', 1, 'battle:1', 'carthag', declaration), TurnActionError);
|
||||
const yes = powers(group('and', power(), pay()), true, 'turn-action:2');
|
||||
const plainNo = powers(power(), false, 'turn-action:3');
|
||||
assert.throws(() => recordTurnActionBattlePower([yes, plainNo], 'respondent', 1, 'battle:1', 'carthag', declaration), TurnActionError);
|
||||
assert.equal(yes.combat!.powers!.voice!.values, null);
|
||||
assert.equal(plainNo.combat!.powers!.voice!.values, null);
|
||||
});
|
||||
|
||||
void test('closing one power preserves the open plan, Traitor and other power rather than expiring the entire battle', () => {
|
||||
const row = powers(group('or', power(), power({ kind: 'prescience', use: true }), plan(), call(), pay()));
|
||||
row.combat!.plan = { closed: false, values: null };
|
||||
row.combat!.traitor = { beneficiary: 'respondent', target: 'other', leader: 'other-0', closed: false, call: null };
|
||||
const closed = closeTurnActionBattlePower([row], 'respondent', 1, 'battle:1', 'carthag', 'voice')[0];
|
||||
assert.deepEqual(closed.combat!.powers!.voice, { beneficiary: 'respondent', target: 'other', closed: true, values: null });
|
||||
assert.equal(closed.combat!.powers!.prescience, row.combat!.powers!.prescience);
|
||||
assert.equal(closed.combat!.plan, row.combat!.plan);
|
||||
assert.equal(closed.combat!.traitor, row.combat!.traitor);
|
||||
assert.equal(branches(closed).length, 4);
|
||||
const allClosed = closeTurnActionCombat([closed], 1, 'battle:1')[0];
|
||||
assert.equal(allClosed.combat!.powers!.prescience!.closed, true);
|
||||
assert.equal(allClosed.combat!.plan!.closed, true);
|
||||
assert.equal(allClosed.combat!.traitor!.closed, true);
|
||||
assert.deepEqual(branches(allClosed), [[signedPay()]]);
|
||||
});
|
||||
|
||||
void test('original power source and owner bind receipts once before any later prevention or alternative declaration', () => {
|
||||
const row = powers(group('and', power(), pay()));
|
||||
const declaration: BattlePowerDeclaration = { power: 'voice', use: true, cardType: 'projectile', must: true };
|
||||
for (const [player, turn, event, territory] of [['other', 1, 'battle:1', 'carthag'], ['respondent', 2, 'battle:1', 'carthag'],
|
||||
['respondent', 1, 'other-battle', 'carthag'], ['respondent', 1, 'battle:1', 'homeworld:guild']] as const)
|
||||
assert.equal(recordTurnActionBattlePower([row], player, turn, event, territory, declaration)[0], row);
|
||||
const actual = recordTurnActionBattlePower([row], 'respondent', 1, 'battle:1', 'carthag', declaration)[0];
|
||||
assert.equal(recordTurnActionBattlePower([actual], 'respondent', 1, 'battle:1', 'carthag', { power: 'voice', use: false })[0], actual);
|
||||
assert.equal(closeTurnActionBattlePower([actual], 'respondent', 1, 'battle:1', 'carthag', 'voice')[0], actual);
|
||||
const expired = closeTurnActionCombat([actual], 1, 'battle:1')[0];
|
||||
assert.deepEqual(expired.combat!.powers!.voice!.values, actual.combat!.powers!.voice!.values);
|
||||
const fresh = powers(group('and', power(), pay()), true, 'turn-action:2');
|
||||
assert.equal(fresh.combat!.powers!.voice!.values, null);
|
||||
validateTurnActionPromises([actual], seats);
|
||||
});
|
||||
|
||||
void test('unresolved power expiry releases without inventing Decline while terminal and actual receipts remain immutable', () => {
|
||||
const pending = powers(power({ kind: 'voice', use: false }));
|
||||
const expired = finishTurnActionTurn([pending], 'respondent', 1)[0];
|
||||
assert.equal(expired.released, true);
|
||||
assert.equal(expired.combat!.powers!.voice!.closed, false);
|
||||
assert.equal(expired.combat!.powers!.voice!.values, null);
|
||||
const no = powers(power(), false, 'turn-action:2');
|
||||
const closedNo = closeTurnActionBattlePower([no], 'respondent', 1, 'battle:1', 'carthag', 'voice')[0];
|
||||
assert.equal(closedNo.fulfilled, true);
|
||||
const used = recordTurnActionBattlePower([powers(power(), true, 'turn-action:3')], 'respondent', 1, 'battle:1', 'carthag',
|
||||
{ power: 'voice', use: true, cardType: 'poison', must: false })[0];
|
||||
const terminal = [expired, closedNo, used];
|
||||
for (const transform of [
|
||||
(rows: TurnActionPromise[]) => closeTurnActionCombat(rows, 1, 'battle:1'),
|
||||
(rows: TurnActionPromise[]) => closeTurnActionBattlePower(rows, 'respondent', 1, 'battle:1', 'carthag', 'voice'),
|
||||
(rows: TurnActionPromise[]) => recordTurnActionBattlePower(rows, 'respondent', 1, 'battle:1', 'carthag', { power: 'voice', use: false }),
|
||||
]) assert.ok(transform(terminal).every((record, index) => record === terminal[index]));
|
||||
validateTurnActionPromises(terminal, seats);
|
||||
});
|
||||
|
||||
void test('native power stages reject premature, incomplete, duplicate and wrong-power predicate receipts', () => {
|
||||
const row = powers(group('and', power(), pay()));
|
||||
const stage = row.combat!.powers!.voice!;
|
||||
for (const powerStages of [undefined, null, {}, { voice: null, prescience: null },
|
||||
{ ...row.combat!.powers, hidden: true },
|
||||
{ ...row.combat!.powers, voice: { ...stage, values: [{ claim: { kind: 'voice', use: true }, value: true }] } },
|
||||
{ ...row.combat!.powers, voice: { ...stage, closed: true, values: [] } },
|
||||
{ ...row.combat!.powers, voice: { ...stage, closed: true, values: [
|
||||
{ claim: { kind: 'voice', use: true }, value: true }, { claim: { kind: 'voice', use: true }, value: true }] } },
|
||||
{ ...row.combat!.powers, voice: { ...stage, closed: true, values: [{ claim: { kind: 'prescience', use: true }, value: true }] } },
|
||||
{ ...row.combat!.powers, voice: { ...stage, beneficiary: 'other' } },
|
||||
{ ...row.combat!.powers, voice: { ...stage, closed: 'yes' } }])
|
||||
assert.throws(() => turnActionValue({ ...row, combat: { ...row.combat!, powers: powerStages } } as TurnActionPromise), TurnActionError);
|
||||
const foreign = { ...row, combat: { ...row.combat!, powers: { ...row.combat!.powers!, voice: { ...stage, target: 'missing' } } } };
|
||||
assert.throws(() => validateTurnActionPromises([foreign], seats), TurnActionError);
|
||||
assert.throws(() => recordTurnActionBattlePower([row], 'respondent', 1, 'battle:1', 'carthag',
|
||||
{ power: 'prescience', use: true, field: 'weapon', value: 'Lasgun' } as never), TurnActionError);
|
||||
});
|
||||
|
||||
void test('power predicates deduplicate only exact original clauses and retain the shared sixteen-leaf limit', () => {
|
||||
const repeated = powers(group('and', power(), power(), pay()));
|
||||
assert.equal(branches(repeated)[0].length, 2);
|
||||
const actual = recordTurnActionBattlePower([repeated], 'respondent', 1, 'battle:1', 'carthag',
|
||||
{ power: 'voice', use: true, cardType: 'shield', must: true });
|
||||
assert.equal(actual[0].combat!.powers!.voice!.values!.length, 1);
|
||||
assert.doesNotThrow(() => turnActionValue(powers(group('or', ...Array.from({ length: 16 }, () => power())))));
|
||||
assert.throws(() => turnActionValue(powers(group('or', ...Array.from({ length: 17 }, () => power())))), TurnActionError);
|
||||
});
|
||||
Reference in new issue
Block a user