Allow replay of the recovered physical Hajr
Count one movement grant per accepted Hajr play after original paid Box recovery. Preserve physical card custody, Basic timing and fresh Advanced post-movement opportunities. Carry unused counts through Guild Cunning with counted declarations and signed final history while retaining legacy receipt compatibility. Validation: frozen12-file102/102, types/lint/build and two original-action/rendered-table programmes; four minimal policies retain third moves in both modes. Real cache and normal purchases, paid shipment and Box2 recovery reuse the single original Hajr. Replay is an explicit source interpretation. Native Cunning replay has pure/related evidence; wider combinations, final assurance and deployed acceptance remain open. No saved-game, reset, restart or deployment operation.
This commit is contained in:
1 parent
72e77d7f4d
commit
b82441c834
17 files changed
+679
-65
No files matched your search
@@ -153,7 +153,7 @@ void test('Weather Control stays available after dials and confirmations, but va
|
||||
'omitting selection asks about timing only',
|
||||
);
|
||||
});
|
||||
void test('Hajr requires the active movement owner and grants only one additional ordinary entitlement', () => {
|
||||
void test('each held Hajr needs the active movement owner and grants its own ordinary entitlement', () => {
|
||||
agrees(
|
||||
'hajr',
|
||||
(g) => {
|
||||
@@ -173,7 +173,7 @@ void test('Hajr requires the active movement owner and grants only one additiona
|
||||
(g) => {
|
||||
g.hajr = ['a'];
|
||||
},
|
||||
false,
|
||||
true,
|
||||
);
|
||||
agrees(
|
||||
'hajr',
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { location } from '../game/board';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import {
|
||||
actOrnithopterHajr, ornithopterHajrClean, ornithopterHajrMove,
|
||||
ornithopterHajrPlayer, type OrnithopterHajrStep,
|
||||
} from './fixture-ornithopter-hajr';
|
||||
|
||||
const canonicalBox = richeseCards().find(card => card.effect === 'nullentropyBox');
|
||||
assert.ok(canonicalBox);
|
||||
export const HAJR_REPLAY_BOX = canonicalBox.id;
|
||||
export type HajrReplayStep = OrnithopterHajrStep;
|
||||
export const hajrReplayPlayer = ornithopterHajrPlayer;
|
||||
export const hajrReplayClean = ornithopterHajrClean;
|
||||
export const actHajrReplay = actOrnithopterHajr;
|
||||
export const hajrReplayMove = ornithopterHajrMove;
|
||||
|
||||
/** Only live original controls are consumed. The cache card is bought in its
|
||||
* ordinary native auction; the single Hajr is bought in the normal auction. */
|
||||
export function nextHajrReplayStep(game: Game): HajrReplayStep | null {
|
||||
if (!game.response && !game.phaseOpening && game.decision) {
|
||||
const decision = game.decision;
|
||||
if (decision.kind === 'richeseCache') return { actor: decision.player, action: {
|
||||
type: 'decision', event: game.richeseBidding!.event, card: HAJR_REPLAY_BOX,
|
||||
method: 'onceAround', direction: 'clockwise',
|
||||
} };
|
||||
if (decision.kind === 'richeseUnbid') return { actor: decision.player,
|
||||
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
||||
}
|
||||
if (hajrReplayClean(game) && game.richeseAuction?.cardId === HAJR_REPLAY_BOX &&
|
||||
!game.richeseAuction.outcome && game.richeseAuction.active === 'emperor' && game.richeseAuction.bid === 0)
|
||||
return { actor: 'emperor', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1, allyPayment: 0 } };
|
||||
if (hajrReplayClean(game) && game.auction?.active === 'emperor' && game.auction.bid === 0 &&
|
||||
game.auction.cards[game.auction.index].effect === 'hajr')
|
||||
return { actor: 'emperor', action: { type: 'bid', amount: 1, allyPayment: 0 } };
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function advanceHajrReplay(game: Game, until: (state: Game) => boolean,
|
||||
actions: HajrReplayStep[] = []): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Hajr/Box programme ended before its next control');
|
||||
const next = nextHajrReplayStep(game); assert.ok(next, 'Finish the real owned Hajr/Box choice before continuing');
|
||||
game = actHajrReplay(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original Hajr/Box programme did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Genuine original host, setup, paid Box/Hajr lots and paid desert shipment.
|
||||
* Only unopened Hajr/Spice deck positions and scalar lotteries are selected.
|
||||
* No hand, wallet, force, phase or completed-effect receipt is installed. */
|
||||
export function createHajrReplayFixture(options: { advanced?: boolean } = {}) {
|
||||
const advanced = options.advanced ?? false;
|
||||
let game = createGame('HAJRREPLAY', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
|
||||
for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: HajrReplayStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actHajrReplay(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceHajrReplay(game, until, actions); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), front: 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);
|
||||
front.push(game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
reach(state => state.phase === 2 && hajrReplayClean(state));
|
||||
const at = game.deck.findIndex(card => card.effect === 'hajr');
|
||||
assert.ok(at >= 0, 'The one original Hajr must still be undealt');
|
||||
const hajr = game.deck[at].id;
|
||||
game.deck.unshift(game.deck.splice(at, 1)[0]);
|
||||
const beforePurchases = structuredClone(game);
|
||||
reach(state => hajrReplayPlayer(state).hand.some(card => card.id === HAJR_REPLAY_BOX) &&
|
||||
hajrReplayPlayer(state).hand.some(card => card.id === hajr));
|
||||
const acquired = structuredClone(game);
|
||||
reach(state => state.phase === 5 && state.active === 'emperor' && hajrReplayClean(state));
|
||||
const beforeShipment = structuredClone(game), shipmentStep: HajrReplayStep = { actor: 'emperor', action: {
|
||||
type: 'ship', territory: 'imperial_basin', sector: 9, amount: 3, elite: advanced ? 2 : 0, allyPayment: 0,
|
||||
} };
|
||||
act(shipmentStep.actor, shipmentStep.action); reach(hajrReplayClean);
|
||||
return { game, lobby, setup, afterSetup, beforePurchases, acquired, beforeShipment, shipmentStep,
|
||||
afterShipment: structuredClone(game), hajr, box: HAJR_REPLAY_BOX, owner: 'emperor',
|
||||
source: 'imperial_basin:9', actions,
|
||||
staging: ['Original native setup and paid cache1/normal1 lots acquire Box and the single Hajr. An original paid3-counter desert shipment leaves the two-spice Box fee. Only unopened deck positions and scalar lotteries are selected.'] };
|
||||
}
|
||||
export type HajrReplayFixture = ReturnType<typeof createHajrReplayFixture>;
|
||||
|
||||
/** The real Box pays once and either exposes its actual private search or
|
||||
* automatically takes the only eligible discard, as the existing rule does. */
|
||||
export function recoverHajrWithBox(game: Game, fixture: HajrReplayFixture, actions: HajrReplayStep[] = []) {
|
||||
assert.ok(game.discard.some(card => card.id === fixture.hajr));
|
||||
const before = structuredClone(game), step: HajrReplayStep = {
|
||||
actor: fixture.owner, action: { type: 'card', card: fixture.box },
|
||||
};
|
||||
game = actHajrReplay(game, step.actor, step.action, actions);
|
||||
const paid = structuredClone(game);
|
||||
let selectionStep: HajrReplayStep | null = null;
|
||||
if (game.pendingNullentropy) {
|
||||
const search = viewGame(game, fixture.owner).nullentropy?.search;
|
||||
assert.ok(search && search.cards.some(card => card.id === fixture.hajr), 'Select the genuinely discarded Hajr from the real paid owner view');
|
||||
selectionStep = { actor: fixture.owner,
|
||||
action: { type: 'decision', event: search.event, card: fixture.hajr } };
|
||||
game = actHajrReplay(game, selectionStep.actor, selectionStep.action, actions);
|
||||
}
|
||||
game = advanceHajrReplay(game, hajrReplayClean, actions);
|
||||
assert.ok(hajrReplayPlayer(game).hand.some(card => card.id === fixture.hajr));
|
||||
return { game, before, step, paid, selectionStep };
|
||||
}
|
||||
|
||||
/** Basic: Hajr, move, Box, recovered Hajr, move, move. Advanced: move,
|
||||
* Hajr, Box, move, recovered Hajr, move. A Box action supplies no movement
|
||||
* and no Advanced timing opportunity; the second actual move opens that. */
|
||||
export function completeHajrReplay(fixture: HajrReplayFixture) {
|
||||
let game = structuredClone(fixture.game);
|
||||
const actions: HajrReplayStep[] = [];
|
||||
const moves: { before: Game; step: HajrReplayStep; after: Game }[] = [];
|
||||
const play = () => {
|
||||
game = actHajrReplay(game, fixture.owner, { type: 'card', card: fixture.hajr }, actions);
|
||||
game = advanceHajrReplay(game, hajrReplayClean, actions);
|
||||
};
|
||||
const move = (from: string) => {
|
||||
const before = structuredClone(game), step: HajrReplayStep = {
|
||||
actor: fixture.owner, action: hajrReplayMove(game, from),
|
||||
};
|
||||
game = actHajrReplay(game, step.actor, step.action, actions);
|
||||
game = advanceHajrReplay(game, hajrReplayClean, actions);
|
||||
moves.push({ before, step, after: structuredClone(game) });
|
||||
return location(String(step.action.territory), Number(step.action.sector));
|
||||
};
|
||||
if (!game.advanced) play();
|
||||
const beforeFirstMove = structuredClone(game), firstDestination = move(fixture.source);
|
||||
if (game.advanced) play();
|
||||
const firstHajr = structuredClone(game), recovery = recoverHajrWithBox(game, fixture, actions);
|
||||
game = recovery.game;
|
||||
const recovered = structuredClone(game);
|
||||
let secondDestination = firstDestination;
|
||||
if (game.advanced) secondDestination = move(firstDestination);
|
||||
const beforeSecondHajr = structuredClone(game);
|
||||
play();
|
||||
const secondHajr = structuredClone(game);
|
||||
if (!game.advanced) secondDestination = move(firstDestination);
|
||||
const beforeThirdMove = structuredClone(game);
|
||||
move(secondDestination);
|
||||
const completed = structuredClone(game);
|
||||
game = actHajrReplay(game, fixture.owner, { type: 'endMovement' }, actions);
|
||||
return { game, completed, beforeFirstMove, firstHajr, recovery, recovered, beforeSecondHajr,
|
||||
secondHajr, beforeThirdMove, firstDestination, secondDestination, moves, actions };
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { ordinaryCardAvailability } from '../game/card-availability';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
HAJR_REPLAY_BOX, actHajrReplay, completeHajrReplay, createHajrReplayFixture,
|
||||
hajrReplayPlayer as player, type HajrReplayFixture,
|
||||
} from './fixture-hajr-replay';
|
||||
|
||||
const own = (game: Game) => viewGame(game, 'emperor').players.find(player => player.id === 'emperor')!;
|
||||
const availability = (game: Game, f: HajrReplayFixture) => ordinaryCardAvailability(viewGame(game, f.owner), f.hajr)?.available;
|
||||
function physicalHajr(game: Game, f: HajrReplayFixture) {
|
||||
return [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
|
||||
.filter(card => card.id === f.hajr);
|
||||
}
|
||||
function source(f: HajrReplayFixture) {
|
||||
assert.equal(baseDeck().filter(card => card.effect === 'hajr').length, 1,
|
||||
'This programme reuses the one printed Hajr rather than creating a second physical copy');
|
||||
assert.equal(f.box, HAJR_REPLAY_BOX);
|
||||
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'bid' && step.action.amount === 1));
|
||||
assert.ok(player(f.game).hand.some(card => card.id === f.box));
|
||||
assert.ok(player(f.game).hand.some(card => card.id === f.hajr));
|
||||
assert.equal(physicalHajr(f.game, f).length, 1);
|
||||
assert.equal(player(f.beforeShipment).spice - player(f.afterShipment).spice, 6);
|
||||
assert.equal(player(f.game).spice, 2, 'The actual purchases and three-counter shipment preserve the Box fee');
|
||||
assert.equal(player(f.game).forces[f.source], 3);
|
||||
assert.equal(own(f.game).moved, 0);
|
||||
assert.equal(own(f.game).movesAllowed, 1);
|
||||
assert.equal(own(f.game).hajrUsed, false);
|
||||
assert.equal(own(f.game).hajrPlays, 0);
|
||||
assert.equal(availability(f.game, f), !f.game.advanced);
|
||||
}
|
||||
function settled(f: HajrReplayFixture, result: ReturnType<typeof completeHajrReplay>) {
|
||||
const game = result.completed;
|
||||
assert.equal(result.moves.length, 3);
|
||||
assert.equal(player(game).moved, 3);
|
||||
assert.equal(own(game).movesAllowed, 3);
|
||||
assert.equal(own(game).hajrUsed, true);
|
||||
assert.equal(own(game).hajrPlays, 2);
|
||||
assert.equal(game.hajr.filter(actor => actor === f.owner).length, 1,
|
||||
'The legacy Hajr summary remains a unique marker rather than a second allowance');
|
||||
assert.deepEqual(result.actions.filter(step => step.action.type === 'card' && step.action.card === f.hajr)
|
||||
.map(step => step.action.card), [f.hajr, f.hajr]);
|
||||
assert.equal(physicalHajr(game, f).length, 1);
|
||||
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
|
||||
assert.equal(game.discard.filter(card => card.id === f.box).length, 1);
|
||||
assert.equal(player(game).spice, player(f.game).spice - 2);
|
||||
assert.equal(player(game).reserves, player(f.game).reserves);
|
||||
assert.equal(player(game).tanks, player(f.game).tanks);
|
||||
assert.equal(Object.values(player(game).forces).reduce((sum, count) => sum + count, 0), 3);
|
||||
assert.notEqual(result.game.active, f.owner, 'The actual final endMovement action finishes the native owner turn');
|
||||
assert.throws(() => actHajrReplay(game, f.owner, { type: 'card', card: f.hajr }),
|
||||
'A third submission after the recovered physical Hajr is discarded cannot grant movement');
|
||||
}
|
||||
function recovered(f: HajrReplayFixture, result: ReturnType<typeof completeHajrReplay>) {
|
||||
const { recovery } = result;
|
||||
assert.equal(player(recovery.before).spice - player(recovery.paid).spice, 2);
|
||||
assert.equal(player(recovery.paid).moved, player(recovery.before).moved,
|
||||
'Paying for the Box supplies no Force Movement');
|
||||
assert.equal(own(recovery.paid).hajrPlays, 1);
|
||||
assert.equal(own(result.firstHajr).hajrPlays, 1);
|
||||
assert.equal(result.firstHajr.discard.filter(card => card.id === f.hajr).length, 1);
|
||||
assert.equal(availability(result.firstHajr, f), false);
|
||||
assert.throws(() => actHajrReplay(result.firstHajr, f.owner, { type: 'card', card: f.hajr }),
|
||||
'The consumed first-use ID is illegal until the Box actually returns that card');
|
||||
assert.equal(player(result.recovered).hand.filter(card => card.id === f.hajr).length, 1);
|
||||
assert.equal(result.recovered.discard.some(card => card.id === f.hajr), false);
|
||||
assert.equal(physicalHajr(result.recovered, f).length, 1);
|
||||
assert.equal(result.recovered.discard.at(-1)!.id, f.box, 'The played Box finishes on top after the real search');
|
||||
assert.equal(result.recovered.pendingNullentropy ?? null, null);
|
||||
assert.equal(result.recovered.pendingTreacheryDiscard ?? null, null);
|
||||
if (recovery.selectionStep) {
|
||||
const search = viewGame(recovery.paid, f.owner).nullentropy!.search!;
|
||||
assert.equal(recovery.selectionStep.action.event, search.event);
|
||||
assert.equal(recovery.selectionStep.action.card, f.hajr);
|
||||
assert.ok(search.cards.some(card => card.id === f.hajr));
|
||||
} else assert.ok(player(recovery.paid).hand.some(card => card.id === f.hajr),
|
||||
'The original Box automatically retrieves its sole eligible discard');
|
||||
}
|
||||
|
||||
void test('genuine Basic paid Box recovers the single discarded Hajr for two grants and three ordinary moves', () => {
|
||||
const f = createHajrReplayFixture();
|
||||
source(f);
|
||||
const result = completeHajrReplay(f);
|
||||
recovered(f, result);
|
||||
assert.equal(own(result.beforeFirstMove).hajrPlays, 1);
|
||||
assert.equal(own(result.beforeFirstMove).movesAllowed, 2);
|
||||
assert.equal(player(result.beforeSecondHajr).moved, 1);
|
||||
assert.equal(availability(result.recovered, f), true);
|
||||
assert.equal(own(result.secondHajr).hajrPlays, 2);
|
||||
assert.equal(own(result.secondHajr).movesAllowed, 3);
|
||||
assert.equal(player(result.secondHajr).moved, 1);
|
||||
assert.equal(player(result.beforeThirdMove).moved, 2);
|
||||
settled(f, result);
|
||||
});
|
||||
|
||||
void test('genuine Advanced paid Box recovery waits for the second typed Force Movement before replaying the same Hajr', () => {
|
||||
const f = createHajrReplayFixture({ advanced: true });
|
||||
source(f);
|
||||
assert.equal(player(f.game).elites!.forces[f.source], 2);
|
||||
const result = completeHajrReplay(f);
|
||||
recovered(f, result);
|
||||
assert.equal(own(result.beforeFirstMove).hajrPlays, 0);
|
||||
assert.equal(player(result.firstHajr).moved, 1);
|
||||
assert.equal(own(result.firstHajr).movesAllowed, 2);
|
||||
assert.equal(own(result.recovered).hajrAvailable, false,
|
||||
'A Box payment/search is voluntary and cannot open an immediate-after-movement window');
|
||||
assert.equal(availability(result.recovered, f), false);
|
||||
assert.throws(() => actHajrReplay(result.recovered, f.owner, { type: 'card', card: f.hajr }),
|
||||
/Force Movement|move|movement|immediately/i);
|
||||
assert.equal(player(result.beforeSecondHajr).moved, 2);
|
||||
assert.equal(own(result.beforeSecondHajr).hajrAvailable, true);
|
||||
assert.equal(availability(result.beforeSecondHajr, f), true);
|
||||
assert.equal(player(result.secondHajr).moved, 2);
|
||||
assert.equal(own(result.secondHajr).movesAllowed, 3);
|
||||
assert.ok(result.moves.every(move => move.step.action.elite === 1),
|
||||
'The same genuinely shipped Sardaukar may take all three ordinary moves');
|
||||
assert.equal(Object.values(player(result.completed).elites!.forces).reduce((sum, count) => sum + count, 0), 2);
|
||||
settled(f, result);
|
||||
});
|
||||
|
||||
void test('all four minimal policies propose a legal third movement after genuine Basic and Advanced Hajr recovery', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = createHajrReplayFixture({ advanced });
|
||||
const { beforeThirdMove } = completeHajrReplay(f);
|
||||
assert.equal(player(beforeThirdMove).moved, 2);
|
||||
assert.equal(own(beforeThirdMove).hajrPlays, 2);
|
||||
assert.equal(own(beforeThirdMove).movesAllowed, 3);
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(beforeThirdMove, f.owner);
|
||||
view.players.find(player => player.id === f.owner)!.bot = difficulty;
|
||||
const action = botActions(view).find(action => action.type === 'move');
|
||||
assert.ok(action, `${difficulty} retains the original recovered-Hajr third move`);
|
||||
assert.equal(action.ornithopterEvent, undefined);
|
||||
assert.equal(action.movementCard, undefined);
|
||||
const next = actHajrReplay(structuredClone(beforeThirdMove), f.owner, action);
|
||||
assert.ok(player(next).moved === 3 || next.response || next.decision,
|
||||
'The actual policy candidate completes the ordinary move or enters its native response');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createNexusGuildCunning,
|
||||
nexusGuildCunningMoves,
|
||||
validateNexusGuildCunning,
|
||||
type NexusGuildCunningCountedBefore,
|
||||
} from '../game/nexus-guild-cunning';
|
||||
|
||||
const context = {
|
||||
turn: 3,
|
||||
phase: 5,
|
||||
active: 'g',
|
||||
players: [
|
||||
{ id: 'a', faction: 'atreides' as const },
|
||||
{ id: 'g', faction: 'guild' as const },
|
||||
{ id: 'f', faction: 'fremen' as const },
|
||||
],
|
||||
};
|
||||
const counted = (moved: number, hajrPlays: number) =>
|
||||
createNexusGuildCunning(context, 'g', { shipped: true, moved, hajrPlays });
|
||||
|
||||
// Counts describe already accepted physical card plays. The engine, rather
|
||||
// than this allowance quote, proves actual Box retrieval and Advanced timing.
|
||||
void test('counted Guild Cunning preserves the exact original boolean receipt bytes and allowance', () => {
|
||||
const receipt = createNexusGuildCunning(context, 'g', {
|
||||
shipped: true, moved: 1, hajrUsed: true,
|
||||
});
|
||||
const original = {
|
||||
version: 1,
|
||||
event: '["nexusGuildCunning",3,"g"]',
|
||||
owner: 'g',
|
||||
turn: 3,
|
||||
phase: 5,
|
||||
mode: 'cunning',
|
||||
roster: [
|
||||
{ id: 'a', faction: 'atreides' },
|
||||
{ id: 'f', faction: 'fremen' },
|
||||
{ id: 'g', faction: 'guild' },
|
||||
],
|
||||
before: { shipped: true, moved: 1, hajrUsed: true },
|
||||
signature: '["nexusGuildCunningReceipt",1,"[\\"nexusGuildCunning\\",3,\\"g\\"]","g",3,5,"cunning",[["a","atreides"],["f","fremen"],["g","guild"]],[true,1,true]]',
|
||||
};
|
||||
assert.equal(JSON.stringify(receipt), JSON.stringify(original));
|
||||
validateNexusGuildCunning(context, receipt);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, true), 1);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, true, 1), 0);
|
||||
assert.throws(() => nexusGuildCunningMoves(receipt, true, 2));
|
||||
});
|
||||
|
||||
void test('a counted Guild Cunning receipt records all earned Hajr moves before its second shipment', () => {
|
||||
const before = Object.freeze({ shipped: true, moved: 3, hajrPlays: 2 });
|
||||
const receipt = createNexusGuildCunning(context, 'g', before);
|
||||
assert.equal(receipt.version, 2);
|
||||
assert.deepEqual(receipt.before, before);
|
||||
assert.notEqual(receipt.before, before);
|
||||
assert.deepEqual(Object.keys(receipt.before).sort(), ['hajrPlays', 'moved', 'shipped']);
|
||||
validateNexusGuildCunning(context, receipt);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 2), 0);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 3), 1,
|
||||
'Another actual play after the shipment earns one fresh move');
|
||||
});
|
||||
|
||||
void test('Cunning forfeits an unused ordinary move while preserving every unspent counted Hajr move', () => {
|
||||
for (const [moved, remaining] of [[0, 2], [1, 2], [2, 1], [3, 0]]) {
|
||||
const receipt = counted(moved, 2);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 2), remaining);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 2, remaining), 0);
|
||||
assert.throws(() => nexusGuildCunningMoves(receipt, 2, remaining + 1));
|
||||
}
|
||||
for (const moved of [0, 1]) {
|
||||
const receipt = counted(moved, 0);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 0), 0,
|
||||
'The extra shipment supplies no ordinary movement, even after declining the first');
|
||||
assert.throws(() => nexusGuildCunningMoves(receipt, 0, 1));
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual Hajr replays after the second shipment add moves rather than renewing an already consumed grant', () => {
|
||||
const receipt = counted(1, 0);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 1), 1);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 1, 1), 0);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 2, 1), 1);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 2, 2), 0);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 3, 2), 1);
|
||||
assert.throws(() => nexusGuildCunningMoves(receipt, 2, 3));
|
||||
|
||||
const legacy = createNexusGuildCunning(context, 'g', {
|
||||
shipped: true, moved: 2, hajrUsed: true,
|
||||
});
|
||||
assert.equal(nexusGuildCunningMoves(legacy, true), 0);
|
||||
assert.equal(nexusGuildCunningMoves(legacy, 2), 1,
|
||||
'An original v1 continuation may gain a genuinely new counted play');
|
||||
assert.equal(nexusGuildCunningMoves(legacy, 2, 1), 0);
|
||||
});
|
||||
|
||||
void test('counted Cunning rejects lost grants and nonintegral or excessive consumed movement', () => {
|
||||
const receipt = counted(2, 2);
|
||||
for (const current of [false, true, 0, 1, -1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER])
|
||||
assert.throws(() => nexusGuildCunningMoves(receipt, current));
|
||||
for (const moved of [-1, 0.5, NaN, Infinity, 2])
|
||||
assert.throws(() => nexusGuildCunningMoves(receipt, 2, moved));
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 2, 1), 0);
|
||||
assert.equal(nexusGuildCunningMoves(counted(1, 1), true), 1,
|
||||
'Boolean callers still describe one actual play for a compatible counted receipt');
|
||||
});
|
||||
|
||||
void test('counted Cunning binds its original count and cannot mix the two receipt formats', () => {
|
||||
const before = { shipped: true, moved: 1, hajrPlays: 2 };
|
||||
for (const bad of [
|
||||
{ ...before, hajrPlays: -1 },
|
||||
{ ...before, hajrPlays: 0.5 },
|
||||
{ ...before, hajrPlays: NaN },
|
||||
{ ...before, hajrPlays: Infinity },
|
||||
{ ...before, hajrPlays: Number.MAX_SAFE_INTEGER },
|
||||
{ ...before, moved: 4 },
|
||||
{ ...before, hajrUsed: true },
|
||||
]) assert.throws(() => createNexusGuildCunning(context, 'g', bad as NexusGuildCunningCountedBefore));
|
||||
const receipt = counted(1, 2);
|
||||
assert.throws(() => validateNexusGuildCunning(context, {
|
||||
...receipt, before: { ...receipt.before, hajrPlays: 1 },
|
||||
}));
|
||||
assert.throws(() => validateNexusGuildCunning(context, {
|
||||
...receipt, version: 1, before: { shipped: true, moved: 1, hajrUsed: true },
|
||||
}));
|
||||
});
|
||||
@@ -88,7 +88,8 @@ void test('Guild special Karama stops its own second shipment without payment or
|
||||
const f = nexusGuildCunningFixture({ advanced: true, homeworlds });
|
||||
const karama = holdGuildCunningCard(f.g, f.owner, 'karama');
|
||||
const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
|
||||
let g = originalGuildCunningTurn(f, false);
|
||||
let g = originalGuildCunningTurn(f);
|
||||
g = applyAction(g, f.owner, {type:'card',card:hajr.id});
|
||||
g = nexusGuildCunningAllow(
|
||||
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
|
||||
);
|
||||
@@ -131,22 +132,20 @@ void test('Guild special Karama stops its own second shipment without payment or
|
||||
);
|
||||
const move: Action = {
|
||||
type: 'move',
|
||||
from: 'arrakeen:10',
|
||||
territory: 'hagga_basin',
|
||||
from: 'hagga_basin:12',
|
||||
territory: 'plastic_basin',
|
||||
sector: 12,
|
||||
amount: 5,
|
||||
};
|
||||
rejected(g, f.owner, move);
|
||||
rejected(g, f.owner, shipment);
|
||||
g = applyAction(nexusGuildCunningReload(g), f.owner, {
|
||||
type: 'card',
|
||||
card: hajr.id,
|
||||
});
|
||||
assert.equal(viewGame(g,f.owner).nexusGuildCunning!.active!.movesLeft,1);
|
||||
// Supplied Advanced timing played Hajr after the first actual movement;
|
||||
// the stopped second shipment preserves that already earned unused move.
|
||||
g = applyAction(g, f.owner, move);
|
||||
rejected(g, f.owner, {
|
||||
type: 'move',
|
||||
from: 'hagga_basin:12',
|
||||
territory: 'arsunt',
|
||||
from: 'plastic_basin:12',
|
||||
territory: 'hagga_basin',
|
||||
sector: 12,
|
||||
amount: 5,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user