Bind Truthtrance card plays to original Storm and spice windows
This commit is contained in:
1 parent
09a81a9b65
commit
e88f871385
20 files changed
+1519
-26
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 native battle powers, Hajr/Ghola plays and this turn’s other actions</option>
|
||||
<option value="turnActions">Combine printed card plays, native battle powers 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
|
||||
@@ -603,6 +603,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 native Voice or Prescience, printed card plays, current battle, Traitor declaration, alliance, ordinary bid, Revival, payment, shipment or ground-movement opportunities this turn.
|
||||
Weather Control, Family Atomics and Harvester refer only to their original current Storm or fresh spice-blow window.
|
||||
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>
|
||||
</>
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 { windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
|
||||
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';
|
||||
@@ -46,6 +47,8 @@ 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 windowEffects: WindowCardEffect[] = ['weather', 'atomics', 'harvester'];
|
||||
const windowRoleNames: Record<WindowCardEffect, string> = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester' };
|
||||
const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
prefix: 'turn-action',
|
||||
legend: 'Current battle and this-turn action conditions, with optional current facts',
|
||||
@@ -74,6 +77,9 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
An earlier play, discard, sale, Karama role or recovery alone does not count; an actual later replay after Box recovery does.
|
||||
Hajr counts when played even if its extra movement is unused. Tleilaxu Ghola is a card play, separate from ordinary Revival.
|
||||
Hajr conditions close at your own Movement turn end; Tleilaxu Ghola conditions close at the actual turn end.
|
||||
Weather Control, Family Atomics and Harvester conditions count an actual accepted future printed-role play after this answer
|
||||
in the original open Storm or fresh spice-blow window. Earlier plays, disposal, other roles and recovery alone do not count.
|
||||
They do not ask for a selected storm distance or resulting spice amount. Each condition closes only when its original native window closes.
|
||||
Shipment conditions concern the single future original reserve shipment after the answer.
|
||||
Ground-movement conditions use the maximum physical forces in one actual future move to that destination; smaller moves never add together.
|
||||
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
|
||||
@@ -106,11 +112,17 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
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.');
|
||||
}
|
||||
if (action.kind === 'windowCardPlay') {
|
||||
const opportunity = windowCardOpportunity(game, action.effect);
|
||||
if (!opportunity || opportunity.turn !== game.turn || action.source !== opportunity.source)
|
||||
throw new Error('Choose the original currently open Storm or fresh spice-blow window for this printed role.');
|
||||
}
|
||||
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;
|
||||
const currentWindow = value.kind === 'windowCardPlay' ? windowCardOpportunity(game, value.effect) : null;
|
||||
return <>
|
||||
<label htmlFor={`${id}-action`}>Future action</label>
|
||||
<select id={`${id}-action`} value={value.kind} disabled={disabled} onChange={event => {
|
||||
@@ -118,6 +130,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 === 'windowCardPlay') {
|
||||
const effect = windowEffects.find(effect => windowCardOpportunity(game, effect)) ?? 'weather';
|
||||
onChange({ kind, effect, source: windowCardOpportunity(game, effect)?.source ?? '' });
|
||||
}
|
||||
if (kind === 'battlePower') {
|
||||
const claim = newPowerClaim(game, target), opportunity = powerOpportunity(game, target, claim);
|
||||
onChange({ kind, battle: opportunity?.event ?? '', territory: opportunity?.territory ?? '', claim });
|
||||
@@ -134,6 +150,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="windowCardPlay">Play Weather Control, Family Atomics or Harvester in this window</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>
|
||||
@@ -155,6 +172,30 @@ 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 === 'windowCardPlay' ? <>
|
||||
<label htmlFor={`${id}-window-effect`}>Printed role in the original window</label>
|
||||
<select id={`${id}-window-effect`} value={windowEffects.includes(value.effect) ? value.effect : ''}
|
||||
disabled={disabled} aria-invalid={!windowEffects.includes(value.effect)} onChange={event => {
|
||||
const effect = event.currentTarget.value;
|
||||
if (effect === 'weather' || effect === 'atomics' || effect === 'harvester')
|
||||
onChange({ kind: 'windowCardPlay', effect, source: windowCardOpportunity(game, effect)?.source ?? '' });
|
||||
}}>
|
||||
<option value="">Choose a printed role</option>
|
||||
{windowEffects.map(effect => <option key={effect} value={effect}>{windowRoleNames[effect]}</option>)}
|
||||
</select>
|
||||
<label htmlFor={`${id}-window-source`}>Original native window</label>
|
||||
<select id={`${id}-window-source`} value={currentWindow?.source === value.source ? value.source : ''}
|
||||
disabled={disabled} aria-invalid={!currentWindow || currentWindow.source !== value.source} onChange={event => {
|
||||
const opportunity = windowCardOpportunity(game, value.effect);
|
||||
if (event.currentTarget.value === '') onChange({ ...value, source: '' });
|
||||
else if (opportunity?.source === event.currentTarget.value) onChange({ ...value, source: opportunity.source });
|
||||
}}>
|
||||
<option value="">Choose the original open window</option>
|
||||
{currentWindow && <option value={currentWindow.source}>{value.effect === 'harvester'
|
||||
? `Turn ${currentWindow.turn} · Fresh spice blow in ${game.combatLocations.find(location => location.id === game.spiceWindow?.territory)?.name ?? game.spiceWindow?.territory}`
|
||||
: `Turn ${currentWindow.turn} · Before this Storm movement`}</option>}
|
||||
</select>
|
||||
{!currentWindow && <p className="fine">This printed role has no current original open native window.</p>}
|
||||
</> : 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 => {
|
||||
|
||||
@@ -9,6 +9,9 @@ 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 { windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
|
||||
import type { TurnActionLeaf } from '@/game/turn-action-promises';
|
||||
import type { MixedActionExpression } from '@/game/mixed-action-question';
|
||||
import { truthQuestionText, type TruthAnswer } from '@/game/truthtrance';
|
||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||
import { Button } from './ui/button';
|
||||
@@ -23,6 +26,11 @@ const publicBattleContext = (game: GameView) => game.battle && ({
|
||||
preparation: game.battle.preparation,
|
||||
traitorVoters: game.battle.traitorVoters, traitorSubmitted: game.battle.traitorSubmitted,
|
||||
});
|
||||
const isWindowEffect = (effect: unknown): effect is WindowCardEffect => effect === 'weather' || effect === 'atomics' || effect === 'harvester';
|
||||
const windowRoleNames: Record<WindowCardEffect, string> = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester' };
|
||||
const namesWindow = (mixed: MixedActionExpression<TurnActionLeaf>, effect: WindowCardEffect, source: string): boolean =>
|
||||
mixed.kind === 'action' ? mixed.action.kind === 'windowCardPlay' && mixed.action.effect === effect && mixed.action.source === source
|
||||
: mixed.kind !== 'fact' && mixed.terms.some(term => namesWindow(term, effect, source));
|
||||
|
||||
/** One projected native packet; no client branch selection or completion solver. */
|
||||
export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
@@ -56,6 +64,24 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
const cardOwner = action.type === 'card' ? game.players.find(player => player.id === actor) : undefined;
|
||||
const heldCard = cardOwner?.hand?.find(card => card.id === action.card);
|
||||
const cardSignature = heldCard && JSON.stringify({ id: heldCard.id, kind: heldCard.kind, effect: heldCard.effect });
|
||||
const windowEffect = isWindowEffect(heldCard?.effect) ? heldCard.effect : null;
|
||||
const cardWindow = windowEffect ? windowCardOpportunity(game, windowEffect) : null;
|
||||
const earlyWindowReadyAction = action.type === 'ready' && (phase === 0 || phase === 1);
|
||||
const windowPromise = action.type === 'card' || earlyWindowReadyAction
|
||||
? game.turnActionPromises.find(promise => promise.event === event && promise.player === actor && promise.turn === turn) : null;
|
||||
const windowQuestion = windowPromise && JSON.stringify(windowPromise.mixed);
|
||||
const publicWindowState = (current: GameView) => JSON.stringify({
|
||||
opportunity: windowEffect ? windowCardOpportunity(current, windowEffect)
|
||||
: (['weather', 'atomics', 'harvester'] as const).map(effect => windowCardOpportunity(current, effect)),
|
||||
stormOpen: current.stormCardWindowOpen,
|
||||
stormPending: current.stormPending, wallDestroyed: current.shieldWallDestroyed,
|
||||
blow: current.spiceWindow,
|
||||
...(windowEffect === 'atomics' ? { forces: current.players.find(player => player.id === actor)?.forces,
|
||||
advisors: current.players.find(player => player.id === actor)?.advisors } : {}),
|
||||
});
|
||||
const windowSignature = windowEffect || heldCard?.effect === 'nullentropyBox' || earlyWindowReadyAction ? publicWindowState(game) : null;
|
||||
const originalBlow = windowEffect === 'harvester' || earlyWindowReadyAction && phase === 1 ? game.spiceWindow : null;
|
||||
const earlyBoxSignature = box && JSON.stringify({ card: box.card.id, blocked: box.blocked, search: box.search?.event });
|
||||
const ghola = heldCard?.effect === 'ghola' ? game.ghola : null, gholaSignature = JSON.stringify(ghola);
|
||||
const hajrSource = (current: GameView) => {
|
||||
const player = current.players.find(player => player.id === actor);
|
||||
@@ -83,7 +109,37 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
const printedGhola = gholaSource && !!ghola && (action.leader !== undefined
|
||||
? typeof action.leader === 'string' && (action.leader === 'kwisatz' ? ghola.kwisatz : ghola.leaders.some(leader => leader.id === action.leader))
|
||||
: action.elite === undefined ? forceChoices.length === 1 : forceChoices.some(choice => choice.elite === action.elite));
|
||||
const genericCard = ownCard && !!heldCard && heldCard.effect !== 'ghola' && heldCard.effect !== 'hajr';
|
||||
const windowPacket = Object.keys(action).every(key => ['type', 'card', 'turnActionPromiseEvent', 'turnActionPromisePhase',
|
||||
...(windowEffect === 'weather' ? ['amount'] : [])].includes(key)) &&
|
||||
(windowEffect !== 'weather' || typeof action.amount === 'number' && Number.isSafeInteger(action.amount) && action.amount >= 0 && action.amount <= 10);
|
||||
const printedWindow = ownCard && !!heldCard && heldCard.kind === 'special' && !!windowEffect && !!cardWindow &&
|
||||
action.mode === undefined && windowPacket && !!windowPromise &&
|
||||
current.turnActionPromises.includes(windowPromise) && JSON.stringify(windowPromise.mixed) === windowQuestion &&
|
||||
namesWindow(windowPromise.mixed, windowEffect, cardWindow.source) &&
|
||||
(windowEffect !== 'harvester' || current.spiceWindow === originalBlow) && publicWindowState(current) === windowSignature &&
|
||||
ordinaryCardAvailability(current, heldCard.id, windowEffect === 'weather' ? { amount: action.amount } : undefined)?.available === true;
|
||||
const genericCard = ownCard && !!heldCard && heldCard.effect !== 'ghola' && heldCard.effect !== 'hajr' && !isWindowEffect(heldCard.effect);
|
||||
const earlyWindowBox = (phase === 0 || phase === 1) && genericCard && heldCard?.effect === 'nullentropyBox' &&
|
||||
action.mode === undefined && Object.keys(action).every(key => ['type', 'card', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key)) &&
|
||||
!!box && current.nullentropy === box &&
|
||||
JSON.stringify({ card: box.card.id, blocked: box.blocked, search: box.search?.event }) === earlyBoxSignature &&
|
||||
!box.blocked && !box.search && box.card.id === heldCard.id &&
|
||||
!!windowPromise && current.turnActionPromises.includes(windowPromise) && JSON.stringify(windowPromise.mixed) === windowQuestion &&
|
||||
(['weather', 'atomics', 'harvester'] as const).some(effect => {
|
||||
const opportunity = windowCardOpportunity(current, effect);
|
||||
return !!opportunity && namesWindow(windowPromise.mixed, effect, opportunity.source);
|
||||
}) && publicWindowState(current) === windowSignature;
|
||||
const earlyWindowReady = earlyWindowReadyAction && !current.ready.includes(actor) &&
|
||||
Object.keys(action).every(key => ['type', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key)) &&
|
||||
!!windowPromise && current.turnActionPromises.includes(windowPromise) && JSON.stringify(windowPromise.mixed) === windowQuestion &&
|
||||
publicWindowState(current) === windowSignature &&
|
||||
(phase === 0 ? current.stormPending !== null && (['weather', 'atomics'] as const).some(effect => {
|
||||
const opportunity = windowCardOpportunity(current, effect);
|
||||
return !!opportunity && namesWindow(windowPromise.mixed, effect, opportunity.source);
|
||||
}) : current.spiceWindow === originalBlow && (() => {
|
||||
const opportunity = windowCardOpportunity(current, 'harvester');
|
||||
return !!opportunity && namesWindow(windowPromise.mixed, 'harvester', opportunity.source);
|
||||
})());
|
||||
const options = current.bribeOptions;
|
||||
const target = action.type === 'bribe' && typeof action.target === 'string'
|
||||
? current.players.find(player => player.id === action.target) : undefined;
|
||||
@@ -140,7 +196,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
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 = ownedPower || payment || paidBox || printedHajr || printedGhola || plan || battlePreparation || declaration || nexusWindow && (ownedAlliance || action.type === 'ready') || (phase === 2
|
||||
const opportunity = earlyWindowReady || earlyWindowBox || printedWindow || 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
|
||||
@@ -173,6 +229,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
: action.type === 'card' && heldCard?.effect === 'ghola' ? action.leader !== undefined
|
||||
? `Play Tleilaxu Ghola to return ${gholaLeader} while honoring your whole answer`
|
||||
: `Play Tleilaxu Ghola to return ${amount} physical forces while honoring your whole answer`
|
||||
: action.type === 'card' && windowEffect ? `Play ${windowRoleNames[windowEffect]} in its original native window${windowEffect === 'weather' ? ` with storm distance ${amount}` : ''} 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`
|
||||
@@ -184,7 +241,8 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
: `Accept ${recipient}’s alliance invitation while preserving your whole answer`
|
||||
: action.type === 'charity' ? 'Claim Charity while preserving your whole answer'
|
||||
: action.type === 'ready' ? phase === 8 ? 'Ready to end this turn while honoring your whole answer'
|
||||
: phase === 1 ? 'Confirm Ready for this Nexus while preserving your whole answer'
|
||||
: earlyWindowReadyAction && (phase === 0 || !!game.spiceWindow) ? `Accept this ${phase === 0 ? 'Storm' : 'fresh spice-blow'} window while preserving your whole answer`
|
||||
: phase === 1 ? 'Confirm Ready for this Nexus while preserving your whole answer'
|
||||
: 'Finish this preparation phase while preserving your whole answer'
|
||||
: action.type === 'passBid' ? 'Pass this bid while preserving your whole answer'
|
||||
: action.type === 'bid' ? `Place the required bid of ${amount} while preserving your whole answer`
|
||||
@@ -253,6 +311,9 @@ export function TurnActionPromises(props: TurnActionProps) {
|
||||
Earlier plays, discards, sales, Karama roles and recovery alone do not count; an actual later Box replay does.
|
||||
Hajr counts even if its extra movement is unused and closes at your own Movement turn end.
|
||||
Tleilaxu Ghola is separate from ordinary Revival and remains open until the actual turn ends.
|
||||
Weather Control, Family Atomics and Harvester conditions count an actual accepted future printed-role play after the answer
|
||||
in the original open Storm or fresh spice-blow window. Earlier plays, disposal, other roles and recovery alone do not count.
|
||||
They do not promise a selected storm distance or resulting spice amount. Each condition closes only when its original native window closes.
|
||||
Shipment conditions concern one future original reserve shipment. Ground-movement conditions count the maximum physical forces in one actual future move,
|
||||
never the sum of smaller moves. Earlier shipment and movement do not count.
|
||||
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
|
||||
|
||||
Reference in new issue
Block a user