Support Harkonnen special Karama truth commitments
Verify and publish checkpoint container / container (push) Successful in 10m17s
Verify and publish checkpoint container / container (push) Successful in 10m17s
This commit is contained in:
1 parent
15a4f3038a
commit
b448144c52
15 files changed
+449
-35
No files matched your search
@@ -215,7 +215,8 @@ export function HomeworldCardGallery() {
|
||||
<section aria-label="Homeworld card collection" className="space-y-4">
|
||||
<p className="m-0 text-base leading-7 text-[#d1c7b0]">
|
||||
13 worlds · Inspect either face to read its complete gameplay effects.
|
||||
Homeworld games are not yet available.
|
||||
Supported Homeworld selections are available as unfinished previews;
|
||||
some combinations remain unavailable.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 items-start gap-5 xl:grid-cols-2">
|
||||
{HOMEWORLD_CARDS.map((card) => (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { parseBattleActionClaim } from '@/game/mixed-battle-question';
|
||||
import { battlePowerOf, nativeBattlePowerOpportunity, type BattlePowerClaim } from '@/game/battle-power-question';
|
||||
import { battleCardLabel, voiceChoices } from '@/game/battle-cards';
|
||||
import { nativeStormDialOpportunity } from '@/game/storm-dial-question';
|
||||
import { karamaBattleCancelOpportunity, WINDOW_CARD_EFFECTS, windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
|
||||
import { karamaBattleCancelOpportunity, parseKaramaHarkonnenSpecialSource, WINDOW_CARD_EFFECTS, 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';
|
||||
@@ -55,8 +55,17 @@ const windowRoleNames: Record<WindowCardEffect, string> = { weather: 'Weather Co
|
||||
karamaAuction: 'Spend a printed Karama for this current ordinary lot',
|
||||
karamaShipment: 'Spend a printed Karama for the current shipment-price opportunity',
|
||||
karamaBattleCancel: 'Spend a printed Karama to cancel this Voice or Prescience',
|
||||
karamaHarkonnenSpecial: 'Use Harkonnen’s special Karama during Bidding',
|
||||
thumper: 'Play Thumper before this turn’s first spice reveal', amal: 'Play Amal in this original phase opening' };
|
||||
const battleCancelOpportunity = karamaBattleCancelOpportunity;
|
||||
/** Harkonnen is public; do not inspect that player's hand or target's cards. */
|
||||
const windowOpportunity = (game: GameView, target: string, effect: WindowCardEffect) => {
|
||||
if (effect === 'karamaBattleCancel') return battleCancelOpportunity(game, target);
|
||||
const opportunity = windowCardOpportunity(game, effect);
|
||||
if (effect === 'karamaHarkonnenSpecial' &&
|
||||
(!opportunity || parseKaramaHarkonnenSpecialSource(opportunity.source)?.owner !== target)) return null;
|
||||
return opportunity;
|
||||
};
|
||||
const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
prefix: 'turn-action',
|
||||
legend: 'Current battle and this-turn action conditions, with optional current facts',
|
||||
@@ -113,6 +122,8 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
Only spending a physical printed Karama to cancel that declaration after this answer counts.
|
||||
Passing, another Karama role, Worthless conversion and a different battle or response do not count.
|
||||
The condition closes when the original response ends; another player's cancellation can end the opportunity.
|
||||
Harkonnen's special Karama condition counts its own printed special power during Bidding, not another Karama role.
|
||||
The condition does not disclose the respondent's hand or the exchange target.
|
||||
Thumper conditions count an actual accepted future printed play before this turn's original first spice reveal.
|
||||
Earlier plays, disposal, other roles and recovery alone do not count. A later reveal is a different opportunity.
|
||||
This condition closes when the actual first spice draw begins.
|
||||
@@ -157,8 +168,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
throw new Error('Choose this player’s original native Voice or Prescience opportunity in the current battle.');
|
||||
}
|
||||
if (action.kind === 'windowCardPlay') {
|
||||
const opportunity = action.effect === 'karamaBattleCancel' ? battleCancelOpportunity(game, target)
|
||||
: windowCardOpportunity(game, action.effect);
|
||||
const opportunity = windowOpportunity(game, target, action.effect);
|
||||
if (!opportunity || opportunity.turn !== game.turn || action.source !== opportunity.source)
|
||||
throw new Error('Choose an original current native window for this printed role, including an eligible battle response.');
|
||||
}
|
||||
@@ -168,8 +178,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
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'
|
||||
? value.effect === 'karamaBattleCancel' ? battleCancelOpportunity(game, target) : windowCardOpportunity(game, value.effect)
|
||||
: null;
|
||||
? windowOpportunity(game, target, value.effect) : null;
|
||||
return <>
|
||||
<label htmlFor={`${id}-action`}>Future action</label>
|
||||
<select id={`${id}-action`} value={value.kind} disabled={disabled} onChange={event => {
|
||||
@@ -184,8 +193,8 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
if (kind === 'windowCardPlay') {
|
||||
const effect = battleCancelOpportunity(game, target) ? 'karamaBattleCancel'
|
||||
: windowCardOpportunity(game, 'amal') ? 'amal'
|
||||
: WINDOW_CARD_EFFECTS.find(effect => effect !== 'karamaBattleCancel' && windowCardOpportunity(game, effect)) ?? 'weather';
|
||||
onChange({ kind, effect, source: windowCardOpportunity(game, effect)?.source ?? '' });
|
||||
: WINDOW_CARD_EFFECTS.find(effect => effect !== 'karamaBattleCancel' && windowOpportunity(game, target, effect)) ?? 'weather';
|
||||
onChange({ kind, effect, source: windowOpportunity(game, target, effect)?.source ?? '' });
|
||||
}
|
||||
if (kind === 'battlePower') {
|
||||
const claim = newPowerClaim(game, target), opportunity = powerOpportunity(game, target, claim);
|
||||
@@ -260,19 +269,17 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
<select id={`${id}-window-effect`} value={WINDOW_CARD_EFFECTS.includes(value.effect) ? value.effect : ''}
|
||||
disabled={disabled} aria-invalid={!WINDOW_CARD_EFFECTS.includes(value.effect)} onChange={event => {
|
||||
const effect = event.currentTarget.value;
|
||||
if (effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'karamaBattleCancel' || effect === 'thumper' || effect === 'amal')
|
||||
onChange({ kind: 'windowCardPlay', effect, source: (effect === 'karamaBattleCancel'
|
||||
? battleCancelOpportunity(game, target) : windowCardOpportunity(game, effect))?.source ?? '' });
|
||||
if (effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'karamaBattleCancel' || effect === 'karamaHarkonnenSpecial' || effect === 'thumper' || effect === 'amal')
|
||||
onChange({ kind: 'windowCardPlay', effect, source: windowOpportunity(game, target, effect)?.source ?? '' });
|
||||
}}>
|
||||
<option value="">Choose a printed role</option>
|
||||
{WINDOW_CARD_EFFECTS.filter(effect => effect !== 'karamaBattleCancel' || !!battleCancelOpportunity(game, target) || value.effect === effect)
|
||||
{WINDOW_CARD_EFFECTS.filter(effect => (effect !== 'karamaBattleCancel' && effect !== 'karamaHarkonnenSpecial') || !!windowOpportunity(game, target, effect) || value.effect === effect)
|
||||
.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 = value.effect === 'karamaBattleCancel' ? battleCancelOpportunity(game, target)
|
||||
: windowCardOpportunity(game, value.effect);
|
||||
const opportunity = windowOpportunity(game, target, value.effect);
|
||||
if (event.currentTarget.value === '') onChange({ ...value, source: '' });
|
||||
else if (opportunity?.source === event.currentTarget.value) onChange({ ...value, source: opportunity.source });
|
||||
}}>
|
||||
@@ -282,6 +289,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
: value.effect === 'karamaAuction' ? `Turn ${currentWindow.turn} · Ordinary lot ${game.bidPromiseLot?.lot}`
|
||||
: value.effect === 'thumper' ? `Turn ${currentWindow.turn} · Before the original first spice reveal`
|
||||
: value.effect === 'amal' ? `Turn ${currentWindow.turn} · Original ${PHASES[game.phase]} opening`
|
||||
: value.effect === 'karamaHarkonnenSpecial' ? `Turn ${currentWindow.turn} · Harkonnen special Karama during Bidding`
|
||||
: value.effect === 'karamaBattleCancel' ? `Turn ${currentWindow.turn} · ${game.response?.kind === 'voice' ? 'Voice' : 'Prescience'} declared by ${game.players.find(player => player.id === game.response?.owner)?.name ?? game.response?.owner} · ${game.combatLocations.find(location => location.id === game.battle?.territory)?.name ?? game.battle?.territory}`
|
||||
: currentWindow.effect === 'karamaShipment' ? `Turn ${currentWindow.turn} · ${game.players.find(player => player.id === currentWindow.recipient)?.name ?? currentWindow.recipient}’s current native shipment price`
|
||||
: `Turn ${currentWindow.turn} · Before this Storm movement`}</option>}
|
||||
|
||||
@@ -29,11 +29,12 @@ const publicBattleContext = (game: GameView) => game.battle && ({
|
||||
preparation: game.battle.preparation,
|
||||
traitorVoters: game.battle.traitorVoters, traitorSubmitted: game.battle.traitorSubmitted,
|
||||
});
|
||||
const isWindowEffect = (effect: unknown): effect is Exclude<WindowCardEffect, 'karamaAuction' | 'karamaShipment' | 'karamaBattleCancel'> =>
|
||||
const isWindowEffect = (effect: unknown): effect is Exclude<WindowCardEffect, 'karamaAuction' | 'karamaShipment' | 'karamaBattleCancel' | 'karamaHarkonnenSpecial'> =>
|
||||
effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'thumper' || effect === 'amal';
|
||||
const windowRoleNames: Record<WindowCardEffect, string> = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester',
|
||||
karamaAuction: 'printed Karama for this ordinary lot', karamaShipment: 'printed Karama for this shipment price',
|
||||
karamaBattleCancel: 'printed Karama for this battle response', thumper: 'Thumper', amal: 'Amal' };
|
||||
karamaBattleCancel: 'printed Karama for this battle response',
|
||||
karamaHarkonnenSpecial: 'Harkonnen special Karama', thumper: 'Thumper', amal: 'Amal' };
|
||||
const namesWindow = (mixed: MixedActionExpression<TurnActionLeaf>, effect: WindowCardEffect, source?: string): boolean =>
|
||||
mixed.kind === 'action' ? mixed.action.kind === 'windowCardPlay' && mixed.action.effect === effect && (source === undefined || mixed.action.source === source)
|
||||
: mixed.kind !== 'fact' && mixed.terms.some(term => namesWindow(term, effect, source));
|
||||
@@ -124,6 +125,10 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
const battleCancelRoleNamed = !!windowPromise && !!currentBattleCancelWindow &&
|
||||
namesWindow(windowPromise.mixed, 'karamaBattleCancel', currentBattleCancelWindow.source);
|
||||
const printedBattleCancelAction = action.type === 'card' && action.mode === 'cancel' && battleCancelRoleNamed;
|
||||
const currentHarkonnenWindow = windowCardOpportunity(game, 'karamaHarkonnenSpecial');
|
||||
const harkonnenRoleNamed = !!windowPromise && !!currentHarkonnenWindow &&
|
||||
namesWindow(windowPromise.mixed, 'karamaHarkonnenSpecial', currentHarkonnenWindow.source);
|
||||
const printedHarkonnenAction = action.type === 'card' && action.mode === 'special' && harkonnenRoleNamed;
|
||||
const printedInvoiceAction = action.type === 'decision' && action.karama === true && auctionRoleNamed;
|
||||
const cardOwner = action.type === 'card' || printedInvoiceAction ? game.players.find(player => player.id === actor) : undefined;
|
||||
const heldCard = cardOwner?.hand?.find(card => card.id === action.card);
|
||||
@@ -131,7 +136,8 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
const windowEffect = (auctionPurchaseAction || printedInvoiceAction) && heldCard?.effect === windowCardNativeEffect('karamaAuction')
|
||||
? 'karamaAuction' : printedShipmentAction && heldCard?.effect === windowCardNativeEffect('karamaShipment')
|
||||
? 'karamaShipment' : printedBattleCancelAction && heldCard?.effect === windowCardNativeEffect('karamaBattleCancel')
|
||||
? 'karamaBattleCancel' : isWindowEffect(heldCard?.effect) ? heldCard.effect : null;
|
||||
? 'karamaBattleCancel' : printedHarkonnenAction && heldCard?.effect === windowCardNativeEffect('karamaHarkonnenSpecial')
|
||||
? 'karamaHarkonnenSpecial' : isWindowEffect(heldCard?.effect) ? heldCard.effect : null;
|
||||
const cardWindow = windowEffect ? windowCardOpportunity(game, windowEffect) : null;
|
||||
const earlyWindowReadyAction = action.type === 'ready' && (phase === 0 || phase === 1);
|
||||
const publicWindowState = (current: GameView) => JSON.stringify({
|
||||
@@ -196,6 +202,11 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
const flightOwnerSignature = flightNeeded ? flightOwnerSource(game) : null;
|
||||
const auctionCards = game.karamaAuctionCards, auctionCardsSignature = JSON.stringify(auctionCards);
|
||||
const shipmentCards = game.karamaShipmentCards, shipmentCardsSignature = JSON.stringify(shipmentCards);
|
||||
const harkonnenTargetSource = (current: GameView) => {
|
||||
const target = current.players.find(player => player.id === action.target);
|
||||
return JSON.stringify(target && { id: target.id, handCount: target.handCount });
|
||||
};
|
||||
const harkonnenTargetSignature = printedHarkonnenAction ? harkonnenTargetSource(game) : null;
|
||||
const battleCancelControls = game.responseControls, battleCancelControlsSignature = JSON.stringify(battleCancelControls);
|
||||
const battleCancelResponse = game.response, battleCancelResponseSignature = JSON.stringify(battleCancelResponse);
|
||||
const thumperCards = game.thumperCards, thumperCardsSignature = JSON.stringify(thumperCards);
|
||||
@@ -262,7 +273,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
? 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 windowPacket = Object.keys(action).every(key => ['type', 'card', 'turnActionPromiseEvent', 'turnActionPromisePhase',
|
||||
...(windowEffect === 'weather' ? ['amount'] : windowEffect === 'karamaAuction' || windowEffect === 'karamaBattleCancel' ? ['mode'] : windowEffect === 'karamaShipment' ? ['mode', 'target'] : [])].includes(key)) &&
|
||||
...(windowEffect === 'weather' ? ['amount'] : windowEffect === 'karamaAuction' || windowEffect === 'karamaBattleCancel' ? ['mode'] : windowEffect === 'karamaShipment' ? ['mode', 'target'] : windowEffect === 'karamaHarkonnenSpecial' ? ['mode', 'target', 'amount'] : [])].includes(key)) &&
|
||||
(windowEffect !== 'weather' || typeof action.amount === 'number' && Number.isSafeInteger(action.amount) && action.amount >= 0 && action.amount <= 10);
|
||||
const printedWindowSource = ownPhysicalCard && !!heldCard && heldCard.kind === 'special' && !!windowEffect && !!cardWindow &&
|
||||
heldCard.effect === windowCardNativeEffect(windowEffect) && !!windowPromise &&
|
||||
@@ -282,6 +293,13 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
current.responseControls === battleCancelControls && JSON.stringify(current.responseControls) === battleCancelControlsSignature &&
|
||||
current.responseControls?.cancelCards.includes(heldCard.id) &&
|
||||
karamaBattleCancelOpportunity(current, actor, cardWindow?.source)?.source === cardWindow?.source;
|
||||
const harkonnenTarget = current.players.find(player => player.id === action.target);
|
||||
const nativeHarkonnenCard = windowEffect === 'karamaHarkonnenSpecial' && !!me && me.faction === 'harkonnen' &&
|
||||
me.specialKaramaUsed === false && phase === 3 && action.mode === 'special' &&
|
||||
typeof action.target === 'string' && action.target !== actor && !!harkonnenTarget &&
|
||||
typeof harkonnenTarget.handCount === 'number' && Number.isSafeInteger(harkonnenTarget.handCount) && harkonnenTarget.handCount >= 1 &&
|
||||
Number.isSafeInteger(action.amount) && action.amount === 1 &&
|
||||
harkonnenTargetSource(current) === harkonnenTargetSignature;
|
||||
const nativeThumperCard = windowEffect === 'thumper' && !!heldCard && phase === 1 && !current.spiceWindow && !current.nexus &&
|
||||
Array.isArray(current.thumperCards) && current.thumperCards === thumperCards &&
|
||||
JSON.stringify(current.thumperCards) === thumperCardsSignature && current.thumperCards.includes(heldCard.id);
|
||||
@@ -295,6 +313,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
Number.isSafeInteger(me.handLimit) && Array.isArray(me.hand) && me.hand.length < me.handLimit
|
||||
: windowEffect === 'karamaShipment' ? action.mode === 'shipment' && nativeShipmentCard
|
||||
: windowEffect === 'karamaBattleCancel' ? action.mode === 'cancel' && nativeBattleCancelCard
|
||||
: windowEffect === 'karamaHarkonnenSpecial' ? nativeHarkonnenCard
|
||||
: windowEffect === 'thumper' ? action.mode === undefined && nativeThumperCard
|
||||
: windowEffect === 'amal' ? action.mode === undefined && nativeAmalCard
|
||||
: action.mode === undefined && ordinaryCardAvailability(current, heldCard.id, windowEffect === 'weather' ? { amount: action.amount } : undefined)?.available === true);
|
||||
@@ -341,7 +360,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
!battle.revealed && [battle.attacker, battle.defender].includes(actor) && action.event === battle.event && action.event === residual.event &&
|
||||
action.target === residual.target && action.target === (battle.attacker === actor ? battle.defender : battle.attacker) &&
|
||||
Object.keys(action).every(key => ['type', 'card', 'event', 'target', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key));
|
||||
const genericCard = ownCard && action.type === 'card' && !auctionPurchaseAction && !printedShipmentAction && !printedBattleCancelAction && quotedBoxCard &&
|
||||
const genericCard = ownCard && action.type === 'card' && !auctionPurchaseAction && !printedShipmentAction && !printedBattleCancelAction && !printedHarkonnenAction && quotedBoxCard &&
|
||||
(!boxRoleNamed || heldCard?.effect !== 'nullentropyBox' || printedBox) &&
|
||||
(!shipmentPriceAction || (action.target ?? actor) === current.active) &&
|
||||
!!heldCard && heldCard.effect !== 'ghola' && heldCard.effect !== 'hajr' && heldCard.effect !== 'distrans' && heldCard.effect !== 'juiceOfSapho' && heldCard.effect !== 'semutaDrug' && heldCard.effect !== 'ornithopter' && heldCard.effect !== 'residualPoison' && !isWindowEffect(heldCard.effect);
|
||||
@@ -456,7 +475,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
Number.isSafeInteger(phase) && phase >= 0 && phase <= 8 && current.phase === phase && current.turn === turn && current.me === actor &&
|
||||
current.turnActionGuide === guide && guide.event === event && guide.action === action &&
|
||||
action.turnActionPromiseEvent === event && action.turnActionPromisePhase === phase && JSON.stringify(action) === signature && opportunity && movementPacket &&
|
||||
(!printedBattleCancelAction || printedWindow) && (!sectorShipmentGuide || currentSectorShipment) &&
|
||||
(!printedBattleCancelAction || printedWindow) && (!printedHarkonnenAction || printedWindow) && (!sectorShipmentGuide || currentSectorShipment) &&
|
||||
current.turnActionPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn &&
|
||||
!promise.fulfilled && !promise.released) &&
|
||||
(!amalReadyAction || openingReady) &&
|
||||
@@ -489,6 +508,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
: action.type === 'card' && windowEffect === 'karamaAuction' ? 'Spend a printed Karama to purchase this ordinary lot while honoring your whole answer'
|
||||
: action.type === 'card' && windowEffect === 'karamaShipment' ? `Spend a printed Karama to grant ${recipient} this native shipment price while honoring your whole answer`
|
||||
: action.type === 'card' && windowEffect === 'karamaBattleCancel' ? `Spend a printed Karama to cancel this ${game.response?.kind === 'voice' ? 'Voice' : 'Prescience'} while honoring your whole answer`
|
||||
: action.type === 'card' && windowEffect === 'karamaHarkonnenSpecial' ? `Use Harkonnen’s special Karama on ${recipient} while honoring your whole answer`
|
||||
: action.type === 'card' && heldCard?.effect === 'nullentropyBox' ? 'Pay 2 spice to activate Nullentropy Box while honoring your whole answer'
|
||||
: action.type === 'card' && heldCard?.effect === 'distrans' ? `Give ${distrans?.choices.find(choice => choice.recipient === action.target)?.cards.find(card => card.id === action.give)?.name ?? 'the quoted card'} to ${recipient} with Distrans while honoring your whole answer`
|
||||
: action.type === 'card' && heldCard?.effect === 'juiceOfSapho' ? `Play Juice of Sapho: ${action.scope === 'battleAggressor'
|
||||
|
||||
@@ -17,6 +17,16 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Harkonnen special Karama Truthtrance — 29 October. Prototyped / Partial.**
|
||||
In clean Advanced Bidding, a whole-turn question can bind Harkonnen's actual
|
||||
once-per-game special Karama use. The shared witness pays the physical card
|
||||
without sampling the opponent's hidden hand; human and bot guidance uses the
|
||||
native exchange action. Five focused files pass 208 cases, including controlled
|
||||
card custody, Yes/No, conflicting card use and hidden-hand permutations. Kull,
|
||||
Semuta and broader interactions remain open. The Homeworld gallery and two
|
||||
Leader Skill error messages also now describe existing preview support accurately.
|
||||
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#harkonnen-special-karama-during-bidding--29-october-2026).
|
||||
|
||||
**Compound freeform Truthtrance facts — 29 October. Prototyped / Partial.**
|
||||
New fact-scoped questions recognize an unambiguous AND/OR of two supported
|
||||
current hand or spice facts, including category and negative conditions. The
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
# Truthtrance combined current-turn actions
|
||||
|
||||
## Harkonnen special Karama during Bidding — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** In a quiet Advanced Bidding window, a whole-turn
|
||||
question can include the Harkonnen respondent's original once-per-game special
|
||||
Karama activation. Its source binds the actual owner and current Bidding phase,
|
||||
not a particular auction lot. A future successful use of a physical printed
|
||||
Karama in that special role after the answer counts; a normal Karama purchase
|
||||
or other role, failed or prevented activation, prior use and another player's
|
||||
card do not. No forbids only the matching special role while the original window
|
||||
remains open. AND/OR answers share the existing native card and payment planner,
|
||||
and spending the last necessary card or closing Bidding cannot evade an owned
|
||||
Yes. The normal native hand-exchange decision follows actual activation.
|
||||
|
||||
The detached legal witness uses the public target hand count and returns the
|
||||
unseen drawn cards to their owner; it never samples or reads the target's hidden
|
||||
faces or invents their benefits for another promised action. The actual native
|
||||
exchange still randomly draws and returns real cards under its existing rules.
|
||||
Questions, answers, receipts and guidance retain their existing public/private
|
||||
boundaries; human controls and the four minimal bot profiles use the same native
|
||||
packet. Current Kull and Semuta profiles are excluded; other faction special
|
||||
Karamas, wider timing, mixed-profile combinations, saved-game assurance and
|
||||
deployed verification remain open. The question and witness policy compose the
|
||||
existing [publisher FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
|
||||
current-turn Truthtrance authority with the native power; they are provisional
|
||||
online interpretations, not a dedicated publisher ruling.
|
||||
|
||||
Five focused files pass 208 cases. The [native runtime fixture](../tests/truth-harkonnen-special-karama-runtime.test.ts)
|
||||
reaches Advanced Bidding through actual actions, with a controlled swap of the
|
||||
original physical Truthtrance and Karama. It covers Yes/No, hand exchange,
|
||||
source identity, private hands, hidden-Hajr permutations, ordinary Karama cost
|
||||
protection and minimal bot guidance. The swap is not claimed as an earned card
|
||||
acquisition. Focused evidence does not certify complete modes.
|
||||
|
||||
## Conditional next-battle leader question — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** In a quiet Storm, Spice Blow, Charity, Bidding,
|
||||
|
||||
+45
-5
@@ -18999,7 +18999,8 @@ function karamaSpendingBlock(g: Game, p: Player, card: Card, purpose?: KullPurpo
|
||||
(required.effect === 'karamaAuction' && purpose === 'auction-free-purchase' ||
|
||||
required.effect === 'karamaShipment' && purpose === 'shipment-price' ||
|
||||
required.effect === 'karamaBattleCancel' && !!g.response &&
|
||||
purpose === karamaUsePurpose(g, { kind: 'cancel', response: g.response })) &&
|
||||
purpose === karamaUsePurpose(g, { kind: 'cancel', response: g.response }) ||
|
||||
required.effect === 'karamaHarkonnenSpecial' && purpose === 'special:harkonnen') &&
|
||||
windowCardOpportunity(g, required.effect)?.source === required.source))
|
||||
return 'Retain this opportunity without a forbidden printed Karama cost.';
|
||||
const kullBlock = kullKaramaBlock(g, p, purpose);
|
||||
@@ -20487,6 +20488,13 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
'Retain your promised printed Karama cancellation or another whole-answer completion.');
|
||||
}
|
||||
const used = discard(g, p, card.id);
|
||||
// Spending this physical Karama for a different role must preserve any
|
||||
// still-open Harkonnen special promise before a foreign response can park
|
||||
// the shared reconciler.
|
||||
if (card.kind === 'special' && card.effect === 'karama' && auctionRows.some(row => {
|
||||
const stage = row.windowCards?.karamaHarkonnenSpecial;
|
||||
return stage && !stage.closed && windowCardOpportunity(g, 'karamaHarkonnenSpecial', stage.source)?.source === stage.source;
|
||||
})) guardTurnActionPrintedCardCost(g, p, used);
|
||||
if (auctionWindow && auctionRows.some(row => row.windowCards?.karamaAuction?.source === auctionWindow))
|
||||
guardTurnActionAuctionClosure(g, p.id, auctionRows);
|
||||
if (shipmentWindow && use.kind === 'shipment') {
|
||||
@@ -21592,7 +21600,7 @@ function validatePlanetologistMove(g: Game, p: Player, move: Pick<MovementOrder,
|
||||
skill.leader === planetologistLeader(g, p.id),
|
||||
'This Planetologist movement no longer has its living, uncaptured skilled leader.');
|
||||
requireRule(planetologistMovementModeSupported(g),
|
||||
'Planetologist movement combined with expansion factions is still being implemented.');
|
||||
'Planetologist movement is not yet supported in this game configuration.');
|
||||
requireRule(!(move.ornithopterEvent && move.discoveryFlight),
|
||||
'Choose one Ornithopter flight source for this Planetologist movement.');
|
||||
// Selected composition: the concealed marker moves as one force for either
|
||||
@@ -22376,6 +22384,22 @@ function quotedAmalReady(g: Game, actor: string): boolean {
|
||||
function* nativeWindowCardActions(g: Game, actor: string, effect: WindowCardEffect): Generator<Action> {
|
||||
if (!windowCardOpportunity(g, effect)) return;
|
||||
const player = getPlayer(g, actor);
|
||||
if (effect === 'karamaHarkonnenSpecial') {
|
||||
const opportunity = windowCardOpportunity(g, effect);
|
||||
// The public source identifies the unique native owner; eligibility and
|
||||
// each target's hand size are checked here, without inspecting its faces.
|
||||
if (player.faction !== 'harkonnen' || !opportunity || g.kullPreview || g.pendingKull ||
|
||||
g.semutaPreview || g.semutaRecruitsPreview || g.semutaAdvancedOrdinaryPreview ||
|
||||
g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingKarama ||
|
||||
g.pendingTreacheryDiscard || g.pendingNullentropy) return;
|
||||
for (const card of player.hand.filter(card => card.kind === 'special' && card.effect === 'karama'))
|
||||
for (const target of g.players.filter(candidate => candidate.id !== actor && candidate.hand.length > 0)) {
|
||||
const action: Action = { type: 'card', mode: 'special', card: card.id, target: target.id, amount: 1 };
|
||||
try { if (prepareSpecialKaramaIntent(g, actor, action).kind === 'harkonnen') yield action; }
|
||||
catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (effect === 'karamaBattleCancel') {
|
||||
for (const card of quotedBattleCancelKaramaCards(g, player)) yield { type: 'card', card, mode: 'cancel' };
|
||||
return;
|
||||
@@ -22459,7 +22483,18 @@ function* turnActionWindowCardSeeds(source: Game, actor: string, requirements: r
|
||||
}
|
||||
for (const action of nativeWindowCardActions(state, actor, required.effect)) {
|
||||
try {
|
||||
const next = applyActionInner(state, actor, action, 'movementPreparation');
|
||||
let next: Game;
|
||||
if (required.effect === 'karamaHarkonnenSpecial') {
|
||||
// An opaque legal witness returns the unseen drawn cards to the
|
||||
// target. It spends the actual card but never samples, copies or
|
||||
// derives a benefit from another player's private hand.
|
||||
next = structuredClone(state);
|
||||
const intent = prepareSpecialKaramaIntent(next, actor, action);
|
||||
requireRule(intent.kind === 'harkonnen', 'Retain the original Harkonnen special opportunity.');
|
||||
const owner = getPlayer(next, actor);
|
||||
discard(next, owner, intent.card);
|
||||
owner.specialKaramaUsed = true;
|
||||
} else next = applyActionInner(state, actor, action, 'movementPreparation');
|
||||
if (required.effect === 'karamaAuction') {
|
||||
const before = getPlayer(state, actor), after = getPlayer(next, actor);
|
||||
const known = before.faction === 'atreides' && state.auction?.peekKnown
|
||||
@@ -34856,11 +34891,11 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
if (quote.sukGraduate) requireRule(
|
||||
ordinaryLeaderSkillModeSupported(g) &&
|
||||
(nativeExpansionLeaderSkillsProfile(g) || g.players.every((p) => faction(p.faction).expansion === 'base')),
|
||||
'Suk Graduate rescue with expansion factions or other optional modules is still being implemented.');
|
||||
'Suk Graduate rescue is not yet supported in this game configuration.');
|
||||
if (quote.rihani || quote.sandmaster) requireRule(
|
||||
ordinaryLeaderSkillModeSupported(g) &&
|
||||
(nativeExpansionLeaderSkillsProfile(g) || g.players.every((p) => faction(p.faction).expansion === 'base')),
|
||||
'These Leader Skill victory effects with expansion factions or other optional modules are still being integrated.');
|
||||
'These Leader Skill victory effects are not yet supported in this game configuration.');
|
||||
const collection = b.smugglerCollection;
|
||||
const collects = !!collection && !quote.effects?.stunned &&
|
||||
!(collection.player === b.attacker ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender);
|
||||
@@ -42787,7 +42822,12 @@ function resolveSpecialKaramaEffect(g: Game, p: Player, card: Card, intent: Spec
|
||||
} else if (intent.kind === 'harkonnen') {
|
||||
const target = getPlayer(g, intent.target),
|
||||
count = intent.amount;
|
||||
const opportunity = windowCardOpportunity(g, 'karamaHarkonnenSpecial');
|
||||
if (opportunity && card.kind === 'special' && card.effect === 'karama')
|
||||
recordNativeTurnActionWindowCard(g, p.id, 'karamaHarkonnenSpecial', opportunity.source);
|
||||
p.specialKaramaUsed = true;
|
||||
if (liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length)
|
||||
guardTurnActionPrintedCardCost(g, p, card);
|
||||
const taken = shuffle(target.hand).slice(0, count);
|
||||
target.hand = target.hand.filter((c) => !taken.some((t) => t.id === c.id));
|
||||
p.hand.push(...taken);
|
||||
|
||||
+3
-2
@@ -3337,7 +3337,7 @@ 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 printed Karama shipment pricing and current-lot costs, original-window Weather/Atomics/Harvester plays, 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.',
|
||||
summary: 'Combine printed Karama shipment pricing and current-lot costs, Advanced Harkonnen special Karama, original-window card plays, native powers and other current-turn actions in one answer, with optional frozen facts.',
|
||||
steps: [
|
||||
'In a supported quiet Storm, Spice Blow, Charity, Bidding, Revival or Shipment and Movement window, ask whether a player will use a named native leader in their first later battle this turn, if one occurs. Storm must have a pending movement after the dials; Spice Blow must be before the draw. The answer waits for an actual battle; if none occurs, it expires. This separate provisional question supports Basic factions and Advanced without Ecaz, excluding optional modules. In a Basic Ecaz combined battle, only the selected lead and opponent bind; the nonleading ally waits for its own later battle. Answers use only currently available leaders.',
|
||||
'Choose Combine printed card plays, native battle powers 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 original-window card plays, 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.',
|
||||
@@ -3364,6 +3364,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Weather Control and Family Atomics conditions concern a future printed play in this original open Storm window, including after dial reveal and before movement starts. Harvester concerns this original fresh blow. A later blow has a different identity even in the same phase. Earlier plays, disposal, other roles and recovery alone do not count.',
|
||||
'These original-window conditions describe playing the card, with the normal chosen Weather distance, Atomics qualifying forces/casualties and Harvester arithmetic. They do not describe a future selected distance or amount. Accepting the window must preserve the whole answer; a later payment alternative can remain. No caps last through actual source closure, rather than ending at your own Ready.',
|
||||
'A current-lot Karama condition counts a future physical printed Karama cost for this original ordinary auction card, either immediate purchase or its winning invoice. Bidding alone, another Karama role, a converted Worthless card, an earlier play, recovery or a later lot does not count. A bid-and-Karama answer must retain the real bid before the purchase; free acquisition still uses the original native controls and hand limit. A whole No shares its payment and bid funding without assuming a forbidden free invoice. Unseen future auction cards cannot fund another promised card play; actual owned knowledge remains usable.',
|
||||
'In a quiet Advanced Bidding window, a whole answer can name Harkonnen’s successful once-game special Karama. The original owner and Bidding turn bind the interval across lots. Only a future actual physical printed Karama used for that special counts; ordinary roles and failed use do not. No and native hand-exchange timing remain authoritative. The shared witness returns unseen drawn cards without sampling their faces; Kull, Semuta and other faction special powers remain outside this prototype.',
|
||||
'A printed Karama shipment-price clause names the current beneficiary’s unused native shipment. The giver spends the physical printed card; the beneficiary still pays the native bank tariff, such as two spice becoming one. Playing that cost counts even if its price is unused. The source remains while that original recipient is entitled, including another actor’s turn; a different beneficiary or a Cunning second opportunity has its own interval. Own shipping or ending must preserve the whole answer before a foreign counter, while foreign actions retain their rights.',
|
||||
'A Thumper condition counts an actual printed play after answering before this turn’s original first Spice reveal. Its cost counts even when the existing first-turn rule ignores the encounter or waiting Sandtrout suppresses it. Own acceptance must retain the whole answer. Known card effects enter the quote; the next hidden Spice Card does not. A forced later draw can release genuine impossibility, while choosing known casualties cannot evade an earlier answer.',
|
||||
'A printed Amal clause names the original phase opening and counts a future printed play after this answer. The actual available-spice reduction occurs before ordinary payments become possible; known Mentat bribe receipts arrive afterward. Definite answers reopen your own opening pass. Own cost or acceptance must retain a completion, while a foreign Amal can release genuine impossibility before another player takes the last pass. Later openings keep separate intervals.',
|
||||
@@ -3380,7 +3381,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Full editor, raw question, private answer, persistent own history and exact current-phase native guide, with a separate conditional future-battle leader editor and reminder.', evidence: ['components/turn-action-fields.tsx', 'components/turn-action-promises.tsx', 'components/truth-future-battle-leader.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-future-battle-leader-controls.test.tsx', 'tests/truth-turn-action-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed roles, exact fresh Semuta controls and original power declarations; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-thumper-runtime.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Current funding, physical event meaning, private alternatives, separate deadlines and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TURN_ACTIONS.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, original Storm/blow sources, shared Revival resources and native power declarations; broader composition and final acceptance remain.', evidence: ['tests/fixture-truth-flight-poison.ts', 'tests/truth-flight-poison-runtime.test.ts', 'tests/fixture-truth-semuta-play.ts', 'tests/truth-semuta-play-runtime.test.ts', 'tests/semuta-reaction-controls.test.tsx', 'tests/fixture-truth-transfer-order-play.ts', 'tests/truth-transfer-order-play-runtime.test.ts', 'tests/fixture-distrans-prebid.ts', 'tests/fixture-sapho-started-movement.ts', 'tests/fixture-truth-box-play.ts', 'tests/truth-box-play-runtime.test.ts', 'tests/truth-opaque-card-plan.test.ts', 'tests/fixture-truth-choam-income.ts', 'tests/truth-choam-income-runtime.test.ts', 'tests/window-card-play.test.ts', 'tests/battle-power-question.test.ts', 'tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/fixture-truth-turn-power.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/fixture-truth-turn-window-card.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/fixture-truth-turn-karama.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-forecast.test.ts', 'tests/fixture-truth-turn-karama-shipment.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-karama-shipment-funding.test.ts', 'tests/fixture-truth-turn-thumper.ts', 'tests/truth-turn-thumper-runtime.test.ts', 'tests/fixture-truth-turn-amal.ts', 'tests/truth-turn-amal-runtime.test.ts', 'game/ordinary-payment-plan.ts', 'tests/ordinary-payment-plan.test.ts', 'tests/fixture-truth-charity-payments.ts', 'tests/truth-charity-payments-runtime.test.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, original Storm/blow sources, shared Revival resources and native power declarations; broader composition and final acceptance remain.', evidence: ['tests/fixture-truth-flight-poison.ts', 'tests/truth-flight-poison-runtime.test.ts', 'tests/fixture-truth-semuta-play.ts', 'tests/truth-semuta-play-runtime.test.ts', 'tests/semuta-reaction-controls.test.tsx', 'tests/fixture-truth-transfer-order-play.ts', 'tests/truth-transfer-order-play-runtime.test.ts', 'tests/fixture-distrans-prebid.ts', 'tests/fixture-sapho-started-movement.ts', 'tests/fixture-truth-box-play.ts', 'tests/truth-box-play-runtime.test.ts', 'tests/truth-opaque-card-plan.test.ts', 'tests/fixture-truth-choam-income.ts', 'tests/truth-choam-income-runtime.test.ts', 'tests/window-card-play.test.ts', 'tests/battle-power-question.test.ts', 'tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/fixture-truth-turn-power.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/fixture-truth-turn-window-card.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/fixture-truth-turn-karama.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-forecast.test.ts', 'tests/truth-harkonnen-special-karama-runtime.test.ts', 'tests/window-card-harkonnen-special.test.ts', 'tests/fixture-truth-turn-karama-shipment.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-karama-shipment-funding.test.ts', 'tests/fixture-truth-turn-thumper.ts', 'tests/truth-turn-thumper-runtime.test.ts', 'tests/fixture-truth-turn-amal.ts', 'tests/truth-turn-amal-runtime.test.ts', 'game/ordinary-payment-plan.ts', 'tests/ordinary-payment-plan.test.ts', 'tests/fixture-truth-charity-payments.ts', 'tests/truth-charity-payments-runtime.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+7
-1
@@ -5,7 +5,7 @@ import { parseTravelActionClaim, type TravelClaim } from './travel-expression-pr
|
||||
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES,
|
||||
turnActionPromiseModeSupported, type TurnActionClaim } from './turn-action-promises';
|
||||
import { battlePowerOf, nativeBattlePowerOpportunity } from './battle-power-question';
|
||||
import { parseKaramaShipmentSource, parseKaramaBattleCancelSource, karamaBattleCancelOpportunity, windowCardOpportunity } from './window-card-play';
|
||||
import { parseKaramaShipmentSource, parseKaramaBattleCancelSource, parseKaramaHarkonnenSpecialSource, karamaBattleCancelOpportunity, windowCardOpportunity } from './window-card-play';
|
||||
import { nativeStormDialOpportunity } from './storm-dial-question';
|
||||
import { parseTraitorActionClaim, type MixedTraitorClaim } from './mixed-traitor-question';
|
||||
import { parseBattleActionClaim, MIXED_BATTLE_MAX_NODES, MIXED_BATTLE_MAX_TERMS, MIXED_BATTLE_MAX_DEPTH,
|
||||
@@ -503,6 +503,10 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
const opportunity = windowCardOpportunity(g, action.effect);
|
||||
check(opportunity && opportunity.turn === g.turn && opportunity.source === action.source,
|
||||
'Choose the exact current native printed-card opportunity.');
|
||||
if (action.effect === 'karamaHarkonnenSpecial')
|
||||
check(parseKaramaHarkonnenSpecialSource(action.source)?.owner === respondent.id &&
|
||||
!g.kullPreview && !g.semutaPreview && !g.semutaRecruitsPreview && !g.semutaAdvancedOrdinaryPreview,
|
||||
'Choose Harkonnen’s own special Karama in a supported clean Bidding profile.');
|
||||
}
|
||||
if (action.kind === 'battlePlan' || action.kind === 'traitorCall' || action.kind === 'battlePower') {
|
||||
const battle = g.battle;
|
||||
@@ -903,6 +907,8 @@ export function truthQuestionText(
|
||||
action => {
|
||||
if (action.kind === 'stormDial') return `you submit ${action.compare === 'gte' ? 'at least' : action.compare === 'lte' ? 'at most' : 'exactly'} ${action.value} on your original storm dial this turn`;
|
||||
if (action.kind === 'windowCardPlay') {
|
||||
if (action.effect === 'karamaHarkonnenSpecial')
|
||||
return 'you use Harkonnen’s special Karama after this answer during Bidding';
|
||||
if (action.effect === 'karamaBattleCancel') {
|
||||
const response = parseKaramaBattleCancelSource(action.source);
|
||||
return `you spend a printed Karama after this answer to cancel ${response?.kind === 'voice' ? 'Voice' : 'Prescience'} in this original battle response`;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { RevivalExpressionPromiseError, type RevivalExpressionReturn } from './r
|
||||
import { parseBattleActionClaim, type MixedBattleActionClaim } from './mixed-battle-question';
|
||||
import { PlanClaimError } from './battle-promises';
|
||||
import { PRINTED_CARD_EFFECTS, type PrintedCardEffect } from './printed-card-play';
|
||||
import { WINDOW_CARD_EFFECTS, type WindowCardEffect } from './window-card-play';
|
||||
import { WINDOW_CARD_EFFECTS, parseKaramaHarkonnenSpecialSource, type WindowCardEffect } from './window-card-play';
|
||||
import { StormDialQuestionError, parseStormDialClaim, parseStormDialSource, stormDialMatches, type StormDialClaim } from './storm-dial-question';
|
||||
import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope';
|
||||
import { BattlePowerQuestionError, battlePowerMatches, battlePowerOf, parseBattlePowerClaim,
|
||||
@@ -167,6 +167,8 @@ export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
|
||||
if (value.kind === 'windowCardPlay') {
|
||||
check(keys(value, 'effect,kind,source') && windowEffect(value.effect) && identifier(value.source),
|
||||
'Choose one original phase-opening, Storm, first-reveal, fresh-blow, ordinary-lot or shipment-price printed-card play after this answer.');
|
||||
if (value.effect === 'karamaHarkonnenSpecial') check(!!parseKaramaHarkonnenSpecialSource(value.source),
|
||||
'Retain the original Harkonnen special Karama Bidding window.');
|
||||
return { kind: 'windowCardPlay', effect: value.effect, source: value.source };
|
||||
}
|
||||
if (value.kind === 'battlePower') {
|
||||
@@ -363,6 +365,11 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
for (const effect of cardEffects) check(!!promise.cardPlays?.[effect], 'Retain the original future interval for each printed-card play clause.');
|
||||
for (const effect of windowSources.keys()) check(!!promise.windowCards?.[effect],
|
||||
'Retain the original native window for every printed-card window clause, including folded ones.');
|
||||
for (const source of windowSources.get('karamaHarkonnenSpecial') ?? []) {
|
||||
const binding = parseKaramaHarkonnenSpecialSource(source);
|
||||
check(binding && binding.turn === promise.turn && binding.owner === promise.player,
|
||||
'Retain the respondent’s original Harkonnen special Karama Bidding window.');
|
||||
}
|
||||
if ('windowCards' in promise) {
|
||||
check(object(promise.windowCards) && keys(promise.windowCards, [...windowSources.keys()].sort().join(',')),
|
||||
'Retain exactly the original named window-card effects.');
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { harvesterAvailable } from './cards';
|
||||
|
||||
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'thumper', 'amal'] as const;
|
||||
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'thumper', 'amal'] as const;
|
||||
export type WindowCardEffect = typeof WINDOW_CARD_EFFECTS[number];
|
||||
export function windowCardNativeEffect(effect: WindowCardEffect): 'weather' | 'atomics' | 'harvester' | 'karama' | 'thumper' | 'amal' {
|
||||
return effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'karamaBattleCancel' ? 'karama' : effect;
|
||||
return effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'karamaBattleCancel' || effect === 'karamaHarkonnenSpecial' ? 'karama' : effect;
|
||||
}
|
||||
export type KaramaHarkonnenSpecialSource = { turn: number; owner: string };
|
||||
export type KaramaBattleCancelSource = {
|
||||
turn: number;
|
||||
battle: string;
|
||||
@@ -52,7 +53,7 @@ export type WindowCardGame = {
|
||||
ixAuction?: unknown;
|
||||
active?: string | null;
|
||||
movementRemaining?: readonly string[] | null;
|
||||
players?: readonly { id: string; shipped?: boolean }[];
|
||||
players?: readonly { id: string; shipped?: boolean; faction?: string; specialKaramaUsed?: boolean }[];
|
||||
karamaShipping?: { player: string } | null;
|
||||
nexusGuildCunningHistory?: readonly { receipt: { turn: number; owner: string; event: string }; stage: string }[];
|
||||
/** Public projection contains no physical cards or private funding. */
|
||||
@@ -66,6 +67,16 @@ const identifier = (value: unknown): value is string =>
|
||||
const whole = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
|
||||
/** Decode the public owner and turn even after the special power is spent. */
|
||||
export function parseKaramaHarkonnenSpecialSource(source: unknown): KaramaHarkonnenSpecialSource | null {
|
||||
if (!identifier(source)) return null;
|
||||
let tuple: unknown;
|
||||
try { tuple = JSON.parse(source); } catch { return null; }
|
||||
if (!Array.isArray(tuple) || tuple.length !== 3 || tuple[0] !== 'karamaHarkonnenSpecial' ||
|
||||
!whole(tuple[1]) || tuple[1] < 1 || !identifier(tuple[2]) || JSON.stringify(tuple) !== source) return null;
|
||||
return { turn: tuple[1], owner: tuple[2] };
|
||||
}
|
||||
|
||||
/** Source decoding remains available after the actual grant closes its window. */
|
||||
export function parseKaramaShipmentSource(source: unknown): KaramaShipmentSource | null {
|
||||
if (!identifier(source)) return null;
|
||||
@@ -158,6 +169,17 @@ export function karamaShipmentOpportunities(game: WindowCardGame): KaramaShipmen
|
||||
* force conditions and chosen effect parameters remain native engine rules. */
|
||||
function globalWindowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect): WindowCardOpportunity | null {
|
||||
if (game.status !== 'playing' || !whole(game.turn) || game.turn < 1) return null;
|
||||
if (effect === 'karamaHarkonnenSpecial') {
|
||||
const players = game.players;
|
||||
if (game.advanced !== true || game.phase !== 3 || !Array.isArray(players) || !players.length ||
|
||||
!players.every(player => object(player) && identifier(player.id) &&
|
||||
typeof player.faction === 'string' && (player.specialKaramaUsed === undefined ||
|
||||
typeof player.specialKaramaUsed === 'boolean')) ||
|
||||
new Set(players.map(player => player.id)).size !== players.length) return null;
|
||||
const owners = players.filter(player => player.faction === 'harkonnen');
|
||||
if (owners.length !== 1 || owners[0].specialKaramaUsed === true) return null;
|
||||
return { effect, turn: game.turn, source: JSON.stringify([effect, game.turn, owners[0].id]) };
|
||||
}
|
||||
if (effect === 'karamaBattleCancel') {
|
||||
const battle = game.battle, response = game.response, players = game.players;
|
||||
if (game.advanced !== false || game.phase !== 6 || !battle || !identifier(battle.event) ||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { windowCardOpportunity } from '../game/window-card-play';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { actTruthBid as act, createTruthBidFixture, truthBidInventory as inventory,
|
||||
truthBidPlayer as player } from './fixture-truth-bid';
|
||||
|
||||
/** Genuine Advanced setup and native Bidding; swap one original Truthtrance
|
||||
* and one original Karama between dealt hands as a controlled custody fixture. */
|
||||
function fixture() {
|
||||
const f = createTruthBidFixture({ advanced: true, targetKarama: true });
|
||||
const game = structuredClone(f.game), harkonnen = player(game, f.asker), emperor = player(game, f.target);
|
||||
const truth = harkonnen.hand.findIndex(card => card.effect === 'truthtrance');
|
||||
const karama = emperor.hand.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(truth >= 0 && karama >= 0);
|
||||
const truthCard = harkonnen.hand[truth], karamaCard = emperor.hand[karama];
|
||||
harkonnen.hand[truth] = karamaCard; emperor.hand[karama] = truthCard;
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
const window = windowCardOpportunity(game, 'karamaHarkonnenSpecial'); assert.ok(window);
|
||||
return { game, asker: emperor.id, owner: harkonnen.id, opponent: f.opponent,
|
||||
truth: truthCard.id, karama: karamaCard.id, source: window.source, cards: f.originalCards };
|
||||
}
|
||||
function reject(game: Game, id: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, id, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function declare(f: ReturnType<typeof fixture>) {
|
||||
let game = act(f.game, f.asker, { type: 'card', card: f.truth });
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const seat = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(seat);
|
||||
game = act(game, seat.id, { type: 'truthPass' });
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return game;
|
||||
}
|
||||
function question(f: ReturnType<typeof fixture>, target = f.owner) {
|
||||
return { kind: 'turnActions', target, turn: f.game.turn, mixed: { kind: 'action', action: {
|
||||
kind: 'windowCardPlay', effect: 'karamaHarkonnenSpecial', source: f.source,
|
||||
} } };
|
||||
}
|
||||
function ask(f: ReturnType<typeof fixture>, game = declare(f)) {
|
||||
return act(game, f.asker, { type: 'truthAsk', question: question(f) });
|
||||
}
|
||||
function special(f: ReturnType<typeof fixture>): Action {
|
||||
return { type: 'card', card: f.karama, mode: 'special', target: f.opponent, amount: 1 };
|
||||
}
|
||||
|
||||
void test('Advanced Harkonnen special Yes records the physical special role and retains private hand exchange', () => {
|
||||
const f = fixture(), asked = ask(f);
|
||||
assert.ok(viewGame(asked, f.owner).truthTurnActionAnswers?.includes('yes'));
|
||||
assert.equal(viewGame(asked, f.opponent).truthTurnActionAnswers, null);
|
||||
let game = act(asked, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
const guide = viewGame(game, f.owner).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'card');
|
||||
assert.equal(guide.action.mode, 'special');
|
||||
assert.equal(guide.action.card, f.karama);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const bot = viewGame(game, f.owner);
|
||||
bot.players.find(p => p.id === f.owner)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(bot)[0], guide.action);
|
||||
}
|
||||
// A normal free-purchase use of the sole Karama cannot evade this Yes.
|
||||
reject(game, f.owner, { type: 'card', card: f.karama, mode: 'purchase' });
|
||||
const bound = game, before = structuredClone(game), lot = structuredClone(game.auction);
|
||||
game = act(game, f.owner, special(f));
|
||||
assert.deepEqual(bound, before, 'Native action works on an immutable input');
|
||||
assert.equal(player(game, f.owner).specialKaramaUsed, true);
|
||||
assert.equal(game.turnActionPromises!.find(p => p.player === f.owner)!.fulfilled, true);
|
||||
assert.equal(game.turnActionPromises!.find(p => p.player === f.owner)!.windowCards!.karamaHarkonnenSpecial!.played, true);
|
||||
assert.equal(game.decision?.kind, 'handExchange');
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
const observer = viewGame(game, f.asker);
|
||||
assert.equal(observer.players.find(p => p.id === f.owner)!.hand, undefined);
|
||||
assert.equal(observer.players.find(p => p.id === f.opponent)!.hand, undefined);
|
||||
assert.deepEqual(observer.turnActionPromises, []);
|
||||
const returned = player(game, f.owner).hand[0]; assert.ok(returned);
|
||||
game = act(game, f.owner, { type: 'decision', returnCards: [returned.id] });
|
||||
assert.deepEqual(game.auction, lot);
|
||||
assert.deepEqual(inventory(game), f.cards);
|
||||
});
|
||||
|
||||
void test('Advanced Harkonnen special No rejects that role and leaves the original Karama held', () => {
|
||||
const f = fixture(), asked = ask(f);
|
||||
assert.ok(viewGame(asked, f.owner).truthTurnActionAnswers?.includes('no'));
|
||||
const game = act(asked, f.owner, { type: 'truthAnswer', answer: 'no' });
|
||||
reject(game, f.owner, special(f));
|
||||
assert.ok(player(game, f.owner).hand.some(card => card.id === f.karama));
|
||||
assert.notEqual(player(game, f.owner).specialKaramaUsed, true);
|
||||
assert.deepEqual(inventory(game), f.cards);
|
||||
});
|
||||
|
||||
void test('a Harkonnen special source cannot bind another respondent or a forged turn', () => {
|
||||
const f = fixture(), game = declare(f);
|
||||
reject(game, f.asker, { type: 'truthAsk', question: question(f, f.opponent) });
|
||||
const malformed = question(f);
|
||||
malformed.mixed.action.source = JSON.stringify(['karamaHarkonnenSpecial', game.turn + 1, f.owner]);
|
||||
reject(game, f.asker, { type: 'truthAsk', question: malformed });
|
||||
});
|
||||
|
||||
/** Stage an existing Hajr into the other player's private hand without adding
|
||||
* or duplicating a physical card. The Harkonnen respondent does not know that
|
||||
* face and cannot treat it as an owned future Movement play. */
|
||||
function withOpponentHajr() {
|
||||
const f = fixture(), game = structuredClone(f.game), other = player(game, f.opponent);
|
||||
const index = game.deck.findIndex(card => card.effect === 'hajr');
|
||||
assert.ok(index >= 0, 'The original undealt Hajr must be available to stage');
|
||||
const prior = other.hand.findIndex(card => card.effect !== 'hajr'); assert.ok(prior >= 0);
|
||||
[game.deck[index], other.hand[prior]] = [other.hand[prior], game.deck[index]];
|
||||
assert.ok(!player(game, f.owner).hand.some(card => card.effect === 'hajr'));
|
||||
assert.deepEqual(inventory(game), f.cards);
|
||||
return { ...f, game };
|
||||
}
|
||||
function compound(f: ReturnType<typeof fixture>, kind: 'and' | 'or') {
|
||||
const simple = question(f);
|
||||
return { ...simple, mixed: { kind, terms: [simple.mixed,
|
||||
{ kind: 'action', action: { kind: 'cardPlay', effect: 'hajr' } },
|
||||
] } };
|
||||
}
|
||||
|
||||
void test('a hidden opponent Hajr cannot fund special-Karama AND Hajr Yes', () => {
|
||||
const f = withOpponentHajr(), declared = declare(f);
|
||||
const asked = act(declared, f.asker, { type: 'truthAsk', question: compound(f, 'and') });
|
||||
assert.ok(!viewGame(asked, f.owner).truthTurnActionAnswers?.includes('yes'));
|
||||
assert.equal(viewGame(asked, f.owner).players.find(p => p.id === f.opponent)!.hand, undefined);
|
||||
// The same Hajr in the undealt deck rather than that opponent's hidden hand
|
||||
// must not change the respondent's available answers.
|
||||
const alternate = structuredClone(f.game), other = player(alternate, f.opponent);
|
||||
const held = other.hand.findIndex(card => card.effect === 'hajr');
|
||||
const spare = alternate.deck.findIndex(card => card.name === 'Baliset');
|
||||
assert.ok(held >= 0 && spare >= 0);
|
||||
[other.hand[held], alternate.deck[spare]] = [alternate.deck[spare], other.hand[held]];
|
||||
const f2 = { ...f, game: alternate };
|
||||
const asked2 = act(declare(f2), f2.asker, { type: 'truthAsk', question: compound(f2, 'and') });
|
||||
assert.deepEqual(viewGame(asked2, f2.owner).truthTurnActionAnswers,
|
||||
viewGame(asked, f.owner).truthTurnActionAnswers);
|
||||
assert.deepEqual(inventory(alternate), f.cards);
|
||||
reject(asked, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
const game = act(asked, f.owner, { type: 'truthAnswer', answer: 'no' });
|
||||
assert.deepEqual(inventory(game), f.cards);
|
||||
});
|
||||
|
||||
void test('special-Karama OR Hajr preserves either alternative and No forbids the actual special', () => {
|
||||
const f = withOpponentHajr(), declared = declare(f);
|
||||
const asked = act(declared, f.asker, { type: 'truthAsk', question: compound(f, 'or') });
|
||||
assert.ok(viewGame(asked, f.owner).truthTurnActionAnswers?.includes('yes'));
|
||||
assert.ok(viewGame(asked, f.owner).truthTurnActionAnswers?.includes('no'));
|
||||
const yes = act(asked, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
const after = act(yes, f.owner, special(f));
|
||||
assert.equal(after.turnActionPromises!.find(row => row.player === f.owner)!.fulfilled, true);
|
||||
const no = act(asked, f.owner, { type: 'truthAnswer', answer: 'no' });
|
||||
reject(no, f.owner, special(f));
|
||||
assert.ok(player(no, f.owner).hand.some(card => card.id === f.karama));
|
||||
assert.deepEqual(inventory(no), f.cards);
|
||||
});
|
||||
@@ -2123,3 +2123,42 @@ void test('turn-action answer callbacks reject stale target, source queue, priva
|
||||
const replaced = { game: answering(), busy: false, act() { assert.fail('Replaced view cannot send'); } };
|
||||
const old = find(TurnActionAnswers(replaced), 'data-turn-action-answer', 'yes'); replaced.game = { ...replaced.game }; click(old);
|
||||
});
|
||||
|
||||
void test('Harkonnen special Karama authoring uses only its public Bidding opportunity', () => {
|
||||
const game = auction();
|
||||
game.advanced = true;
|
||||
const harkonnen = game.players.find(player => player.id === 'p')!;
|
||||
const other = game.players.find(player => player.id === 'r')!;
|
||||
harkonnen.faction = 'harkonnen'; harkonnen.specialKaramaUsed = false; other.faction = 'emperor';
|
||||
const source = JSON.stringify(['karamaHarkonnenSpecial', game.turn, 'p']);
|
||||
const initial = windowClaim('karamaHarkonnenSpecial', source);
|
||||
const e = editor(initial, false, game);
|
||||
Object.defineProperty(harkonnen, 'hand', { get() { throw Error('Question authoring read private hand'); }, configurable: true });
|
||||
assert.equal(e.error(), null);
|
||||
assert.match(e.html(), /Use Harkonnen’s special Karama during Bidding/);
|
||||
assert.match(e.html(), /Turn 2 · Harkonnen special Karama during Bidding/);
|
||||
select(e.tree(), '1', 'window-source', ''); assert.ok(e.error());
|
||||
select(e.tree(), '1', 'window-source', source); assert.equal(e.error(), null);
|
||||
assert.ok(turnActionInputError(initial, game, 'r'), 'Only Harkonnen is a valid respondent');
|
||||
harkonnen.specialKaramaUsed = true;
|
||||
assert.ok(e.error(), 'The completed once-per-game power closes the opportunity');
|
||||
harkonnen.specialKaramaUsed = false; game.phase = 4;
|
||||
assert.ok(e.error(), 'The original Bidding window has closed');
|
||||
});
|
||||
|
||||
void test('Harkonnen special Karama guide forwards only the exact live printed-card packet', () => {
|
||||
const source = JSON.stringify(['karamaHarkonnenSpecial', 2, 'p']);
|
||||
const game = projected(3, { type: 'card', mode: 'special', card: 'controlled-printed-karama', target: 'r', amount: 1 });
|
||||
game.advanced = true;
|
||||
const owner = game.players.find(player => player.id === 'p')!;
|
||||
const target = game.players.find(player => player.id === 'r')!;
|
||||
owner.faction = 'harkonnen'; owner.specialKaramaUsed = false; target.faction = 'emperor'; target.handCount = 1;
|
||||
game.turnActionPromises[0].mixed = windowClaim('karamaHarkonnenSpecial', source);
|
||||
const sent: Action[] = [], props = { game, busy: false, act(action: Action) { sent.push(action); } };
|
||||
const button = guide(props);
|
||||
assert.equal(button.props.disabled, false);
|
||||
assert.match(renderToStaticMarkup(button), /Harkonnen’s special Karama/);
|
||||
press(button); assert.deepEqual(sent, [game.turnActionGuide!.action]);
|
||||
target.handCount = 0; press(button); assert.equal(sent.length, 1);
|
||||
target.handCount = 1; owner.specialKaramaUsed = true; press(button); assert.equal(sent.length, 1);
|
||||
});
|
||||
@@ -61,7 +61,8 @@ const card = (effect: PrintedCardEffect = 'hajr'): Expression => ({ kind: 'actio
|
||||
const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expression =>
|
||||
({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } });
|
||||
const windowSource = (effect: WindowCardEffect) => effect === 'karamaShipment'
|
||||
? JSON.stringify(['karamaShipment', 1, 'recipient', 'original']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
|
||||
? JSON.stringify(['karamaShipment', 1, 'recipient', 'original']) : effect === 'karamaHarkonnenSpecial'
|
||||
? JSON.stringify(['karamaHarkonnenSpecial', 1, 'respondent']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
|
||||
effect === 'amal' ? 'phase-opening:1:2' : effect === 'thumper' ? 'first-spice-reveal:1' :
|
||||
effect === 'harvester' ? 'spice-blow:1' : 'storm:1';
|
||||
const windowCard = (effect: WindowCardEffect = 'weather', source = windowSource(effect)): Expression =>
|
||||
@@ -1863,3 +1864,37 @@ void test('Ornithopter and Residual Poison whole receipts preserve dynamic alter
|
||||
assert.equal(recordTurnActionCardPlay([fresh], 'respondent', 1, 'ornithopter')[0].fulfilled, true);
|
||||
validateTurnActionPromises(poisoned, seats);
|
||||
});
|
||||
|
||||
void test('Harkonnen special Karama window records only the original owner and phase after the answer', () => {
|
||||
const source = windowSource('karamaHarkonnenSpecial');
|
||||
const question = windowed(group('and', windowCard('karamaHarkonnenSpecial'), pay()));
|
||||
const signed = { kind: 'windowCardPlay', effect: 'karamaHarkonnenSpecial', source, answer: true };
|
||||
assert.deepEqual(branches(question), [[signed, signedPay()]]);
|
||||
const otherOwner = recordTurnActionWindowCardPlay([question], 'other', 1, 'karamaHarkonnenSpecial', source);
|
||||
assert.equal(otherOwner[0], question);
|
||||
const actual = recordTurnActionWindowCardPlay([question], 'respondent', 1, 'karamaHarkonnenSpecial', source);
|
||||
assert.deepEqual(branches(actual[0]), [[signedPay()]]);
|
||||
assert.equal(recordTurnActionPayment(actual, 'respondent', 1, payment())[0].fulfilled, true);
|
||||
assert.equal(question.windowCards!.karamaHarkonnenSpecial!.played, false);
|
||||
validateTurnActionPromises(actual, seats);
|
||||
const closed = closeTurnActionWindowCardPlay([question], 'respondent', 1, 'karamaHarkonnenSpecial', source);
|
||||
assert.equal(closed[0].windowCards!.karamaHarkonnenSpecial!.closed, true);
|
||||
assert.deepEqual(branches(closed[0]), []);
|
||||
});
|
||||
|
||||
void test('Harkonnen special No and source binding retain exact responder, turn and old receipts', () => {
|
||||
const source = windowSource('karamaHarkonnenSpecial');
|
||||
const negative = windowed(windowCard('karamaHarkonnenSpecial'), false);
|
||||
assert.throws(() => recordTurnActionWindowCardPlay([negative], 'respondent', 1, 'karamaHarkonnenSpecial', source), TurnActionError);
|
||||
const passed = closeTurnActionWindowCardPlay([negative], 'respondent', 1, 'karamaHarkonnenSpecial', source);
|
||||
assert.equal(passed[0].fulfilled, true);
|
||||
for (const bad of ['', 'storm:1', JSON.stringify(['karamaHarkonnenSpecial', 0, 'respondent']),
|
||||
JSON.stringify(['karamaHarkonnenSpecial', 1, '']), JSON.stringify(['karamaHarkonnenSpecial', 1, 'respondent', 'extra'])])
|
||||
assert.throws(() => parseTurnActionLeaf({ kind: 'windowCardPlay', effect: 'karamaHarkonnenSpecial', source: bad }), TurnActionError);
|
||||
for (const bad of [JSON.stringify(['karamaHarkonnenSpecial', 2, 'respondent']),
|
||||
JSON.stringify(['karamaHarkonnenSpecial', 1, 'other'])])
|
||||
assert.throws(() => turnActionValue(windowed(windowCard('karamaHarkonnenSpecial', bad))), TurnActionError);
|
||||
const old = windowed(windowCard('karamaAuction'));
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(old)), old);
|
||||
assert.doesNotThrow(() => validateTurnActionPromises([old], seats));
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseKaramaHarkonnenSpecialSource as parse, windowCardNativeEffect,
|
||||
windowCardOpportunity, type WindowCardGame } from '../game/window-card-play';
|
||||
|
||||
const source = (): WindowCardGame => ({ status: 'playing', advanced: true, phase: 3, turn: 2,
|
||||
shieldWallDestroyed: false, players: [{ id: 'h', faction: 'harkonnen', specialKaramaUsed: false },
|
||||
{ id: 'a', faction: 'atreides', specialKaramaUsed: false }] });
|
||||
const event = JSON.stringify(['karamaHarkonnenSpecial', 2, 'h']);
|
||||
|
||||
void test('Harkonnen special window is public, turn-bound and independent of private custody', () => {
|
||||
const game = source(), before = structuredClone(game);
|
||||
assert.deepEqual(windowCardOpportunity(game, 'karamaHarkonnenSpecial'),
|
||||
{ effect: 'karamaHarkonnenSpecial', source: event, turn: 2 });
|
||||
assert.deepEqual(parse(event), { turn: 2, owner: 'h' });
|
||||
assert.equal(windowCardNativeEffect('karamaHarkonnenSpecial'), 'karama');
|
||||
assert.equal(windowCardOpportunity({ ...game, response: { kind: 'voice' }, decision: { kind: 'other' } },
|
||||
'karamaHarkonnenSpecial')?.source, event);
|
||||
assert.equal(windowCardOpportunity(game, 'karamaHarkonnenSpecial', event)?.source, event);
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('special opportunity ends with use, turn or phase; malformed sources reject', () => {
|
||||
const game = source();
|
||||
for (const changes of [{ status: 'lobby' }, { advanced: false }, { phase: 4 }, { turn: 0 },
|
||||
{ players: [{ id: 'h', faction: 'harkonnen', specialKaramaUsed: true }] },
|
||||
{ players: [{ id: 'h', faction: 'atreides' }] },
|
||||
{ players: [{ id: 'h', faction: 'harkonnen' }, { id: 'h', faction: 'atreides' }] },
|
||||
{ players: [{ id: 'h', faction: 'harkonnen' }, { id: 'h2', faction: 'harkonnen' }] }])
|
||||
assert.equal(windowCardOpportunity({ ...game, ...changes } as WindowCardGame, 'karamaHarkonnenSpecial'), null);
|
||||
for (const value of ['', '[]', '["karamaHarkonnenSpecial",2," h"]',
|
||||
'["karamaHarkonnenSpecial", 2,"h"]', '["karamaHarkonnenSpecial",0,"h"]',
|
||||
'["karamaHarkonnenSpecial",2,"h","extra"]']) assert.equal(parse(value), null);
|
||||
assert.equal(windowCardOpportunity(game, 'karamaHarkonnenSpecial', JSON.stringify(['karamaHarkonnenSpecial', 2, 'a'])), null);
|
||||
assert.equal(windowCardOpportunity({ ...game, turn: 3 }, 'karamaHarkonnenSpecial', event), null);
|
||||
assert.ok(windowCardOpportunity({ ...game, players: [{ id: 'h', faction: 'harkonnen' }] }, 'karamaHarkonnenSpecial'));
|
||||
});
|
||||
@@ -23,8 +23,8 @@ function shipments(): WindowCardGame {
|
||||
}
|
||||
|
||||
void test('window roles expose a shared exhaustive tuple and exact native printed-card mapping', () => {
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'karama', 'karama', 'thumper', 'amal']);
|
||||
});
|
||||
|
||||
void test('Amal names each original phase opening with only its public turn and phase identity', () => {
|
||||
|
||||
Reference in new issue
Block a user