Scope Advanced Kull to the attempted Karama effect
Adopt the supplied Advanced specific-effect wording, preserving Basic and legacy phase-wide semantics. Bind signed semantic purposes through ordinary/special activations, counters, feasibility and settlement; retain unrelated human controls and minimal bot actions. Phase expiry remains provisional; prior counter, BG and deferred-overbid choices stay. Validation: frozen12-file111/111, types/lint/build and two original-action/rendered-table programmes. Explicit unopened deal/auction/spice order supports real paid Baliset and shipments; Advanced retains Karama for Baliset cancellation while Basic stays blocked. Four minimal policies remain legal. Correct stale exchange/phase-market/Fremen fixture assumptions. Recovery schema expectations maintained without running deferred SQL assurance. No full modes, browser/deployed acceptance, saved-game/reset/restart or deployment operation.
This commit is contained in:
1 parent
b82441c834
commit
884a09fa92
24 files changed
+951
-64
No files matched your search
@@ -35,7 +35,8 @@ void test('every profile uses the projected physical Kull and cannot act from a
|
||||
assert.equal(settled.karamaShipping, null);
|
||||
assert.ok(settled.players[1].hand.some(card => card.id === attemptedCard));
|
||||
assert.ok(settled.discard.some(card => card.id === 'ix-kull-wahad'));
|
||||
assert.ok(viewGame(settled, 'e').karamaBlocked);
|
||||
assert.equal(viewGame(settled, 'e').karamaBlocked, null);
|
||||
assert.deepEqual(viewGame(settled, 'e').karamaPurposeBlocks.map(rule => rule.purpose), ['shipment-price']);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ void test('all four profiles settle saved Basic and Advanced Nexus Kull with onl
|
||||
assert.equal(settled.karamaShipping, null);
|
||||
assert.equal(settled.discard.filter(card => card.id === fixture.fuel).length, 1);
|
||||
assert.ok(settled.players.find(player => player.id === fixture.actor)!.hand.some(card => card.id === fixture.original));
|
||||
assert.ok(viewGame(settled, fixture.actor).karamaBlocked);
|
||||
assert.equal(!!viewGame(settled, fixture.actor).karamaBlocked, !advanced);
|
||||
assert.deepEqual(viewGame(settled, fixture.actor).karamaPurposeBlocks.map(rule => rule.purpose), advanced ? ['shipment-price'] : []);
|
||||
assert.equal(settled.nexusCards!.cards!.hands[fixture.choam], null);
|
||||
assert.equal(settled.nexusCards!.cards!.discard.filter(face => face === 'choam').length, 1);
|
||||
assert.equal(settled.nexusChoamHistory!.at(-1)!.stage, 'complete');
|
||||
@@ -91,7 +92,8 @@ void test('all profiles leave the interrupted BG Worthless card unconverted when
|
||||
const original = settled.players.find(player => player.id === fixture.actor)!.hand.find(card => card.id === fixture.original);
|
||||
assert.equal(original?.kind, 'worthless');
|
||||
assert.ok(!settled.discard.some(card => card.id === fixture.original));
|
||||
assert.ok(viewGame(settled, fixture.actor).karamaBlocked);
|
||||
assert.equal(viewGame(settled, fixture.actor).karamaBlocked, null);
|
||||
assert.deepEqual(viewGame(settled, fixture.actor).karamaPurposeBlocks.map(rule => rule.purpose), ['shipment-price']);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ void test('the real saved table mounts the offer instead of shipment and volunta
|
||||
assert.match(disabled, /disabled=""[^>]*>Use Kull Wahad/);
|
||||
});
|
||||
|
||||
void test('a successful phase ban removes the retained Karama shipment-rate control', () => {
|
||||
void test('a successful effect restriction removes the retained Karama shipment-rate control', () => {
|
||||
const initial = choamKullGame();
|
||||
let g = applyAction(initial, 'e', kullShipmentAttempt(initial));
|
||||
g = applyAction(g, 'c', {
|
||||
@@ -102,9 +102,10 @@ void test('a successful phase ban removes the retained Karama shipment-rate cont
|
||||
const render = (game: GameView) => renderToStaticMarkup(createElement(GameTable, {
|
||||
game, send: async () => { assert.fail('Rendering must not submit gameplay.'); }, busy: false, onExit() {},
|
||||
}));
|
||||
assert.ok(blocked.karamaBlocked);
|
||||
assert.equal(blocked.karamaBlocked, null);
|
||||
assert.deepEqual(blocked.karamaPurposeBlocks.map(rule => rule.purpose), ['shipment-price']);
|
||||
assert.doesNotMatch(render(blocked), />Guild rates for/);
|
||||
const available = { ...blocked, karamaBlocked: null };
|
||||
const available = { ...blocked, karamaPurposeBlocks: [] };
|
||||
assert.match(render(available), />Guild rates for/);
|
||||
});
|
||||
|
||||
|
||||
@@ -378,7 +378,8 @@ void test('a canceled BG counter does not release the original card or premature
|
||||
assert.equal(result.karamaShipping, null);
|
||||
assert.equal(result.discard.filter(card => card.id === bg).length, 1);
|
||||
assert.equal(result.discard.filter(card => card.id === 'ix-kull-wahad').length, 1);
|
||||
assert.equal(viewGame(result, 'e').karamaBlocked !== null, true);
|
||||
assert.equal(viewGame(result, 'e').karamaBlocked, null);
|
||||
assert.deepEqual(viewGame(result, 'e').karamaPurposeBlocks.map(rule => rule.purpose), ['shipment-price']);
|
||||
});
|
||||
|
||||
void test('nonopted games keep native Karama execution and no Kull opportunity', () => {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeKullGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type KullPurposeStep = { actor: string; action: Action };
|
||||
export const kullPurposePlayer = (game: Game, actor = 'emperor') => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const kullPurposeClean = (game: Game) => !game.pendingKull && !game.response &&
|
||||
!game.decision && !game.phaseOpening && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function actKullPurpose(game: Game, actor: string, action: Action, actions: KullPurposeStep[] = []): Game {
|
||||
const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) });
|
||||
return next;
|
||||
}
|
||||
/** Neutral native choices only. No card is granted and no optional power or
|
||||
* sealed plan is selected by this finite continuation driver. */
|
||||
export function nextKullPurposeStep(game: Game): KullPurposeStep | null {
|
||||
if (game.pendingKull?.stage === 'offer') return { actor: game.pendingKull.player,
|
||||
action: { type: 'kullDecision', event: game.pendingKull.event, decline: true } };
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'choamMovement')
|
||||
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||||
if (kullPurposeClean(game) && game.auction?.active === 'choam' && game.auction.bid === 0 &&
|
||||
game.auction.cards[game.auction.index].name === 'Baliset')
|
||||
return { actor: 'choam', action: { type: 'bid', amount: 1, allyPayment: 0 } };
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceKullPurpose(game: Game, until: (state: Game) => boolean,
|
||||
actions: KullPurposeStep[] = []): Game {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Kull purpose programme ended before its next control');
|
||||
const step = nextKullPurposeStep(game); assert.ok(step, 'Complete the real private Kull purpose choice before advancing');
|
||||
game = actKullPurpose(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original Kull purpose programme did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Explicit original Kull preview entry from a ready native lobby. The exact
|
||||
* initial deal is controlled while every card remains in the unopened deck:
|
||||
* CHOAM gets physical Kull and the two classic seats each get one physical
|
||||
* Karama. This is a selected deck order, not a naturally discovered random seed.
|
||||
* Baliset is subsequently bought in an actual ordinary auction. */
|
||||
export function createKullPurposeFixture(options: { advanced?: boolean } = {}) {
|
||||
const advanced = options.advanced ?? true;
|
||||
let game = createGame('KULLPURPOSE', newPlayer('choam', 'CHOAM', 'choam'), advanced, ['choam', 'ix']);
|
||||
for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: KullPurposeStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actKullPurpose(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceKullPurpose(game, until, actions); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeKullGameForAudit(game));
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.ok(game.players.every(player => player.hand.length === 0), 'Select only the unopened original starting deal');
|
||||
const front: Game['deck'] = [];
|
||||
for (const kind of ['kull', 'karama', 'karama']) {
|
||||
const at = game.deck.findIndex(card => kind === 'kull' ? card.id === 'ix-kull-wahad' : card.effect === 'karama');
|
||||
assert.ok(at >= 0); front.push(game.deck.splice(at, 1)[0]);
|
||||
}
|
||||
const unopenedDeal = front.map((card, index) => ({ actor: game.players[index].id, card: card.id }));
|
||||
game.deck.unshift(...front);
|
||||
const beforeInitialDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), karama = unopenedDeal[1].card, counter = unopenedDeal[2].card;
|
||||
assert.ok(kullPurposePlayer(game, 'choam').hand.some(card => card.id === 'ix-kull-wahad'));
|
||||
assert.ok(kullPurposePlayer(game).hand.some(card => card.id === karama));
|
||||
assert.ok(kullPurposePlayer(game, 'guild').hand.some(card => card.id === counter));
|
||||
const land: Game['spiceDeck'] = [];
|
||||
for (let count = 0; count < (advanced ? 2 : 1); count++) {
|
||||
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
|
||||
land.push(game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...land);
|
||||
reach(state => state.phase === 2 && kullPurposeClean(state));
|
||||
const at = game.deck.findIndex(card => card.name === 'Baliset' && card.kind === 'worthless'); assert.ok(at >= 0);
|
||||
const baliset = game.deck[at].id;
|
||||
game.deck.unshift(game.deck.splice(at, 1)[0]);
|
||||
const beforePurchase = structuredClone(game);
|
||||
reach(state => kullPurposePlayer(state, 'choam').hand.some(card => card.id === baliset));
|
||||
const acquired = structuredClone(game);
|
||||
reach(state => state.phase === 5 && state.active === 'choam' && kullPurposeClean(state));
|
||||
const beforeChoamShipment = structuredClone(game), choamShipment: KullPurposeStep = {
|
||||
actor: 'choam', action: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 },
|
||||
};
|
||||
act(choamShipment.actor, choamShipment.action); reach(kullPurposeClean);
|
||||
const afterChoamShipment = structuredClone(game);
|
||||
act('choam', { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === 'emperor' && kullPurposeClean(state));
|
||||
return { game, lobby, setup, beforeInitialDeal, unopenedDeal, afterSetup, beforePurchase, acquired,
|
||||
beforeChoamShipment, choamShipment, afterChoamShipment, karama, counter, baliset, kull: 'ix-kull-wahad',
|
||||
actor: 'emperor', reactor: 'choam', source: 'imperial_basin:10', destination: 'arrakeen:10',
|
||||
entry: 'initializeKullGameForAudit' as const, sourceActionCount: actions.length, actions,
|
||||
staging: ['Exact original unopened starting deal and unopened Baliset/Spice positions are selected, with scalar lottery order. Every subsequent card, wallet and force comes from native setup, actual paid auction and actual paid shipment; no playing-state inventory or clock is installed.'] };
|
||||
}
|
||||
export type KullPurposeFixture = ReturnType<typeof createKullPurposeFixture>;
|
||||
export const kullPurposeShipmentAction = (fixture: KullPurposeFixture): Action => ({
|
||||
type: 'card', mode: 'shipment', card: fixture.karama, target: fixture.actor,
|
||||
});
|
||||
|
||||
/** Success prevents the original price attempt before cost. A normal funded
|
||||
* shipment and real ground move then produce CHOAM's native Baliset response. */
|
||||
export function reachKullPurposeBaliset(fixture: KullPurposeFixture) {
|
||||
let game = structuredClone(fixture.game);
|
||||
const actions: KullPurposeStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actKullPurpose(game, actor, action, actions); };
|
||||
act(fixture.actor, kullPurposeShipmentAction(fixture));
|
||||
const offered = structuredClone(game), reaction = viewGame(game, fixture.reactor).kullReaction;
|
||||
assert.ok(reaction && reaction.plays.some(play => play.source === 'printed' && play.card.id === fixture.kull && !play.blocked));
|
||||
act(fixture.reactor, { type: 'kullDecision', event: reaction.event, source: 'printed', card: fixture.kull });
|
||||
const declared = structuredClone(game);
|
||||
game = advanceKullPurpose(game, kullPurposeClean, actions);
|
||||
const restricted = structuredClone(game), shipmentStep: KullPurposeStep = { actor: fixture.actor, action: {
|
||||
type: 'ship', territory: 'imperial_basin', sector: 10, amount: 1, elite: game.advanced ? 1 : 0, allyPayment: 0,
|
||||
} };
|
||||
act(shipmentStep.actor, shipmentStep.action);
|
||||
game = advanceKullPurpose(game, kullPurposeClean, actions);
|
||||
const shipped = structuredClone(game), moveStep: KullPurposeStep = { actor: fixture.actor, action: {
|
||||
type: 'move', from: fixture.source, territory: 'arrakeen', sector: 10, amount: 1, elite: game.advanced ? 1 : 0,
|
||||
} };
|
||||
act(moveStep.actor, moveStep.action);
|
||||
const beforeBaliset = structuredClone(game);
|
||||
assert.equal(game.decision?.kind, 'choamMovement');
|
||||
act(fixture.reactor, { type: 'card', mode: 'choam', card: fixture.baliset, target: fixture.actor, territory: 'arrakeen' });
|
||||
const baliset = structuredClone(game);
|
||||
assert.equal(game.response?.kind, 'choamWorthless');
|
||||
assert.equal(game.response.intent, 'Baliset');
|
||||
return { game, offered, declared, restricted, shipped, shipmentStep, beforeBaliset, moveStep, baliset, actions };
|
||||
}
|
||||
|
||||
/** The retained original Karama may cancel a different purpose in Advanced.
|
||||
* Its second neutral Kull offer is actually declined after the cost was spent;
|
||||
* the native Baliset cancellation resumes exactly the declared typed move. */
|
||||
export function completeKullPurpose(fixture: KullPurposeFixture) {
|
||||
assert.equal(fixture.game.advanced, true, 'Basic retains its original whole-phase restriction');
|
||||
const programme = reachKullPurposeBaliset(fixture), actions = [...programme.actions];
|
||||
let game = actKullPurpose(programme.game, fixture.actor,
|
||||
{ type: 'card', mode: 'cancel', card: fixture.karama }, actions);
|
||||
const differentOffer = structuredClone(game), reaction = viewGame(game, fixture.reactor).kullReaction;
|
||||
assert.ok(reaction && reaction.plays.every(play => play.card.id !== fixture.kull));
|
||||
game = actKullPurpose(game, fixture.reactor, { type: 'kullDecision', event: reaction.event, decline: true }, actions);
|
||||
game = advanceKullPurpose(game, kullPurposeClean, actions);
|
||||
const completed = structuredClone(game);
|
||||
game = actKullPurpose(game, fixture.actor, { type: 'endMovement' }, actions);
|
||||
return { ...programme, game, completed, differentOffer, actions };
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
actKullPurpose, advanceKullPurpose, completeKullPurpose, createKullPurposeFixture,
|
||||
kullPurposeClean, kullPurposePlayer as player, kullPurposeShipmentAction,
|
||||
reachKullPurposeBaliset, type KullPurposeFixture,
|
||||
} from './fixture-kull-purpose';
|
||||
|
||||
function source(f: KullPurposeFixture) {
|
||||
assert.equal(f.entry, 'initializeKullGameForAudit');
|
||||
assert.equal(f.setup.kullPreview, true);
|
||||
assert.ok(f.beforeInitialDeal.players.every(player => player.hand.length === 0));
|
||||
assert.deepEqual(f.beforeInitialDeal.deck.slice(0, 3).map(card => card.id), f.unopenedDeal.map(deal => deal.card));
|
||||
for (const deal of f.unopenedDeal)
|
||||
assert.ok(player(f.afterSetup, deal.actor).hand.some(card => card.id === deal.card));
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
assert.ok(f.actions.some(step => step.actor === f.reactor && step.action.type === 'bid' && step.action.amount === 1));
|
||||
assert.ok(player(f.acquired, f.reactor).hand.some(card => card.id === f.baliset));
|
||||
assert.equal(player(f.beforeChoamShipment, f.reactor).spice - player(f.afterChoamShipment, f.reactor).spice, 1);
|
||||
assert.equal(player(f.game, f.reactor).forces[f.destination], 1);
|
||||
assert.equal(f.game.phase, 5);
|
||||
assert.equal(f.game.active, f.actor);
|
||||
assert.equal(player(f.game).shipped, false);
|
||||
}
|
||||
function restriction(f: KullPurposeFixture, programme: ReturnType<typeof reachKullPurposeBaliset>) {
|
||||
const { offered, restricted } = programme;
|
||||
const reaction = viewGame(offered, f.reactor).kullReaction!;
|
||||
assert.equal(reaction.scope, f.game.advanced ? 'effect' : 'phase');
|
||||
assert.match(reaction.effect, /shipment|price|discount/i);
|
||||
assert.ok(player(offered).hand.some(card => card.id === f.karama));
|
||||
assert.equal(offered.karamaShipping, null);
|
||||
assert.equal(restricted.pendingKull ?? null, null);
|
||||
assert.equal(restricted.karamaShipping, null);
|
||||
assert.equal(player(restricted).spice, player(f.game).spice);
|
||||
assert.equal(player(restricted).shipped, false);
|
||||
assert.equal(player(restricted).specialKaramaUsed, undefined);
|
||||
assert.ok(player(restricted).hand.some(card => card.id === f.karama));
|
||||
assert.equal(restricted.discard.some(card => card.id === f.karama), false);
|
||||
assert.equal(restricted.discard.filter(card => card.id === f.kull).length, 1);
|
||||
const receipt = restricted.kullRestrictions!.find(receipt => receipt.player === f.actor)!;
|
||||
assert.deepEqual(receipt, { player: f.actor, turn: restricted.turn, phase: 5,
|
||||
...(f.game.advanced ? { purpose: 'shipment-price' } : {}) });
|
||||
const before = JSON.stringify(restricted);
|
||||
assert.throws(() => actKullPurpose(restricted, f.actor, kullPurposeShipmentAction(f)), /Kull Wahad/i);
|
||||
assert.equal(JSON.stringify(restricted), before, 'Repeating the blocked purpose stops before a fresh offer, card cost or funding change');
|
||||
}
|
||||
|
||||
void test('genuine Advanced Kull blocks shipment-price while retained Karama cancels native Baliset in the same phase', () => {
|
||||
const f = createKullPurposeFixture();
|
||||
source(f);
|
||||
const result = completeKullPurpose(f);
|
||||
restriction(f, result);
|
||||
const own = viewGame(result.restricted, f.actor);
|
||||
assert.equal(own.karamaBlocked, null, 'A single-purpose restriction is not an all-purpose activation ban');
|
||||
const blocks = own.karamaPurposeBlocks;
|
||||
assert.equal(blocks.length, 1);
|
||||
assert.equal(blocks[0].purpose, 'shipment-price');
|
||||
assert.ok(blocks[0].label.length > 0 && blocks[0].message.length > 0);
|
||||
assert.equal(player(result.restricted).spice - player(result.shipped).spice, 2,
|
||||
'The denied price benefit does not alter the real one-counter desert shipment cost');
|
||||
assert.equal(player(result.shipped).forces[f.source], 1);
|
||||
assert.equal(player(result.shipped).elites!.forces[f.source], 1);
|
||||
assert.equal(player(result.beforeBaliset).moved, 0);
|
||||
assert.ok(viewGame(result.baliset, f.actor).responseControls!.cancelCards.includes(f.karama));
|
||||
assert.equal(result.differentOffer.turn, result.restricted.turn);
|
||||
assert.equal(result.differentOffer.phase, result.restricted.phase);
|
||||
assert.notEqual(result.differentOffer.pendingKull!.event, result.offered.pendingKull!.event);
|
||||
assert.equal(viewGame(result.differentOffer, f.reactor).kullReaction!.scope, 'effect');
|
||||
assert.ok(viewGame(result.differentOffer, f.reactor).kullReaction!.plays.every(play => play.card.id !== f.kull));
|
||||
const done: Game = result.completed;
|
||||
assert.equal(done.phase, 5);
|
||||
assert.equal(done.pendingKull ?? null, null);
|
||||
assert.equal(done.pendingChoamMove ?? null, null);
|
||||
assert.equal(done.pendingChoamWorthless ?? null, null);
|
||||
assert.deepEqual(done.kullRestrictions, result.restricted.kullRestrictions,
|
||||
'Declining the different-purpose opportunity adds no new restriction');
|
||||
assert.equal(player(done).moved, 1);
|
||||
assert.equal(player(done).forces[f.source] ?? 0, 0);
|
||||
assert.equal(player(done).forces[f.destination], 1);
|
||||
assert.equal(player(done).elites!.forces[f.destination], 1);
|
||||
assert.equal(done.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.equal(done.discard.filter(card => card.id === f.kull).length, 1);
|
||||
assert.ok(player(done, f.reactor).hand.some(card => card.id === f.baliset),
|
||||
'Native prevention retains the canceled Baliset cost under the existing CHOAM policy');
|
||||
assert.deepEqual(result.game.movementRemaining, []);
|
||||
assert.equal(result.game.decision?.kind, 'choamMarket', 'Ending movement reaches the real CHOAM phase-end market');
|
||||
});
|
||||
|
||||
void test('genuine Basic Kull retains the phase-wide policy at the same paid shipment and native Baliset opportunity', () => {
|
||||
const f = createKullPurposeFixture({ advanced: false });
|
||||
source(f);
|
||||
const programme = reachKullPurposeBaliset(f);
|
||||
restriction(f, programme);
|
||||
assert.ok(viewGame(programme.restricted, f.actor).karamaBlocked);
|
||||
assert.equal(viewGame(programme.restricted, f.actor).karamaPurposeBlocks.length, 0);
|
||||
assert.equal(viewGame(programme.baliset, f.actor).responseControls!.cancelCards.includes(f.karama), false);
|
||||
assert.throws(() => actKullPurpose(programme.baliset, f.actor,
|
||||
{ type: 'card', mode: 'cancel', card: f.karama }), /Kull Wahad/i);
|
||||
const allowed = advanceKullPurpose(programme.game, kullPurposeClean);
|
||||
assert.equal(player(allowed).moved, 0);
|
||||
assert.equal(player(allowed).forces[f.source], 1);
|
||||
assert.ok(player(allowed).hand.some(card => card.id === f.karama));
|
||||
assert.equal(allowed.discard.filter(card => card.id === f.baliset).length, 1);
|
||||
});
|
||||
|
||||
void test('all four minimal policies make a legal different-purpose response after original Advanced Kull success', () => {
|
||||
const f = createKullPurposeFixture();
|
||||
const programme = reachKullPurposeBaliset(f);
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(programme.baliset, f.actor);
|
||||
view.players.find(player => player.id === f.actor)!.bot = difficulty;
|
||||
assert.ok(view.responseControls!.cancelCards.includes(f.karama));
|
||||
const action = botActions(view).find(action => action.type === 'passResponse' ||
|
||||
(action.type === 'card' && action.mode === 'cancel' && action.card === f.karama));
|
||||
assert.ok(action, `${difficulty} retains a legal native response without a blanket Karama filter`);
|
||||
let next = actKullPurpose(structuredClone(programme.game), f.actor, action);
|
||||
if (action.type === 'card') {
|
||||
const reaction = viewGame(next, f.reactor).kullReaction!;
|
||||
assert.ok(reaction);
|
||||
next = actKullPurpose(next, f.reactor, { type: 'kullDecision', event: reaction.event, decline: true });
|
||||
next = advanceKullPurpose(next, kullPurposeClean);
|
||||
assert.equal(player(next).moved, 1);
|
||||
assert.equal(next.discard.filter(card => card.id === f.karama).length, 1);
|
||||
} else {
|
||||
next = advanceKullPurpose(next, kullPurposeClean);
|
||||
assert.equal(player(next).moved, 0);
|
||||
assert.ok(player(next).hand.some(card => card.id === f.karama));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
|
||||
import { choamKullGame, kullShipmentAttempt, takeKullCard } from './fixture-choam-kull';
|
||||
|
||||
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
|
||||
function allowKull(offer: Game): Game {
|
||||
let g = applyAction(offer, 'c', {type:'kullDecision', event:offer.pendingKull!.event,
|
||||
source:'printed', card:'ix-kull-wahad'});
|
||||
for (let step = 0; g.pendingKull && step < 16; step++) {
|
||||
const responder = g.players.find(p => {
|
||||
const controls = viewGame(g, p.id).responseControls;
|
||||
return controls && !controls.hasPassed && controls.cancelCards.length;
|
||||
});
|
||||
g = responder ? applyAction(g, responder.id, {type:'passResponse'}) : normalizeAutomaticGame(g);
|
||||
}
|
||||
assert.equal(g.pendingKull, null);
|
||||
return g;
|
||||
}
|
||||
function normalLot(g: Game) {
|
||||
g.phase = 3; g.movementRemaining = [];
|
||||
g.auction = {cards:[g.deck.shift()!], index:0, bid:0, bidder:null,
|
||||
active:'e', passed:[], opener:0};
|
||||
}
|
||||
|
||||
void test('Advanced Emperor force and leader specials share one effect while ordinary cancellation remains legal', () => {
|
||||
const initial = choamKullGame(); initial.phase = 4;
|
||||
const emperor = player(initial, 'e'); emperor.tanks = 2; emperor.reserves -= 2;
|
||||
const leader = emperor.leaders[0]; leader.dead = true;
|
||||
const la = takeKullCard(initial, 'La La La'); player(initial, 'c').hand.push(la);
|
||||
const card = emperor.hand[0].id;
|
||||
const offer = applyAction(initial, 'e', {type:'card', mode:'special', card, amount:1});
|
||||
assert.equal(offer.pendingKull?.purpose, 'special:emperor');
|
||||
const stopped = allowKull(offer);
|
||||
assert.equal(player(stopped, 'e').specialKaramaUsed, undefined);
|
||||
assert.deepEqual(stopped.kullRestrictions, [{player:'e',turn:2,phase:4,purpose:'special:emperor'}]);
|
||||
for (const change of [{amount:2}, {leader:leader.id}]) {
|
||||
const before = JSON.stringify(stopped);
|
||||
assert.throws(() => applyAction(stopped, 'e', {type:'card',mode:'special',card,...change}), /Kull Wahad/);
|
||||
assert.equal(JSON.stringify(stopped), before);
|
||||
}
|
||||
const parent = applyAction(stopped, 'c', {type:'card',mode:'choam',card:la.id,target:'e'});
|
||||
assert.ok(viewGame(parent, 'e').responseControls?.cancelCards.includes(card));
|
||||
const cancelOffer = applyAction(parent, 'e', {type:'card',mode:'cancel',card});
|
||||
assert.equal(cancelOffer.pendingKull?.purpose, 'cancel:choamWorthless:laLaLa');
|
||||
const canceled = applyAction(cancelOffer, 'c', {type:'kullDecision',event:cancelOffer.pendingKull!.event,decline:true});
|
||||
assert.ok(player(canceled, 'c').hand.some(c => c.id === la.id));
|
||||
assert.ok(canceled.choamWorthlessBlocked?.cards.includes(la.id));
|
||||
assert.equal(canceled.discard.filter(c => c.id === card).length, 1);
|
||||
});
|
||||
|
||||
void test('Advanced free purchase and winning Karama payment share one effect and do not block a faction special', () => {
|
||||
const initial = choamKullGame({opponents:['harkonnen','beneGesserit','emperor']});
|
||||
normalLot(initial);
|
||||
const card = player(initial, 'e').hand[0].id;
|
||||
const offer = applyAction(initial, 'e', {type:'card',mode:'purchase',card});
|
||||
assert.equal(offer.pendingKull?.purpose, 'auction-free-purchase');
|
||||
const stopped = allowKull(offer);
|
||||
assert.throws(() => applyAction(stopped, 'e', {type:'card',mode:'purchase',card}), /Kull Wahad/);
|
||||
// Controlled winning-payment state on the same original unresolved physical lot.
|
||||
const payment = structuredClone(stopped);
|
||||
payment.auction!.bid = 1; payment.auction!.bidder = 'e';
|
||||
payment.decision = {kind:'auctionPayment',player:'e'};
|
||||
assert.throws(() => applyAction(payment, 'e', {type:'decision',karama:true,card}), /Kull Wahad/);
|
||||
assert.ok(player(payment, 'e').hand.some(c => c.id === card));
|
||||
const special = applyAction(stopped, 'e', {type:'card',mode:'special',card,target:'b',amount:1});
|
||||
assert.equal(special.pendingKull?.purpose, 'special:harkonnen');
|
||||
const continued = applyAction(special, 'c', {type:'kullDecision',event:special.pendingKull!.event,decline:true});
|
||||
assert.equal(player(continued, 'e').specialKaramaUsed, true);
|
||||
assert.equal(continued.discard.filter(c => c.id === card).length, 1);
|
||||
// Spending the only original card leaves a forced one-card return; it completes automatically.
|
||||
assert.equal(continued.pendingExchange ?? null, null);
|
||||
assert.equal(player(continued, 'b').hand.length, 1);
|
||||
});
|
||||
|
||||
void test('Basic attempts and unstamped Advanced attempts retain the original all-purpose phase restriction', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const initial = choamKullGame({advanced});
|
||||
let offer = applyAction(initial, 'e', kullShipmentAttempt(initial));
|
||||
if (advanced) {
|
||||
// Exact legacy protocol fixture: old signed JSON omitted this new field.
|
||||
offer = structuredClone(offer);
|
||||
delete offer.pendingKull!.purpose;
|
||||
const signature = JSON.parse(offer.pendingKull!.signature);
|
||||
delete signature.purpose;
|
||||
offer.pendingKull!.signature = JSON.stringify(signature);
|
||||
}
|
||||
assert.equal(viewGame(offer, 'c').kullReaction?.scope, 'phase');
|
||||
const stopped = allowKull(offer);
|
||||
assert.deepEqual(stopped.kullRestrictions, [{player:'e',turn:2,phase:5}]);
|
||||
assert.ok(viewGame(stopped, 'e').karamaBlocked);
|
||||
assert.deepEqual(viewGame(stopped, 'e').karamaPurposeBlocks, []);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { FACTIONS, type FactionId } from '../game/catalog';
|
||||
import type { ResponseWindow } from '../game/engine';
|
||||
import {
|
||||
isKullPurpose, kullCancellationPurpose, kullSpecialPurpose, kullPurposeLabel,
|
||||
kullPurposeBlock, type KullPurpose,
|
||||
} from '../game/kull-purpose';
|
||||
import {
|
||||
activeKullRestrictions, kullBlocksKarama, validateKullPhaseRestrictions,
|
||||
type KullContext, type KullPhaseRestriction,
|
||||
} from '../game/choam-kull';
|
||||
|
||||
const context: KullContext = { turn: 3, phase: 5, players: [
|
||||
{ id: 'choam', faction: 'choam' }, { id: 'actor', faction: 'emperor' }, { id: 'other', faction: 'guild' },
|
||||
] };
|
||||
const stamp = { player: 'actor', turn: 3, phase: 5 };
|
||||
|
||||
void test('Advanced Kull restricts the exact public purpose while legacy/Basic restrictions remain all-purpose', () => {
|
||||
const history: KullPhaseRestriction[] = [{ ...stamp, purpose: 'shipment-price' }];
|
||||
validateKullPhaseRestrictions(context, history);
|
||||
assert.equal(kullBlocksKarama(history, 3, 5, 'actor', 'shipment-price'), true);
|
||||
assert.equal(kullBlocksKarama(history, 3, 5, 'actor', 'auction-free-purchase'), false);
|
||||
assert.equal(kullBlocksKarama(history, 3, 5, 'actor', kullSpecialPurpose('emperor')), false);
|
||||
assert.equal(kullBlocksKarama(history, 3, 5, 'actor'), false);
|
||||
assert.equal(kullBlocksKarama(history, 3, 5, 'other', 'shipment-price'), false);
|
||||
const legacy = [stamp];
|
||||
assert.equal(kullBlocksKarama(legacy, 3, 5, 'actor'), true);
|
||||
for (const purpose of ['shipment-price', 'auction-free-purchase', 'special:emperor', 'cancel:voice'] as const)
|
||||
assert.equal(kullBlocksKarama(legacy, 3, 5, 'actor', purpose), true);
|
||||
});
|
||||
|
||||
void test('active purpose history is copied faithfully and expires by both phase and turn', () => {
|
||||
const current = Object.freeze({ ...stamp, purpose: 'cancel:choamWorthless:baliset' as const });
|
||||
const history = Object.freeze([current, Object.freeze({ ...stamp, turn: 2, purpose: 'special:emperor' as const })]);
|
||||
validateKullPhaseRestrictions(context, history);
|
||||
const active = activeKullRestrictions(history, 3, 5);
|
||||
assert.deepEqual(active, [current]); assert.notEqual(active[0], current);
|
||||
for (const [turn, phase] of [[3, 6], [4, 5], [4, 0]]) {
|
||||
assert.deepEqual(activeKullRestrictions(history, turn, phase), []);
|
||||
assert.equal(kullBlocksKarama(history, turn, phase, 'actor', current.purpose), false);
|
||||
}
|
||||
assert.equal(kullBlocksKarama(history, 3, 5, 'actor', 'cancel:choamWorthless:kulon'), false);
|
||||
});
|
||||
|
||||
void test('distinct same-phase purposes coexist, while exact purpose and legacy duplicates reject', () => {
|
||||
const history: KullPhaseRestriction[] = [stamp, { ...stamp, purpose: 'shipment-price' },
|
||||
{ ...stamp, purpose: 'auction-free-purchase' }, { ...stamp, purpose: 'cancel:voice' }];
|
||||
validateKullPhaseRestrictions(context, history);
|
||||
assert.equal(activeKullRestrictions(history, 3, 5).length, 4);
|
||||
for (const repeated of history)
|
||||
assert.throws(() => validateKullPhaseRestrictions(context, [...history, { ...repeated }]), /duplicate/);
|
||||
});
|
||||
|
||||
void test('restriction history rejects noncanonical purpose strings, undefined extra fields, unrelated fields and future stamps', () => {
|
||||
const invalid: unknown[] = [
|
||||
undefined, null, '', 'all', 'special:Emperor', 'special:emperor:extra', 'cancel:choamWorthless',
|
||||
'cancel:choamWorthless:lalala', 'cancel:choamWorthless:laLaLa:extra', 'cancel:eliteStrength',
|
||||
'cancel:eliteStrength:unknown', 'cancel:voice:amount', 'cancel:constructor', 5, {},
|
||||
'cancel:nexusPrescience', 'cancel:nexusAdvisorFlip',
|
||||
];
|
||||
for (const purpose of invalid) {
|
||||
const restriction = { ...stamp, purpose }, before = structuredClone(restriction);
|
||||
assert.throws(() => validateKullPhaseRestrictions(context, [restriction]));
|
||||
assert.deepEqual(restriction, before);
|
||||
assert.equal(isKullPurpose(purpose), false);
|
||||
}
|
||||
for (const restriction of [
|
||||
{ ...stamp, purpose: 'shipment-price', extra: true },
|
||||
{ ...stamp, purpose: 'shipment-price', turn: 4 },
|
||||
{ ...stamp, purpose: 'shipment-price', phase: 6 },
|
||||
]) assert.throws(() => validateKullPhaseRestrictions(context, [restriction]));
|
||||
});
|
||||
|
||||
void test('each CHOAM ordinary power has its own canonical cancellation purpose without accepting card-name aliases', () => {
|
||||
const effects = ['baliset', 'kulon', 'laLaLa', 'gamont', 'jubba', 'kull'];
|
||||
const purposes = effects.map(effect => kullCancellationPurpose('choamWorthless', effect));
|
||||
assert.equal(new Set(purposes).size, effects.length);
|
||||
assert.ok(purposes.every(isKullPurpose));
|
||||
for (const detail of [undefined, 'lalala', 'La, La, La!', 'Trip to Gamont', 'baliset:actor', 'constructor'])
|
||||
assert.throws(() => kullCancellationPurpose('choamWorthless', detail), /canonical CHOAM effect/);
|
||||
const history = [{ ...stamp, purpose: purposes[0] }];
|
||||
assert.equal(kullBlocksKarama(history, 3, 5, 'actor', purposes[0]), true);
|
||||
for (const purpose of purposes.slice(1)) assert.equal(kullBlocksKarama(history, 3, 5, 'actor', purpose), false);
|
||||
});
|
||||
|
||||
void test('special native variants share the actual faction key and special-force cancellation retains its owning faction', () => {
|
||||
for (const faction of FACTIONS) {
|
||||
const special = kullSpecialPurpose(faction.id);
|
||||
assert.equal(special, `special:${faction.id}`);
|
||||
assert.ok(isKullPurpose(special));
|
||||
const strength = kullCancellationPurpose('eliteStrength', faction.id);
|
||||
assert.equal(strength, `cancel:eliteStrength:${faction.id}`);
|
||||
assert.ok(isKullPurpose(strength));
|
||||
}
|
||||
const special = kullSpecialPurpose('emperor');
|
||||
const history = [{ ...stamp, purpose: special }];
|
||||
// The engine normalizes its native variants to their actual seated faction.
|
||||
assert.equal(kullBlocksKarama(history, 3, 5, 'actor', kullSpecialPurpose('emperor')), true);
|
||||
assert.equal(kullBlocksKarama(history, 3, 5, 'actor', kullSpecialPurpose('guild')), false);
|
||||
assert.notEqual(kullCancellationPurpose('eliteStrength', 'emperor'), kullCancellationPurpose('eliteStrength', 'fremen'));
|
||||
for (const invalid of ['emperorRevival', 'emperorGift', 'Guild', 'unknown', 'constructor'])
|
||||
assert.throws(() => kullSpecialPurpose(invalid as FactionId), /actual native faction/);
|
||||
assert.throws(() => kullCancellationPurpose('eliteStrength'), /owner faction/);
|
||||
});
|
||||
|
||||
void test('ordinary cancellations use response semantics alone and reject route/card/private-value suffixes', () => {
|
||||
for (const kind of ['voice', 'guildIncome', 'guildRate', 'guildTransportPermission', 'nexusFremenCunning',
|
||||
'richeseNoField', 'richeseAuction', 'worthlessKarama'] as const) {
|
||||
assert.equal(kullCancellationPurpose(kind), `cancel:${kind}`);
|
||||
assert.ok(isKullPurpose(kullCancellationPurpose(kind)));
|
||||
assert.throws(() => kullCancellationPurpose(kind, 'private-card-or-route'), /without extra details/);
|
||||
}
|
||||
assert.throws(() => kullCancellationPurpose('unknown' as ResponseWindow['kind']), /canonical response/);
|
||||
assert.throws(() => kullPurposeLabel('special:unknown' as KullPurpose), /canonical purpose/);
|
||||
});
|
||||
|
||||
void test('Nexus aliases share their native purpose while distinct transport and worm powers remain separate', () => {
|
||||
for (const [alias, native] of [['nexusPrescience', 'prescience'], ['nexusAdvisorFlip', 'advisorFlip']] as const) {
|
||||
const purpose = kullCancellationPurpose(native);
|
||||
assert.equal(kullCancellationPurpose(alias), purpose);
|
||||
assert.equal(isKullPurpose(`cancel:${alias}`), false);
|
||||
assert.throws(() => validateKullPhaseRestrictions(context, [{ ...stamp, purpose: `cancel:${alias}` }]));
|
||||
assert.equal(kullBlocksKarama([{ ...stamp, purpose }], 3, 5, 'actor', kullCancellationPurpose(alias)), true);
|
||||
assert.throws(() => kullCancellationPurpose(alias, 'extra'), /without extra details/);
|
||||
}
|
||||
for (const [first, second] of [['guildRate', 'guildTransportPermission'], ['wormSurvival', 'wormAllyProtection']] as const) {
|
||||
const firstPurpose = kullCancellationPurpose(first), secondPurpose = kullCancellationPurpose(second);
|
||||
assert.notEqual(firstPurpose, secondPurpose);
|
||||
assert.ok(isKullPurpose(firstPurpose) && isKullPurpose(secondPurpose));
|
||||
assert.equal(kullBlocksKarama([{ ...stamp, purpose: firstPurpose }], 3, 5, 'actor', secondPurpose), false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('purpose labels and projected blocks are readable and preserve legacy priority without suppressing another purpose', () => {
|
||||
assert.equal(kullPurposeLabel('shipment-price'), 'Karama shipment pricing');
|
||||
assert.equal(kullPurposeLabel('auction-free-purchase'), 'a free Treachery purchase');
|
||||
assert.equal(kullPurposeLabel(kullSpecialPurpose('emperor')), 'the Emperor special Karama power');
|
||||
assert.equal(kullPurposeLabel(kullCancellationPurpose('choamWorthless', 'baliset')), 'cancellation of Baliset');
|
||||
const view = { karamaPurposeBlocks: [
|
||||
{ purpose: 'shipment-price' as const, message: 'Shipment pricing is restricted this phase.' },
|
||||
{ purpose: 'cancel:voice' as const, message: 'Canceling Voice is restricted this phase.' },
|
||||
] };
|
||||
assert.equal(kullPurposeBlock(view, 'shipment-price'), view.karamaPurposeBlocks[0].message);
|
||||
assert.equal(kullPurposeBlock(view, 'cancel:voice'), view.karamaPurposeBlocks[1].message);
|
||||
assert.equal(kullPurposeBlock(view, 'special:emperor'), null);
|
||||
assert.equal(kullPurposeBlock({}, 'shipment-price'), null);
|
||||
assert.equal(kullPurposeBlock({ ...view, karamaBlocked: 'All Karama is restricted.' }, 'shipment-price'), 'All Karama is restricted.');
|
||||
assert.equal(kullPurposeBlock({ ...view, karamaBlocked: 'All Karama is restricted.' }, 'special:emperor'), 'All Karama is restricted.');
|
||||
});
|
||||
@@ -404,7 +404,7 @@ for (const source of ['printed', 'nexus'] as const)
|
||||
assert.equal(paid.players[0].spice, 1);
|
||||
});
|
||||
|
||||
void test('the Nexus-only Kull profile keeps unsupported Fremen remote Cunning behind its existing pre-offer guard', () => {
|
||||
void test('a declined Kull resumes the original Fremen special into its native Cunning offer', () => {
|
||||
const f = createNexusChoamKullFixture({ actorFaction: 'fremen', fuel: 'weapon', counter: false });
|
||||
const game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true });
|
||||
game.players[1].hand.push(takeKullCard(game, f.original));
|
||||
@@ -435,12 +435,19 @@ void test('the Nexus-only Kull profile keeps unsupported Fremen remote Cunning b
|
||||
const summoned = applyAction(reload(offered), f.choam, {
|
||||
type: 'kullDecision', event: offered.pendingKull!.event, decline: true });
|
||||
assert.equal(summoned.players[1].specialKaramaUsed, true);
|
||||
assert.equal(summoned.nexusFremenCunningOffer ?? null, null);
|
||||
assert.ok(summoned.nexusFremenCunningOffer);
|
||||
assert.equal(summoned.nexusFremenCunningOffer.occurrence.parent, summoned.summonedWorm?.event);
|
||||
assert.equal(summoned.decision?.kind, 'nexusFremenCunningOffer');
|
||||
assert.equal(summoned.nexusFremenCunningRides ?? null, null);
|
||||
assert.equal(summoned.nexusCards!.cards!.hands[f.actor], 'fremen');
|
||||
assert.equal(summoned.discard.filter(card => card.id === f.original).length, 1);
|
||||
reject(summoned, f.actor, { type: 'decision', event: 'unsupported-remote-ride', accept: true });
|
||||
viewGame(reload(summoned), f.actor);
|
||||
const declined = applyAction(summoned, f.actor, {type:'decision',
|
||||
event:summoned.nexusFremenCunningOffer.occurrence.event, accept:false});
|
||||
assert.equal(declined.nexusFremenCunningOffer ?? null, null);
|
||||
assert.equal(declined.nexusCards!.cards!.hands[f.actor], 'fremen');
|
||||
assert.equal(declined.discard.filter(card => card.id === f.original).length, 1);
|
||||
});
|
||||
|
||||
void test('a canceled CHOAM-effect block does not remove ordinary Karama auction-payment coverage', () => {
|
||||
|
||||
@@ -286,8 +286,9 @@ for (const advanced of [false, true])
|
||||
assert.equal(done.nexusChoamHistory![0].stage, 'complete');
|
||||
assert.deepEqual(done.nexusChoamLast, { event: nexusEvent, stage: 'complete' });
|
||||
assert.deepEqual((done.kullRestrictions ?? []).filter(rule => rule.player === actor),
|
||||
[{ player: actor, turn: done.turn, phase: done.phase }]);
|
||||
assert.ok(viewGame(done, actor).karamaBlocked, 'the stamped phase ban is publicly visible');
|
||||
[{ player: actor, turn: done.turn, phase: done.phase, ...(advanced ? {purpose: 'shipment-price'} : {}) }]);
|
||||
assert.equal(!!viewGame(done, actor).karamaBlocked, !advanced);
|
||||
assert.deepEqual(viewGame(done, actor).karamaPurposeBlocks.map(rule => rule.purpose), advanced ? ['shipment-price'] : []);
|
||||
assert.equal(viewGame(done, choam).kullReaction, null);
|
||||
await restored(f, done);
|
||||
|
||||
@@ -590,7 +591,8 @@ void test('SQLite completed Nexus Kull history stays intrinsic after later physi
|
||||
f.writes.length = 0;
|
||||
const views = await restored(f, later);
|
||||
assert.equal(views[0].kullReaction ?? null, null, 'a settled opportunity is never reopened by recycling');
|
||||
assert.ok(views[1].karamaBlocked, 'the stamped phase ban survives the later card movement');
|
||||
assert.equal(views[1].karamaBlocked, null);
|
||||
assert.deepEqual(views[1].karamaPurposeBlocks.map(rule => rule.purpose), ['shipment-price'], 'the effect restriction survives the later card movement');
|
||||
assert.equal(later.nexusChoamHistory!.length, 1, 'history never duplicates an event');
|
||||
assert.equal(later.nexusChoamHistory![0].stage, 'complete');
|
||||
assert.deepEqual(later.nexusChoamLast, { event: nexusEvent, stage: 'complete' });
|
||||
|
||||
Reference in new issue
Block a user