Support Truthtrance battle cancellation questions
This commit is contained in:
1 parent
813a2bacbe
commit
56b3aeab68
15 files changed
+675
-29
No files matched your search
+78
-8
@@ -10,7 +10,7 @@ import { PrintedCardPlayError, printedCardPlayCount, recordPrintedCardPlay,
|
||||
import { battlePowerOf, battlePowerMatches, nativeBattlePowerOpportunity,
|
||||
type NativeBattlePower, type BattlePowerDeclaration } from './battle-power-question';
|
||||
import { WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, karamaShipmentOpportunities,
|
||||
parseKaramaShipmentSource, type WindowCardEffect } from './window-card-play';
|
||||
parseKaramaShipmentSource, karamaBattleCancelOpportunity, type WindowCardEffect } from './window-card-play';
|
||||
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
|
||||
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, closeTurnActionPayments, finishTurnActionTurn,
|
||||
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival, recordTurnActionCardPlay,
|
||||
@@ -17166,7 +17166,9 @@ function karamaSpendingBlock(g: Game, p: Player, card: Card, purpose?: KullPurpo
|
||||
if (card.kind === 'special' && card.effect === 'karama' && policy?.player === p.id &&
|
||||
policy.forbidden.some(required =>
|
||||
(required.effect === 'karamaAuction' && purpose === 'auction-free-purchase' ||
|
||||
required.effect === 'karamaShipment' && purpose === 'shipment-price') &&
|
||||
required.effect === 'karamaShipment' && purpose === 'shipment-price' ||
|
||||
required.effect === 'karamaBattleCancel' && !!g.response &&
|
||||
purpose === karamaUsePurpose(g, { kind: 'cancel', response: g.response })) &&
|
||||
windowCardOpportunity(g, required.effect)?.source === required.source))
|
||||
return 'Retain this opportunity without a forbidden printed Karama cost.';
|
||||
const kullBlock = kullKaramaBlock(g, p, purpose);
|
||||
@@ -17225,7 +17227,17 @@ function responseCancelCards(
|
||||
// A physically spendable card whose Truthtrance feasibility is not proven
|
||||
// remains a possible choice; the authoritative action still checks promises.
|
||||
return p.hand
|
||||
.filter((card) => !karamaSpendingBlock(g, p, card, karamaUsePurpose(g, {kind:'cancel', response})))
|
||||
.filter((card) => {
|
||||
if (karamaSpendingBlock(g, p, card, karamaUsePurpose(g, {kind:'cancel', response}))) return false;
|
||||
const window = card.kind === 'special' && card.effect === 'karama' && g.response === response
|
||||
? karamaBattleCancelOpportunity(g, p.id) : null;
|
||||
if (!window || !g.turnActionPromises) return true;
|
||||
try {
|
||||
// Pure receipt quote: do not simulate the cancellation or its suffix.
|
||||
recordTurnActionWindowCardPlay(g.turnActionPromises, p.id, g.turn, 'karamaBattleCancel', window.source);
|
||||
return true;
|
||||
} catch (error) { if (error instanceof TurnActionError) return false; throw error; }
|
||||
})
|
||||
.map((card) => card.id);
|
||||
}
|
||||
/** The same conversion can be suspended by an independent private interaction. */
|
||||
@@ -18404,6 +18416,19 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
}
|
||||
if (shipmentWindow && card.kind === 'special' && card.effect === 'karama')
|
||||
recordNativeTurnActionWindowCard(g, p.id, 'karamaShipment', shipmentWindow.source);
|
||||
const battleCancel = use.kind === 'cancel' ? karamaBattleCancelOpportunity(g, p.id) : null;
|
||||
if (battleCancel && card.kind === 'special' && card.effect === 'karama')
|
||||
recordNativeTurnActionWindowCard(g, p.id, 'karamaBattleCancel', battleCancel.source);
|
||||
else if (battleCancel && liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).some(row =>
|
||||
row.windowCards?.karamaBattleCancel?.source === battleCancel.source && !row.windowCards.karamaBattleCancel.closed)) {
|
||||
// A different Karama role ends this exact response without paying the
|
||||
// promised printed cost. Check its closure before any discard reaction.
|
||||
const trial = structuredClone(g);
|
||||
trial.response = null;
|
||||
trial.turnActionPromises = ownTurnActionWindowClosure(trial, p.id, ['karamaBattleCancel']);
|
||||
requireRule(!!turnActionCompletion(trial, p.id),
|
||||
'Retain your promised printed Karama cancellation or another whole-answer completion.');
|
||||
}
|
||||
const used = discard(g, p, card.id);
|
||||
if (auctionWindow && auctionRows.some(row => row.windowCards?.karamaAuction?.source === auctionWindow))
|
||||
guardTurnActionAuctionClosure(g, p.id, auctionRows);
|
||||
@@ -20156,6 +20181,18 @@ function quotedShipmentKaramaCards(g: Game, player: Player): string[] {
|
||||
} catch (error) { if (error instanceof RuleError) return false; throw error; }
|
||||
}).map(card => card.id);
|
||||
}
|
||||
function quotedBattleCancelKaramaCards(g: Game, player: Player): string[] {
|
||||
const response = g.response;
|
||||
if (!response || !karamaBattleCancelOpportunity(g, player.id) || g.truthtrance || g.decision ||
|
||||
g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingKull) return [];
|
||||
const use = { kind: 'cancel' as const, response };
|
||||
return player.hand.filter(card => {
|
||||
if (card.kind !== 'special' || card.effect !== 'karama' ||
|
||||
karamaSpendingBlock(g, player, card, karamaUsePurpose(g, use))) return false;
|
||||
try { validateKaramaUse(g, player, use, card); return true; }
|
||||
catch (error) { if (error instanceof RuleError) return false; throw error; }
|
||||
}).map(card => card.id);
|
||||
}
|
||||
function thumperReadyStartsDraw(g: Game): boolean {
|
||||
return !!windowCardOpportunity(g, 'thumper') && !g.truthtrance && !g.phaseOpening && !g.response &&
|
||||
!g.decision && !g.pendingTreacheryDiscard && !g.spiceWindow && (!g.nexus || !!g.summonedBeforeBlow);
|
||||
@@ -20179,6 +20216,10 @@ 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 === 'karamaBattleCancel') {
|
||||
for (const card of quotedBattleCancelKaramaCards(g, player)) yield { type: 'card', card, mode: 'cancel' };
|
||||
return;
|
||||
}
|
||||
if (effect === 'amal') {
|
||||
for (const card of quotedAmalCards(g, player)) yield { type: 'card', card };
|
||||
return;
|
||||
@@ -20321,13 +20362,18 @@ function recordNativeTurnActionWindowCard(g: Game, actor: string, effect: Window
|
||||
}
|
||||
function reconcileTurnActionWindowCards(g: Game) {
|
||||
if (!g.turnActionPromises) return;
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
// A completed Truthtrance parks its original response until the physical
|
||||
// discard settles; it has not closed the native cancellation opportunity.
|
||||
const windowGame = continuation?.kind === 'truthtranceDiscard'
|
||||
? { ...g, response: continuation.resume.response } : g;
|
||||
for (const promise of g.turnActionPromises) {
|
||||
if (promise.fulfilled || promise.released) continue;
|
||||
for (const effect of WINDOW_CARD_EFFECTS) {
|
||||
const stage = promise.windowCards?.[effect];
|
||||
const foreignInvoice = effect === 'karamaAuction' && g.decision?.kind === 'auctionPayment' &&
|
||||
g.decision.player !== promise.player;
|
||||
if (!stage || stage.closed || !foreignInvoice && promise.turn === g.turn && windowCardOpportunity(g, effect, stage.source)?.source === stage.source) continue;
|
||||
if (!stage || stage.closed || !foreignInvoice && promise.turn === g.turn && windowCardOpportunity(windowGame, effect, stage.source)?.source === stage.source) continue;
|
||||
g.turnActionPromises = turnActionRule(() => closeTurnActionWindowCardPlay(g.turnActionPromises!,
|
||||
promise.player, promise.turn, effect, stage.source));
|
||||
}
|
||||
@@ -20483,10 +20529,12 @@ function guardTurnActionShipmentPriceDelivery(g: Game, shipment: PendingShipment
|
||||
trial.turnActionPromises = structuredClone(g.turnActionPromises);
|
||||
guardTurnActionShipmentPriceBoundary(trial, shipment.player, original, undefined, receipt);
|
||||
}
|
||||
function turnActionTargetAvailable(g: Game, _actor: string): boolean {
|
||||
function turnActionTargetAvailable(g: Game, actor: string): boolean {
|
||||
const context = g.phaseOpening ? { ...g, phaseOpening: null } : g;
|
||||
return turnActionPromiseTurn(g) && !bribeTimingBlock({ ...context, truthtrance: null,
|
||||
inflation: context.inflation?.side === 'double' ? null : context.inflation });
|
||||
const battleCancel = karamaBattleCancelOpportunity(context, actor);
|
||||
return turnActionPromiseTurn(g) && (!g.response || !!battleCancel) &&
|
||||
!bribeTimingBlock({ ...context, truthtrance: null, response: battleCancel ? null : context.response,
|
||||
inflation: context.inflation?.side === 'double' ? null : context.inflation });
|
||||
}
|
||||
/** Whole native commitments retain the existing Semuta discard continuation.
|
||||
* This does not expand the separate legacy bribe or Kull/module envelopes. */
|
||||
@@ -21264,10 +21312,20 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
|
||||
const ownedInvoice = g.decision?.kind === 'auctionPayment' && g.decision.player === player.id;
|
||||
const saphoExtortion = g.decision?.kind === 'moritaniExtortion' &&
|
||||
saphoOptions(g, player).some(option => option.scope === 'extortion');
|
||||
if (!promise || g.truthtrance || g.response || g.pendingTreacheryDiscard ||
|
||||
const battleCancel = karamaBattleCancelOpportunity(g, player.id);
|
||||
if (!promise || g.truthtrance || g.response && !battleCancel || g.pendingTreacheryDiscard ||
|
||||
g.decision && !ownedBox && !ownedInvoice && !saphoExtortion) return null;
|
||||
const completion = turnActionCompletion(g, player.id);
|
||||
if (!completion) return null;
|
||||
if (battleCancel) {
|
||||
const row = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(item =>
|
||||
item.windowCards?.karamaBattleCancel && !item.windowCards.karamaBattleCancel.closed &&
|
||||
item.windowCards.karamaBattleCancel.source === battleCancel.source);
|
||||
const action = completion.cardPreparations?.find(item => item.type === 'card' && item.mode === 'cancel' &&
|
||||
quotedBattleCancelKaramaCards(g, player).includes(item.card as string));
|
||||
return row && action ? { event: row.event, action: { ...action,
|
||||
turnActionPromiseEvent: row.event, turnActionPromisePhase: g.phase } } : null;
|
||||
}
|
||||
if (g.phaseOpening) {
|
||||
const opportunity = windowCardOpportunity(g, 'amal');
|
||||
const row = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(row =>
|
||||
@@ -40824,6 +40882,18 @@ function applyActionInner(
|
||||
!response.passed.includes(id),
|
||||
'You have already passed this response window.',
|
||||
);
|
||||
const battleCancel = karamaBattleCancelOpportunity(g, id);
|
||||
if (battleCancel && liveTurnActionPromises(g.turnActionPromises ?? [], id, g.turn).some(row =>
|
||||
row.windowCards?.karamaBattleCancel?.source === battleCancel.source && !row.windowCards.karamaBattleCancel.closed)) {
|
||||
const trial = structuredClone(g);
|
||||
trial.response!.passed.push(id);
|
||||
if (!trial.players.some(other => !trial.response!.passed.includes(other.id) &&
|
||||
responseCancelCards(trial, other, trial.response!).length > 0)) {
|
||||
trial.turnActionPromises = ownTurnActionWindowClosure(trial, id, ['karamaBattleCancel']);
|
||||
requireRule(!!turnActionCompletion(trial, id),
|
||||
'Play your promised printed Karama or retain another completion before closing this response.');
|
||||
}
|
||||
}
|
||||
response.passed.push(id);
|
||||
} else {
|
||||
requireRule(
|
||||
|
||||
@@ -3281,6 +3281,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A Traitor condition describes the original eligible voter’s Reveal or Decline declaration in this revealed battle, before any later Karama prevention. A prevented Reveal stays a declared Reveal. No missing battle or declaration is invented as a zero plan or Decline, and an absent matching Traitor never creates one.',
|
||||
'A native-power condition names your original Voice or Prescience opportunity in this battle. Ask about actual use or explicit Decline, an exact Voice category and require/forbid command, or the requested Prescience field. A requested field is not the inspected answer. The declaration counts before later Karama prevention.',
|
||||
'Native owners can provide their eligible ally’s power. Voice finishes before Prescience; a pending Prescience condition does not advance its turn. Original command and No-Field restrictions remain. All predicates concern the same one native choice; source expiry is absence and never becomes a synthetic Decline. Borrowed Nexus and special powers retain separate scope.',
|
||||
'In a supported Basic battle response, ask whether a named non-owner will spend a physical printed Karama to cancel the current Voice or Prescience. The question may combine this exact response with current facts; other future action families remain unavailable while the response is open. Yes requires that actual card use while possible; No prevents it. A different player may cancel first, releasing an impossible Yes. Passing does not itself surrender a still-open response, but the target cannot close their last opportunity while a required Yes remains.',
|
||||
'A printed-card condition requires an actual Hajr or Tleilaxu Ghola play in its printed role after this answer. Earlier plays, discards, sales, Karama roles and recovery alone do not count; a genuine later Box recovery followed by replay can count. No hidden card is needed merely to ask the public named condition.',
|
||||
'Hajr counts even if its extra move is unused, closes at your own Movement end and must earn its actual Advanced timing. Ghola remains available through this turn’s Mentat and uses real Tanks, eligible leader or Kwisatz and typed selection; it does not spend or satisfy ordinary Revival quotas. Ordinary returns and Ghola share the actual remaining Tanks and purse.',
|
||||
'A printed Nullentropy Box clause counts its accepted two-spice search activation after answering this turn, before private selection. It does not name the recovered card; earlier use or recovery alone does not count. No lasts through Mentat. The actual fee shares the purse with payments and physical actions; an unpaid unknown recovered card cannot supply another effect. Whole native Basic Richese answers retain their real Semuta discard continuation. Current guides use native availability and paid selection keeps its own panel.',
|
||||
|
||||
+9
-2
@@ -4,7 +4,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, windowCardOpportunity } from './window-card-play';
|
||||
import { parseKaramaShipmentSource, parseKaramaBattleCancelSource, karamaBattleCancelOpportunity, windowCardOpportunity } from './window-card-play';
|
||||
import { parseTraitorActionClaim, type MixedTraitorClaim } from './mixed-traitor-question';
|
||||
import { parseBattleActionClaim, MIXED_BATTLE_MAX_NODES, MIXED_BATTLE_MAX_TERMS, MIXED_BATTLE_MAX_DEPTH,
|
||||
MIXED_BATTLE_MAX_LEAVES, type MixedBattleClaim } from './mixed-battle-question';
|
||||
@@ -421,13 +421,16 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
return { kind: 'bid', target: v.target, ...claim };
|
||||
}
|
||||
if (v.kind === 'turnActions') {
|
||||
const battleCancel = g.response ? karamaBattleCancelOpportunity(g, v.target as string) : null;
|
||||
check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && v.turn === g.turn &&
|
||||
turnActionPromiseModeSupported(g) && g.status === 'playing' && Number.isInteger(g.phase) && g.phase >= 0 && g.phase <= 8 &&
|
||||
!g.response && !g.decision && (!g.phaseOpening || !!windowCardOpportunity(g, 'amal')) && !g.pendingTreacheryDiscard && !g.pendingKarama,
|
||||
(!g.response || !!battleCancel) && !g.decision && (!g.phaseOpening || !!windowCardOpportunity(g, 'amal')) && !g.pendingTreacheryDiscard && !g.pendingKarama,
|
||||
'Supply the current turn, complete turn-action question and a supported quiet native window.');
|
||||
const respondent = g.players.find(player => player.id === v.target)!;
|
||||
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), input => {
|
||||
const action = parseTurnActionLeaf(input);
|
||||
if (battleCancel) check(action.kind === 'windowCardPlay' && action.effect === 'karamaBattleCancel' &&
|
||||
action.source === battleCancel.source, 'Ask only about the current printed Karama cancellation in this response.');
|
||||
if (action.kind === 'windowCardPlay') {
|
||||
const opportunity = windowCardOpportunity(g, action.effect);
|
||||
check(opportunity && opportunity.turn === g.turn && opportunity.source === action.source,
|
||||
@@ -786,6 +789,10 @@ export function truthQuestionText(
|
||||
if (q.kind === 'turnActions') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
|
||||
action => {
|
||||
if (action.kind === 'windowCardPlay') {
|
||||
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`;
|
||||
}
|
||||
if (action.effect === 'amal') return 'you play Amal after this answer during this original phase opening, halving available spice before ordinary payments';
|
||||
if (action.effect === 'karamaAuction') return 'you spend a printed Karama after this answer on this original ordinary auction lot, by immediate purchase or its winning invoice';
|
||||
if (action.effect === 'karamaShipment') {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { harvesterAvailable } from './cards';
|
||||
|
||||
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'thumper', 'amal'] as const;
|
||||
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', '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' ? 'karama' : effect;
|
||||
return effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'karamaBattleCancel' ? 'karama' : effect;
|
||||
}
|
||||
export type KaramaBattleCancelSource = {
|
||||
turn: number;
|
||||
battle: string;
|
||||
territory: string;
|
||||
kind: 'voice' | 'prescience';
|
||||
owner: string;
|
||||
};
|
||||
export type KaramaShipmentOpportunity = { effect: 'karamaShipment'; source: string; turn: number; recipient: string };
|
||||
export type KaramaShipmentSource = { turn: number; recipient: string; kind: 'original' } |
|
||||
{ turn: number; recipient: string; kind: 'cunning'; event: string };
|
||||
@@ -12,6 +19,7 @@ export type WindowCardOpportunity = { effect: Exclude<WindowCardEffect, 'karamaS
|
||||
KaramaShipmentOpportunity;
|
||||
export type WindowCardGame = {
|
||||
status: string;
|
||||
advanced?: boolean;
|
||||
phase: number;
|
||||
turn: number;
|
||||
shieldWallDestroyed: boolean;
|
||||
@@ -31,6 +39,8 @@ export type WindowCardGame = {
|
||||
harvesterClosed?: boolean;
|
||||
} | null;
|
||||
response?: unknown;
|
||||
/** Public battle identity; plans and card custody are deliberately omitted. */
|
||||
battle?: { event?: string | null; territory: string; revealed: boolean } | null;
|
||||
decision?: unknown;
|
||||
phaseOpening?: unknown;
|
||||
truthtrance?: unknown;
|
||||
@@ -68,6 +78,30 @@ export function parseKaramaShipmentSource(source: unknown): KaramaShipmentSource
|
||||
? { turn: tuple[1], recipient: tuple[2], kind: 'cunning', event: tuple[4] } : null;
|
||||
}
|
||||
|
||||
/** Decode only the canonical event-bound public response identity. This stays
|
||||
* available after the original response closes for accepted promise receipts. */
|
||||
export function parseKaramaBattleCancelSource(source: unknown): KaramaBattleCancelSource | null {
|
||||
if (!identifier(source)) return null;
|
||||
let tuple: unknown;
|
||||
try { tuple = JSON.parse(source); } catch { return null; }
|
||||
if (!Array.isArray(tuple) || tuple.length !== 6 || tuple[0] !== 'karamaBattleCancel' ||
|
||||
!whole(tuple[1]) || tuple[1] < 1 || !identifier(tuple[2]) || !identifier(tuple[3]) ||
|
||||
(tuple[4] !== 'voice' && tuple[4] !== 'prescience') || !identifier(tuple[5]) ||
|
||||
JSON.stringify(tuple) !== source) return null;
|
||||
return { turn: tuple[1], battle: tuple[2], territory: tuple[3], kind: tuple[4], owner: tuple[5] };
|
||||
}
|
||||
|
||||
/** A battle response is public. The target check deliberately reads no hand,
|
||||
* card reservation, response pass or other private cancellation eligibility. */
|
||||
export function karamaBattleCancelOpportunity(game: WindowCardGame, target: string,
|
||||
source?: string): WindowCardOpportunity | null {
|
||||
const window = windowCardOpportunity(game, 'karamaBattleCancel', source);
|
||||
const parsed = window && parseKaramaBattleCancelSource(window.source);
|
||||
if (!parsed || !identifier(target) || !game.players?.some(player => player.id === target) ||
|
||||
target === parsed.owner) return null;
|
||||
return window;
|
||||
}
|
||||
|
||||
/** Every still-unused original entitlement remains bound through other actors'
|
||||
* turns. A declared Cunning continuation permanently retires its original one. */
|
||||
export function karamaShipmentOpportunities(game: WindowCardGame): KaramaShipmentOpportunity[] {
|
||||
@@ -124,6 +158,19 @@ 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 === 'karamaBattleCancel') {
|
||||
const battle = game.battle, response = game.response, players = game.players;
|
||||
if (game.advanced !== false || game.phase !== 6 || !battle || !identifier(battle.event) ||
|
||||
!identifier(battle.territory) || battle.revealed !== false || !object(response) ||
|
||||
(response.kind !== 'voice' && response.kind !== 'prescience') || !identifier(response.owner) ||
|
||||
!Array.isArray(players) || !players.length ||
|
||||
!players.every(player => object(player) && identifier(player.id)) ||
|
||||
new Set(players.map(player => player.id)).size !== players.length ||
|
||||
!players.some(player => player.id === response.owner)) return null;
|
||||
return { effect, turn: game.turn, source: JSON.stringify([
|
||||
effect, game.turn, battle.event, battle.territory, response.kind, response.owner,
|
||||
]) };
|
||||
}
|
||||
if (effect === 'amal') {
|
||||
if (!whole(game.phase) || game.phase > 8 || game.amalWindowOpen !== undefined && typeof game.amalWindowOpen !== 'boolean' ||
|
||||
game.amalWindowOpen === false) return null;
|
||||
|
||||
Reference in new issue
Block a user