2126 lines
170 KiB
TypeScript
2126 lines
170 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||
import { renderToStaticMarkup } from 'react-dom/server';
|
||
import { TurnActionFields, turnActionInputError } from '../components/turn-action-fields';
|
||
import { TurnActionAnswers, TurnActionGuideButton, TurnActionPromises } from '../components/turn-action-promises';
|
||
import { TruthFactFields } from '../components/mixed-shipment-fields';
|
||
import { BattleClaimFields } from '../components/battle-promises';
|
||
import { baseDeck, ixSpecialCards } from '../game/cards';
|
||
import { richeseCards } from '../game/richese-cards';
|
||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView, type Plan } from '../game/engine';
|
||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||
import type { MixedBattleActionClaim } from '../game/mixed-battle-question';
|
||
import type { BattlePowerClaim } from '../game/battle-power-question';
|
||
import type { WindowCardEffect } from '../game/window-card-play';
|
||
import { createRevivalOrder } from '../game/revival-order';
|
||
import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction';
|
||
import type { TurnActionLeaf } from '../game/turn-action-promises';
|
||
import type { TruthFact } from '../game/truthtrance';
|
||
|
||
type Expression = MixedActionExpression<TurnActionLeaf>;
|
||
type ElementProps = { children?: ReactNode; [key: string]: unknown };
|
||
type Element = ReactElement<ElementProps>;
|
||
type Props = Parameters<typeof TurnActionGuideButton>[0];
|
||
const event = 'controlled-turn-action';
|
||
const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression => ({ kind: 'fact', fact: value });
|
||
const physical = (kind: 'ship' | 'move' = 'ship', minimum = 1, territory = 'carthag'): Expression =>
|
||
({ kind: 'action', action: { kind, territory, minimum } });
|
||
const payment = (minimum = 3, recipient = 'r'): Expression => ({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
|
||
const bid = (minimum = 3, lot = 2): Expression => ({ kind: 'action', action: { kind: 'bid', lot, minimum } });
|
||
const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', minimum = 1): Expression =>
|
||
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
|
||
const leaderRevival = (leader: string): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
|
||
const finalAlliance = (partner = 'r'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
|
||
const printedCard = (effect: 'hajr' | 'ghola' | 'nullentropyBox' | 'distrans' | 'juiceOfSapho' | 'semutaDrug' | 'ornithopter' | 'residualPoison' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
|
||
const shipmentPriceSource = (recipient = 'p', cunning?: string) => JSON.stringify(['karamaShipment', 2, recipient,
|
||
...(cunning ? ['cunning', cunning] : ['original'])]);
|
||
const windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow'
|
||
: effect === 'karamaAuction' ? 'ordinary-auction:2:2' : effect === 'karamaShipment' ? shipmentPriceSource()
|
||
: effect === 'thumper' ? 'first-spice-reveal:2' : effect === 'amal' ? 'phase-opening:2:4' : 'storm:2'): Expression =>
|
||
({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } });
|
||
const planClaim = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 3 }): Expression =>
|
||
({ kind: 'action', action: { kind: 'battlePlan', battle: 'controlled-native-battle', territory: 'arrakeen', claim } });
|
||
const traitorClaim = (call = true): Expression =>
|
||
({ kind: 'action', action: { kind: 'traitorCall', battle: 'controlled-native-battle', territory: 'arrakeen', call } });
|
||
const powerClaim = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expression =>
|
||
({ kind: 'action', action: { kind: 'battlePower', battle: 'controlled-native-battle', territory: 'arrakeen', claim } });
|
||
const claim = (): Expression => ({ kind: 'or', terms: [payment(), { kind: 'and', terms: [fact(), physical('ship', 3), physical('move', 4, 'arrakeen')] }] });
|
||
|
||
function elements(node: ReactNode): Element[] {
|
||
if (Array.isArray(node)) return node.flatMap(elements);
|
||
if (!isValidElement<ElementProps>(node)) return [];
|
||
return [node, ...elements(node.props.children)];
|
||
}
|
||
function find(tree: ReactNode, key: string, value: string): Element {
|
||
const found = elements(tree).find(element => element.props[key] === value);
|
||
assert.ok(found, `${key}=${value}`); return found;
|
||
}
|
||
const control = (tree: ReactNode, key: string) => find(tree, 'data-turn-action-control', key);
|
||
const click = (button: Element) => (button.props.onClick as () => void)();
|
||
function select(tree: ReactNode, path: string, field: string, value: string) {
|
||
(find(tree, 'id', `truth-turn-action-${path}-${field}`).props.onChange as
|
||
(event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
|
||
}
|
||
function minimum(tree: ReactNode, path: string, valueAsNumber: number) {
|
||
(find(tree, 'id', `truth-turn-action-${path}-minimum`).props.onChange as
|
||
(event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } });
|
||
}
|
||
function editFact(tree: ReactNode, path: string, value: TruthFact) {
|
||
const scope = find(tree, 'data-turn-action-fact-path', path);
|
||
const fields = elements(scope).find(element => element.type === TruthFactFields); assert.ok(fields);
|
||
(fields.props.onChange as (next: TruthFact) => void)(value);
|
||
}
|
||
|
||
/** Controlled projections verify current UI ownership and exact dispatch only;
|
||
* they do not establish native answer feasibility, payment or force receipts. */
|
||
function projected(phase = 5, next: Action = { type: 'move', forces: { 'arrakeen:10': 3, 'arrakeen:11': 1 },
|
||
eliteForces: { 'arrakeen:10': 1 }, territory: 'carthag', sector: 11 }): GameView {
|
||
const source = createGame('TURNACTIONCONTROLS', newPlayer('p', 'Respondent', 'emperor'));
|
||
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
|
||
joinGame(source, newPlayer('r', 'Recipient', 'harkonnen'));
|
||
const game = viewGame(source, 'p');
|
||
game.status = 'playing'; game.turn = 2; game.phase = phase; game.active = 'p'; game.ready = [];
|
||
game.bribeOptions = { available: 8, incoming: 0, blocked: null,
|
||
targets: [{ id: 'r', maximum: 6, limitation: null }, { id: 'a', maximum: 8, limitation: null }] };
|
||
game.turnActionPromises = [{ event, player: 'p', asker: 'a', turn: 2, mixed: claim(), answer: true, fulfilled: false, released: false }];
|
||
game.turnActionGuide = { event, action: { ...next, turnActionPromiseEvent: event, turnActionPromisePhase: phase } };
|
||
game.truthTurnActionAnswers = null;
|
||
if (next.type === 'charity') {
|
||
const me = game.players.find(player => player.id === 'p')!; me.spice = 0; me.charityClaimed = false;
|
||
game.charity = { ...game.charity, ordinary: 2, homeworld: 0, total: 2, amount: 2, incomePending: false };
|
||
}
|
||
if (next.type === 'card' && typeof next.card === 'string') {
|
||
const cardId = next.card, effect = (['hajr', 'ghola', 'weather', 'atomics', 'harvester', 'thumper', 'amal'] as const)
|
||
.find(effect => cardId.includes(effect)) ?? 'karama';
|
||
const face = [...baseDeck(), ...ixSpecialCards()].find(card => card.effect === effect); assert.ok(face);
|
||
const me = game.players.find(player => player.id === 'p')!;
|
||
me.hand = [{ ...face, id: next.card }]; me.hajrAvailable = effect === 'hajr';
|
||
if (effect === 'ghola') {
|
||
me.tanks = 5; me.elites = { reserves: 4, tanks: 1, forces: {}, revived: 0 };
|
||
game.ghola = { cards: [next.card], leaders: [{ id: 'controlled-dead-leader', name: 'Returned Commander', strength: 3 }],
|
||
kwisatz: false, maxForces: 5, eliteRemaining: 1, eliteBlock: null, available: true, reason: null };
|
||
}
|
||
}
|
||
return game;
|
||
}
|
||
function auction(next: Action = { type: 'passBid' }): GameView {
|
||
const game = projected(3, next);
|
||
game.bidPromiseLot = { turn: 2, lot: 2 };
|
||
game.auction = { bid: 7, bidder: next.type === 'decision' ? 'p' : 'a', active: 'p', remaining: 2, inspectionBlocked: null, card: null };
|
||
if (next.type === 'decision') { game.auction.active = 'a'; game.decision = { kind: 'auctionPayment', player: 'p' }; }
|
||
return game;
|
||
}
|
||
function auctionKarama(invoice = false): GameView {
|
||
const card = baseDeck().find(card => card.kind === 'special' && card.effect === 'karama'); assert.ok(card);
|
||
const id = 'controlled-printed-karama';
|
||
const game = auction(invoice ? { type: 'decision', karama: true, card: id } : { type: 'card', card: id, mode: 'purchase' });
|
||
const me = game.players.find(player => player.id === 'p')!; me.hand = [{ ...card, id }]; me.handLimit = 4;
|
||
game.karamaAuctionCards = [id];
|
||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('karamaAuction'), payment()] };
|
||
if (!invoice) { game.auction!.active = 'a'; game.active = 'a'; }
|
||
return game;
|
||
}
|
||
function shipmentKarama(recipient = 'p', cunning?: string): GameView {
|
||
const game = projected(5, { type: 'card', card: 'controlled-printed-karama', mode: 'shipment', target: recipient });
|
||
game.active = recipient; game.movementRemaining = ['p', 'a', 'r']; game.karamaShipping = null;
|
||
game.karamaShipmentWindows = game.movementRemaining.map(player => ({ effect: 'karamaShipment' as const,
|
||
turn: 2, recipient: player, source: shipmentPriceSource(player, player === recipient ? cunning : undefined) }));
|
||
game.karamaShipmentCards = ['controlled-printed-karama'];
|
||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('karamaShipment', shipmentPriceSource(recipient, cunning)), payment()] };
|
||
return game;
|
||
}
|
||
function firstReveal(next: Action = { type: 'card', card: 'controlled-thumper' }): GameView {
|
||
const game = projected(1, next); game.thumperWindowOpen = true; game.thumperReady = true; game.spiceWindow = null; game.nexus = false;
|
||
game.thumperCards = ['controlled-thumper']; game.active = 'a';
|
||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('thumper'), payment()] };
|
||
return game;
|
||
}
|
||
function openingAmal(phase = 4, next: Action = { type: 'card', card: 'controlled-amal' }): GameView {
|
||
const game = projected(phase, next); game.amalWindowOpen = true; game.phaseOpening = { passed: [] };
|
||
game.amalCards = ['controlled-amal']; game.amalReady = true; game.active = 'a'; game.ready = ['p'];
|
||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('amal', `phase-opening:2:${phase}`), payment()] };
|
||
return game;
|
||
}
|
||
function boxPlay(phase = 7): GameView {
|
||
const game = projected(phase, { type: 'card', card: 'controlled-box' });
|
||
const card = { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special' as const, effect: 'nullentropyBox' };
|
||
const me = game.players.find(player => player.id === 'p')!; me.hand = [card]; me.handLimit = 4; me.spice = 3;
|
||
game.active = 'a'; game.nullentropy = { card, blocked: null, search: null };
|
||
game.turnActionPromises[0].mixed = { kind: 'and', terms: [printedCard('nullentropyBox'), payment()] };
|
||
return game;
|
||
}
|
||
function distransPlay(): GameView {
|
||
const card = richeseCards().find(card => card.effect === 'distrans'), given = baseDeck().find(card => card.name === 'Shield');
|
||
assert.ok(card); assert.ok(given);
|
||
const game = projected(2, { type: 'card', card: card.id, target: 'r', give: given.id });
|
||
const me = game.players.find(player => player.id === 'p')!; me.hand = [card, given]; me.handLimit = 4;
|
||
const recipient = game.players.find(player => player.id === 'r')!; recipient.handCount = 1; recipient.handLimit = 4;
|
||
game.active = 'a'; game.distrans = { card, blocked: null, choices: [{ recipient: 'r', blocked: null, cards: [given], unavailable: [] }] };
|
||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard('distrans'), payment()] };
|
||
return game;
|
||
}
|
||
function saphoPlay(phase = 5, option: NonNullable<GameView['saphoOptions']>[number] =
|
||
{ scope: 'movement', event: 'controlled-movement-order', mode: 'last', afterCurrent: 'a' }): GameView {
|
||
const card = richeseCards().find(card => card.effect === 'juiceOfSapho'); assert.ok(card);
|
||
const game = projected(phase, { type: 'card', card: card.id, scope: option.scope, event: option.event, mode: option.mode });
|
||
const me = game.players.find(player => player.id === 'p')!; me.hand = [card]; me.handLimit = 4;
|
||
game.active = 'a'; game.saphoOptions = [option];
|
||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard('juiceOfSapho'), payment()] };
|
||
return game;
|
||
}
|
||
function semutaPlay(stage: 'offer' | 'select' = 'offer', next?: Action): GameView {
|
||
const card = richeseCards().find(card => card.effect === 'semutaDrug'), candidate = baseDeck().find(card => card.name === 'Shield');
|
||
assert.ok(card); assert.ok(candidate);
|
||
const reactionEvent = 'controlled-fresh-discard';
|
||
const game = projected(3, next ?? (stage === 'offer' ? { type: 'semutaCommit', event: reactionEvent }
|
||
: { type: 'semutaSelect', event: reactionEvent, card: candidate.id }));
|
||
game.players.find(player => player.id === 'p')!.hand = [card]; game.active = 'a';
|
||
game.semutaReaction = { event: reactionEvent, stage, passed: stage === 'select', canCommit: stage === 'offer', blocked: null,
|
||
candidates: stage === 'select' ? [candidate] : [] };
|
||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard('semutaDrug'), payment()] };
|
||
return game;
|
||
}
|
||
function flightPlay(mode: 'range3' | 'twoGroups' = 'range3', continuation = false): GameView {
|
||
const card = richeseCards().find(card => card.effect === 'ornithopter'); assert.ok(card);
|
||
const game = projected(5, { type: 'move', forces: { 'arrakeen:10': 3, 'arrakeen:11': 1 }, eliteForces: { 'arrakeen:10': 1 },
|
||
territory: 'carthag', sector: 11, fighters: false,
|
||
...(continuation ? { ornithopterEvent: 'controlled-flight' } : { movementCard: card.id, ornithopter: mode }) });
|
||
const me = game.players.find(player => player.id === 'p')!; me.hand = continuation ? [] : [card];
|
||
me.forces = { 'arrakeen:10': 4, 'arrakeen:11': 1 }; me.moved = continuation ? 1 : 0;
|
||
game.ornithopter = { card, blocked: null, modes: [{ mode: 'range3', blocked: null }, { mode: 'twoGroups', blocked: null }],
|
||
active: continuation ? { event: 'controlled-flight', mode, completed: 1, remaining: 1,
|
||
cohort: { forces: { 'arrakeen:10': 4, 'arrakeen:11': 1 }, elites: { 'arrakeen:10': 1 } } } : null };
|
||
game.turnActionPromises[0].mixed = { kind: 'and', terms: [printedCard('ornithopter'), physical('move', 4, 'carthag'), payment()] };
|
||
return game;
|
||
}
|
||
function residualPlay(): GameView {
|
||
const card = richeseCards().find(card => card.effect === 'residualPoison'); assert.ok(card);
|
||
const game = combat(false, { type: 'card', card: card.id, event: 'controlled-native-battle', target: 'a' });
|
||
game.players.find(player => player.id === 'p')!.hand = [card];
|
||
game.residualPoison = { card, event: 'controlled-native-battle', target: 'a', blocked: null };
|
||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard('residualPoison'), payment()] };
|
||
return game;
|
||
}
|
||
function nexus(next: Action = { type: 'alliance', target: 'r' }): GameView {
|
||
const game = projected(1, next);
|
||
game.nexus = true; game.spiceWindow = null; game.active = 'a';
|
||
game.alliancePromiseNexus = { turn: 2 }; game.truthMixedAllianceTargets = ['p', 'a', 'r'];
|
||
game.allianceOffers = { r: 'p' };
|
||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [finalAlliance(), payment()] };
|
||
return game;
|
||
}
|
||
function combat(revealed = false, next: Action = { type: 'battlePlan', dial: 3, support: 2, bankerSpice: 1, allyPayment: 1,
|
||
leader: 'controlled-leader', weapon: 'controlled-weapon', defense: 'controlled-defense', kwisatz: true,
|
||
planetologistSpecial: true, reinforcementsSources: { arrakeen: { normal: 1, elite: 0 } } }): GameView {
|
||
const game = projected(6, next);
|
||
game.battle = { event: 'controlled-native-battle', territory: 'arrakeen', attacker: 'p', defender: 'a', revealed,
|
||
submitted: [], traitorVoters: ['p', 'a'], traitorSubmitted: [], plans: {}, truthPromises: [],
|
||
} as unknown as NonNullable<GameView['battle']>;
|
||
game.truthMixedBattleTargets = ['p', 'a']; game.truthMixedTraitorTargets = revealed ? ['p', 'a'] : [];
|
||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [revealed ? traitorClaim() : planClaim(), payment()] };
|
||
return game;
|
||
}
|
||
function battlePower(power: 'voice' | 'prescience' = 'voice', next?: Action, allied = false): GameView {
|
||
const game = combat(false, next ?? (power === 'voice' ? { type: 'voice', kind: 'poison', must: false } : { type: 'prescience', field: 'weapon' }));
|
||
const me = game.players.find(player => player.id === 'p')!, other = game.players.find(player => player.id === 'a')!;
|
||
me.faction = power === 'voice' ? 'beneGesserit' : 'atreides'; other.faction = 'emperor';
|
||
game.battle!.attacker = allied ? 'a' : 'p'; game.battle!.defender = 'r';
|
||
if (allied) { me.ally = 'a'; other.ally = 'p'; }
|
||
game.battle!.preparation = { kind: power, owner: 'p', beneficiary: allied ? 'a' : 'p' };
|
||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [powerClaim({ kind: power, use: true }), payment()] };
|
||
return game;
|
||
}
|
||
function windowCard(effect: WindowCardEffect = 'weather', next?: Action): GameView {
|
||
const game = projected(effect === 'harvester' ? 1 : 0, next ?? { type: 'card', card: `controlled-${effect}`,
|
||
...(effect === 'weather' ? { amount: 4 } : {}) });
|
||
game.stormCardWindowOpen = true; game.shieldWallDestroyed = false; game.stormPending = null;
|
||
game.spiceWindow = effect === 'harvester' ? { event: 'controlled-native-blow', territory: 'carthag', sector: 11,
|
||
amount: 6, harvested: false } : null;
|
||
game.players.find(player => player.id === 'p')!.forces = { 'shield_wall:8': 1 };
|
||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim(effect), payment()] };
|
||
return game;
|
||
}
|
||
function revival(next: Action = { type: 'revive', amount: 3, elite: 1 }): GameView {
|
||
const game = projected(4, next);
|
||
game.revivalOrder = createRevivalOrder({ turn: 2, order: ['p', 'a', 'r'], ready: [] });
|
||
return game;
|
||
}
|
||
function richese(method: 'silent' | 'onceAround'): GameView {
|
||
const native = createRicheseAuction({ event: 'controlled-native-lot', cardId: 'controlled-native-card', owner: 'a', source: 'cache',
|
||
method, eligible: ['p', 'a', 'r'], order: ['p', 'a', 'r'], tieOrder: ['p', 'a', 'r'] });
|
||
const game = projected(3, { type: 'richeseBid', event: native.event, amount: method === 'silent' ? 0 : null, allyPayment: 0 });
|
||
game.richeseAuction = { ...projectRicheseAuction(native, 'p'), card: null, claim: null,
|
||
ownAllyPayment: 0, ownAvailable: 4, allyAvailable: 0, peekKnown: false, inspectionBlocked: null };
|
||
return game;
|
||
}
|
||
function editor(initial: Expression, disabled = false, game = projected()) {
|
||
let value = initial, changes = 0;
|
||
return {
|
||
get value() { return value; }, get changes() { return changes; },
|
||
tree: () => TurnActionFields({ value, game, target: 'p', disabled, onChange(next) { value = next; changes++; } }),
|
||
html: () => renderToStaticMarkup(createElement(TurnActionFields, { value, game, target: 'p', disabled, onChange() {} })),
|
||
error: () => turnActionInputError(value, game, 'p'),
|
||
};
|
||
}
|
||
function guide(props: Props) { const button = TurnActionGuideButton(props); assert.ok(button); return button; }
|
||
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
|
||
const markup = (game: GameView) => renderToStaticMarkup(createElement(TurnActionPromises, { game, busy: false, act() {} }));
|
||
function answering(phase = 5): GameView {
|
||
const game = projected(phase); game.turnActionGuide = null;
|
||
game.truthtrance = { stage: 'answer', queue: [{ player: 'a', card: 'controlled-truth-card' }], passed: [],
|
||
question: { kind: 'turnActions', target: 'p', turn: 2, mixed: claim() } };
|
||
game.truthTurnActionAnswers = ['yes', 'no', 'unknown'];
|
||
return game;
|
||
}
|
||
function editPlan(tree: ReactNode, path: string, claim: MixedBattleActionClaim) {
|
||
const fields = elements(find(tree, 'data-turn-action-plan-fields', `truth-turn-action-${path}`))
|
||
.find(element => element.type === BattleClaimFields); assert.ok(fields);
|
||
(fields.props.onChange as (claim: MixedBattleActionClaim) => void)(claim);
|
||
}
|
||
|
||
void test('turn-action editor keeps all action families and optional facts in the original whole expression', () => {
|
||
const e = editor(physical('ship', 3)); assert.equal(e.error(), null);
|
||
select(e.tree(), '1', 'action', 'move'); select(e.tree(), '1', 'territory', 'arrakeen'); minimum(e.tree(), '1', 5);
|
||
assert.deepEqual(e.value, physical('move', 5, 'arrakeen'));
|
||
select(e.tree(), '1', 'action', 'payment'); assert.ok(e.error()); select(e.tree(), '1', 'recipient', 'r');
|
||
assert.deepEqual(e.value, payment(5));
|
||
click(control(e.tree(), 'or-1')); select(e.tree(), '1.2', 'timing', 'action');
|
||
select(e.tree(), '1.2', 'action', 'move'); minimum(e.tree(), '1.2', 4);
|
||
assert.deepEqual(e.value, { kind: 'or', terms: [payment(5), physical('move', 4)] }); assert.equal(e.error(), null);
|
||
const html = e.html();
|
||
for (const text of [/Make an ordinary payment/, /Ship from reserves/, /Make ground movement/, /sum future real bribes/,
|
||
/single future original reserve shipment/, /smaller moves never add together/, /No makes the whole expression false/,
|
||
/payment conditions last until the actual turn ends/]) assert.match(html, text);
|
||
click(control(e.tree(), 'remove-1.1')); assert.deepEqual(e.value, physical('move', 4));
|
||
select(e.tree(), '1', 'timing', 'fact'); assert.ok(e.error(), 'The expression still requires a future action.');
|
||
});
|
||
|
||
void test('turn-action editor preserves sixteen clauses and four shared grouping levels without truncation', () => {
|
||
const game = auction(), e = editor({ kind: 'or', terms: [payment(), physical('move', 4)] }, false, game);
|
||
for (let count = 3; count <= 16; count++) {
|
||
click(control(e.tree(), 'add-action-1')); minimum(e.tree(), `1.${count}`, count);
|
||
if (count % 4 === 0) select(e.tree(), `1.${count}`, 'action', 'forces');
|
||
if (count % 4 === 1) select(e.tree(), `1.${count}`, 'action', 'leader');
|
||
if (count % 4 === 2) select(e.tree(), `1.${count}`, 'action', 'bid');
|
||
}
|
||
assert.equal(e.error(), null); assert.match(e.html(), /16 \/ 16 conditions/);
|
||
const full = structuredClone(e.value), changes = e.changes;
|
||
for (const key of ['add-action-1', 'add-fact-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'or-1']) {
|
||
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
|
||
}
|
||
assert.deepEqual(e.value, full); assert.equal(e.changes, changes);
|
||
const deep = editor({ kind: 'and', terms: [fact(), forceRevival()] });
|
||
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
|
||
assert.match(deep.html(), /4 \/ 4 levels of grouping/);
|
||
assert.equal(control(deep.tree(), 'and-1.2.1.1.1').props.disabled, true);
|
||
editFact(deep.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 3 }); assert.equal(deep.error(), null);
|
||
click(control(deep.tree(), 'remove-1.2.1.1.2')); assert.match(deep.html(), /3 \/ 4 levels of grouping/);
|
||
});
|
||
|
||
void test('turn-action inputs keep invalid drafts repairable and reject disabled callbacks', () => {
|
||
for (const expression of [physical(), payment(), forceRevival()]) {
|
||
for (const invalid of [NaN, 0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||
const e = editor(expression); minimum(e.tree(), '1', invalid); assert.ok(e.error());
|
||
if (!Number.isFinite(invalid)) assert.match(e.html(), /value=""/);
|
||
minimum(e.tree(), '1', 20); assert.equal(e.error(), null);
|
||
}
|
||
}
|
||
const e = editor(physical()); minimum(e.tree(), '1', 21); assert.ok(e.error());
|
||
select(e.tree(), '1', 'action', 'payment'); select(e.tree(), '1', 'recipient', 'r'); assert.equal(e.error(), null);
|
||
minimum(e.tree(), '1', Number.MAX_SAFE_INTEGER); assert.equal(e.error(), null);
|
||
select(e.tree(), '1', 'action', 'ship'); assert.ok(e.error()); minimum(e.tree(), '1', 1);
|
||
select(e.tree(), '1', 'territory', 'invented-place'); assert.ok(e.error()); assert.match(e.html(), /aria-invalid="true"/);
|
||
const frozen = editor(claim(), true), original = structuredClone(frozen.value);
|
||
select(frozen.tree(), '1', 'meaning', 'and'); minimum(frozen.tree(), '1.1', 7);
|
||
select(frozen.tree(), '1.1', 'action', 'move'); select(frozen.tree(), '1.1', 'recipient', 'a');
|
||
editFact(frozen.tree(), '1.2.1', { kind: 'spice', compare: 'gte', value: 9 });
|
||
for (const key of ['and-1', 'add-action-1', 'remove-1.1', 'nested-or-1']) click(control(frozen.tree(), key));
|
||
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, original);
|
||
});
|
||
|
||
void test('turn-action fields use public recipients and printed destinations without private eligibility reads', () => {
|
||
const game = projected(); game.players.find(player => player.id === 'p')!.ally = 'a';
|
||
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves'])
|
||
Object.defineProperty(player, key, { get() { throw Error(`No ${key} inspection`); }, configurable: true });
|
||
for (const expression of [payment(), physical('ship', 20, 'arrakeen'), { kind: 'and', terms: [fact({ kind: 'stormForecast', compare: 'gte', value: 3 }), physical('move')] } as Expression]) {
|
||
const e = editor(expression, false, game); assert.equal(e.error(), null);
|
||
assert.match(e.html(), /Only the aggregate answer is shared/);
|
||
}
|
||
const e = editor(payment(), false, game); const choices = find(e.tree(), 'id', 'truth-turn-action-1-recipient');
|
||
assert.deepEqual(elements(choices).filter(element => element.type === 'option').map(element => element.props.value), ['', 'r']);
|
||
select(e.tree(), '1', 'recipient', 'a'); assert.deepEqual(e.value, payment());
|
||
for (const recipient of ['p', 'a', 'missing']) assert.ok(turnActionInputError(payment(3, recipient), game, 'p'));
|
||
assert.match(editor(physical(), false, game).html(), /Carthag/);
|
||
});
|
||
|
||
void test('turn-action bid leaves use only the current public ordinary lot and retain repairable drafts', () => {
|
||
const game = auction(), e = editor(payment(), false, game);
|
||
Object.defineProperty(game.auction!, 'card', { get() { throw Error('No hidden lot face inspection'); }, configurable: true });
|
||
select(e.tree(), '1', 'action', 'bid'); assert.deepEqual(e.value, bid()); assert.equal(e.error(), null);
|
||
assert.match(e.html(), /Turn 2 · Ordinary lot 2/);
|
||
for (const invalid of [NaN, 0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||
minimum(e.tree(), '1', invalid); assert.ok(e.error()); minimum(e.tree(), '1', 3); assert.equal(e.error(), null);
|
||
}
|
||
minimum(e.tree(), '1', Number.MAX_SAFE_INTEGER); assert.equal(e.error(), null);
|
||
select(e.tree(), '1', 'lot', ''); assert.ok(e.error()); assert.deepEqual(e.value, bid(Number.MAX_SAFE_INTEGER, NaN));
|
||
select(e.tree(), '1', 'lot', '999'); assert.deepEqual(e.value, bid(Number.MAX_SAFE_INTEGER, NaN));
|
||
select(e.tree(), '1', 'lot', '2'); assert.equal(e.error(), null);
|
||
game.bidPromiseLot = { turn: 2, lot: 3 }; assert.ok(e.error()); assert.match(e.html(), /aria-invalid="true"/);
|
||
assert.deepEqual(elements(find(e.tree(), 'id', 'truth-turn-action-1-lot')).filter(element => element.type === 'option')
|
||
.map(element => element.props.value), ['', 3]);
|
||
select(e.tree(), '1', 'lot', '3'); assert.equal(e.error(), null);
|
||
game.bidPromiseLot = { turn: 1, lot: 3 }; assert.ok(e.error()); assert.match(e.html(), /No public ordinary lot is currently available/);
|
||
game.bidPromiseLot = null; assert.ok(e.error()); select(e.tree(), '1', 'action', 'forces');
|
||
minimum(e.tree(), '1', 2); assert.equal(e.error(), null);
|
||
});
|
||
|
||
void test('turn-action Revival leaves use public faction names and physical counters without casualties, custody or private preparation', () => {
|
||
const game = auction(); game.advanced = true; game.players.find(player => player.id === 'p')!.faction = 'atreides';
|
||
const ownLeader = game.allLeaders.find(leader => leader.faction === 'atreides'); assert.ok(ownLeader);
|
||
const foreignLeader = game.allLeaders.find(leader => leader.faction === 'emperor'); assert.ok(foreignLeader);
|
||
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves', 'leaders', 'tanks', 'capturedLeaders'])
|
||
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||
Object.defineProperty(game, 'revival', { get() { throw Error('No private Revival eligibility inspection'); }, configurable: true });
|
||
const e = editor(forceRevival('total', 20), false, game); assert.equal(e.error(), null);
|
||
select(e.tree(), '1', 'counter', 'normal'); assert.deepEqual(e.value, forceRevival('normal', 20));
|
||
select(e.tree(), '1', 'counter', 'elite'); assert.deepEqual(e.value, forceRevival('elite', 20));
|
||
minimum(e.tree(), '1', 21); assert.ok(e.error()); minimum(e.tree(), '1', 1); assert.equal(e.error(), null);
|
||
select(e.tree(), '1', 'action', 'leader'); assert.deepEqual(e.value, leaderRevival(ownLeader.id));
|
||
const choices = elements(find(e.tree(), 'id', 'truth-turn-action-1-leader')).filter(element => element.type === 'option')
|
||
.map(element => element.props.value);
|
||
assert.ok(choices.includes('kwisatz')); assert.ok(choices.includes(ownLeader.id)); assert.ok(!choices.includes(foreignLeader.id));
|
||
select(e.tree(), '1', 'leader', foreignLeader.id); assert.deepEqual(e.value, leaderRevival(ownLeader.id));
|
||
assert.ok(turnActionInputError(leaderRevival(foreignLeader.id), game, 'p'));
|
||
select(e.tree(), '1', 'leader', ''); assert.ok(e.error()); select(e.tree(), '1', 'leader', 'kwisatz'); assert.equal(e.error(), null);
|
||
assert.match(e.html(), /Kwisatz Haderach/); game.advanced = false; assert.ok(e.error());
|
||
select(e.tree(), '1', 'leader', ownLeader.id); assert.equal(e.error(), null);
|
||
for (const initial of [bid(), forceRevival(), leaderRevival(ownLeader.id)]) {
|
||
const frozen = editor(initial, true, game), original = structuredClone(frozen.value);
|
||
select(frozen.tree(), '1', 'action', 'payment');
|
||
if (initial.kind === 'action' && initial.action.kind === 'bid') { select(frozen.tree(), '1', 'lot', ''); minimum(frozen.tree(), '1', 7); }
|
||
if (initial.kind === 'action' && initial.action.kind === 'forces') { select(frozen.tree(), '1', 'counter', 'elite'); minimum(frozen.tree(), '1', 7); }
|
||
if (initial.kind === 'action' && initial.action.kind === 'leader') select(frozen.tree(), '1', 'leader', '');
|
||
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, original);
|
||
}
|
||
});
|
||
|
||
void test('turn-action preparation history and guides use original public leaves without reading private stage progress', () => {
|
||
const game = auction({ type: 'bid', amount: 8, allyPayment: 0 }), named = game.allLeaders.find(leader => leader.faction === 'emperor');
|
||
assert.ok(named);
|
||
game.turnActionPromises[0].mixed = { kind: 'and', terms: [bid(8), forceRevival('elite', 2), leaderRevival(named.id), payment(), physical(), fact()] };
|
||
for (const key of ['preparation', 'compiled', 'progress', 'payments'])
|
||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||
const html = markup(game);
|
||
assert.match(html, /ordinary lot 2/); assert.match(html, /8 spice/); assert.match(html, /elite forces/); assert.match(html, new RegExp(named.name));
|
||
assert.match(html, /Bid conditions close with their named ordinary lot/); assert.match(html, /Revival conditions close with your native Revival opportunity/);
|
||
assert.doesNotMatch(html, /compiled|bidProgress|revivalProgress/);
|
||
const sent: Action[] = []; press(guide({ game, busy: false, act(value) { sent.push(value); } }));
|
||
assert.equal(sent[0], game.turnActionGuide!.action);
|
||
const answerGame = answering(3); answerGame.truthtrance!.question = { kind: 'turnActions', target: 'p', turn: 2, mixed: game.turnActionPromises[0].mixed };
|
||
const answerMarkup = renderToStaticMarkup(createElement(TurnActionAnswers, { game: answerGame, busy: false, act() {} }));
|
||
assert.match(answerMarkup, /ordinary force returns add together/); assert.match(answerMarkup, /Bids close with their lot/);
|
||
});
|
||
|
||
void test('turn-action alliance fields offer every public other partner including the current ally only in the open native Nexus', () => {
|
||
const game = nexus(); game.players.find(player => player.id === 'p')!.ally = 'r'; game.players.find(player => player.id === 'r')!.ally = 'p';
|
||
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves', 'leaders', 'tanks'])
|
||
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||
Object.defineProperty(game.turnActionPromises[0], 'alliance', { get() { throw Error('No private alliance binding'); } });
|
||
const e = editor(physical(), false, game);
|
||
select(e.tree(), '1', 'action', 'alliance'); assert.deepEqual(e.value, finalAlliance('')); assert.ok(e.error());
|
||
const options = elements(find(e.tree(), 'id', 'truth-turn-action-1-partner')).filter(element => element.type === 'option');
|
||
assert.deepEqual(options.map(option => option.props.value), ['', 'a', 'r']);
|
||
select(e.tree(), '1', 'partner', 'r'); assert.equal(e.error(), null); assert.deepEqual(e.value, finalAlliance());
|
||
select(e.tree(), '1', 'partner', 'p'); select(e.tree(), '1', 'partner', 'missing'); assert.deepEqual(e.value, finalAlliance());
|
||
for (const partner of ['', 'p', 'missing', ' r ']) assert.ok(turnActionInputError(finalAlliance(partner), game, 'p'));
|
||
select(e.tree(), '1', 'partner', ''); assert.ok(e.error()); select(e.tree(), '1', 'partner', 'a'); assert.equal(e.error(), null);
|
||
assert.match(e.html(), /actual final membership when this current Nexus closes/);
|
||
assert.match(e.html(), /No can allow temporary membership followed by a break/);
|
||
const frozen = editor(finalAlliance(), true, game); select(frozen.tree(), '1', 'partner', 'a'); select(frozen.tree(), '1', 'action', 'payment');
|
||
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, finalAlliance());
|
||
for (const change of [
|
||
(current: GameView) => { current.status = 'setup'; }, (current: GameView) => { current.phase = 2; },
|
||
(current: GameView) => { current.nexus = false; },
|
||
(current: GameView) => { current.spiceWindow = { territory: 'hagga_basin', sector: 12, amount: 6, harvested: false }; },
|
||
(current: GameView) => { current.alliancePromiseNexus = null; }, (current: GameView) => { current.alliancePromiseNexus!.turn = 1; },
|
||
(current: GameView) => { current.truthMixedAllianceTargets = []; },
|
||
(current: GameView) => { current.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||
(current: GameView) => { current.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||
(current: GameView) => { current.phaseOpening = { passed: [] }; },
|
||
(current: GameView) => { current.automaticContinuationPending = true; },
|
||
]) {
|
||
const current = nexus(); change(current); const invalid = editor(finalAlliance(), false, current);
|
||
assert.ok(invalid.error()); assert.match(invalid.html(), /Alliance conditions require the current open Nexus/);
|
||
}
|
||
});
|
||
|
||
void test('turn-action alliance leaves retain the complete sixteen-clause and four-level optional-fact expression', () => {
|
||
const game = nexus(), e = editor({ kind: 'or', terms: [finalAlliance(), fact()] }, false, game);
|
||
for (let count = 3; count <= 16; count++) {
|
||
click(control(e.tree(), 'add-action-1')); select(e.tree(), `1.${count}`, 'action', 'alliance');
|
||
select(e.tree(), `1.${count}`, 'partner', count % 2 ? 'a' : 'r');
|
||
}
|
||
assert.equal(e.error(), null); assert.match(e.html(), /16 \/ 16 conditions/);
|
||
const full = structuredClone(e.value), changes = e.changes;
|
||
for (const key of ['add-action-1', 'add-fact-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'or-1']) {
|
||
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
|
||
}
|
||
assert.deepEqual(e.value, full); assert.equal(e.changes, changes);
|
||
const deep = editor({ kind: 'and', terms: [fact(), finalAlliance()] }, false, game);
|
||
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
|
||
assert.match(deep.html(), /4 \/ 4 levels of grouping/); assert.equal(deep.error(), null);
|
||
assert.equal(control(deep.tree(), 'and-1.2.1.1.1').props.disabled, true);
|
||
});
|
||
|
||
void test('turn-action Nexus guide forwards only exact breaks, withdrawals, reciprocal acceptances and Ready without private alliance reads', () => {
|
||
for (const advanced of [false, true]) for (const kind of ['accept', 'break', 'withdraw', 'ready'] as const) {
|
||
const game = nexus(kind === 'ready' ? { type: 'ready' } : { type: 'alliance', target: kind === 'accept' ? 'r' : null });
|
||
game.advanced = advanced;
|
||
if (kind === 'break') { game.players.find(player => player.id === 'p')!.ally = 'r'; game.players.find(player => player.id === 'r')!.ally = 'p'; }
|
||
if (kind === 'withdraw') game.allianceOffers = { p: 'r' };
|
||
if (kind === 'ready') {
|
||
game.players.find(player => player.id === 'p')!.ally = 'r'; game.players.find(player => player.id === 'r')!.ally = 'p';
|
||
game.turnActionPromises[0].mixed = { kind: 'and', terms: [finalAlliance(), payment()] }; game.turnActionPromises[0].answer = false;
|
||
}
|
||
for (const key of ['alliance', 'compiled', 'preparation', 'progress', 'payments'])
|
||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves'])
|
||
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.equal(game.turnActionPromises[0].fulfilled, false, 'A guide dispatch does not announce final Nexus fulfillment.');
|
||
const html = markup(game);
|
||
assert.match(html, kind === 'accept' ? /Accept Recipient’s alliance invitation/ : kind === 'break' ? /Break your current alliance/
|
||
: kind === 'withdraw' ? /Withdraw your alliance offer/ : /Confirm Ready for this Nexus/);
|
||
assert.match(html, /forming an alliance does not fulfill it early/);
|
||
assert.doesNotMatch(html, /Fulfilled: this whole commitment is complete/);
|
||
}
|
||
const invalid = [nexus(), nexus(), nexus(), nexus()];
|
||
invalid[0].allianceOffers = {};
|
||
invalid[1].turnActionGuide!.action.target = 'p';
|
||
invalid[2].turnActionGuide!.action.target = 'missing';
|
||
invalid[3].players.find(player => player.id === 'r')!.ally = 'a';
|
||
for (const game of invalid) {
|
||
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||
assert.equal(button.props.disabled, true); press(button); assert.deepEqual(sent, []);
|
||
}
|
||
});
|
||
|
||
void test('turn-action Nexus callbacks expire with public consent, membership, readiness, source and quiet-window changes', () => {
|
||
const changes: ((game: GameView) => void)[] = [
|
||
game => { game.nexus = false; }, game => { game.phase = 2; }, game => { game.turn = 3; }, game => { game.me = 'a'; },
|
||
game => { game.spiceWindow = { territory: 'hagga_basin', sector: 12, amount: 6, harvested: false }; },
|
||
game => { game.alliancePromiseNexus = null; }, game => { game.alliancePromiseNexus = { turn: 2 }; },
|
||
game => { game.alliancePromiseNexus!.turn = 1; }, game => { game.allianceOffers = {}; }, game => { game.allianceOffers.a = 'p'; },
|
||
game => { game.ready.push('p'); }, game => { game.ready.push('a'); },
|
||
game => { game.players.find(player => player.id === 'p')!.ally = 'a'; }, game => { game.players.find(player => player.id === 'a')!.ally = 'r'; },
|
||
game => { game.players = game.players.filter(player => player.id !== 'r'); },
|
||
game => { game.turnActionGuide = { ...game.turnActionGuide! }; }, game => { game.turnActionGuide!.action = { ...game.turnActionGuide!.action }; },
|
||
game => { game.turnActionGuide!.action.turnActionPromisePhase = 2; }, game => { game.turnActionGuide!.action.turnActionPromiseEvent = 'stale'; },
|
||
game => { game.turnActionPromises[0].event = 'stale'; }, game => { game.turnActionPromises[0].fulfilled = true; },
|
||
game => { game.truthtrance = answering(1).truthtrance; }, game => { game.phaseOpening = { passed: [] }; },
|
||
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; }, game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||
game => { game.automaticContinuationPending = true; }, game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||
];
|
||
for (const action of [{ type: 'alliance', target: 'r' }, { type: 'ready' }] satisfies Action[]) for (const change of changes) {
|
||
const game = nexus(action), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
|
||
}
|
||
const busy = { game: nexus(), busy: false, act() { assert.fail('Busy Nexus guide cannot send'); } };
|
||
const button = guide(busy); busy.busy = true; press(button);
|
||
const replaced = { game: nexus(), busy: false, act() { assert.fail('Replaced Nexus view cannot send'); } };
|
||
const old = guide(replaced); replaced.game = { ...replaced.game }; press(old);
|
||
const source = nexus({ type: 'ready' }); source.nexus = false;
|
||
const retained = guide({ game: source, busy: false, act() { assert.fail('A closed rendered Nexus cannot become a fresh opportunity'); } });
|
||
source.nexus = true; press(retained);
|
||
});
|
||
|
||
void test('turn-action current battle editor reuses atomic plan fields and public declaration sources within the global logical budget', () => {
|
||
const game = combat(), e = editor(physical(), false, game);
|
||
for (const player of game.players) for (const key of ['hand', 'leaders', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves', 'tanks'])
|
||
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||
for (const key of ['plans', 'cards', 'ownForces', 'compliantPlan', 'compliantPreparation'])
|
||
Object.defineProperty(game.battle!, key, { get() { throw Error(`No private battle ${key} inspection`); }, configurable: true });
|
||
select(e.tree(), '1', 'action', 'battlePlan'); editPlan(e.tree(), '1', { kind: 'dial', compare: 'eq', value: 3 });
|
||
assert.deepEqual(e.value, planClaim()); assert.equal(e.error(), null);
|
||
for (const invalid of [NaN, -1, 0.25, Infinity, 40.5]) {
|
||
editPlan(e.tree(), '1', { kind: 'dial', compare: 'eq', value: invalid }); assert.ok(e.error());
|
||
editPlan(e.tree(), '1', { kind: 'dial', compare: 'gte', value: 0.5 }); assert.equal(e.error(), null);
|
||
}
|
||
const foreign = game.allLeaders.find(leader => leader.faction === 'atreides'); assert.ok(foreign);
|
||
editPlan(e.tree(), '1', { kind: 'leader', leader: foreign.id }); assert.equal(e.error(), null);
|
||
assert.match(e.html(), /Cheap Hero \/ Heroine/);
|
||
editPlan(e.tree(), '1', { kind: 'weapon', name: 'Shield' }); assert.equal(e.error(), null);
|
||
assert.match(e.html(), /already sealed or revealed/);
|
||
game.battle!.event = 'later-native-battle'; assert.ok(e.error()); select(e.tree(), '1', 'battle', 'later-native-battle'); assert.equal(e.error(), null);
|
||
game.battle!.revealed = true; assert.equal(e.error(), null, 'A publicly fixed plan remains a valid question source.');
|
||
select(e.tree(), '1', 'action', 'traitorCall'); assert.ok(e.error()); game.truthMixedTraitorTargets = ['p', 'a'];
|
||
select(e.tree(), '1', 'call', 'decline'); assert.equal(e.error(), null);
|
||
game.battle!.traitorSubmitted.push('p'); assert.ok(e.error());
|
||
const frozen = editor(planClaim(), true, combat()); editPlan(frozen.tree(), '1', { kind: 'support', compare: 'eq', value: 7 });
|
||
select(frozen.tree(), '1', 'battle', ''); assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, planClaim());
|
||
const fullGame = combat(), full = editor({ kind: 'or', terms: [planClaim(), fact()] }, false, fullGame);
|
||
for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'battlePlan'); }
|
||
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
|
||
assert.doesNotMatch(full.html(), /truth-battle-plan-expression/);
|
||
assert.equal(control(full.tree(), 'add-action-1').props.disabled, true);
|
||
const deep = editor({ kind: 'and', terms: [fact(), planClaim()] }, false, fullGame);
|
||
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
|
||
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
|
||
});
|
||
|
||
void test('turn-action battle-plan guide fills the exact native packet for human review and never seals automatically', () => {
|
||
const game = combat(), filled: Plan[] = [], sent: Action[] = [];
|
||
for (const key of ['combat', 'compiled', 'preparation'])
|
||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||
for (const key of ['plans', 'ownForces', 'compliantPlan', 'compliantPreparation'])
|
||
Object.defineProperty(game.battle!, key, { get() { throw Error(`No private battle ${key} inspection`); }, configurable: true });
|
||
const props = { game, busy: false, fillPlan(plan: Plan) { filled.push(plan); }, act(action: Action) { sent.push(action); } };
|
||
const button = guide(props); assert.equal(button.props.disabled, false); press(button);
|
||
assert.equal(filled[0], game.turnActionGuide!.action); assert.deepEqual(sent, []);
|
||
assert.equal(filled[0].dial, 3); assert.equal(filled[0].support, 2); assert.equal(filled[0].allyPayment, 1);
|
||
assert.equal(filled[0].bankerSpice, 1); assert.equal(filled[0].weapon, 'controlled-weapon'); assert.equal(filled[0].defense, 'controlled-defense');
|
||
assert.deepEqual(filled[0].reinforcementsSources, { arrakeen: { normal: 1, elite: 0 } });
|
||
assert.equal(filled[0].kwisatz, true); assert.equal(filled[0].planetologistSpecial, true);
|
||
assert.equal((filled[0] as Plan & Action).turnActionPromiseEvent, event);
|
||
assert.match(renderToStaticMarkup(createElement(TurnActionPromises, props)), /Fill the quoted battle plan for review before sealing/);
|
||
const noFill = guide({ game, busy: false, act() { assert.fail('A human plan guide cannot auto seal without a fill callback'); } });
|
||
assert.equal(noFill.props.disabled, true); press(noFill);
|
||
props.fillPlan = () => assert.fail('A replaced fill callback cannot receive a saved plan'); press(button); assert.equal(filled.length, 1);
|
||
const preparation = combat(false, { type: 'card', card: 'controlled-ghola', leader: 'controlled-dead-leader' }), prepared: Action[] = [];
|
||
press(guide({ game: preparation, busy: false, act(action) { prepared.push(action); } }));
|
||
assert.equal(prepared[0], preparation.turnActionGuide!.action);
|
||
const declaration = combat(true, { type: 'traitorCall', call: false }), declared: Action[] = [];
|
||
declaration.battle!.attacker = 'r'; declaration.battle!.traitorVoters = ['p'];
|
||
press(guide({ game: declaration, busy: false, act(action) { declared.push(action); } }));
|
||
assert.equal(declared[0], declaration.turnActionGuide!.action, 'A native allied voter retains their own declaration packet.');
|
||
});
|
||
|
||
void test('turn-action combat callbacks retain current battle ownership and raw history without private stage reconstruction', () => {
|
||
const cases: [() => GameView, ((game: GameView) => void)[]][] = [
|
||
[() => combat(), [game => { game.battle!.submitted.push('p'); }, game => { game.battle!.revealed = true; }, game => { game.battle!.attacker = 'r'; }]],
|
||
[() => combat(true, { type: 'traitorCall', call: true }), [game => { game.battle!.traitorSubmitted.push('p'); },
|
||
game => { game.battle!.revealed = false; }, game => { game.battle!.traitorVoters = ['a']; }]],
|
||
];
|
||
for (const [build, specific] of cases) for (const change of [...specific,
|
||
(game: GameView) => { game.battle = { ...game.battle! }; }, (game: GameView) => { game.battle!.event = 'later-battle'; },
|
||
(game: GameView) => { game.battle!.territory = 'carthag'; }, (game: GameView) => { game.phase = 5; },
|
||
(game: GameView) => { game.truthtrance = answering(6).truthtrance; },
|
||
]) {
|
||
const game = build(), button = guide({ game, busy: false, fillPlan() { assert.fail('Stale battle cannot fill'); }, act() { assert.fail('Stale battle cannot send'); } });
|
||
assert.equal(button.props.disabled, false); change(game); press(button);
|
||
}
|
||
const game = combat(); game.turnActionGuide = null; game.battle = null;
|
||
for (const key of ['combat', 'compiled', 'binding', 'preparation'])
|
||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||
const html = markup(game);
|
||
assert.match(html, /sealing your own plan does not finish it/); assert.match(html, /original Reveal or decline declaration/);
|
||
assert.match(html, /You answered Yes/); assert.doesNotMatch(html, /controlled-native-battle|Fill the quoted battle plan|compiled/);
|
||
});
|
||
|
||
void test('native battle-power editor uses public owned opportunities and repairs all four strict declaration forms', () => {
|
||
const game = battlePower(), e = editor(physical(), false, game);
|
||
for (const player of game.players) for (const key of ['hand', 'leaders', 'traitors', 'spice', 'tanks', 'forces'])
|
||
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} power authoring read`); }, configurable: true });
|
||
for (const key of ['plans', 'prescience', 'voice', 'compliantPlan'])
|
||
Object.defineProperty(game.battle!, key, { get() { throw Error(`No hidden battle ${key} read`); }, configurable: true });
|
||
for (const key of ['combat', 'compiled', 'battlePowers'])
|
||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} read`); }, configurable: true });
|
||
select(e.tree(), '1', 'action', 'battlePower'); assert.deepEqual(e.value, powerClaim()); assert.equal(e.error(), null);
|
||
select(e.tree(), '1', 'power-use', 'decline'); assert.deepEqual(e.value, powerClaim({ kind: 'voice', use: false }));
|
||
select(e.tree(), '1', 'power-claim', 'voiceCommand'); select(e.tree(), '1', 'power-card-type', 'shield');
|
||
select(e.tree(), '1', 'power-must', 'must'); assert.deepEqual(e.value, powerClaim({ kind: 'voiceCommand', cardType: 'shield', must: true }));
|
||
select(e.tree(), '1', 'power-card-type', ''); assert.ok(e.error()); assert.match(e.html(), /aria-invalid="true"/);
|
||
select(e.tree(), '1', 'power-card-type', 'projectile'); assert.equal(e.error(), null);
|
||
game.battle!.event = 'new-native-battle'; assert.ok(e.error()); assert.match(e.html(), /Choose an owned native opportunity/);
|
||
select(e.tree(), '1', 'power-battle', 'new-native-battle'); assert.equal(e.error(), null);
|
||
select(e.tree(), '1', 'power-claim', 'prescience'); assert.ok(e.error(), 'Voice ownership grants no Prescience entitlement.');
|
||
game.players.find(player => player.id === 'p')!.faction = 'atreides';
|
||
game.battle!.preparation = { kind: 'prescience', owner: 'p', beneficiary: 'p' };
|
||
select(e.tree(), '1', 'power-battle', 'new-native-battle'); assert.equal(e.error(), null);
|
||
select(e.tree(), '1', 'power-use', 'decline');
|
||
select(e.tree(), '1', 'power-claim', 'prescienceField'); select(e.tree(), '1', 'power-field', 'dial'); assert.equal(e.error(), null);
|
||
assert.match(e.html(), /requested field is not the opponent/);
|
||
for (const claim of [{ kind: 'voice', use: 'yes' }, { kind: 'voiceCommand', cardType: 'invented', must: true },
|
||
{ kind: 'prescienceField', field: 'weapon', value: 'private-card' }, { kind: 'prescienceField', field: 'support' }])
|
||
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'battlePower', battle: 'new-native-battle', territory: 'arrakeen', claim } }, game, 'p'));
|
||
});
|
||
|
||
void test('native power defaults follow actual owned ordering and share the full sixteen-leaf four-level editor budget', () => {
|
||
const game = battlePower('prescience'), e = editor(physical(), false, game);
|
||
select(e.tree(), '1', 'action', 'battlePower'); assert.deepEqual(e.value, powerClaim({ kind: 'prescience', use: true }));
|
||
const full = editor({ kind: 'or', terms: [powerClaim({ kind: 'prescience', use: true }), fact()] }, false, game);
|
||
for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'battlePower'); }
|
||
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/); assert.equal(control(full.tree(), 'add-action-1').props.disabled, true);
|
||
const deep = editor({ kind: 'and', terms: [fact(), powerClaim({ kind: 'prescienceField', field: 'leader' })] }, false, game);
|
||
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
|
||
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
|
||
const frozen = editor(powerClaim({ kind: 'prescienceField', field: 'leader' }), true, game);
|
||
select(frozen.tree(), '1', 'power-claim', 'voice'); select(frozen.tree(), '1', 'power-field', 'dial');
|
||
select(frozen.tree(), '1', 'power-battle', ''); assert.equal(frozen.changes, 0);
|
||
const future = battlePower('prescience'); future.players.find(player => player.id === 'a')!.faction = 'beneGesserit';
|
||
future.players.find(player => player.id === 'a')!.ally = 'r'; future.players.find(player => player.id === 'r')!.ally = 'a';
|
||
future.battle!.preparation = { kind: 'voice', owner: 'a', beneficiary: 'r' };
|
||
const futureEditor = editor(physical(), false, future); select(futureEditor.tree(), '1', 'action', 'battlePower');
|
||
assert.deepEqual(futureEditor.value, powerClaim({ kind: 'prescience', use: true })); assert.equal(futureEditor.error(), null);
|
||
});
|
||
|
||
void test('native power guides forward exact original allied-owner declarations and explicit declines without inspecting answers', () => {
|
||
for (const power of ['voice', 'prescience'] as const) for (const decline of [false, true]) {
|
||
const game = battlePower(power, decline ? { type: 'declineBattlePower' } : undefined, true), sent: Action[] = [];
|
||
for (const key of ['combat', 'compiled', 'battlePowers'])
|
||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} guide read`); }, configurable: true });
|
||
for (const key of ['plans', 'prescience', 'voice', 'insight'])
|
||
Object.defineProperty(game.battle!, key, { get() { throw Error(`No inspected ${key} guide read`); }, configurable: true });
|
||
const button = guide({ game, busy: false, act(action) { sent.push(action); } }); assert.equal(button.props.disabled, false); press(button);
|
||
assert.equal(sent[0], game.turnActionGuide!.action); assert.equal(sent[0].turnActionPromiseEvent, event);
|
||
assert.equal(sent[0].turnActionPromisePhase, 6);
|
||
if (decline) assert.match(renderToStaticMarkup(button), /Explicitly decline native/);
|
||
else if (power === 'voice') { assert.equal(sent[0].kind, 'poison'); assert.equal(sent[0].must, false); }
|
||
else { assert.equal(sent[0].field, 'weapon'); assert.match(renderToStaticMarkup(button), /Request the weapon field/); }
|
||
}
|
||
});
|
||
|
||
void test('saved native power callbacks reject changed battle source, preparation, beneficiary, owner and modal priority', () => {
|
||
for (const power of ['voice', 'prescience'] as const) for (const change of [
|
||
(game: GameView) => { game.battle!.event = 'later-native-battle'; },
|
||
(game: GameView) => { game.battle!.territory = 'carthag'; },
|
||
(game: GameView) => { game.battle!.preparation = { ...game.battle!.preparation! }; },
|
||
(game: GameView) => { game.battle!.preparation!.owner = 'a'; },
|
||
(game: GameView) => { game.battle!.preparation!.beneficiary = 'r'; },
|
||
(game: GameView) => { game.battle!.preparation!.kind = 'prescienceAnswer'; },
|
||
(game: GameView) => { game.players.find(player => player.id === 'p')!.ally = null; },
|
||
(game: GameView) => { game.battle!.defender = 'p'; },
|
||
(game: GameView) => { game.battle!.revealed = true; },
|
||
(game: GameView) => { game.response = { kind: 'voice', owner: 'p', passed: [] }; },
|
||
(game: GameView) => { game.truthtrance = answering(6).truthtrance; },
|
||
(game: GameView) => { game.phase = 5; },
|
||
(game: GameView) => { game.turnActionGuide!.action.field = 'dial'; },
|
||
]) {
|
||
const game = battlePower(power, undefined, true), button = guide({ game, busy: false, act() { assert.fail('A stale native declaration cannot send'); } });
|
||
assert.equal(button.props.disabled, false); change(game); press(button);
|
||
}
|
||
const game = battlePower(), props: Props = { game, busy: false, act() { assert.fail('A stale callback cannot send'); } };
|
||
const button = guide(props); props.act = () => assert.fail('A replaced action callback cannot send'); press(button);
|
||
const busy = battlePower(), busyProps = { game: busy, busy: false, act() { assert.fail('A busy power cannot send'); } };
|
||
const busyButton = guide(busyProps); busyProps.busy = true; press(busyButton);
|
||
});
|
||
|
||
void test('native power guides refuse future stages, borrowed powers, inspected answers and forged packet fields', () => {
|
||
for (const [power, action, kind] of [
|
||
['voice', { type: 'voice', kind: 'invented', must: true }, 'voice'],
|
||
['voice', { type: 'voice', kind: 'poison', must: 'yes' }, 'voice'],
|
||
['voice', { type: 'voice', kind: 'poison', must: true, value: 'private-answer' }, 'voice'],
|
||
['voice', { type: 'declineBattlePower', field: 'weapon' }, 'voice'],
|
||
['voice', { type: 'voice', kind: 'poison', must: true }, 'nexusVoice'],
|
||
['prescience', { type: 'prescience', field: 'support' }, 'prescience'],
|
||
['prescience', { type: 'prescience', field: 'weapon', value: 'private-card' }, 'prescience'],
|
||
['prescience', { type: 'prescience', field: 'weapon' }, 'prescienceAnswer'],
|
||
['prescience', { type: 'declineBattlePower' }, 'nexusPrescienceAnswer'],
|
||
] as const) {
|
||
const game = battlePower(power, action); game.battle!.preparation!.kind = kind;
|
||
const button = guide({ game, busy: false, act() { assert.fail('An invalid power packet cannot send'); } });
|
||
assert.equal(button.props.disabled, true); press(button);
|
||
}
|
||
const future = battlePower('prescience'); future.players.find(player => player.id === 'a')!.faction = 'beneGesserit';
|
||
future.players.find(player => player.id === 'a')!.ally = 'r'; future.players.find(player => player.id === 'r')!.ally = 'a';
|
||
future.battle!.preparation = { kind: 'voice', owner: 'a', beneficiary: 'r' };
|
||
const button = guide({ game: future, busy: false, act() { assert.fail('Ordered future Prescience cannot send yet'); } });
|
||
assert.equal(button.props.disabled, true); press(button);
|
||
for (const power of ['voice', 'prescience'] as const) for (const decline of [false, true]) {
|
||
const game = battlePower(power, decline ? { type: 'declineBattlePower' } : undefined), sent: Action[] = [];
|
||
game.battle!.preparation!.blocked = 'Native use is currently blocked; explicit Decline remains available.';
|
||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, !decline); press(button); assert.equal(sent.length, decline ? 1 : 0);
|
||
}
|
||
});
|
||
|
||
void test('native power history keeps raw declarations distinct from effects and inspected values', () => {
|
||
const game = battlePower(); game.turnActionGuide = null; game.battle = null;
|
||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [powerClaim({ kind: 'voiceCommand', cardType: 'shield', must: true }),
|
||
powerClaim({ kind: 'prescienceField', field: 'weapon' }), fact()] };
|
||
for (const key of ['combat', 'compiled', 'battlePowers', 'declarations', 'preparation'])
|
||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private history ${key} read`); }, configurable: true });
|
||
const html = markup(game); assert.match(html, /Voice/); assert.match(html, /Prescience/); assert.match(html, /You answered Yes/);
|
||
assert.match(html, /before later Karama prevention/); assert.match(html, /requested field is not the inspected answer or value/);
|
||
assert.match(html, /close at the actual native choice or when its original source expires/);
|
||
assert.doesNotMatch(html, /controlled-native-battle|battlePowers|declarations|private-card/);
|
||
});
|
||
|
||
void test('window-role editor and raw history use original public sources without hidden holdings or eligibility reads', () => {
|
||
const game = windowCard(), e = editor(physical(), false, game);
|
||
for (const player of game.players) for (const key of ['hand', 'spice', 'forces', 'advisors', 'tanks', 'leaders', 'traitors'])
|
||
Object.defineProperty(player, key, { get() { throw Error(`No private window authoring ${key} read`); }, configurable: true });
|
||
for (const key of ['windowCards', 'compiled', 'cardPlays'])
|
||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} read`); }, configurable: true });
|
||
select(e.tree(), '1', 'action', 'windowCardPlay'); assert.deepEqual(e.value, windowClaim()); assert.equal(e.error(), null);
|
||
select(e.tree(), '1', 'window-effect', 'atomics'); assert.deepEqual(e.value, windowClaim('atomics')); assert.equal(e.error(), null);
|
||
game.shieldWallDestroyed = true; assert.ok(e.error()); assert.match(e.html(), /no current original open native window/);
|
||
select(e.tree(), '1', 'window-effect', 'weather'); assert.equal(e.error(), null, 'Weather needs no wall or private forces.');
|
||
game.stormCardWindowOpen = false; assert.ok(e.error()); game.stormCardWindowOpen = true;
|
||
select(e.tree(), '1', 'window-effect', 'harvester'); assert.ok(e.error());
|
||
game.phase = 1; game.spiceWindow = { event: 'new-original-blow', territory: 'carthag', sector: 11, amount: 6, harvested: false };
|
||
select(e.tree(), '1', 'window-source', 'new-original-blow'); assert.deepEqual(e.value, windowClaim('harvester', 'new-original-blow'));
|
||
assert.equal(e.error(), null);
|
||
delete game.spiceWindow.event; assert.ok(e.error()); select(e.tree(), '1', 'window-source', 'legacy-spice-blow:2:carthag:11');
|
||
assert.deepEqual(e.value, windowClaim('harvester', 'legacy-spice-blow:2:carthag:11')); assert.equal(e.error(), null);
|
||
game.spiceWindow.harvesterClosed = true; assert.ok(e.error()); game.spiceWindow.harvesterClosed = false;
|
||
game.turnActionGuide = null; game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim(), windowClaim('atomics'), windowClaim('harvester')] };
|
||
const html = markup(game); assert.match(html, /Weather Control/); assert.match(html, /Family Atomics/); assert.match(html, /Harvester/);
|
||
assert.match(html, /recovery alone do not count/); assert.match(html, /original native window closes/);
|
||
assert.match(html, /do not promise a selected storm distance or resulting spice amount/);
|
||
assert.doesNotMatch(html, /windowCards|compiled|controlled-weather/);
|
||
});
|
||
|
||
void test('window-role editor retains strict repairable drafts and the shared sixteen-leaf four-level budget', () => {
|
||
const game = windowCard(), e = editor(windowClaim(), false, game);
|
||
select(e.tree(), '1', 'window-source', ''); assert.ok(e.error()); assert.deepEqual(e.value, windowClaim('weather', ''));
|
||
select(e.tree(), '1', 'window-source', 'storm:2'); assert.equal(e.error(), null);
|
||
for (const action of [{ kind: 'windowCardPlay', effect: 'weather', source: 'storm:2', amount: 4 },
|
||
{ kind: 'windowCardPlay', effect: 'ghola', source: 'storm:2' },
|
||
{ kind: 'windowCardPlay', effect: 'weather', source: 'storm:1' }])
|
||
assert.ok(turnActionInputError({ kind: 'action', action }, game, 'p'));
|
||
const full = editor({ kind: 'or', terms: [windowClaim(), fact()] }, false, game);
|
||
for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'windowCardPlay'); }
|
||
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/); assert.equal(control(full.tree(), 'add-action-1').props.disabled, true);
|
||
const deep = editor({ kind: 'and', terms: [fact(), windowClaim()] }, false, game);
|
||
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
|
||
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
|
||
const frozen = editor(windowClaim(), true, game); select(frozen.tree(), '1', 'window-effect', 'atomics');
|
||
select(frozen.tree(), '1', 'window-source', ''); assert.equal(frozen.changes, 0);
|
||
});
|
||
|
||
void test('printed window guides forward exact owned Weather, Atomics and Harvester native packets out of turn', () => {
|
||
for (const effect of ['weather', 'atomics', 'harvester'] as const) {
|
||
const game = windowCard(effect), sent: Action[] = []; game.active = 'a';
|
||
for (const key of ['windowCards', 'compiled', 'cardPlays'])
|
||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} guide read`); }, configurable: true });
|
||
const button = guide({ game, busy: false, act(action) { sent.push(action); } }); assert.equal(button.props.disabled, false); press(button);
|
||
assert.equal(sent[0], game.turnActionGuide!.action); assert.equal(sent[0].turnActionPromiseEvent, event);
|
||
assert.equal(sent[0].turnActionPromisePhase, effect === 'harvester' ? 1 : 0);
|
||
assert.match(renderToStaticMarkup(button), /original native window/);
|
||
}
|
||
for (const amount of [0, 10]) {
|
||
const game = windowCard('weather', { type: 'card', card: 'controlled-weather', amount });
|
||
let sent = 0; const button = guide({ game, busy: false, act() { sent++; } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent, 1);
|
||
}
|
||
});
|
||
|
||
void test('saved window-card callbacks reject source closure, public window mutation, physical card changes and modal handoffs', () => {
|
||
for (const effect of ['weather', 'atomics', 'harvester'] as const) for (const change of [
|
||
(game: GameView) => { if (effect === 'harvester') game.spiceWindow!.event = 'later-blow'; else game.stormCardWindowOpen = false; },
|
||
(game: GameView) => { if (effect === 'harvester') game.spiceWindow!.amount *= 2; else game.stormPending = 7; },
|
||
(game: GameView) => { game.turnActionPromises[0].mixed = windowClaim(effect, 'different-original-source'); },
|
||
(game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
|
||
(game: GameView) => { game.players.find(player => player.id === 'p')!.hand![0].effect = 'ghola'; },
|
||
(game: GameView) => { game.response = { kind: 'voice', owner: 'a', passed: [] }; },
|
||
(game: GameView) => { game.truthtrance = answering(game.phase).truthtrance; },
|
||
]) {
|
||
const game = windowCard(effect), button = guide({ game, busy: false, act() { assert.fail('A stale window card cannot send'); } });
|
||
assert.equal(button.props.disabled, false); change(game); press(button);
|
||
}
|
||
const game = windowCard('harvester'), button = guide({ game, busy: false, act() { assert.fail('A replaced original blow cannot send'); } });
|
||
game.spiceWindow = { ...game.spiceWindow! }; press(button);
|
||
const atomics = windowCard('atomics'), atomicButton = guide({ game: atomics, busy: false, act() { assert.fail('Changed native Atomics eligibility cannot send'); } });
|
||
atomics.players.find(player => player.id === 'p')!.forces = {}; press(atomicButton);
|
||
});
|
||
|
||
void test('window-role guides reject unsupported packets, missing native eligibility and generic card fallback', () => {
|
||
for (const amount of [undefined, -1, 11, 1.5, NaN, Infinity]) {
|
||
const game = windowCard('weather', { type: 'card', card: 'controlled-weather', amount });
|
||
const button = guide({ game, busy: false, act() { assert.fail('An invalid Weather distance cannot send'); } });
|
||
assert.equal(button.props.disabled, true); press(button);
|
||
}
|
||
for (const effect of ['weather', 'atomics', 'harvester'] as const) {
|
||
for (const extra of [{ mode: 'shipment' }, { leader: 'foreign-leader' }, { forces: { 'shield_wall:8': 1 } },
|
||
...(effect === 'weather' ? [] : [{ amount: 2 }])]) {
|
||
const game = windowCard(effect); Object.assign(game.turnActionGuide!.action, extra);
|
||
const button = guide({ game, busy: false, act() { assert.fail('An unsupported native window packet cannot send'); } });
|
||
assert.equal(button.props.disabled, true); press(button);
|
||
}
|
||
for (const phase of [2, 4, 5, 6]) {
|
||
const game = windowCard(effect); game.phase = phase; game.turnActionGuide!.action.turnActionPromisePhase = phase;
|
||
const button = guide({ game, busy: false, act() { assert.fail('A window role cannot bypass validation through generic preparation'); } });
|
||
assert.equal(button.props.disabled, true); press(button);
|
||
}
|
||
const wrongSource = windowCard(effect); wrongSource.turnActionPromises[0].mixed = payment();
|
||
assert.equal(guide({ game: wrongSource, busy: false, act() { assert.fail('A printed window guide needs its original raw source'); } }).props.disabled, true);
|
||
}
|
||
const atomics = windowCard('atomics'); atomics.players.find(player => player.id === 'p')!.forces = {};
|
||
assert.equal(guide({ game: atomics, busy: false, act() { assert.fail('Atomics needs actual owned native forces'); } }).props.disabled, true);
|
||
});
|
||
|
||
void test('early owned Box guides require the exact quoted Box and live original global question window', () => {
|
||
for (const effect of ['weather', 'harvester'] as const) {
|
||
const game = windowCard(effect), me = game.players.find(player => player.id === 'p')!;
|
||
me.hand = [{ id: 'controlled-box', name: 'Nullentropy Box', kind: 'special', effect: 'nullentropyBox' }];
|
||
game.nullentropy = { card: me.hand[0], blocked: null, search: null };
|
||
game.turnActionGuide!.action = { type: 'card', card: me.hand[0].id, turnActionPromiseEvent: event, turnActionPromisePhase: game.phase };
|
||
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
game.nullentropy.blocked = 'The native Box opening is no longer legal.'; press(button); assert.equal(sent.length, 1);
|
||
game.nullentropy.blocked = null;
|
||
if (effect === 'harvester') game.spiceWindow!.harvesterClosed = true; else game.stormCardWindowOpen = false;
|
||
press(button); assert.equal(sent.length, 1);
|
||
const unrelated = windowCard(effect); unrelated.turnActionPromises[0].mixed = payment();
|
||
unrelated.players.find(player => player.id === 'p')!.hand = me.hand;
|
||
unrelated.nullentropy = { card: me.hand[0], blocked: null, search: null };
|
||
unrelated.turnActionGuide!.action = { ...game.turnActionGuide!.action };
|
||
assert.equal(guide({ game: unrelated, busy: false, act() { assert.fail('An unrelated row cannot create a new early Box control'); } }).props.disabled, true);
|
||
}
|
||
});
|
||
|
||
void test('window Ready guides retain the exact live raw source, known Storm distance and fresh-blow callback ownership', () => {
|
||
const readyWindow = (effect: WindowCardEffect) => {
|
||
const game = windowCard(effect); game.stormPending = effect === 'harvester' ? null : 4;
|
||
game.turnActionGuide!.action = { type: 'ready', turnActionPromiseEvent: event, turnActionPromisePhase: game.phase };
|
||
if (effect === 'harvester') { game.turnActionPromises[0].answer = false; game.turnActionPromises[0].mixed = windowClaim(effect); }
|
||
Object.defineProperty(game.turnActionPromises[0], 'windowCards', { get() { throw Error('Ready must not read private window receipts'); } });
|
||
return game;
|
||
};
|
||
for (const effect of ['weather', 'atomics', 'harvester'] as const) {
|
||
const game = readyWindow(effect), sent: Action[] = [];
|
||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.match(renderToStaticMarkup(button), /Accept this .* window while preserving your whole answer/);
|
||
game.ready.push('p'); press(button); assert.equal(sent.length, 1, 'An accepted window cannot be reopened by the saved Ready callback.');
|
||
for (const change of [
|
||
(game: GameView) => { game.turnActionPromises[0].mixed = windowClaim(effect, 'later-original-source'); },
|
||
(game: GameView) => { if (effect === 'harvester') game.spiceWindow!.event = 'later-blow'; else game.stormCardWindowOpen = false; },
|
||
(game: GameView) => { if (effect === 'harvester') game.spiceWindow!.harvesterClosed = true; else game.stormPending = null; },
|
||
]) {
|
||
const stale = readyWindow(effect), saved = guide({ game: stale, busy: false, act() { assert.fail('A changed original Ready source cannot send'); } });
|
||
assert.equal(saved.props.disabled, false); change(stale); press(saved);
|
||
}
|
||
const extra = readyWindow(effect); extra.turnActionGuide!.action.amount = 4;
|
||
assert.equal(guide({ game: extra, busy: false, act() { assert.fail('Window Ready accepts no extra native fields'); } }).props.disabled, true);
|
||
const unrelated = readyWindow(effect); unrelated.turnActionPromises[0].mixed = payment();
|
||
assert.equal(guide({ game: unrelated, busy: false, act() { assert.fail('Window Ready needs its own original raw window clause'); } }).props.disabled, true);
|
||
}
|
||
const unknownStorm = readyWindow('weather'); unknownStorm.stormPending = null;
|
||
assert.equal(guide({ game: unknownStorm, busy: false, act() { assert.fail('Storm Ready waits for a known native movement'); } }).props.disabled, true);
|
||
const replaced = readyWindow('harvester'), saved = guide({ game: replaced, busy: false, act() { assert.fail('A replaced fresh blow cannot accept an old Ready callback'); } });
|
||
replaced.spiceWindow = { ...replaced.spiceWindow! }; press(saved);
|
||
});
|
||
|
||
void test('current ordinary-lot Karama picker is public, source-bound and repairable within the shared logical budget', () => {
|
||
const game = auctionKarama(), e = editor(physical(), false, game);
|
||
for (const player of game.players) for (const key of ['hand', 'spice', 'leaders', 'traitors', 'forces'])
|
||
Object.defineProperty(player, key, { get() { throw Error(`No private Karama authoring ${key} read`); }, configurable: true });
|
||
Object.defineProperty(game, 'karamaAuctionCards', { get() { throw Error('The public question must not inspect the owner’s native card quote'); } });
|
||
select(e.tree(), '1', 'action', 'windowCardPlay'); assert.deepEqual(e.value, windowClaim('karamaAuction')); assert.equal(e.error(), null);
|
||
assert.match(e.html(), /Spend a printed Karama for this current ordinary lot/); assert.match(e.html(), /Ordinary lot 2/);
|
||
select(e.tree(), '1', 'window-source', ''); assert.ok(e.error());
|
||
select(e.tree(), '1', 'window-source', 'ordinary-auction:2:2'); assert.equal(e.error(), null);
|
||
game.bidPromiseLot = { turn: 2, lot: 3 }; assert.ok(e.error());
|
||
select(e.tree(), '1', 'window-source', 'ordinary-auction:2:3'); assert.equal(e.error(), null);
|
||
const originalAuction = game.auction;
|
||
game.auction = null; assert.ok(e.error()); assert.match(e.html(), /no current original open native window/); game.auction = originalAuction;
|
||
for (const extra of [{ card: 'private-karama' }, { mode: 'purchase' }, { amount: 0 }])
|
||
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'karamaAuction', source: 'ordinary-auction:2:3', ...extra } }, game, 'p'));
|
||
const fullGame = auctionKarama(), full = editor({ kind: 'or', terms: [windowClaim('karamaAuction'), fact()] }, false, fullGame);
|
||
for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'windowCardPlay'); }
|
||
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
|
||
const deep = editor({ kind: 'and', terms: [fact(), windowClaim('karamaAuction')] }, false, fullGame);
|
||
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
|
||
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
|
||
});
|
||
|
||
void test('printed current-lot Karama guides forward exact purchase and winning-invoice packets using native owner quotes', () => {
|
||
for (const invoice of [false, true]) {
|
||
const game = auctionKarama(invoice), sent: Action[] = [];
|
||
for (const key of ['windowCards', 'compiled', 'cardPlays'])
|
||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} Karama guide read`); }, configurable: true });
|
||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.equal(sent[0].card, 'controlled-printed-karama'); assert.equal(sent[0].turnActionPromisePhase, 3);
|
||
assert.equal(sent[0].turnActionPromiseEvent, event);
|
||
if (invoice) assert.match(renderToStaticMarkup(button), /printed Karama on this ordinary-lot winning invoice/);
|
||
else { assert.equal(sent[0].mode, 'purchase'); assert.equal(game.auction!.active, 'a', 'The native purchase remains possible outside the bidder’s turn.'); }
|
||
game.turnActionGuide = null;
|
||
const html = markup(game); assert.match(html, /printed Karama/); assert.match(html, /immediate purchase/);
|
||
assert.match(html, /Worthless conversion and a successful bid alone do not count/); assert.match(html, /closes with the original ordinary lot/);
|
||
assert.doesNotMatch(html, /windowCards|controlled-printed-karama/);
|
||
}
|
||
});
|
||
|
||
void test('saved printed-Karama auction callbacks expire with lot, native quote, physical card, owner and callback changes', () => {
|
||
for (const invoice of [false, true]) for (const change of [
|
||
(game: GameView) => { game.bidPromiseLot = { turn: 2, lot: 3 }; },
|
||
(game: GameView) => { game.auction = { ...game.auction! }; },
|
||
(game: GameView) => { game.auction!.active = 'r'; },
|
||
(game: GameView) => { game.karamaAuctionCards = []; },
|
||
(game: GameView) => { const card = game.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card); card.kind = 'worthless'; },
|
||
(game: GameView) => { game.turnActionPromises[0].mixed = windowClaim('karamaAuction', 'ordinary-auction:2:1'); },
|
||
(game: GameView) => { game.me = 'a'; },
|
||
]) {
|
||
const game = auctionKarama(invoice), button = guide({ game, busy: false, act() { assert.fail('A stale ordinary-lot printed Karama cannot send'); } });
|
||
assert.equal(button.props.disabled, false); change(game); press(button);
|
||
}
|
||
const game = auctionKarama(), props: Props = { game, busy: false, act() { assert.fail('A saved Karama callback cannot send after replacement'); } };
|
||
const saved = guide(props); props.act = () => assert.fail('The replacement callback cannot receive an old purchase'); press(saved);
|
||
const full = auctionKarama(), fullButton = guide({ game: full, busy: false, act() { assert.fail('A newly full hand cannot purchase the lot'); } });
|
||
full.players.find(player => player.id === 'p')!.handLimit = 1; press(fullButton);
|
||
assert.equal(guide({ game: full, busy: false, act() { assert.fail('An initially full hand cannot purchase the lot'); } }).props.disabled, true);
|
||
});
|
||
|
||
void test('printed auction-role guides refuse conversion, unsupported packets and generic purchase fallback while retaining unrelated invoices', () => {
|
||
for (const invoice of [false, true]) for (const extra of [{ amount: 0 }, { target: 'r' }, { leader: 'foreign-leader' },
|
||
...(invoice ? [{ mode: 'purchase' }] : [{ mode: 'shipment' }])]) {
|
||
const game = auctionKarama(invoice); Object.assign(game.turnActionGuide!.action, extra);
|
||
const button = guide({ game, busy: false, act() { assert.fail('An unsupported auction-role packet cannot send'); } });
|
||
assert.equal(button.props.disabled, true); press(button);
|
||
}
|
||
for (const invoice of [false, true]) {
|
||
const game = auctionKarama(invoice), card = game.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card);
|
||
card.kind = 'worthless'; delete card.effect;
|
||
assert.equal(guide({ game, busy: false, act() { assert.fail('Worthless conversion is not printed Karama use'); } }).props.disabled, true);
|
||
const missingQuote = auctionKarama(invoice); missingQuote.karamaAuctionCards = [];
|
||
assert.equal(guide({ game: missingQuote, busy: false, act() { assert.fail('A native card quote is required'); } }).props.disabled, true);
|
||
}
|
||
for (const phase of [2, 4, 5, 6]) {
|
||
const game = auctionKarama(); game.phase = phase; game.turnActionGuide!.action.turnActionPromisePhase = phase;
|
||
assert.equal(guide({ game, busy: false, act() { assert.fail('A purchase cannot become generic preparation'); } }).props.disabled, true);
|
||
}
|
||
const foreign = auctionKarama(true); foreign.decision = { kind: 'auctionPayment', player: 'a' };
|
||
assert.equal(guide({ game: foreign, busy: false, act() { assert.fail('A foreign invoice cannot consume this printed Karama'); } }).props.disabled, true);
|
||
const prior = auctionKarama(true); prior.turnActionPromises[0].mixed = payment(); prior.karamaAuctionCards = [];
|
||
for (const player of prior.players) Object.defineProperty(player, 'hand', { get() { throw Error('Existing unrelated invoice guides keep their original native authority'); } });
|
||
const sent: Action[] = [], oldInvoice = guide({ game: prior, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(oldInvoice.props.disabled, false); press(oldInvoice); assert.equal(sent[0], prior.turnActionGuide!.action);
|
||
const later = auctionKarama(true); later.bidPromiseLot = { turn: 2, lot: 3 }; later.karamaAuctionCards = [];
|
||
for (const player of later.players) Object.defineProperty(player, 'hand', { get() { throw Error('An older raw window cannot impose printed-role eligibility on an unrelated later invoice'); } });
|
||
const laterSent: Action[] = [], laterInvoice = guide({ game: later, busy: false, act(action) { laterSent.push(action); } });
|
||
assert.equal(laterInvoice.props.disabled, false); press(laterInvoice); assert.equal(laterSent[0], later.turnActionGuide!.action);
|
||
});
|
||
|
||
void test('current ordinary-lot printed-role questions retain quoted owned Box routes before purchase and inside their own invoice', () => {
|
||
for (const invoice of [false, true]) {
|
||
const game = auctionKarama(invoice), me = game.players.find(player => player.id === 'p')!;
|
||
me.hand = [{ id: 'controlled-box', name: 'Nullentropy Box', kind: 'special', effect: 'nullentropyBox' }];
|
||
game.nullentropy = { card: me.hand[0], blocked: null, search: null };
|
||
game.turnActionGuide!.action = { type: 'card', card: me.hand[0].id, turnActionPromiseEvent: event, turnActionPromisePhase: 3 };
|
||
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
game.bidPromiseLot = { turn: 2, lot: 3 }; press(button); assert.equal(sent.length, 1);
|
||
}
|
||
});
|
||
|
||
void test('shipment-price picker names the current public beneficiary without reading card or native funding quotes', () => {
|
||
const game = shipmentKarama('a'), e = editor(physical(), false, game);
|
||
for (const player of game.players) for (const key of ['hand', 'spice', 'forces', 'leaders', 'traitors', 'reserves'])
|
||
Object.defineProperty(player, key, { get() { throw Error(`No private shipment-price authoring ${key} read`); }, configurable: true });
|
||
Object.defineProperty(game, 'karamaShipmentCards', { get() { throw Error('The public picker must not inspect the giver’s native card quote'); } });
|
||
select(e.tree(), '1', 'action', 'windowCardPlay'); assert.deepEqual(e.value, windowClaim('karamaShipment', shipmentPriceSource('a')));
|
||
assert.equal(e.error(), null); assert.match(e.html(), /Asker’s current native shipment price/);
|
||
select(e.tree(), '1', 'window-source', ''); assert.ok(e.error());
|
||
select(e.tree(), '1', 'window-source', shipmentPriceSource('p')); assert.ok(e.error(), 'An inactive entitlement is not a new public picker option.');
|
||
select(e.tree(), '1', 'window-source', shipmentPriceSource('a')); assert.equal(e.error(), null);
|
||
game.active = 'p'; assert.ok(e.error()); select(e.tree(), '1', 'window-source', shipmentPriceSource('p')); assert.equal(e.error(), null);
|
||
const cunning = shipmentPriceSource('p', 'controlled-cunning-window');
|
||
game.karamaShipmentWindows = game.karamaShipmentWindows.map(window => window.recipient === 'p' ? { ...window, source: cunning } : window);
|
||
assert.ok(e.error()); select(e.tree(), '1', 'window-source', cunning); assert.equal(e.error(), null);
|
||
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'karamaShipment', source: cunning, target: 'p' } }, game, 'p'));
|
||
const fullGame = shipmentKarama(), full = editor({ kind: 'or', terms: [windowClaim('karamaShipment'), fact()] }, false, fullGame);
|
||
for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'windowCardPlay'); }
|
||
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
|
||
const deep = editor({ kind: 'and', terms: [fact(), windowClaim('karamaShipment')] }, false, fullGame);
|
||
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
|
||
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
|
||
});
|
||
|
||
void test('printed shipment-price guides forward exact own or foreign-giver grants and explain the distinct actual shipment', () => {
|
||
for (const recipient of ['p', 'a']) for (const cunning of [undefined, 'controlled-cunning-window']) {
|
||
const game = shipmentKarama(recipient, cunning), sent: Action[] = [];
|
||
for (const key of ['windowCards', 'compiled', 'preparation'])
|
||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} shipment-price read`); }, configurable: true });
|
||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.equal(sent[0].type, 'card'); assert.equal(sent[0].mode, 'shipment'); assert.equal(sent[0].target, recipient);
|
||
assert.equal(sent[0].turnActionPromiseEvent, event); assert.equal(sent[0].turnActionPromisePhase, 5);
|
||
assert.match(renderToStaticMarkup(button), /grant .* this native shipment price/);
|
||
game.turnActionGuide = null;
|
||
const html = markup(game); assert.match(html, /price grant counts separately from an actual force shipment/);
|
||
assert.match(html, /does not require the recipient to ship forces/); assert.match(html, /original shipment-price opportunity ends/);
|
||
assert.doesNotMatch(html, /windowCards|compiled|controlled-printed-karama/);
|
||
}
|
||
});
|
||
|
||
void test('saved shipment-price guides expire with the active source, target, quote, physical card and grant state', () => {
|
||
for (const recipient of ['p', 'a']) for (const change of [
|
||
(game: GameView) => { game.active = recipient === 'p' ? 'a' : 'p'; },
|
||
(game: GameView) => { game.karamaShipmentWindows = game.karamaShipmentWindows.map(window => window.recipient === recipient
|
||
? { ...window, source: shipmentPriceSource(recipient, 'later-cunning-window') } : window); },
|
||
(game: GameView) => { game.turnActionGuide!.action.target = recipient === 'p' ? 'a' : 'p'; },
|
||
(game: GameView) => { game.karamaShipmentCards = []; },
|
||
(game: GameView) => { const card = game.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card); card.kind = 'worthless'; },
|
||
(game: GameView) => { game.karamaShipping = { player: recipient, owner: 'p' }; },
|
||
(game: GameView) => { game.turnActionPromises[0].mixed = windowClaim('karamaShipment', shipmentPriceSource('r')); },
|
||
]) {
|
||
const game = shipmentKarama(recipient), saved = guide({ game, busy: false, act() { assert.fail('A stale shipment-price grant cannot send'); } });
|
||
assert.equal(saved.props.disabled, false); change(game); press(saved);
|
||
}
|
||
const game = shipmentKarama('a'), props: Props = { game, busy: false, act() { assert.fail('A replaced grant callback cannot send'); } };
|
||
const saved = guide(props); props.act = () => assert.fail('A replaced callback cannot receive an old grant'); press(saved);
|
||
});
|
||
|
||
void test('current printed shipment roles reject forged grants and inactive targets without blocking older native preparation guides', () => {
|
||
for (const extra of [{ target: 'r' }, { target: undefined }, { amount: 3 }, { leader: 'foreign-leader' }, { mode: 'purchase' }]) {
|
||
const game = shipmentKarama(); Object.assign(game.turnActionGuide!.action, extra);
|
||
const button = guide({ game, busy: false, act() { assert.fail('An unsupported shipment-price packet cannot send'); } });
|
||
assert.equal(button.props.disabled, true); press(button);
|
||
}
|
||
const converted = shipmentKarama(), card = converted.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card);
|
||
card.kind = 'worthless'; delete card.effect;
|
||
assert.equal(guide({ game: converted, busy: false, act() { assert.fail('Worthless conversion is not printed shipment-role use'); } }).props.disabled, true);
|
||
const unquoted = shipmentKarama(); unquoted.karamaShipmentCards = [];
|
||
assert.equal(guide({ game: unquoted, busy: false, act() { assert.fail('An absent native shipment card quote cannot be bypassed'); } }).props.disabled, true);
|
||
const inactive = shipmentKarama('a'); inactive.active = 'p';
|
||
assert.equal(guide({ game: inactive, busy: false, act() { assert.fail('An inactive beneficiary cannot receive this grant control'); } }).props.disabled, true);
|
||
for (const mixed of [payment(), windowClaim('karamaShipment', shipmentPriceSource('a'))]) {
|
||
const legacy = shipmentKarama(); legacy.turnActionPromises[0].mixed = mixed; legacy.karamaShipmentCards = [];
|
||
const sent: Action[] = [], button = guide({ game: legacy, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], legacy.turnActionGuide!.action);
|
||
}
|
||
});
|
||
|
||
void test('a current shipment-price question retains a quoted Box route for an owned or foreign giver', () => {
|
||
for (const recipient of ['p', 'a']) {
|
||
const game = shipmentKarama(recipient), me = game.players.find(player => player.id === 'p')!;
|
||
const box = { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special' as const, effect: 'nullentropyBox' };
|
||
me.hand = [box]; game.nullentropy = { card: box, blocked: null, search: null };
|
||
game.turnActionGuide!.action = { type: 'card', card: box.id, turnActionPromiseEvent: event, turnActionPromisePhase: 5 };
|
||
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
game.nullentropy.blocked = 'The native Box is no longer available.'; press(button); assert.equal(sent.length, 1);
|
||
}
|
||
});
|
||
|
||
void test('Thumper authoring uses the original public first-reveal source and preserves repairable shared logical drafts', () => {
|
||
const game = firstReveal(), e = editor(physical(), false, game);
|
||
for (const player of game.players) for (const key of ['hand', 'spice', 'forces', 'leaders', 'traitors'])
|
||
Object.defineProperty(player, key, { get() { throw Error(`No private Thumper authoring ${key} read`); }, configurable: true });
|
||
for (const key of ['thumperCards', 'spiceSequence', 'spiceResolution'])
|
||
Object.defineProperty(game, key, { get() { throw Error(`No native Thumper authoring ${key} read`); }, configurable: true });
|
||
select(e.tree(), '1', 'action', 'windowCardPlay'); assert.deepEqual(e.value, windowClaim('thumper')); assert.equal(e.error(), null);
|
||
assert.match(e.html(), /Before the original first spice reveal/);
|
||
game.nexus = true; game.response = { kind: 'voice', owner: 'a', passed: [] };
|
||
assert.equal(e.error(), null, 'A temporary native control does not change the original public first-reveal source.');
|
||
game.nexus = false; game.response = null;
|
||
select(e.tree(), '1', 'window-source', ''); assert.ok(e.error());
|
||
select(e.tree(), '1', 'window-source', 'first-spice-reveal:2'); assert.equal(e.error(), null);
|
||
game.thumperWindowOpen = false; assert.ok(e.error()); assert.match(e.html(), /no current original open native window/);
|
||
game.thumperWindowOpen = true;
|
||
for (const extra of [{ amount: 0 }, { mode: 'shipment' }, { target: 'p' }])
|
||
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'thumper', source: 'first-spice-reveal:2', ...extra } }, game, 'p'));
|
||
const fullGame = firstReveal(), full = editor({ kind: 'or', terms: [windowClaim('thumper'), fact()] }, false, fullGame);
|
||
for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'windowCardPlay'); }
|
||
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
|
||
const deep = editor({ kind: 'and', terms: [fact(), windowClaim('thumper')] }, false, fullGame);
|
||
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
|
||
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
|
||
});
|
||
|
||
void test('Thumper play and same-source Ready guides forward exact native packets while first-turn history retains its printed warning', () => {
|
||
const played = firstReveal(), sent: Action[] = [];
|
||
Object.defineProperty(played.turnActionPromises[0], 'windowCards', { get() { throw Error('No private Thumper receipt read'); } });
|
||
const button = guide({ game: played, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], played.turnActionGuide!.action);
|
||
assert.equal(sent[0].type, 'card'); assert.equal(sent[0].card, 'controlled-thumper'); assert.equal(sent[0].turnActionPromisePhase, 1);
|
||
const alreadyReady = firstReveal(); alreadyReady.ready.push('p'); alreadyReady.thumperReady = false;
|
||
let latePlay = 0; const lateThumper = guide({ game: alreadyReady, busy: false, act() { latePlay++; } });
|
||
assert.equal(lateThumper.props.disabled, false); press(lateThumper); assert.equal(latePlay, 1, 'The native card quote can permit a held Thumper after own readiness.');
|
||
const keeper = firstReveal({ type: 'ready' }), readied: Action[] = [];
|
||
keeper.thumperCards = []; keeper.players.find(player => player.id === 'p')!.hand = [];
|
||
keeper.turnActionPromises[0].answer = false; keeper.turnActionPromises[0].mixed = windowClaim('thumper');
|
||
const ready = guide({ game: keeper, busy: false, act(action) { readied.push(action); } });
|
||
assert.equal(ready.props.disabled, false); press(ready); assert.equal(readied[0], keeper.turnActionGuide!.action);
|
||
assert.match(renderToStaticMarkup(ready), /Ready for the original first spice reveal/);
|
||
keeper.ready.push('p'); press(ready); assert.equal(readied.length, 1);
|
||
const summoned = firstReveal({ type: 'ready' }); summoned.nexus = true; summoned.thumperCards = [];
|
||
Object.defineProperty(summoned, 'summonedBeforeBlow', { get() { throw Error('Use the native public Ready quote, not private summoned timing'); } });
|
||
let summonedReady = 0; const quotedReady = guide({ game: summoned, busy: false, act() { summonedReady++; } });
|
||
assert.equal(quotedReady.props.disabled, false); press(quotedReady); assert.equal(summonedReady, 1);
|
||
const first = firstReveal(); first.turn = 1; first.turnActionGuide = null;
|
||
first.turnActionPromises[0].turn = 1; first.turnActionPromises[0].mixed = windowClaim('thumper', 'first-spice-reveal:1');
|
||
const html = markup(first); assert.match(html, /On turn 1, playing Thumper still discards the card/);
|
||
assert.match(html, /worm encounter is ignored and the normal Spice Card draw continues/);
|
||
assert.match(html, /closes when the actual first spice draw begins/); assert.doesNotMatch(html, /windowCards|controlled-thumper/);
|
||
assert.match(editor(windowClaim('thumper', 'first-spice-reveal:1'), false, first).html(), /On turn 1, playing Thumper still discards the card/);
|
||
});
|
||
|
||
void test('saved Thumper play and Ready callbacks reject original-source expiry and native modal changes', () => {
|
||
for (const action of [{ type: 'card', card: 'controlled-thumper' }, { type: 'ready' }]) for (const change of [
|
||
(game: GameView) => { game.thumperWindowOpen = false; },
|
||
(game: GameView) => { game.thumperReady = false; },
|
||
(game: GameView) => { game.nexus = true; },
|
||
(game: GameView) => { game.spiceWindow = { event: 'later-native-blow', territory: 'carthag', sector: 11, amount: 6, harvested: false }; },
|
||
(game: GameView) => { game.phaseOpening = { passed: [] }; },
|
||
(game: GameView) => { game.response = { kind: 'voice', owner: 'a', passed: [] }; },
|
||
(game: GameView) => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||
(game: GameView) => { game.turnActionPromises[0].mixed = windowClaim('thumper', 'first-spice-reveal:1'); },
|
||
]) {
|
||
const game = firstReveal(action), saved = guide({ game, busy: false, act() { assert.fail('A stale first-reveal native action cannot send'); } });
|
||
assert.equal(saved.props.disabled, false); change(game); press(saved);
|
||
}
|
||
for (const change of [
|
||
(game: GameView) => { game.thumperCards = []; },
|
||
(game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
|
||
(game: GameView) => { const card = game.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card); card.effect = 'ghola'; },
|
||
]) {
|
||
const game = firstReveal(), saved = guide({ game, busy: false, act() { assert.fail('A changed Thumper quote or physical card cannot send'); } });
|
||
assert.equal(saved.props.disabled, false); change(game); press(saved);
|
||
}
|
||
});
|
||
|
||
void test('Thumper guides reject unsupported native packets, unavailable quotes and generic preparation fallback', () => {
|
||
for (const extra of [{ amount: 0 }, { mode: 'shipment' }, { target: 'p' }, { leader: 'foreign-leader' }]) {
|
||
const game = firstReveal(); Object.assign(game.turnActionGuide!.action, extra);
|
||
const button = guide({ game, busy: false, act() { assert.fail('Thumper accepts only its exact native printed-play packet'); } });
|
||
assert.equal(button.props.disabled, true); press(button);
|
||
}
|
||
for (const phase of [0, 2, 4, 5, 6, 8]) {
|
||
const game = firstReveal(); game.phase = phase; game.active = 'p'; game.turnActionGuide!.action.turnActionPromisePhase = phase;
|
||
const button = guide({ game, busy: false, act() { assert.fail('A held Thumper cannot become generic native preparation'); } });
|
||
assert.equal(button.props.disabled, true); press(button);
|
||
}
|
||
const unquoted = firstReveal(); unquoted.thumperCards = [];
|
||
assert.equal(guide({ game: unquoted, busy: false, act() { assert.fail('The root native quote must make Thumper available'); } }).props.disabled, true);
|
||
const unrelated = firstReveal(); unrelated.turnActionPromises[0].mixed = payment();
|
||
assert.equal(guide({ game: unrelated, busy: false, act() { assert.fail('Thumper requires its original own raw first-reveal source'); } }).props.disabled, true);
|
||
const extraReady = firstReveal({ type: 'ready', amount: 0 });
|
||
assert.equal(guide({ game: extraReady, busy: false, act() { assert.fail('First-reveal Ready accepts no extra native fields'); } }).props.disabled, true);
|
||
const unavailableReady = firstReveal({ type: 'ready' }); unavailableReady.thumperReady = false;
|
||
assert.equal(guide({ game: unavailableReady, busy: false, act() { assert.fail('An open source alone cannot invent a native Ready stage'); } }).props.disabled, true);
|
||
});
|
||
|
||
void test('Thumper first-reveal questions retain the quoted phase-one Box route before the actual first draw', () => {
|
||
const game = firstReveal(), me = game.players.find(player => player.id === 'p')!;
|
||
const box = { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special' as const, effect: 'nullentropyBox' };
|
||
me.hand = [box]; game.nullentropy = { card: box, blocked: null, search: null };
|
||
game.turnActionGuide!.action = { type: 'card', card: box.id, turnActionPromiseEvent: event, turnActionPromisePhase: 1 };
|
||
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
game.thumperWindowOpen = false; press(button); assert.equal(sent.length, 1);
|
||
});
|
||
|
||
void test('Amal editor uses the public original phase opening and preserves repairable sixteen-leaf four-level expressions', () => {
|
||
const game = openingAmal(), e = editor(physical(), false, game);
|
||
for (const player of game.players) for (const key of ['hand', 'spice', 'forces', 'leaders', 'traitors'])
|
||
Object.defineProperty(player, key, { get() { throw Error(`No private Amal authoring ${key} read`); }, configurable: true });
|
||
for (const key of ['amalCards', 'amalReady']) Object.defineProperty(game, key, { get() { throw Error(`No native Amal authoring ${key} quote read`); } });
|
||
select(e.tree(), '1', 'action', 'windowCardPlay'); assert.deepEqual(e.value, windowClaim('amal')); assert.equal(e.error(), null);
|
||
assert.match(e.html(), /Original Revival opening/); assert.match(e.html(), /Amal halves available cash before ordinary payments become legal/);
|
||
game.phaseOpening!.passed.push('p'); assert.equal(e.error(), null, 'The public source is separate from individual native eligibility.');
|
||
game.phaseOpening = null; assert.equal(e.error(), null, 'The explicit original source survives temporary controls.');
|
||
select(e.tree(), '1', 'window-source', ''); assert.ok(e.error()); select(e.tree(), '1', 'window-source', 'phase-opening:2:4'); assert.equal(e.error(), null);
|
||
game.phase = 0; assert.ok(e.error()); select(e.tree(), '1', 'window-source', 'phase-opening:2:0'); assert.equal(e.error(), null);
|
||
game.amalWindowOpen = false; assert.ok(e.error()); assert.match(e.html(), /no current original open native window/);
|
||
const fullGame = openingAmal(), full = editor({ kind: 'or', terms: [windowClaim('amal'), fact()] }, false, fullGame);
|
||
for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'windowCardPlay'); }
|
||
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
|
||
const deep = editor({ kind: 'and', terms: [fact(), windowClaim('amal')] }, false, fullGame);
|
||
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
|
||
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
|
||
});
|
||
|
||
void test('Amal play and Ready guides use exact native packets and opening passes rather than ordinary readiness', () => {
|
||
for (const phase of [0, 4]) for (const ready of [false, true]) {
|
||
const game = openingAmal(phase, ready ? { type: 'ready' } : { type: 'card', card: 'controlled-amal' }), sent: Action[] = [];
|
||
for (const key of ['windowCards', 'compiled', 'preparation'])
|
||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private Amal ${key} read`); }, configurable: true });
|
||
if (ready) { game.amalCards = []; game.players.find(player => player.id === 'p')!.hand = []; }
|
||
assert.ok(game.ready.includes('p')); assert.ok(!game.phaseOpening!.passed.includes('p'));
|
||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.equal(sent[0].turnActionPromiseEvent, event); assert.equal(sent[0].turnActionPromisePhase, phase);
|
||
if (ready) assert.match(renderToStaticMarkup(button), /Pass this original phase opening/);
|
||
else assert.equal(sent[0].card, 'controlled-amal');
|
||
game.turnActionGuide = null;
|
||
const html = markup(game); assert.match(html, /condition closes with this opening/);
|
||
assert.match(html, /Amal halves available cash before ordinary payments become legal/); assert.doesNotMatch(html, /windowCards|controlled-amal/);
|
||
}
|
||
});
|
||
|
||
void test('saved Amal callbacks retain opening identity, passed list, original source, owner quotes and physical custody', () => {
|
||
for (const ready of [false, true]) for (const change of [
|
||
(game: GameView) => { game.phaseOpening = { passed: [] }; },
|
||
(game: GameView) => { game.phaseOpening!.passed.push('a'); },
|
||
(game: GameView) => { game.phaseOpening!.passed.push('p'); },
|
||
(game: GameView) => { game.phaseOpening = null; game.ready = []; },
|
||
(game: GameView) => { game.amalWindowOpen = false; },
|
||
(game: GameView) => { game.turnActionPromises[0].mixed = windowClaim('amal', 'phase-opening:2:3'); },
|
||
(game: GameView) => { game.response = { kind: 'voice', owner: 'a', passed: [] }; },
|
||
]) {
|
||
const game = openingAmal(4, ready ? { type: 'ready' } : { type: 'card', card: 'controlled-amal' });
|
||
const saved = guide({ game, busy: false, act() { assert.fail('An obsolete original-opening action cannot send'); } });
|
||
assert.equal(saved.props.disabled, false); change(game); press(saved);
|
||
}
|
||
const quoted = openingAmal(), saved = guide({ game: quoted, busy: false, act() { assert.fail('A removed physical Amal quote cannot send'); } });
|
||
quoted.amalCards = []; press(saved);
|
||
const cardGame = openingAmal(), cardButton = guide({ game: cardGame, busy: false, act() { assert.fail('A changed physical Amal cannot send'); } });
|
||
const card = cardGame.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card); card.effect = 'ghola'; press(cardButton);
|
||
const readyGame = openingAmal(4, { type: 'ready' }), readyButton = guide({ game: readyGame, busy: false, act() { assert.fail('A removed native Amal Ready quote cannot send'); } });
|
||
readyGame.amalReady = false; press(readyButton);
|
||
});
|
||
|
||
void test('Amal opening exception rejects extra fields, unquoted actions, Box and generic card fallback', () => {
|
||
for (const ready of [false, true]) for (const extra of [{ amount: 0 }, { mode: 'shipment' }, { target: 'p' }, { event: 'literal-event' }, { phase: 4 }]) {
|
||
const game = openingAmal(4, ready ? { type: 'ready' } : { type: 'card', card: 'controlled-amal' });
|
||
Object.assign(game.turnActionGuide!.action, extra);
|
||
const button = guide({ game, busy: false, act() { assert.fail('Only the original native Amal packet and existing envelope may send'); } });
|
||
assert.equal(button.props.disabled, true); press(button);
|
||
}
|
||
const unquoted = openingAmal(); unquoted.amalCards = [];
|
||
assert.equal(guide({ game: unquoted, busy: false, act() { assert.fail('The actual Amal card must be natively quoted'); } }).props.disabled, true);
|
||
const unavailableReady = openingAmal(4, { type: 'ready' }); unavailableReady.amalReady = false; unavailableReady.ready = [];
|
||
assert.equal(guide({ game: unavailableReady, busy: false, act() { assert.fail('Ordinary Ready cannot bypass an unavailable opening Ready'); } }).props.disabled, true);
|
||
const outside = openingAmal(); outside.phaseOpening = null;
|
||
assert.equal(guide({ game: outside, busy: false, act() { assert.fail('An explicit source alone does not create a native Amal action'); } }).props.disabled, true);
|
||
const other = openingAmal(5, { type: 'card', card: 'controlled-owned-karama' }); other.ready = []; other.active = 'p';
|
||
assert.equal(guide({ game: other, busy: false, act() { assert.fail('Other native cards keep the phase-opening veto'); } }).props.disabled, true);
|
||
const boxed = openingAmal(0), me = boxed.players.find(player => player.id === 'p')!;
|
||
const box = { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special' as const, effect: 'nullentropyBox' };
|
||
me.hand = [box]; boxed.nullentropy = { card: box, blocked: null, search: null };
|
||
boxed.turnActionGuide!.action = { type: 'card', card: box.id, turnActionPromiseEvent: event, turnActionPromisePhase: 0 };
|
||
assert.equal(guide({ game: boxed, busy: false, act() { assert.fail('A quoted Box cannot bypass the native opening priority'); } }).props.disabled, true);
|
||
});
|
||
|
||
void test('native turn-action answers work in an unchanged quoted opening and reject changed passed or modal sources', () => {
|
||
for (const mixed of [windowClaim('amal'), claim()]) {
|
||
const game = answering(4); game.phaseOpening = { passed: ['a'] }; game.amalWindowOpen = true;
|
||
game.truthtrance!.question = { kind: 'turnActions', target: 'p', turn: 2, mixed };
|
||
const sent: Action[] = [], tree = TurnActionAnswers({ game, busy: false, act(action) { sent.push(action); } });
|
||
const button = find(tree, 'data-turn-action-answer', 'yes'); assert.equal(button.props.disabled, false); click(button);
|
||
assert.deepEqual(sent, [{ type: 'truthAnswer', answer: 'yes' }]);
|
||
game.phaseOpening.passed.push('r'); click(button); assert.equal(sent.length, 1);
|
||
}
|
||
for (const change of [
|
||
(game: GameView) => { game.phaseOpening = { passed: [] }; },
|
||
(game: GameView) => { game.phaseOpening = null; },
|
||
(game: GameView) => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||
]) {
|
||
const game = answering(4); game.phaseOpening = { passed: [] };
|
||
const button = find(TurnActionAnswers({ game, busy: false, act() { assert.fail('A changed opening answer source cannot send'); } }), 'data-turn-action-answer', 'yes');
|
||
assert.equal(button.props.disabled, false); change(game); click(button);
|
||
}
|
||
});
|
||
|
||
void test('turn-action printed-role editor uses public names without holdings or private stages and retains the shared logical budget', () => {
|
||
const game = projected();
|
||
for (const player of game.players) for (const key of ['hand', 'spice', 'traitors', 'faceDancers', 'tanks', 'leaders', 'hajrAvailable'])
|
||
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} authoring inspection`); }, configurable: true });
|
||
Object.defineProperty(game, 'ghola', { get() { throw Error('No private Ghola authoring inspection'); }, configurable: true });
|
||
Object.defineProperty(game, 'nullentropy', { get() { throw Error('No private Box authoring inspection'); }, configurable: true });
|
||
Object.defineProperty(game, 'distrans', { get() { throw Error('No private Distrans authoring inspection'); }, configurable: true });
|
||
Object.defineProperty(game, 'saphoOptions', { get() { throw Error('No private Sapho authoring inspection'); }, configurable: true });
|
||
Object.defineProperty(game, 'semutaReaction', { get() { throw Error('No private Semuta authoring inspection'); }, configurable: true });
|
||
Object.defineProperty(game, 'ornithopter', { get() { throw Error('No private flight authoring inspection'); }, configurable: true });
|
||
Object.defineProperty(game, 'residualPoison', { get() { throw Error('No private poison authoring inspection'); }, configurable: true });
|
||
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private card-play stage'); } });
|
||
const e = editor(physical(), false, game); select(e.tree(), '1', 'action', 'cardPlay');
|
||
assert.deepEqual(e.value, printedCard()); assert.equal(e.error(), null);
|
||
const choices = elements(find(e.tree(), 'id', 'truth-turn-action-1-effect')).filter(element => element.type === 'option');
|
||
assert.deepEqual(choices.map(choice => choice.props.value), ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug', 'ornithopter', 'residualPoison']);
|
||
select(e.tree(), '1', 'effect', 'ghola'); assert.deepEqual(e.value, printedCard('ghola')); assert.equal(e.error(), null);
|
||
select(e.tree(), '1', 'effect', 'karama'); assert.deepEqual(e.value, printedCard('ghola'));
|
||
select(e.tree(), '1', 'effect', 'nullentropyBox'); assert.deepEqual(e.value, printedCard('nullentropyBox')); assert.equal(e.error(), null);
|
||
for (const effect of ['distrans', 'juiceOfSapho', 'semutaDrug', 'ornithopter', 'residualPoison'] as const) {
|
||
select(e.tree(), '1', 'effect', effect); assert.deepEqual(e.value, printedCard(effect)); assert.equal(e.error(), null);
|
||
for (const extra of [{ target: 'r' }, { give: 'private-card' }, { scope: 'movement' }, { event: 'private-source' }, { mode: 'first' },
|
||
{ territory: 'carthag' }, { ornithopter: 'range3' }, { victim: 'private-leader' }])
|
||
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect, ...extra } }, game, 'p'));
|
||
}
|
||
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect: 'ghola', card: 'private-card-id' } }, game, 'p'));
|
||
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect: 'karama' } }, game, 'p'));
|
||
for (const extra of [{ card: 'private-box-id' }, { selected: 'private-recovery' }, { amount: 2 }])
|
||
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect: 'nullentropyBox', ...extra } }, game, 'p'));
|
||
const draft = editor({ kind: 'action', action: { kind: 'cardPlay', effect: 'unknown-role' } } as unknown as Expression, false, game);
|
||
assert.ok(draft.error()); select(draft.tree(), '1', 'effect', 'nullentropyBox');
|
||
assert.deepEqual(draft.value, printedCard('nullentropyBox')); assert.equal(draft.error(), null);
|
||
const html = e.html(); assert.match(html, /Play Hajr in its printed role after this answer this turn/);
|
||
assert.match(html, /Play Tleilaxu Ghola in its printed role after this answer this turn/);
|
||
assert.match(html, /Activate Nullentropy Box and pay 2 spice after this answer this turn/);
|
||
assert.match(html, /The selected recovered card stays private and is not promised/);
|
||
assert.match(html, /Play Distrans in its printed role after this answer this turn/);
|
||
assert.match(html, /Play Juice of Sapho in its printed role after this answer this turn/);
|
||
assert.match(html, /Complete a printed Semuta Drug transfer and disposal after this answer this turn/);
|
||
assert.match(html, /Committing to inspect multiple candidates, an earlier play or recovery alone does not count/);
|
||
assert.match(html, /Declare a physical Ornithopter flight after this answer this turn/);
|
||
assert.match(html, /Complete a printed Residual Poison play after this answer this turn/);
|
||
assert.match(html, /Later groups and disposal do not count again/);
|
||
assert.match(html, /not a particular recipient, transferred card or order outcome/);
|
||
assert.match(html, /Hajr counts when played even if its extra movement is unused/);
|
||
assert.match(html, /an actual later replay after Box recovery does/); assert.doesNotMatch(html, /private-card-id|controlled-ghola|controlled-hajr/);
|
||
const full = editor({ kind: 'or', terms: [printedCard(), printedCard('nullentropyBox')] }, false, game);
|
||
for (let count = 3; count <= 16; count++) {
|
||
click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'cardPlay');
|
||
select(full.tree(), `1.${count}`, 'effect', count % 2 ? 'ornithopter' : 'residualPoison');
|
||
}
|
||
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
|
||
assert.equal(control(full.tree(), 'add-action-1').props.disabled, true);
|
||
const deep = editor({ kind: 'and', terms: [fact(), printedCard('residualPoison')] }, false, game);
|
||
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
|
||
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
|
||
const frozen = editor(printedCard(), true, game); select(frozen.tree(), '1', 'effect', 'ghola');
|
||
select(frozen.tree(), '1', 'action', 'payment'); assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, printedCard());
|
||
});
|
||
|
||
void test('printed Ghola guides forward exact native force, leader and KH choices out of turn and reject unavailable or forged choices', () => {
|
||
for (let phase = 0; phase <= 8; phase++) {
|
||
const game = projected(phase, { type: 'card', card: 'controlled-ghola', amount: 3, elite: 1 }); game.active = 'a'; game.advanced = true;
|
||
game.turnActionPromises[0].mixed = printedCard('ghola');
|
||
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Ghola stage inspection'); } });
|
||
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.match(markup(game), /Play Tleilaxu Ghola to return 3 physical forces/);
|
||
}
|
||
for (const leader of ['controlled-dead-leader', 'kwisatz']) {
|
||
const game = projected(7, { type: 'card', card: 'controlled-ghola', leader }); game.active = 'a';
|
||
if (leader === 'kwisatz') { game.advanced = true; game.players.find(player => player.id === 'p')!.faction = 'atreides'; game.ghola.kwisatz = true; }
|
||
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.match(markup(game), leader === 'kwisatz' ? /return Kwisatz Haderach/ : /return Returned Commander/);
|
||
if (leader === 'kwisatz') game.ghola.kwisatz = false; else game.ghola.leaders = [];
|
||
press(button); assert.equal(sent.length, 1);
|
||
}
|
||
for (const change of [
|
||
(game: GameView) => { game.ghola = { ...game.ghola }; }, (game: GameView) => { game.ghola.available = false; },
|
||
(game: GameView) => { game.ghola.cards = []; }, (game: GameView) => { game.ghola.maxForces = 0; },
|
||
(game: GameView) => { game.ghola.eliteRemaining = 0; }, (game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
|
||
(game: GameView) => { game.phase = 7; }, (game: GameView) => { game.truthtrance = answering(2).truthtrance; },
|
||
]) {
|
||
const game = projected(2, { type: 'card', card: 'controlled-ghola', amount: 3, elite: 1 }), sent: Action[] = [];
|
||
const button = guide({ game, busy: false, act(action) { sent.push(action); } }); assert.equal(button.props.disabled, false);
|
||
change(game); press(button); assert.deepEqual(sent, []);
|
||
}
|
||
for (const malformed of [
|
||
{ card: 'missing-ghola' }, { amount: 0 }, { amount: 6 }, { amount: 1.5 }, { amount: 3, elite: 2 }, { amount: 3, elite: -1 },
|
||
{ amount: 5, elite: 0 }, { leader: 'foreign-living-leader' }, { mode: 'shipment' },
|
||
]) {
|
||
const game = projected(2, { type: 'card', card: 'controlled-ghola', amount: 3, elite: 1 });
|
||
Object.assign(game.turnActionGuide!.action, malformed);
|
||
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, true); press(button); assert.deepEqual(sent, []);
|
||
}
|
||
});
|
||
|
||
void test('printed Hajr guides require the actual held card and current own immediate Movement opportunity', () => {
|
||
for (const advanced of [false, true]) {
|
||
const game = projected(5, { type: 'card', card: 'controlled-hajr' }); game.advanced = advanced;
|
||
game.turnActionPromises[0].mixed = printedCard();
|
||
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Hajr stage inspection'); } });
|
||
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.match(markup(game), /Play Hajr in its printed role while honoring your whole answer/);
|
||
}
|
||
for (const change of [
|
||
(game: GameView) => { game.players.find(player => player.id === 'p')!.hajrAvailable = false; },
|
||
(game: GameView) => { const player = game.players.find(player => player.id === 'p')!; player.moved = (player.moved ?? 0) + 1; },
|
||
(game: GameView) => { game.players.find(player => player.id === 'p')!.hajrPlays = 1; },
|
||
(game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
|
||
(game: GameView) => { game.active = 'a'; }, (game: GameView) => { game.phase = 4; },
|
||
(game: GameView) => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||
]) {
|
||
const game = projected(5, { type: 'card', card: 'controlled-hajr' }), sent: Action[] = [];
|
||
const button = guide({ game, busy: false, act(action) { sent.push(action); } }); assert.equal(button.props.disabled, false);
|
||
change(game); press(button); assert.deepEqual(sent, []);
|
||
}
|
||
for (const phase of [0, 2, 4, 6, 8]) {
|
||
const game = projected(phase, { type: 'card', card: 'controlled-hajr' }), sent: Action[] = [];
|
||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, true); press(button); assert.deepEqual(sent, []);
|
||
}
|
||
const missing = projected(5, { type: 'card', card: 'controlled-hajr' }); missing.turnActionGuide!.action.card = 'missing-hajr';
|
||
assert.equal(guide({ game: missing, busy: false, act() { assert.fail('A missing Hajr cannot dispatch'); } }).props.disabled, true);
|
||
});
|
||
|
||
void test('printed Box guides activate the quoted physical card without inspecting private recovery choices', () => {
|
||
for (const phase of [0, 6, 8]) {
|
||
const game = boxPlay(phase), sent: Action[] = [];
|
||
Object.defineProperty(game, 'discard', { get() { throw Error('The activation guide does not search the discard'); }, configurable: true });
|
||
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Box receipt inspection'); } });
|
||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.deepEqual(sent[0], { type: 'card', card: 'controlled-box', turnActionPromiseEvent: event, turnActionPromisePhase: phase });
|
||
const html = markup(game);
|
||
assert.match(html, /Pay 2 spice to activate Nullentropy Box/);
|
||
assert.match(html, /you activate Nullentropy Box in its printed role and pay its 2-spice search cost/);
|
||
assert.match(html, /The selected recovered card stays private and is not promised/);
|
||
assert.match(html, /complete the paid search with the existing native controls/);
|
||
assert.doesNotMatch(html, /cardPlays|controlled-box|recovered-card-id/);
|
||
}
|
||
});
|
||
|
||
void test('printed Box activation callbacks reject changed native quotes, custody, source and paid-search state', () => {
|
||
const changes: ((game: GameView) => void)[] = [
|
||
game => { game.nullentropy = null; },
|
||
game => { game.nullentropy = { ...game.nullentropy! }; },
|
||
game => { game.nullentropy!.blocked = 'The printed search cost is not currently available.'; },
|
||
game => { game.nullentropy!.card = { ...game.nullentropy!.card, id: 'different-box' }; },
|
||
game => { game.players.find(player => player.id === 'p')!.hand = []; },
|
||
game => { game.players.find(player => player.id === 'p')!.hand![0].kind = 'worthless'; },
|
||
game => { game.players.find(player => player.id === 'p')!.spice = 2; },
|
||
game => { game.players.find(player => player.id === 'p')!.handLimit = 5; },
|
||
game => { game.turnActionPromises[0].mixed = payment(); },
|
||
game => { game.phase = 8; },
|
||
game => { game.turnActionGuide!.event = 'different-answer'; },
|
||
game => { game.phaseOpening = { passed: [] }; },
|
||
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||
game => {
|
||
game.nullentropy!.search = { event: 'now-paid-search', cards: [] };
|
||
Object.defineProperty(game.nullentropy!.search, 'cards', { get() { throw Error('An activation guide cannot inspect paid recovery choices'); } });
|
||
},
|
||
];
|
||
for (const change of changes) {
|
||
const game = boxPlay(2), sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
|
||
}
|
||
for (const change of [
|
||
(game: GameView) => { game.nullentropy = null; },
|
||
(game: GameView) => { game.nullentropy!.blocked = 'No legal search is currently quoted.'; },
|
||
(game: GameView) => { game.nullentropy!.search = { event: 'already-paid', cards: [] }; },
|
||
]) {
|
||
const game = boxPlay(); change(game);
|
||
const button = guide({ game, busy: false, act() { assert.fail('An unavailable or already paid Box cannot activate'); } });
|
||
assert.equal(button.props.disabled, true); press(button);
|
||
}
|
||
});
|
||
|
||
void test('printed Box activation accepts no private selection or extra native parameters through generic preparation', () => {
|
||
for (const extra of [{ amount: 2 }, { mode: 'shipment' }, { target: 'a' }, { leader: 'private-leader' },
|
||
{ selected: 'recovered-card-id' }, { cards: ['recovered-card-id'] }]) {
|
||
const game = boxPlay(2); Object.assign(game.turnActionGuide!.action, extra);
|
||
const button = guide({ game, busy: false, act() { assert.fail('A Box activation must remain the exact quoted card packet'); } });
|
||
assert.equal(button.props.disabled, true); press(button);
|
||
}
|
||
const game = boxPlay(8); game.turnActionPromises[0].mixed = payment();
|
||
assert.equal(guide({ game, busy: false, act() { assert.fail('A turn-end Box activation requires its matching raw role'); } }).props.disabled, true);
|
||
});
|
||
|
||
void test('printed Distrans guide forwards its exact owned transfer quote without inspecting foreign hands', () => {
|
||
const game = distransPlay(), sent: Action[] = [];
|
||
for (const player of game.players.filter(player => player.id !== 'p'))
|
||
Object.defineProperty(player, 'hand', { get() { throw Error('Distrans uses the native capacity quote, not foreign hands'); }, configurable: true });
|
||
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Distrans receipt inspection'); } });
|
||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.deepEqual(sent[0], { type: 'card', card: game.distrans!.card.id, target: 'r', give: game.distrans!.choices[0].cards[0].id,
|
||
turnActionPromiseEvent: event, turnActionPromisePhase: 2 });
|
||
assert.match(markup(game), /Give Shield to Recipient with Distrans/);
|
||
});
|
||
|
||
void test('printed Distrans callbacks reject stale availability, physical cards, capacity and raw source', () => {
|
||
const changes: ((game: GameView) => void)[] = [
|
||
game => { game.distrans = null; },
|
||
game => { game.distrans = { ...game.distrans! }; },
|
||
game => { game.distrans!.blocked = 'Finish the current native interaction.'; },
|
||
game => { game.distrans!.choices[0].blocked = 'The recipient is full.'; },
|
||
game => { game.distrans!.choices[0].cards = []; },
|
||
game => { game.players.find(player => player.id === 'p')!.hand!.pop(); },
|
||
game => { game.players.find(player => player.id === 'r')!.handCount = 4; },
|
||
game => { game.players.find(player => player.id === 'r')!.handLimit = 3; },
|
||
game => { game.turnActionPromises[0].mixed = payment(); },
|
||
game => { game.phase = 3; },
|
||
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||
game => { game.turnActionGuide!.action.give = 'different-held-card'; },
|
||
];
|
||
for (const change of changes) {
|
||
const game = distransPlay(), sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
|
||
}
|
||
for (const change of [
|
||
(game: GameView) => { game.turnActionGuide!.action.target = 'p'; },
|
||
(game: GameView) => { game.turnActionGuide!.action.target = 'a'; },
|
||
(game: GameView) => { game.turnActionGuide!.action.give = game.distrans!.card.id; },
|
||
(game: GameView) => { game.turnActionGuide!.action.give = 'unquoted-card'; },
|
||
]) {
|
||
const game = distransPlay(); change(game);
|
||
const button = guide({ game, busy: false, act() { assert.fail('Distrans must use its current quoted recipient and other held card'); } });
|
||
assert.equal(button.props.disabled, true); press(button);
|
||
}
|
||
});
|
||
|
||
void test('printed Sapho guides forward every quoted native scope and retain display metadata outside the action', () => {
|
||
const options: [number, NonNullable<GameView['saphoOptions']>[number]][] = [
|
||
[3, { scope: 'onceAround', event: 'controlled-once-around', mode: 'first' }],
|
||
[3, { scope: 'cyclicAuction', event: 'controlled-cyclic', mode: 'last' }],
|
||
[4, { scope: 'revival', event: 'controlled-revival', mode: 'last', afterCurrent: 'a' }],
|
||
[5, { scope: 'movement', event: 'controlled-movement', mode: 'first', afterCurrent: 'a' }],
|
||
[6, { scope: 'battleOrder', event: 'controlled-battle-order', mode: 'last' }],
|
||
[6, { scope: 'battleAggressor', event: 'controlled-aggressor', mode: 'aggressor' }],
|
||
[8, { scope: 'extortion', event: 'controlled-extortion-order', mode: 'first' }],
|
||
];
|
||
for (const [phase, option] of options) {
|
||
const game = saphoPlay(phase, option), sent: Action[] = [];
|
||
if (option.scope === 'extortion') game.decision = { kind: 'moritaniExtortion', player: 'p', event: 'controlled-native-extortion' };
|
||
for (const player of game.players.filter(player => player.id !== 'p'))
|
||
Object.defineProperty(player, 'hand', { get() { throw Error('Sapho uses the native option, not foreign hands'); }, configurable: true });
|
||
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Sapho receipt inspection'); } });
|
||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.deepEqual(sent[0], { type: 'card', card: 'richese-juice-of-sapho', scope: option.scope, event: option.event, mode: option.mode,
|
||
turnActionPromiseEvent: event, turnActionPromisePhase: phase });
|
||
assert.equal(Object.hasOwn(sent[0], 'afterCurrent'), false);
|
||
assert.match(markup(game), /Play Juice of Sapho:/);
|
||
}
|
||
});
|
||
|
||
void test('printed Sapho callbacks and both role guides reject stale quotes or modified packets without generic fallback', () => {
|
||
for (const change of [
|
||
(game: GameView) => { game.saphoOptions = []; },
|
||
(game: GameView) => { game.saphoOptions = [...game.saphoOptions]; },
|
||
(game: GameView) => { game.saphoOptions[0].event = 'different-native-source'; },
|
||
(game: GameView) => { game.saphoOptions[0].mode = 'first'; },
|
||
(game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
|
||
(game: GameView) => { game.players.find(player => player.id === 'p')!.handLimit = 5; },
|
||
(game: GameView) => { game.turnActionPromises[0].mixed = payment(); },
|
||
(game: GameView) => { game.phaseOpening = { passed: [] }; },
|
||
(game: GameView) => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||
]) {
|
||
const game = saphoPlay(), sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
|
||
}
|
||
for (const change of [
|
||
(game: GameView) => {
|
||
const current = game.decision; assert.ok(current?.kind === 'moritaniExtortion');
|
||
game.decision = { ...current, event: 'next-native-extortion' };
|
||
},
|
||
(game: GameView) => { game.decision = null; },
|
||
]) {
|
||
const game = saphoPlay(8, { scope: 'extortion', event: 'controlled-extortion-order', mode: 'first' });
|
||
game.decision = { kind: 'moritaniExtortion', player: 'p', event: 'controlled-native-extortion' };
|
||
const saved = guide({ game, busy: false, act() { assert.fail('A changed Extortion source cannot submit an earlier Sapho option'); } });
|
||
assert.equal(saved.props.disabled, false); change(game); press(saved);
|
||
}
|
||
for (const create of [distransPlay, () => saphoPlay(4, { scope: 'revival', event: 'controlled-revival', mode: 'last' })]) {
|
||
for (const extra of [{ amount: 1 }, { mode: 'shipment' }, { target: 'a' }, { give: 'unquoted-card' },
|
||
{ scope: 'unsupported' }, { event: 'different-source' }, { afterCurrent: 'a' }]) {
|
||
const game = create(); Object.assign(game.turnActionGuide!.action, extra);
|
||
const button = guide({ game, busy: false, act() { assert.fail('Printed-role guides forward only the exact native action fields'); } });
|
||
assert.equal(button.props.disabled, true); press(button);
|
||
}
|
||
const game = create(), sent: Action[] = [], props = { game, busy: false, act(action: Action) { sent.push(action); } }, button = guide(props);
|
||
assert.equal(button.props.disabled, false); props.busy = true; press(button); assert.deepEqual(sent, []);
|
||
}
|
||
});
|
||
|
||
void test('Semuta offer guides forward exact native commit or pass without reading candidates or holdings', () => {
|
||
for (const type of ['semutaCommit', 'semutaPass']) {
|
||
const game = semutaPlay('offer', { type, event: 'controlled-fresh-discard' }), sent: Action[] = [];
|
||
if (type === 'semutaPass') {
|
||
game.semutaReaction!.canCommit = false; game.semutaReaction!.blocked = 'The physical card is unavailable.';
|
||
game.turnActionPromises[0].answer = false;
|
||
}
|
||
Object.defineProperty(game.semutaReaction!, 'candidates', { get() { throw Error('Offer guides cannot inspect private candidates'); } });
|
||
for (const player of game.players)
|
||
Object.defineProperty(player, 'hand', { get() { throw Error('The native Semuta quote already owns custody'); }, configurable: true });
|
||
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Semuta receipt inspection'); } });
|
||
game.decision = { kind: 'auctionPayment', player: 'a' };
|
||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.deepEqual(sent[0], { type, event: 'controlled-fresh-discard', turnActionPromiseEvent: event, turnActionPromisePhase: 3 });
|
||
assert.match(markup(game), type === 'semutaCommit' ? /Commit Semuta Drug to this fresh discard/ : /Continue without committing Semuta Drug/);
|
||
}
|
||
});
|
||
|
||
void test('Semuta selection guides use only the actual current private candidate and cannot pass or commit again', () => {
|
||
const game = semutaPlay('select'), sent: Action[] = [];
|
||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.match(markup(game), /Complete Semuta Drug by claiming Shield/);
|
||
assert.match(markup(game), /Committing to inspect multiple candidates, an earlier play or recovery alone does not count/);
|
||
for (const type of ['semutaPass', 'semutaCommit']) {
|
||
const choosing = semutaPlay('select', { type, event: 'controlled-fresh-discard' });
|
||
const disabled = guide({ game: choosing, busy: false, act() { assert.fail('A committed private choice cannot be canceled'); } });
|
||
assert.equal(disabled.props.disabled, true); press(disabled);
|
||
}
|
||
for (const change of [
|
||
(state: GameView) => { state.semutaReaction!.candidates = []; },
|
||
(state: GameView) => { state.turnActionGuide!.action.card = 'foreign-or-unquoted-card'; },
|
||
]) {
|
||
const choosing = semutaPlay('select'); change(choosing);
|
||
const disabled = guide({ game: choosing, busy: false, act() { assert.fail('Only the actual selector receives a legal current candidate'); } });
|
||
assert.equal(disabled.props.disabled, true); press(disabled);
|
||
}
|
||
});
|
||
|
||
void test('Semuta whole guides reject stale native stages, events, ownership, choices and parent modals', () => {
|
||
const changes: ((game: GameView) => void)[] = [
|
||
game => { game.semutaReaction = null; },
|
||
game => { game.semutaReaction = { ...game.semutaReaction! }; },
|
||
game => { game.semutaReaction!.event = 'next-fresh-discard'; },
|
||
game => { game.semutaReaction!.stage = 'select'; },
|
||
game => { game.semutaReaction!.passed = true; },
|
||
game => { game.semutaReaction!.canCommit = false; },
|
||
game => { game.semutaReaction!.blocked = 'The physical card became reserved.'; },
|
||
game => { game.semutaReaction!.passBlocked = 'The original whole answer now requires this exchange.'; },
|
||
game => { game.turnActionPromises[0].mixed = payment(); },
|
||
game => { game.me = 'a'; },
|
||
game => { game.phase = 4; },
|
||
game => { game.turnActionGuide!.action.event = 'old-offer-event'; },
|
||
game => { game.decision = { kind: 'auctionPayment', player: 'a' }; },
|
||
];
|
||
for (const change of changes) {
|
||
const game = semutaPlay(), sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
|
||
}
|
||
for (const change of [
|
||
(game: GameView) => { game.semutaReaction!.candidates = []; },
|
||
(game: GameView) => { game.semutaReaction!.candidates[0].kind = 'worthless'; },
|
||
]) {
|
||
const choosing = semutaPlay('select'), sent: Action[] = [], saved = guide({ game: choosing, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(saved.props.disabled, false); change(choosing); press(saved); assert.deepEqual(sent, []);
|
||
}
|
||
const props = { game: semutaPlay(), busy: false, act() { assert.fail('Busy Semuta callbacks cannot send'); } }, busy = guide(props);
|
||
assert.equal(busy.props.disabled, false); props.busy = true; press(busy);
|
||
for (const field of ['paused', 'closed'] as const) {
|
||
const game = semutaPlay(); game.roomControl = { paused: false, joinLocked: false, revision: 1, updatedAt: null, [field]: true };
|
||
assert.equal(guide({ game, busy: false, act() { assert.fail('Room controls retain priority'); } }).props.disabled, true);
|
||
}
|
||
});
|
||
|
||
void test('Semuta guides reject extra fields, offer-stage selection and fake card-role actions without fallback', () => {
|
||
for (const type of ['semutaCommit', 'semutaPass', 'semutaSelect']) for (const extra of [{ amount: 1 }, { target: 'a' }, { mode: 'claim' }, { give: 'private-card' }, { card: 'unopened-candidate' }]) {
|
||
const game = semutaPlay(type === 'semutaSelect' ? 'select' : 'offer', { type, event: 'controlled-fresh-discard',
|
||
...(type === 'semutaSelect' ? { card: baseDeck().find(card => card.name === 'Shield')!.id } : {}), ...extra });
|
||
const button = guide({ game, busy: false, act() { assert.fail('Only the exact native Semuta packet is permitted'); } });
|
||
assert.equal(button.props.disabled, true); press(button);
|
||
}
|
||
const offer = semutaPlay('offer', { type: 'semutaSelect', event: 'controlled-fresh-discard', card: 'unopened-candidate' });
|
||
Object.defineProperty(offer.semutaReaction!, 'candidates', { get() { throw Error('Offer-stage selection cannot inspect candidates'); } });
|
||
assert.equal(guide({ game: offer, busy: false, act() { assert.fail('A private choice has not opened'); } }).props.disabled, true);
|
||
const physical = richeseCards().find(card => card.effect === 'semutaDrug'); assert.ok(physical);
|
||
const forged = projected(2, { type: 'card', card: physical.id }); forged.players.find(player => player.id === 'p')!.hand = [physical];
|
||
forged.turnActionPromises[0].mixed = printedCard('semutaDrug');
|
||
assert.equal(guide({ game: forged, busy: false, act() { assert.fail('Semuta is played through a real fresh-discard reaction'); } }).props.disabled, true);
|
||
const unrelated = projected(); unrelated.semutaReaction = semutaPlay().semutaReaction;
|
||
assert.equal(guide({ game: unrelated, busy: false, act() { assert.fail('A pending fresh discard owns its native controls'); } }).props.disabled, true);
|
||
});
|
||
|
||
void test('Ornithopter guides preserve exact native initial modes and played-zone continuation without requiring a new role atom', () => {
|
||
for (const mode of ['range3', 'twoGroups'] as const) {
|
||
const game = flightPlay(mode), sent: Action[] = [];
|
||
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private flight receipt inspection'); } });
|
||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.equal(sent[0].movementCard, 'richese-ornithopter'); assert.equal(sent[0].ornithopter, mode);
|
||
assert.equal(Object.hasOwn(sent[0], 'ornithopterEvent'), false);
|
||
assert.deepEqual(sent[0].eliteForces, { 'arrakeen:10': 1 }); assert.equal(sent[0].fighters, false);
|
||
assert.match(markup(game), /Declare Ornithopter/);
|
||
}
|
||
const game = flightPlay('twoGroups', true), sent: Action[] = [];
|
||
game.turnActionPromises[0].mixed = claim();
|
||
assert.equal(game.players.find(player => player.id === 'p')!.hand!.length, 0);
|
||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.equal(sent[0].ornithopterEvent, 'controlled-flight'); assert.equal(Object.hasOwn(sent[0], 'movementCard'), false);
|
||
assert.equal(Object.hasOwn(sent[0], 'ornithopter'), false);
|
||
const preparation = flightPlay(); preparation.turnActionPromises[0].mixed = claim();
|
||
assert.equal(guide({ game: preparation, busy: false, act() {} }).props.disabled, false);
|
||
});
|
||
|
||
void test('Ornithopter quotes and active events cannot be bypassed by generic move packets or stale callbacks', () => {
|
||
for (const change of [
|
||
(game: GameView) => { game.ornithopter = null; },
|
||
(game: GameView) => { game.ornithopter = { ...game.ornithopter! }; },
|
||
(game: GameView) => { game.ornithopter!.modes[0].blocked = 'A binding No prevents this printed use.'; },
|
||
(game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
|
||
(game: GameView) => { game.players.find(player => player.id === 'p')!.forces['arrakeen:10'] = 1; },
|
||
(game: GameView) => { game.players.find(player => player.id === 'p')!.moved = 1; },
|
||
(game: GameView) => { game.active = 'a'; },
|
||
(game: GameView) => { game.turnActionPromises[0].mixed = payment(); },
|
||
(game: GameView) => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||
]) {
|
||
const game = flightPlay(), sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
|
||
}
|
||
for (const extra of [{ movementCard: 'unheld-flight-card' }, { ornithopter: 'unsupported' }, { ornithopterEvent: 'old-flight' },
|
||
{ target: 'a' }, { leader: 'private-leader' }, { mode: 'range3' }]) {
|
||
const game = flightPlay(); Object.assign(game.turnActionGuide!.action, extra);
|
||
assert.equal(guide({ game, busy: false, act() { assert.fail('A new flight must use the current native declaration'); } }).props.disabled, true);
|
||
}
|
||
for (const extra of [{ ornithopterEvent: 'old-flight' }, { movementCard: 'richese-ornithopter' }, { ornithopter: 'twoGroups' }]) {
|
||
const game = flightPlay('twoGroups', true); Object.assign(game.turnActionGuide!.action, extra);
|
||
assert.equal(guide({ game, busy: false, act() { assert.fail('A continuation cannot declare or recount its played card'); } }).props.disabled, true);
|
||
}
|
||
const missingEvent = flightPlay('twoGroups', true); delete missingEvent.turnActionGuide!.action.ornithopterEvent;
|
||
assert.equal(guide({ game: missingEvent, busy: false, act() { assert.fail('An active flight requires its current event'); } }).props.disabled, true);
|
||
const current = flightPlay('twoGroups', true), sent: Action[] = [], saved = guide({ game: current, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(saved.props.disabled, false); current.ornithopter!.active!.event = 'next-flight'; press(saved); assert.deepEqual(sent, []);
|
||
const direct = flightPlay(); direct.turnActionGuide!.action = { type: 'card', card: 'richese-ornithopter',
|
||
turnActionPromiseEvent: event, turnActionPromisePhase: 5 };
|
||
assert.equal(guide({ game: direct, busy: false, act() { assert.fail('Ornithopter requires a physical native flight declaration'); } }).props.disabled, true);
|
||
const no = flightPlay(); no.turnActionPromises[0].answer = false; no.ornithopter!.blocked = 'A binding No prevents this printed use.';
|
||
assert.equal(guide({ game: no, busy: false, act() { assert.fail('The generic route cannot bypass the native No quote'); } }).props.disabled, true);
|
||
});
|
||
|
||
void test('Residual Poison guides use the exact canonical card and actual current opponent without inspecting leaders', () => {
|
||
const game = residualPlay(), sent: Action[] = [];
|
||
for (const player of game.players) {
|
||
Object.defineProperty(player, 'leaders', { get() { throw Error('The native quote owns the random victim pool'); }, configurable: true });
|
||
if (player.id !== 'p') Object.defineProperty(player, 'hand', { get() { throw Error('No foreign custody inspection'); }, configurable: true });
|
||
}
|
||
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private poison receipt inspection'); } });
|
||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.deepEqual(sent[0], { type: 'card', card: 'richese-residual-poison', event: 'controlled-native-battle', target: 'a',
|
||
turnActionPromiseEvent: event, turnActionPromisePhase: 6 });
|
||
assert.match(markup(game), /Play Residual Poison against Asker/); assert.match(markup(game), /the native leader death is random/);
|
||
});
|
||
|
||
void test('Residual Poison rejects blocked No quotes, stale battles, false targets and victim selectors without generic fallback', () => {
|
||
for (const change of [
|
||
(game: GameView) => { game.residualPoison = null; },
|
||
(game: GameView) => { game.residualPoison = { ...game.residualPoison! }; },
|
||
(game: GameView) => { game.residualPoison!.blocked = 'A binding No prevents this printed use.'; },
|
||
(game: GameView) => { game.residualPoison!.event = 'next-battle'; },
|
||
(game: GameView) => { game.battle!.revealed = true; },
|
||
(game: GameView) => { game.battle!.event = 'next-battle'; },
|
||
(game: GameView) => { game.battle!.defender = 'r'; },
|
||
(game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
|
||
(game: GameView) => { game.turnActionPromises[0].mixed = planClaim(); },
|
||
(game: GameView) => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||
]) {
|
||
const game = residualPlay(), sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
|
||
}
|
||
for (const extra of [{ event: 'old-battle' }, { target: 'p' }, { target: 'r' }, { leader: 'selected-victim' },
|
||
{ victim: 'selected-victim' }, { amount: 1 }, { mode: 'poison' }, { random: 0 }]) {
|
||
const game = residualPlay(); Object.assign(game.turnActionGuide!.action, extra);
|
||
const button = guide({ game, busy: false, act() { assert.fail('Only the actual printed Residual Poison packet is allowed'); } });
|
||
assert.equal(button.props.disabled, true); press(button);
|
||
}
|
||
const no = residualPlay(); no.turnActionPromises[0].answer = false; no.residualPoison!.blocked = 'A binding No prevents this printed use.';
|
||
assert.equal(guide({ game: no, busy: false, act() { assert.fail('Generic preparation cannot bypass the native No quote'); } }).props.disabled, true);
|
||
});
|
||
|
||
void test('printed-card history retains only raw aggregate conditions and their distinct native deadlines', () => {
|
||
const game = projected(8); game.turnActionGuide = null;
|
||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard(), { kind: 'and', terms: [fact(), printedCard('ghola'), printedCard('nullentropyBox'), printedCard('distrans'), printedCard('juiceOfSapho'), printedCard('semutaDrug'), printedCard('ornithopter'), printedCard('residualPoison')] }] };
|
||
game.turnActionPromises[0].answer = false;
|
||
for (const key of ['cardPlays', 'compiled', 'combat', 'preparation'])
|
||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||
const html = markup(game);
|
||
assert.match(html, /Hajr/); assert.match(html, /Tleilaxu Ghola/); assert.match(html, /You answered No/);
|
||
assert.match(html, /Hajr counts even if its extra movement is unused/);
|
||
assert.match(html, /Earlier plays, discards, sales, Karama roles and recovery alone do not count/);
|
||
assert.match(html, /actual later Box replay does/); assert.match(html, /Ghola is separate from ordinary Revival/);
|
||
assert.match(html, /you activate Nullentropy Box/); assert.match(html, /Box conditions close at the actual turn end/);
|
||
assert.match(html, /Distrans/); assert.match(html, /Juice of Sapho/);
|
||
assert.match(html, /The deadline is the actual turn end, including for a whole No answer/);
|
||
assert.match(html, /Semuta Drug counts only when its native transfer and disposal actually complete after the answer/);
|
||
assert.match(html, /A committed private choice cannot be canceled/);
|
||
assert.match(html, /Ornithopter counts its actual physical flight declaration after the answer/);
|
||
assert.match(html, /Later groups and disposal do not count again/);
|
||
assert.match(html, /Residual Poison counts the actual printed play, random leader death and disposal after the answer/);
|
||
assert.match(html, /Its whole No deadline is the original turn end/);
|
||
assert.match(html, /remains open until the actual turn ends/); assert.doesNotMatch(html, /cardPlays|compiled|controlled-hajr|controlled-ghola/);
|
||
});
|
||
|
||
void test('turn-action history retains original own whole questions and terminal status without private compiled branches', () => {
|
||
const game = projected(); game.turnActionGuide = null;
|
||
game.turnActionPromises.push(
|
||
{ ...game.turnActionPromises[0], event: 'controlled-no', answer: false },
|
||
{ ...game.turnActionPromises[0], event: 'controlled-complete', turn: 1, fulfilled: true },
|
||
{ ...game.turnActionPromises[0], event: 'controlled-release', released: true },
|
||
{ ...game.turnActionPromises[0], event: 'controlled-foreign', player: 'a', mixed: { kind: 'and', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), physical('ship', 19, 'sietch_tabr')] } },
|
||
);
|
||
for (const promise of game.turnActionPromises) for (const key of ['compiled', 'binding', 'factAnswers', 'residual', 'route', 'payments', 'progress', 'preparation', 'prep', 'bids', 'returns', 'leaders', 'alliance'])
|
||
Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||
const html = markup(game);
|
||
for (const text of [/Turn 1/, /You answered Yes/, /You answered No/, /you hold Shield/, / OR /, / AND /,
|
||
/Fulfilled: this whole commitment is complete/, /Released: this whole commitment is no longer possible/,
|
||
/No negates the whole expression, not each condition separately/, /Separate No answers each create their own restriction/,
|
||
/maximum physical forces in one actual future move/, /Individual fact answers and any reduced future branch remain private/,
|
||
/highest accepted future declaration/, /cumulative future ordinary returns/, /Revival conditions close with your native Revival opportunity/,
|
||
/ordinary payments remain possible until the actual turn ends/]) assert.match(html, text);
|
||
assert.doesNotMatch(html, /Poison Blade|19 physical|controlled-foreign|controlled-complete|factAnswers/);
|
||
assert.equal(TurnActionPromises({ game: { ...game, me: 'missing' }, busy: false, act() {} }), null);
|
||
game.turnActionPromises = game.turnActionPromises.filter(promise => promise.fulfilled || promise.released);
|
||
assert.doesNotMatch(markup(game), /<button|Your Movement turn is still ahead/);
|
||
});
|
||
|
||
void test('turn-action guide forwards exact shipment, movement, preparation and own movement-end packets', () => {
|
||
const actions: Action[] = [
|
||
projected().turnActionGuide!.action,
|
||
{ type: 'ship', territory: 'arrakeen', sector: 10, amount: 4, elite: 1, allyPayment: 0 },
|
||
{ type: 'guildShip', from: 'reserves', territory: 'carthag', sector: 11, amount: 6, elite: 0, allyPayment: 3 },
|
||
{ type: 'card', card: 'controlled-hajr' }, { type: 'prepareAdvisors', territory: 'arrakeen' },
|
||
{ type: 'revealNoField', territory: 'arrakeen', sector: 10 }, { type: 'endMovement' },
|
||
];
|
||
for (const action of actions) {
|
||
const game = projected(5, action), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||
assert.equal(button.props.disabled, false); press(button);
|
||
assert.deepEqual(sent, [game.turnActionGuide!.action]); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.match(markup(game), /data-testid="turn-action-promise-guide"/);
|
||
}
|
||
const game = projected(5, { type: 'endMovement' });
|
||
game.turnActionPromises[0].mixed = { kind: 'and', terms: [physical('move', 4), payment()] };
|
||
assert.match(markup(game), /Finish movement while preserving any later payment conditions/);
|
||
game.active = 'a'; game.turnActionGuide = { event, action: { type: 'bribe', target: 'r', amount: 3,
|
||
turnActionPromiseEvent: event, turnActionPromisePhase: 5 } };
|
||
assert.doesNotMatch(markup(game), /Your Movement turn is still ahead/);
|
||
const sent: Action[] = []; press(guide({ game, busy: false, act(value) { sent.push(value); } }));
|
||
assert.equal(sent[0], game.turnActionGuide.action);
|
||
});
|
||
|
||
void test('turn-action ordinary-payment guide retains native out-of-turn timing and quiet Mentat readiness', () => {
|
||
for (let phase = 0; phase <= 8; phase++) {
|
||
const game = projected(phase, { type: 'bribe', target: 'r', amount: 3 }); game.active = 'a'; game.ready = ['p'];
|
||
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.match(markup(game), /Pay 3 spice to Recipient while honoring your whole answer/);
|
||
if (phase === 8) assert.match(markup(game), /Late payment recipients collect next turn/);
|
||
}
|
||
const game = projected(8, { type: 'ready' }); game.active = null;
|
||
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.match(markup(game), /Ready to end this turn while honoring your whole answer/);
|
||
game.ready.push('p'); press(button); assert.equal(sent.length, 1);
|
||
for (const phase of [1, 7]) {
|
||
const current = phase === 1 ? nexus({ type: 'ready' }) : projected(phase, { type: 'ready' });
|
||
const dispatched: Action[] = [], ready = guide({ game: current, busy: false, act(value) { dispatched.push(value); } });
|
||
assert.equal(ready.props.disabled, false); press(ready); assert.equal(dispatched[0], current.turnActionGuide!.action);
|
||
if (phase === 1) { current.nexus = false; press(ready); assert.equal(dispatched.length, 1); }
|
||
}
|
||
for (const change of [
|
||
(current: GameView) => { current.bribeOptions.blocked = 'Complete the current decision.'; },
|
||
(current: GameView) => { current.bribeOptions.blocked = ''; },
|
||
(current: GameView) => { current.bribeOptions.available = 2; },
|
||
(current: GameView) => { current.bribeOptions.available = NaN; },
|
||
(current: GameView) => { current.bribeOptions.targets[0].maximum = 2; },
|
||
(current: GameView) => { current.bribeOptions.targets = []; },
|
||
(current: GameView) => { current.players.find(player => player.id === 'p')!.ally = 'r'; },
|
||
(current: GameView) => { current.players = current.players.filter(player => player.id !== 'r'); },
|
||
]) {
|
||
const current = projected(3, { type: 'bribe', target: 'r', amount: 3 }), dispatched: Action[] = [];
|
||
const original = guide({ game: current, busy: false, act(value) { dispatched.push(value); } }); change(current); press(original);
|
||
assert.deepEqual(dispatched, []);
|
||
}
|
||
});
|
||
|
||
void test('turn-action current early native opportunities retain exact preparation, bids and owned cash or Karama invoices', () => {
|
||
const cases: GameView[] = [
|
||
projected(2, { type: 'charity' }), projected(2, { type: 'ready' }), projected(2, { type: 'card', card: 'controlled-preparation', amount: 3 }),
|
||
auction({ type: 'bid', amount: 8, allyPayment: 0 }), auction(),
|
||
auction({ type: 'decision', karama: false }), auction({ type: 'decision', karama: true, card: 'controlled-owned-karama' }),
|
||
revival(), revival({ type: 'ready' }), revival({ type: 'reviveLeader', leader: 'controlled-leader', early: true, allyPayment: 2 }),
|
||
revival({ type: 'acceptLeaderRevival', leaderRevivalPrice: 3 }), revival({ type: 'reviveKwisatz' }),
|
||
revival({ type: 'card', card: 'controlled-ghola', amount: 1, elite: 0 }), richese('silent'), richese('onceAround'),
|
||
];
|
||
for (const game of cases) {
|
||
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
}
|
||
assert.match(markup(auction({ type: 'bid', amount: 8, allyPayment: 0 })), /Place the required bid of 8/);
|
||
assert.match(markup(auction({ type: 'decision', karama: true, card: 'controlled-owned-karama' })), /Use the quoted Karama for the current auction payment/);
|
||
});
|
||
|
||
void test('whole payment timelines forward an exact positive unused Charity quote without treating it as current cash', () => {
|
||
const game = projected(2, { type: 'charity' }), sent: Action[] = [];
|
||
game.turnActionPromises[0].mixed = payment(3); game.bribeOptions.available = 0; game.bribeOptions.targets = [];
|
||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.deepEqual(sent[0], { type: 'charity', turnActionPromiseEvent: event, turnActionPromisePhase: 2 });
|
||
assert.equal(game.players.find(player => player.id === 'p')!.spice, 0);
|
||
assert.match(markup(game), /Claim 2 Charity spice while preserving your whole answer/);
|
||
assert.match(markup(game), /future claim is not current spendable spice/);
|
||
});
|
||
|
||
void test('whole CHOAM commitments explain pending automatic income while ordinary Charity still waits for its native quote', () => {
|
||
const game = projected(2, { type: 'charity' }); game.players.find(player => player.id === 'p')!.faction = 'choam'; game.expansions = ['choam'];
|
||
game.charity.incomePending = true; game.charity.incomeCanceled = false;
|
||
const html = markup(game); assert.match(html, /data-testid="choam-pending-payment-income"/);
|
||
assert.match(html, /not current spendable cash/); assert.match(html, /ordinary Charity claim is separate/);
|
||
assert.match(html, /Karama or opposing claims can change the funding/); assert.match(html, /may still offer legal prepayments/);
|
||
const sent: Action[] = [], pending = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(pending.props.disabled, true); press(pending); assert.equal(sent.length, 0);
|
||
game.charity.incomePending = false; game.charity.incomeCanceled = true;
|
||
press(pending); assert.equal(sent.length, 0, 'The old source quote remains stale after automatic income is canceled.');
|
||
assert.doesNotMatch(markup(game), /data-testid="choam-pending-payment-income"/);
|
||
const current = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||
assert.equal(current.props.disabled, false); press(current); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
});
|
||
|
||
void test('whole automatic-income guidance stays private to live CHOAM owners before the Charity opening resolves', () => {
|
||
for (const change of [
|
||
(game: GameView) => { game.players.find(player => player.id === 'p')!.faction = 'emperor'; game.players.find(player => player.id === 'a')!.faction = 'choam'; },
|
||
(game: GameView) => { game.me = 'a'; },
|
||
(game: GameView) => { game.charity.incomePending = false; game.charity.incomeCanceled = true; },
|
||
(game: GameView) => { game.charity.multiplier = 0; },
|
||
(game: GameView) => { game.phase = 3; },
|
||
(game: GameView) => { game.turnActionPromises[0].fulfilled = true; },
|
||
(game: GameView) => { game.turnActionPromises[0].released = true; },
|
||
]) {
|
||
const game = projected(1); game.players.find(player => player.id === 'p')!.faction = 'choam'; game.expansions = ['choam'];
|
||
game.charity.incomePending = true; game.charity.incomeCanceled = false; game.turnActionGuide = null;
|
||
assert.match(markup(game), /data-testid="choam-pending-payment-income"/); change(game);
|
||
assert.doesNotMatch(markup(game), /data-testid="choam-pending-payment-income"/);
|
||
}
|
||
});
|
||
|
||
void test('whole Charity guides reject claimed, stale, unready native quotes and extra fields without generic fallback', () => {
|
||
for (const change of [
|
||
(game: GameView) => { game.players.find(player => player.id === 'p')!.charityClaimed = true; },
|
||
(game: GameView) => { game.charity.amount = 0; },
|
||
(game: GameView) => { game.charity.incomePending = true; },
|
||
(game: GameView) => { game.charity = { ...game.charity }; },
|
||
(game: GameView) => { game.players.find(player => player.id === 'p')!.spice = 1; },
|
||
(game: GameView) => { game.ready.push('p'); },
|
||
(game: GameView) => { game.phase = 3; },
|
||
]) {
|
||
const game = projected(2, { type: 'charity' }), saved = guide({ game, busy: false, act() { assert.fail('A stale whole-answer Charity claim cannot send'); } });
|
||
assert.equal(saved.props.disabled, false); change(game); press(saved);
|
||
}
|
||
for (const extra of [{ target: 'r' }, { amount: 2 }, { card: 'private-card' }, { mode: 'shipment' }]) {
|
||
const game = projected(2, { type: 'charity' }); Object.assign(game.turnActionGuide!.action, extra);
|
||
const button = guide({ game, busy: false, act() { assert.fail('Charity accepts no extra native parameters'); } });
|
||
assert.equal(button.props.disabled, true); press(button);
|
||
}
|
||
const pending = projected(2, { type: 'charity' }); pending.charity.incomePending = true;
|
||
assert.equal(guide({ game: pending, busy: false, act() { assert.fail('Pending CHOAM income cannot be counted as an ordinary claim'); } }).props.disabled, true);
|
||
const ready = projected(2, { type: 'charity' }); ready.ready.push('p');
|
||
assert.equal(guide({ game: ready, busy: false, act() { assert.fail('A readied owner cannot open this preparation guide'); } }).props.disabled, true);
|
||
});
|
||
|
||
void test('turn-action current paid Box choices are exact through phases zero to eight without private custody reads', () => {
|
||
for (let phase = 0; phase <= 8; phase++) {
|
||
const game = projected(phase, { type: 'decision', event: 'controlled-paid-search', card: 'controlled-recovered-hajr' });
|
||
game.decision = { kind: 'nullentropy', player: 'p' };
|
||
game.nullentropy = { card: { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special', effect: 'nullentropyBox' }, blocked: null,
|
||
search: { event: 'controlled-paid-search', cards: [{ id: 'controlled-recovered-hajr', name: 'Hajr', kind: 'special', effect: 'hajr' }] } };
|
||
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves'])
|
||
Object.defineProperty(player, key, { get() { throw Error(`No ${key} inspection`); }, configurable: true });
|
||
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||
assert.match(markup(game), /Complete your paid Box search/);
|
||
game.nullentropy.search = { ...game.nullentropy.search! }; press(button); assert.equal(sent.length, 1);
|
||
}
|
||
});
|
||
|
||
void test('turn-action native guides reject stale auction, invoice, Revival and hidden Richese source changes', () => {
|
||
const cases: [() => GameView, ((game: GameView) => void)[]][] = [
|
||
[() => auction({ type: 'bid', amount: 8, allyPayment: 0 }), [
|
||
game => { game.auction = { ...game.auction! }; }, game => { game.auction!.active = 'a'; },
|
||
game => { game.auction!.bid = 8; }, game => { game.auction!.remaining = 1; },
|
||
game => { game.bidPromiseLot = { turn: 2, lot: 3 }; },
|
||
]],
|
||
[() => auction({ type: 'decision', karama: false }), [
|
||
game => { game.decision = { ...game.decision! }; }, game => { game.decision = null; },
|
||
game => { game.decision = { kind: 'auctionPayment', player: 'a' }; },
|
||
game => { game.auction!.bidder = 'a'; }, game => { game.auction!.bid = 8; }, game => { game.auction!.allyPayment = 1; },
|
||
]],
|
||
[() => revival(), [
|
||
game => { game.revivalOrder = { ...game.revivalOrder! }; }, game => { game.revivalOrder!.turn = 3; },
|
||
game => { game.revivalOrder!.remaining = ['a', 'p']; }, game => { game.active = 'a'; },
|
||
game => { game.revival = { ...game.revival }; }, game => { game.revival.orderBlocked = 'Wait for your turn.'; },
|
||
game => { game.revival.pending = true; }, game => { game.ready.push('p'); },
|
||
]],
|
||
[() => richese('silent'), [
|
||
game => { game.richeseAuction = { ...game.richeseAuction! }; }, game => { game.richeseAuction!.event = 'another-lot'; },
|
||
game => { game.richeseAuction!.eligible = ['a']; }, game => { game.richeseAuction!.submitted.push('p'); },
|
||
game => { game.richeseAuction!.outcome = { kind: 'unbid' }; },
|
||
]],
|
||
];
|
||
for (const [build, changes] of cases) for (const change of changes) {
|
||
const game = build(), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
|
||
}
|
||
for (const method of ['silent', 'onceAround'] as const) {
|
||
const game = richese(method);
|
||
Object.defineProperty(game.richeseAuction!, 'sealed', { get() { throw Error('No hidden bids'); } });
|
||
Object.defineProperty(game.richeseAuction!, 'card', { get() { throw Error('No hidden lot'); } });
|
||
const sent: Action[] = []; press(guide({ game, busy: false, act(value) { sent.push(value); } })); assert.equal(sent.length, 1);
|
||
}
|
||
});
|
||
|
||
void test('turn-action guide rejects stale event, packet identity, current phase, owner and quiet-window changes', () => {
|
||
const changes: ((game: GameView) => void)[] = [
|
||
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 4; }, game => { game.status = 'setup'; },
|
||
game => { game.active = 'a'; }, game => { game.turnActionGuide = null; },
|
||
game => { game.turnActionGuide = { ...game.turnActionGuide! }; },
|
||
game => { game.turnActionGuide!.action = { ...game.turnActionGuide!.action }; },
|
||
game => { game.turnActionGuide!.event = 'changed'; }, game => { game.turnActionGuide!.action.turnActionPromiseEvent = 'changed'; },
|
||
game => { game.turnActionGuide!.action.turnActionPromisePhase = 4; }, game => { delete game.turnActionGuide!.action.turnActionPromisePhase; },
|
||
game => { (game.turnActionGuide!.action.forces as Record<string, number>)['arrakeen:10'] = 2; },
|
||
game => { (game.turnActionGuide!.action.eliteForces as Record<string, number>)['arrakeen:10'] = 0; },
|
||
game => { game.turnActionPromises[0].event = 'expired'; }, game => { game.turnActionPromises[0].player = 'a'; },
|
||
game => { game.turnActionPromises[0].turn = 1; }, game => { game.turnActionPromises[0].fulfilled = true; },
|
||
game => { game.turnActionPromises[0].released = true; }, game => { game.turnActionPromises = []; },
|
||
game => { game.phaseOpening = { passed: [] }; }, game => { game.automaticContinuationPending = true; },
|
||
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; }, game => { game.truthtrance = answering().truthtrance; },
|
||
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||
game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
|
||
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||
];
|
||
for (const change of changes) {
|
||
const game = projected(), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||
change(game); press(button); assert.deepEqual(sent, []);
|
||
}
|
||
const busy = { game: projected(), busy: false, act() { assert.fail('Busy guide cannot send'); } };
|
||
const busyButton = guide(busy); busy.busy = true; press(busyButton);
|
||
const replaced = { game: projected(), busy: false, act() { assert.fail('Replaced view cannot send'); } };
|
||
const old = guide(replaced); replaced.game = { ...replaced.game }; press(old);
|
||
});
|
||
|
||
void test('turn-action guides reject wrong native opportunities even when a packet is projected', () => {
|
||
const invalid: GameView[] = [];
|
||
for (const phase of [0, 1, 2, 3, 4, 6, 7, 8]) for (const action of [
|
||
{ type: 'ship', amount: 3, territory: 'carthag', sector: 11 }, { type: 'move', forces: { 'arrakeen:10': 4 }, territory: 'carthag', sector: 11 },
|
||
{ type: 'endMovement' },
|
||
] satisfies Action[]) invalid.push(projected(phase, action));
|
||
invalid.push(projected(0, { type: 'ready' }), projected(1, { type: 'ready' }), projected(6, { type: 'ready' }), projected(4, { type: 'charity' }),
|
||
projected(2, { type: 'revive', amount: 3 }), projected(3, { type: 'decision', karama: false }), auction({ type: 'decision', karama: true }),
|
||
projected(5, { type: 'guildShip', from: 'arrakeen', territory: 'carthag', amount: 3 }));
|
||
for (const game of invalid) {
|
||
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||
assert.equal(button.props.disabled, true); press(button); assert.deepEqual(sent, []);
|
||
}
|
||
});
|
||
|
||
void test('turn-action answers offer only the current respondent native choices throughout phases zero through eight', () => {
|
||
for (let phase = 0; phase <= 8; phase++) {
|
||
const game = answering(phase), sent: Action[] = [], props = { game, busy: false, act(value: Action) { sent.push(value); } };
|
||
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves'])
|
||
Object.defineProperty(player, key, { get() { throw Error(`No ${key} inspection`); }, configurable: true });
|
||
for (const answer of game.truthTurnActionAnswers!) click(find(TurnActionAnswers(props), 'data-turn-action-answer', answer));
|
||
assert.deepEqual(sent, ['yes', 'no', 'unknown'].map(answer => ({ type: 'truthAnswer', answer })));
|
||
game.truthTurnActionAnswers = ['no'];
|
||
const html = renderToStaticMarkup(createElement(TurnActionAnswers, { game, busy: false, act() {} }));
|
||
assert.match(html, /Answer No/); assert.doesNotMatch(html, /Answer Yes|I don’t know yet/);
|
||
assert.equal(TurnActionAnswers({ game: { ...game, me: 'a' }, busy: false, act() {} }), null);
|
||
assert.equal(TurnActionAnswers({ game: { ...game, truthTurnActionAnswers: null }, busy: false, act() {} }), null);
|
||
}
|
||
});
|
||
|
||
void test('turn-action answer callbacks reject stale target, source queue, private choices, phase and modal ownership', () => {
|
||
const changes: ((game: GameView) => void)[] = [
|
||
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 4; }, game => { game.status = 'setup'; },
|
||
game => { game.truthtrance = null; }, game => { game.truthtrance!.stage = 'ask'; },
|
||
game => { game.truthtrance!.question = { ...game.truthtrance!.question! }; }, game => { game.truthtrance!.question!.target = 'a'; },
|
||
game => { game.truthtrance!.queue[0] = { ...game.truthtrance!.queue[0] }; }, game => { game.truthtrance!.queue[0].card = 'another-card'; },
|
||
game => { game.truthTurnActionAnswers = ['yes']; }, game => { game.truthTurnActionAnswers!.splice(0); },
|
||
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; }, game => { game.phaseOpening = { passed: [] }; },
|
||
game => { game.automaticContinuationPending = true; }, game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||
game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
|
||
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||
];
|
||
for (const change of changes) {
|
||
const game = answering(), sent: Action[] = [], tree = TurnActionAnswers({ game, busy: false, act(value) { sent.push(value); } });
|
||
const button = find(tree, 'data-turn-action-answer', 'yes'); change(game); click(button); assert.deepEqual(sent, []);
|
||
}
|
||
for (const phase of [-1, 9, 1.5]) {
|
||
const game = answering(phase), sent: Action[] = [], button = find(TurnActionAnswers({ game, busy: false, act(value) { sent.push(value); } }), 'data-turn-action-answer', 'yes');
|
||
assert.equal(button.props.disabled, true); click(button); assert.deepEqual(sent, []);
|
||
}
|
||
const busy = { game: answering(), busy: false, act() { assert.fail('Busy answer cannot send'); } };
|
||
const button = find(TurnActionAnswers(busy), 'data-turn-action-answer', 'yes'); busy.busy = true; click(button);
|
||
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);
|
||
});
|