Compose Guild Cunning with physical Ornithopter movement
Provisionally let either physical card mode replace an earned unused Hajr movement after the second shipment. Keep active group rights separate from actual completed credits and original movement counts. Add v3 original-count receipts while preserving old formats; abandon partial flights through a bound discard suffix before Cunning resumes. Connect persistent controls and minimal legal bot candidates. Validation: frozen13-file125/125, types/lint/build and eight original-action/rendered-table continuations. Public ready/start, cache1/Hajr1 purchases, real worm/alliance/Guild Nexus draw and paid first2/second1 shipments cover both modes, earlier flights and Basic early endings. Eight minimal policy proposals are legal. No frozen repair; bounded continuation review found no concrete defect. New Semuta grammar lacks direct combined claim/capture proof. Wider combinations, complete modes, final assurance and browser/deployed acceptance remain open. No saved-room/reset/restart/deployment operation.
This commit is contained in:
1 parent
7fe84ffbbd
commit
561152d58e
16 files changed
+887
-54
No files matched your search
@@ -0,0 +1,215 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { location, splitLocation, TERRITORIES } from '../game/board';
|
||||
import { botEntryAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
|
||||
import { nexusGuildCunningAction } from '../game/nexus-guild-cunning-options';
|
||||
import type { OrnithopterMode } from '../game/ornithopter';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
const card = richeseCards().find(card => card.effect === 'ornithopter'); assert.ok(card);
|
||||
export const GUILD_CUNNING_ORNITHOPTER = card.id;
|
||||
export type GuildCunningOrnithopterStep = { actor: string; action: Action };
|
||||
export const guildCunningOrnithopterPlayer = (game: Game, actor = 'guild') => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const guildCunningOrnithopterClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingGuildBetrayal;
|
||||
export function actGuildCunningOrnithopter(game: Game, actor: string, action: Action,
|
||||
actions: GuildCunningOrnithopterStep[] = []): Game {
|
||||
const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
/** Consume original neutral native controls without choosing an optional
|
||||
* power, physical hand substitution or private Battle Plan. */
|
||||
export function nextGuildCunningOrnithopterStep(game: Game): GuildCunningOrnithopterStep | null {
|
||||
if (game.pendingGuildBetrayal) {
|
||||
const actor = game.pendingGuildBetrayal.required.find(actor => !game.pendingGuildBetrayal!.passed.includes(actor));
|
||||
assert.ok(actor); return { actor, action: { type: 'guildBetrayalPass', event: game.pendingGuildBetrayal.invoice.event } };
|
||||
}
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'nexusIxianReplacement') return { actor: decision.player,
|
||||
action: { type: 'nexusIxianReplacementPass', event: decision.event } };
|
||||
if (decision?.kind === 'guildTiming') return { actor: decision.player, action: { type: 'decision', take: false } };
|
||||
if (decision?.kind === 'richeseAllyOpportunity') return { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
if (decision?.kind === 'richeseCache' && game.richeseCache?.some(card => card.id === GUILD_CUNNING_ORNITHOPTER))
|
||||
return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
|
||||
card: GUILD_CUNNING_ORNITHOPTER, method: 'onceAround', direction: 'clockwise' } };
|
||||
if (decision?.kind === 'richeseUnbid') return { actor: decision.player,
|
||||
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
||||
if (!decision && game.nexusCards?.phase?.stage === 'drawing') {
|
||||
const phase = game.nexusCards.phase, actor = phase.eligible.find(actor => !phase.done.includes(actor));
|
||||
assert.ok(actor); return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: game.nexusCards.cards!.hands[actor] ?? null, choice: 'keep', ownRedraws: 0 } };
|
||||
}
|
||||
}
|
||||
if (guildCunningOrnithopterClean(game) && game.richeseAuction?.cardId === GUILD_CUNNING_ORNITHOPTER &&
|
||||
!game.richeseAuction.outcome && game.richeseAuction.active === 'guild' && game.richeseAuction.bid === 0)
|
||||
return { actor: 'guild', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1, allyPayment: 0 } };
|
||||
if (guildCunningOrnithopterClean(game) && game.auction?.active === 'guild' && game.auction.bid === 0 &&
|
||||
game.auction.cards[game.auction.index].effect === 'hajr')
|
||||
return { actor: 'guild', action: { type: 'bid', amount: 1, allyPayment: 0 } };
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function advanceGuildCunningOrnithopter(game: Game, until: (state: Game) => boolean,
|
||||
actions: GuildCunningOrnithopterStep[] = []): Game {
|
||||
for (let count = 0; count < 2400; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Cunning/Ornithopter programme ended before its owned control');
|
||||
const step = nextGuildCunningOrnithopterStep(game); assert.ok(step, 'Complete the actual private choice before advancing');
|
||||
game = actGuildCunningOrnithopter(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original Cunning/Ornithopter programme did not reach its finite boundary');
|
||||
}
|
||||
function orderBlow(game: Game, worm = false) {
|
||||
const front: Game['spiceDeck'] = [];
|
||||
if (worm) {
|
||||
const at = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
|
||||
assert.ok(at >= 0); front.push(game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
for (let count = 0; count < (game.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);
|
||||
}
|
||||
|
||||
/** Public original host/setup. Native Guild buys the physical cache card and
|
||||
* normal Hajr, then receives its actual Guild Nexus through an original closing
|
||||
* draw after Richese/Emperor form a reciprocal alliance at the real worm. Only
|
||||
* still-unopened deck order/scalar lotteries are selected; no held Nexus,
|
||||
* Treachery, force, money, clock or completed grant is installed. */
|
||||
export function createGuildCunningOrnithopterFixture(options: { advanced?: boolean } = {}) {
|
||||
const advanced = options.advanced ?? false;
|
||||
let game = createGame('GUILDCUNNINGORNITHOPTER', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
|
||||
for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: GuildCunningOrnithopterStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actGuildCunningOrnithopter(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceGuildCunningOrnithopter(game, until, actions); };
|
||||
act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
|
||||
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);
|
||||
orderBlow(game);
|
||||
reach(state => state.phase === 2 && guildCunningOrnithopterClean(state));
|
||||
const at = game.deck.findIndex(card => card.effect === 'hajr'); assert.ok(at >= 0, 'Acquire the still-undealt original Hajr');
|
||||
const hajr = game.deck[at].id; game.deck.unshift(game.deck.splice(at, 1)[0]);
|
||||
const beforePurchases = structuredClone(game);
|
||||
reach(state => guildCunningOrnithopterPlayer(state).hand.some(card => card.id === GUILD_CUNNING_ORNITHOPTER) &&
|
||||
guildCunningOrnithopterPlayer(state).hand.some(card => card.id === hajr));
|
||||
const acquired = structuredClone(game);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && guildCunningOrnithopterClean(state));
|
||||
const afterFirstTurn = structuredClone(game);
|
||||
orderBlow(game, true);
|
||||
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && guildCunningOrnithopterClean(state));
|
||||
const beforeAlliance = structuredClone(game);
|
||||
act('richese', { type: 'alliance', target: 'emperor' });
|
||||
act('emperor', { type: 'alliance', target: 'richese' });
|
||||
reach(state => state.nexusCards?.phase?.stage === 'drawing' && guildCunningOrnithopterClean(state));
|
||||
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('guild'); assert.ok(index >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
||||
const beforeNexusDraw = structuredClone(game), draw: GuildCunningOrnithopterStep = { actor: 'guild', action: {
|
||||
type: 'nexusCardChoice', turn: game.turn, card: cards.hands.guild ?? null, choice: 'draw', ownRedraws: 0,
|
||||
} };
|
||||
act(draw.actor, draw.action);
|
||||
const afterNexusDraw = structuredClone(game);
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === 'guild' && guildCunningOrnithopterClean(state));
|
||||
const beforeFirstShipment = structuredClone(game), firstShipment: GuildCunningOrnithopterStep = { actor: 'guild', action: {
|
||||
type: 'ship', territory: 'imperial_basin', sector: 9, amount: 2, elite: 0, allyPayment: 0,
|
||||
} };
|
||||
act(firstShipment.actor, firstShipment.action); reach(guildCunningOrnithopterClean);
|
||||
return { game, lobby, setup, afterSetup, beforePurchases, acquired, afterFirstTurn, beforeAlliance,
|
||||
beforeNexusDraw, draw, afterNexusDraw, beforeFirstShipment, firstShipment,
|
||||
afterFirstShipment: structuredClone(game), hajr, card: GUILD_CUNNING_ORNITHOPTER, owner: 'guild',
|
||||
source: 'imperial_basin:9', sourceActionCount: actions.length, actions,
|
||||
staging: ['Original host selects Nexus before setup. Only unopened Hajr, Spice and closing Guild Nexus positions plus scalar lotteries are selected. Cache1/normal1 purchases, real worm/alliance/draw and native paid reserve shipment2 supply every card, counter, coin and grant source.'] };
|
||||
}
|
||||
export type GuildCunningOrnithopterFixture = ReturnType<typeof createGuildCunningOrnithopterFixture>;
|
||||
|
||||
export function guildCunningOrnithopterMove(game: Game, from: string, options: {
|
||||
range?: number; mode?: OrnithopterMode;
|
||||
} = {}): Action {
|
||||
const view = viewGame(game, 'guild'), own = view.players.find(player => player.id === view.me)!;
|
||||
assert.ok((own.forces[from] ?? 0) > 0);
|
||||
const target = TERRITORIES.filter(place => place.type === 'sand' || place.type === 'rock')
|
||||
.flatMap(place => place.sectors.map(sector => location(place.id, sector)))
|
||||
.find(key => {
|
||||
const at = splitLocation(key);
|
||||
return game.players.every(player => Object.entries(player.forces).every(([source, count]) =>
|
||||
count === 0 || splitLocation(source).territory !== at.territory)) &&
|
||||
botEntryAllowed(view, own, at.territory, at.sector, 'move', splitLocation(from).territory) &&
|
||||
groundMoveRouteDistance(view, own, from, key) === (options.range ?? 1);
|
||||
});
|
||||
assert.ok(target, 'The original Guild group needs an empty printed destination at the selected range');
|
||||
return { type: 'move', from, amount: 1, elite: 0, ...splitLocation(target),
|
||||
...(options.mode ? { movementCard: GUILD_CUNNING_ORNITHOPTER, ornithopter: options.mode }
|
||||
: view.ornithopter?.active ? { ornithopterEvent: view.ornithopter.active.event } : {}) };
|
||||
}
|
||||
|
||||
export function completeGuildCunningOrnithopter(fixture: GuildCunningOrnithopterFixture, options: {
|
||||
mode?: OrnithopterMode; position?: 'before' | 'after'; early?: 'beforeCunning' | 'afterCunning';
|
||||
} = {}) {
|
||||
const mode = options.mode ?? 'twoGroups', position = options.early === 'beforeCunning' ? 'before' : options.position ?? 'after';
|
||||
assert.ok(!options.early || mode === 'twoGroups', 'Only a two-group flight has a group to abandon');
|
||||
assert.ok(options.early !== 'afterCunning' || position === 'after');
|
||||
let game = structuredClone(fixture.game), firstFlightGroup: Game | null = null, finishedFlight: Game | null = null;
|
||||
let firstFlightDestination = '';
|
||||
const actions: GuildCunningOrnithopterStep[] = [], moves: {
|
||||
kind: 'ordinary' | 'flight-before' | 'flight-after' | 'post-cunning-hajr';
|
||||
before: Game; step: GuildCunningOrnithopterStep; after: Game;
|
||||
}[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actGuildCunningOrnithopter(game, actor, action, actions); };
|
||||
const settle = () => { game = advanceGuildCunningOrnithopter(game, guildCunningOrnithopterClean, actions); };
|
||||
const hajr = () => { act(fixture.owner, { type: 'card', card: fixture.hajr }); settle(); };
|
||||
const move = (kind: typeof moves[number]['kind'], from: string, settings: Parameters<typeof guildCunningOrnithopterMove>[2] = {}) => {
|
||||
const before = structuredClone(game), step: GuildCunningOrnithopterStep = {
|
||||
actor: fixture.owner, action: guildCunningOrnithopterMove(game, from, settings),
|
||||
};
|
||||
act(step.actor, step.action); settle();
|
||||
moves.push({ kind, before, step, after: structuredClone(game) });
|
||||
return location(String(step.action.territory), Number(step.action.sector));
|
||||
};
|
||||
if (!game.advanced) hajr();
|
||||
if (position === 'after') move('ordinary', fixture.source);
|
||||
else {
|
||||
firstFlightDestination = move('flight-before', fixture.source, { mode, range: mode === 'range3' ? 3 : 1 });
|
||||
firstFlightGroup = structuredClone(game);
|
||||
}
|
||||
if (game.advanced) hajr();
|
||||
if (position === 'before' && mode === 'twoGroups' && options.early !== 'beforeCunning')
|
||||
move('flight-before', fixture.source);
|
||||
if (position === 'before' && options.early !== 'beforeCunning') finishedFlight = structuredClone(game);
|
||||
const beforeCunning = structuredClone(game), offer = viewGame(game, fixture.owner).nexusGuildCunning?.offer;
|
||||
assert.ok(offer && !offer.blocked, offer?.blocked ?? 'The actual Guild must retain its Cunning declaration');
|
||||
const declaration = nexusGuildCunningAction(viewGame(game, fixture.owner), offer.event); assert.ok(declaration);
|
||||
act(fixture.owner, declaration);
|
||||
const declared = structuredClone(game);
|
||||
settle();
|
||||
const allowed = structuredClone(game), view = viewGame(game, fixture.owner), own = view.players.find(player => player.id === fixture.owner)!;
|
||||
const secondTarget = TERRITORIES.filter(place => place.type === 'sand' || place.type === 'rock')
|
||||
.flatMap(place => place.sectors.map(sector => ({ territory: place.id, sector })))
|
||||
.find(target => game.players.every(player => Object.entries(player.forces).every(([source, count]) =>
|
||||
count === 0 || splitLocation(source).territory !== target.territory)) &&
|
||||
botEntryAllowed(view, own, target.territory, target.sector, 'ship'));
|
||||
assert.ok(secondTarget, 'Pay for a positive native second shipment into an actually empty territory');
|
||||
const beforeSecondShipment = structuredClone(game), secondShipment: GuildCunningOrnithopterStep = { actor: fixture.owner,
|
||||
action: { type: 'ship', ...secondTarget, amount: 1, elite: 0, allyPayment: 0 } };
|
||||
act(secondShipment.actor, secondShipment.action); settle();
|
||||
const afterSecondShipment = structuredClone(game), secondSource = location(secondTarget.territory, secondTarget.sector);
|
||||
if (position === 'after') {
|
||||
firstFlightDestination = move('flight-after', secondSource, { mode, range: mode === 'range3' ? 3 : 1 });
|
||||
firstFlightGroup = structuredClone(game);
|
||||
if (mode === 'twoGroups' && options.early !== 'afterCunning') move('flight-after', fixture.source);
|
||||
if (options.early !== 'afterCunning') finishedFlight = structuredClone(game);
|
||||
} else move('post-cunning-hajr', secondSource);
|
||||
const completed = structuredClone(game);
|
||||
if (game.phase === 5 && game.active === fixture.owner) act(fixture.owner, { type: 'endMovement' });
|
||||
return { game, completed, beforeCunning, declared, allowed, declaration, beforeSecondShipment,
|
||||
secondShipment, afterSecondShipment, secondSource, firstFlightGroup: firstFlightGroup!, firstFlightDestination,
|
||||
finishedFlight, moves, actions, mode, position, early: options.early ?? null };
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { nexusGuildCunningAction, nexusGuildMovementAvailable, nexusGuildShipmentAvailable } from '../game/nexus-guild-cunning-options';
|
||||
import {
|
||||
actGuildCunningOrnithopter, advanceGuildCunningOrnithopter, completeGuildCunningOrnithopter, createGuildCunningOrnithopterFixture,
|
||||
guildCunningOrnithopterClean,
|
||||
guildCunningOrnithopterMove, guildCunningOrnithopterPlayer as player, type GuildCunningOrnithopterFixture,
|
||||
} from './fixture-guild-cunning-ornithopter';
|
||||
|
||||
const own = (game: Game) => viewGame(game, 'guild').players.find(player => player.id === 'guild')!;
|
||||
const record = (game: Game) => game.nexusGuildCunningHistory!.at(-1)!;
|
||||
function source(f: GuildCunningOrnithopterFixture) {
|
||||
assert.ok(f.actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'nexusCards' && step.action.enabled));
|
||||
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.acquired).hand.some(card => card.id === f.card));
|
||||
assert.ok(player(f.acquired).hand.some(card => card.id === f.hajr));
|
||||
assert.equal(f.beforeNexusDraw.nexusCards!.cards!.hands.guild, null);
|
||||
assert.equal(f.beforeNexusDraw.nexusCards!.cards!.deck[0], 'guild');
|
||||
assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands.guild, 'guild');
|
||||
assert.equal(player(f.afterNexusDraw).ally ?? null, null);
|
||||
assert.equal(player(f.afterNexusDraw, 'richese').ally, 'emperor');
|
||||
assert.equal(player(f.afterNexusDraw, 'emperor').ally, 'richese');
|
||||
assert.equal(player(f.beforeFirstShipment).spice - player(f.afterFirstShipment).spice, 2);
|
||||
assert.equal(player(f.beforeFirstShipment).reserves - player(f.afterFirstShipment).reserves, 2);
|
||||
assert.equal(player(f.game).forces[f.source], 2);
|
||||
assert.equal(player(f.game).moved, 0);
|
||||
assert.equal(player(f.game).shipped, true);
|
||||
assert.ok(player(f.game).spice >= 1, 'The original Guild retains funds for the positive second shipment');
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function settled(f: GuildCunningOrnithopterFixture, result: ReturnType<typeof completeGuildCunningOrnithopter>, count: number) {
|
||||
assert.equal(player(result.completed).moved, count);
|
||||
assert.equal(player(result.completed).shipped, true);
|
||||
assert.equal(player(result.completed).hajrPlays, 1);
|
||||
assert.equal(result.completed.discard.filter(card => card.id === f.hajr).length, 1);
|
||||
assert.equal(result.game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(result.game.ornithopter ?? null, null);
|
||||
assert.equal(result.game.nexusCards!.cards!.hands.guild, null);
|
||||
assert.equal(result.game.nexusCards!.cards!.discard.filter(card => card === 'guild').length, 1);
|
||||
assert.equal(player(result.beforeSecondShipment).spice - player(result.afterSecondShipment).spice, 1);
|
||||
assert.equal(player(result.beforeSecondShipment).reserves - player(result.afterSecondShipment).reserves, 1);
|
||||
assert.equal(player(result.completed).spice, player(result.afterSecondShipment).spice);
|
||||
assert.equal(record(result.game).stage, 'completed');
|
||||
assert.equal(record(result.game).shipment!.kind, 'reserve');
|
||||
assert.equal(record(result.game).shipment!.stage, 'complete');
|
||||
assert.equal(result.actions.at(-1)!.action.type, 'endMovement');
|
||||
}
|
||||
|
||||
void test('genuine Basic Guild Cunning permits both paid physical Ornithopter modes only through its unused Hajr move', () => {
|
||||
const f = createGuildCunningOrnithopterFixture();
|
||||
source(f);
|
||||
for (const mode of ['range3', 'twoGroups'] as const) {
|
||||
const result = completeGuildCunningOrnithopter(f, { mode });
|
||||
assert.equal(player(result.beforeCunning).moved, 1);
|
||||
assert.equal(player(result.beforeCunning).hajrPlays, 1);
|
||||
assert.equal(result.declaration.nexus, viewGame(result.beforeCunning, f.owner).nexusGuildCunning!.offer!.event);
|
||||
const receipt = record(result.allowed).receipt;
|
||||
assert.equal(receipt.version, 3);
|
||||
if (receipt.version === 3) assert.deepEqual(receipt.before,
|
||||
{ shipped: true, moved: 1, hajrPlays: 1, ornithopterExtraMoves: 0 });
|
||||
assert.equal(record(result.allowed).stage, 'secondShipment');
|
||||
assert.equal(nexusGuildMovementAvailable(viewGame(result.allowed, f.owner)), false);
|
||||
assert.equal(nexusGuildShipmentAvailable(viewGame(result.allowed, f.owner)), true);
|
||||
assert.equal(nexusGuildMovementAvailable(viewGame(result.afterSecondShipment, f.owner)), true);
|
||||
assert.equal(viewGame(result.afterSecondShipment, f.owner).ornithopter!.blocked, null);
|
||||
assert.equal(result.moves[1].step.action.from, result.secondSource,
|
||||
'The genuine newly shipped counter can take the Hajr-backed card flight');
|
||||
if (mode === 'twoGroups') {
|
||||
assert.equal(player(result.firstFlightGroup).moved, 2);
|
||||
assert.equal(own(result.firstFlightGroup).movesAllowed, 3);
|
||||
assert.equal(viewGame(result.firstFlightGroup, f.owner).ornithopter!.active!.remaining, 1);
|
||||
assert.equal(nexusGuildMovementAvailable(viewGame(result.firstFlightGroup, f.owner)), true);
|
||||
const repeat = guildCunningOrnithopterMove(result.firstFlightGroup, result.firstFlightDestination);
|
||||
assert.throws(() => actGuildCunningOrnithopter(result.firstFlightGroup, f.owner, repeat), /cohort|unmoved|original|force/i);
|
||||
assert.equal(player(result.completed).ornithopterExtraMoves, 1);
|
||||
assert.equal(record(result.completed).movesAfter, 2);
|
||||
} else assert.equal(record(result.completed).movesAfter, 1);
|
||||
settled(f, result, mode === 'twoGroups' ? 3 : 2);
|
||||
}
|
||||
});
|
||||
|
||||
void test('genuine Advanced Guild earns Hajr after its actual move before Cunning and either physical card mode', () => {
|
||||
const f = createGuildCunningOrnithopterFixture({ advanced: true });
|
||||
source(f);
|
||||
for (const mode of ['range3', 'twoGroups'] as const) {
|
||||
const result = completeGuildCunningOrnithopter(f, { mode });
|
||||
const movement = result.moves[0];
|
||||
assert.equal(own(movement.before).hajrPlays, 0);
|
||||
assert.equal(own(movement.after).hajrAvailable, true);
|
||||
assert.equal(own(result.beforeCunning).hajrPlays, 1);
|
||||
assert.equal(own(result.afterSecondShipment).hajrAvailable, false);
|
||||
assert.equal(nexusGuildMovementAvailable(viewGame(result.afterSecondShipment, f.owner)), true);
|
||||
settled(f, result, mode === 'twoGroups' ? 3 : 2);
|
||||
}
|
||||
});
|
||||
|
||||
void test('genuine completed two-group flight before Cunning preserves its earned credit and one unused Hajr move', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = createGuildCunningOrnithopterFixture({ advanced });
|
||||
const result = completeGuildCunningOrnithopter(f, { position: 'before' });
|
||||
assert.equal(result.beforeCunning.ornithopter ?? null, null);
|
||||
assert.equal(player(result.beforeCunning).moved, 2);
|
||||
assert.equal(player(result.beforeCunning).ornithopterExtraMoves, 1);
|
||||
const receipt = record(result.allowed).receipt;
|
||||
assert.equal(receipt.version, 3);
|
||||
if (receipt.version === 3) assert.deepEqual(receipt.before,
|
||||
{ shipped: true, moved: 2, hajrPlays: 1, ornithopterExtraMoves: 1 });
|
||||
assert.equal(viewGame(result.afterSecondShipment, f.owner).nexusGuildCunning!.active!.movesLeft, 1);
|
||||
assert.equal(result.moves.at(-1)!.kind, 'post-cunning-hajr');
|
||||
assert.equal(result.moves.at(-1)!.step.action.movementCard, undefined);
|
||||
assert.equal(result.moves.at(-1)!.step.action.ornithopterEvent, undefined);
|
||||
assert.equal(record(result.completed).movesAfter, 1);
|
||||
settled(f, result, 3);
|
||||
}
|
||||
});
|
||||
|
||||
void test('genuine partial flights end before or after Cunning without earning an abandoned second-group credit', () => {
|
||||
const f = createGuildCunningOrnithopterFixture();
|
||||
for (const early of ['beforeCunning', 'afterCunning'] as const) {
|
||||
const result = completeGuildCunningOrnithopter(f, { early });
|
||||
assert.equal(result.firstFlightGroup.ornithopter!.completed, 1);
|
||||
assert.equal(result.firstFlightGroup.ornithopter!.mode, 'twoGroups');
|
||||
assert.equal(result.finishedFlight, null);
|
||||
assert.equal(player(result.game).ornithopterExtraMoves ?? 0, 0);
|
||||
assert.equal(result.moves.length, 2);
|
||||
if (early === 'beforeCunning') {
|
||||
assert.equal(result.beforeCunning.ornithopter!.completed, 1);
|
||||
assert.equal(result.allowed.ornithopter ?? null, null);
|
||||
const receipt = record(result.allowed).receipt;
|
||||
if (receipt.version === 3) assert.equal(receipt.before.ornithopterExtraMoves, 0);
|
||||
assert.equal(result.moves.at(-1)!.kind, 'post-cunning-hajr');
|
||||
} else assert.equal(result.completed.ornithopter!.completed, 1);
|
||||
settled(f, result, 2);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal policies retain legal Hajr-backed card groups during original Guild Cunning', () => {
|
||||
const f = createGuildCunningOrnithopterFixture();
|
||||
const result = completeGuildCunningOrnithopter(f);
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
for (const game of [result.afterSecondShipment, result.firstFlightGroup]) {
|
||||
const view = viewGame(game, f.owner);
|
||||
view.players.find(player => player.id === f.owner)!.bot = difficulty;
|
||||
const action = botActions(view).find(action => action.type === 'move' &&
|
||||
(game.ornithopter ? action.ornithopterEvent === view.ornithopter!.active!.event : action.movementCard === f.card));
|
||||
assert.ok(action, `${difficulty} retains a legal physical-card continuation inside the native Cunning grant`);
|
||||
const next = actGuildCunningOrnithopter(structuredClone(game), f.owner, action);
|
||||
assert.ok(player(next).moved === player(game).moved + 1 || next.response || next.decision);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('the genuine held physical card cannot restore ordinary movement or a Hajr grant already spent before Cunning', () => {
|
||||
const f = createGuildCunningOrnithopterFixture();
|
||||
const sample = completeGuildCunningOrnithopter(f);
|
||||
for (const spentHajr of [false, true]) {
|
||||
let game = structuredClone(f.game);
|
||||
const act = (action: Parameters<typeof actGuildCunningOrnithopter>[2]) => {
|
||||
game = actGuildCunningOrnithopter(game, f.owner, action);
|
||||
game = advanceGuildCunningOrnithopter(game, guildCunningOrnithopterClean);
|
||||
};
|
||||
if (spentHajr) act({type:'card',card:f.hajr});
|
||||
act(guildCunningOrnithopterMove(game, f.source));
|
||||
if (spentHajr) act(guildCunningOrnithopterMove(game, f.source));
|
||||
const offer = viewGame(game,f.owner).nexusGuildCunning!.offer!;
|
||||
const declare = nexusGuildCunningAction(viewGame(game,f.owner), offer.event); assert.ok(declare);
|
||||
act(declare);
|
||||
act(sample.secondShipment.action);
|
||||
assert.equal(own(game).movesAllowed, player(game).moved);
|
||||
assert.equal(nexusGuildMovementAvailable(viewGame(game,f.owner)), false);
|
||||
assert.match(viewGame(game,f.owner).ornithopter!.blocked!, /unused ordinary or Hajr/);
|
||||
const illegal = guildCunningOrnithopterMove(game, sample.secondSource, {mode:'range3',range:3});
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => actGuildCunningOrnithopter(game,f.owner,illegal), /movement|move/i);
|
||||
assert.deepEqual(game,before);
|
||||
assert.ok(player(game).hand.some(card => card.id === f.card));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createNexusGuildCunning,
|
||||
nexusGuildCunningMoves,
|
||||
validateNexusGuildCunning,
|
||||
type NexusGuildCunningMovementBefore,
|
||||
type NexusGuildCunningMovementReceipt,
|
||||
type NexusGuildCunningReceipt,
|
||||
} 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 clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
const movement = (moved: number, hajrPlays: number, ornithopterExtraMoves: number) =>
|
||||
createNexusGuildCunning(context, 'g', {
|
||||
shipped: true, moved, hajrPlays, ornithopterExtraMoves,
|
||||
});
|
||||
|
||||
// These pure inputs represent accepted physical movement/card-play counts.
|
||||
// Original acquisition, grant replacement and an active flight's unfinished
|
||||
// rights remain engine integration evidence, not claims made by these quotes.
|
||||
void test('v3 Guild Cunning records completed preflight credits without losing an unused Hajr grant', () => {
|
||||
const before = Object.freeze({
|
||||
shipped: true, moved: 3, hajrPlays: 2, ornithopterExtraMoves: 1,
|
||||
});
|
||||
const receipt: NexusGuildCunningMovementReceipt = createNexusGuildCunning(context, 'g', before);
|
||||
assert.equal(receipt.version, 3);
|
||||
assert.deepEqual(receipt.before, before);
|
||||
assert.notEqual(receipt.before, before);
|
||||
assert.deepEqual(Object.keys(receipt.before).sort(), [
|
||||
'hajrPlays', 'moved', 'ornithopterExtraMoves', 'shipped',
|
||||
]);
|
||||
assert.equal(receipt.signature, JSON.stringify([
|
||||
'nexusGuildCunningReceipt', 3, '["nexusGuildCunning",3,"g"]',
|
||||
'g', 3, 5, 'cunning', [['a', 'atreides'], ['f', 'fremen'], ['g', 'guild']],
|
||||
[true, 3, 2, 1],
|
||||
]));
|
||||
validateNexusGuildCunning(context, clone(receipt));
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 2, 0, 1), 1,
|
||||
'Two first-flight groups plus one later move consumed three physical moves, not both Hajr grants');
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 2, 1, 1), 0);
|
||||
assert.throws(() => nexusGuildCunningMoves(receipt, 2, 2, 1));
|
||||
assert.deepEqual(receipt.before, before);
|
||||
});
|
||||
|
||||
void test('completed first-flight groups consume their earned extra credit before Cunning', () => {
|
||||
const oneHajrUnused = movement(2, 1, 1);
|
||||
assert.equal(nexusGuildCunningMoves(oneHajrUnused, 1, 0, 1), 1);
|
||||
const oneHajrSpent = movement(3, 1, 1);
|
||||
assert.equal(nexusGuildCunningMoves(oneHajrSpent, 1, 0, 1), 0);
|
||||
assert.throws(() => nexusGuildCunningMoves(oneHajrSpent, 1, 1, 1));
|
||||
assert.equal(nexusGuildCunningMoves(oneHajrSpent, 2, 0, 1), 1,
|
||||
'A genuinely new Hajr play can still earn one new grant');
|
||||
const bothHajrSpent = movement(4, 2, 1);
|
||||
assert.equal(nexusGuildCunningMoves(bothHajrSpent, 2, 0, 1), 0);
|
||||
});
|
||||
|
||||
void test('post-shipment Hajr and a completed two-group card use retain the actual physical movement count', () => {
|
||||
const receipt = movement(1, 0, 0);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 0), 0);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 1), 1,
|
||||
'The actual Hajr play earns one replacement allowance');
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 1, 1), 0,
|
||||
'A range-three move or an unfinished flight has no completed extra credit');
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 1, 2, 1), 0,
|
||||
'The completed two-group use records one extra credit and both physical moves');
|
||||
assert.throws(() => nexusGuildCunningMoves(receipt, 1, 3, 1));
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 2, 2, 1), 1,
|
||||
'A further actual Hajr adds one grant without replaying either card group');
|
||||
});
|
||||
|
||||
void test('Cunning never restores an ordinary move or an already spent Hajr or card credit', () => {
|
||||
for (const moved of [0, 1]) {
|
||||
const receipt = movement(moved, 0, 0);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, false), 0);
|
||||
assert.throws(() => nexusGuildCunningMoves(receipt, false, 1));
|
||||
}
|
||||
const completedFlightWithoutHajr = movement(2, 0, 1);
|
||||
assert.equal(nexusGuildCunningMoves(completedFlightWithoutHajr, 0, 0, 1), 0);
|
||||
assert.throws(() => nexusGuildCunningMoves(completedFlightWithoutHajr, 0, 1, 1));
|
||||
const consumedHajr = movement(2, 1, 0);
|
||||
assert.equal(nexusGuildCunningMoves(consumedHajr, true), 0);
|
||||
assert.throws(() => nexusGuildCunningMoves(consumedHajr, true, 1));
|
||||
});
|
||||
|
||||
void test('v1 and v2 Guild Cunning receipt bytes and default movement quotes remain unchanged', () => {
|
||||
const common = {
|
||||
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' },
|
||||
],
|
||||
};
|
||||
const legacy = createNexusGuildCunning(context, 'g', {
|
||||
shipped: true, moved: 1, hajrUsed: true,
|
||||
});
|
||||
const counted = createNexusGuildCunning(context, 'g', {
|
||||
shipped: true, moved: 1, hajrPlays: 2,
|
||||
});
|
||||
assert.equal(JSON.stringify(legacy), JSON.stringify({
|
||||
version: 1, ...common,
|
||||
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(counted), JSON.stringify({
|
||||
version: 2, ...common,
|
||||
before: { shipped: true, moved: 1, hajrPlays: 2 },
|
||||
signature: '["nexusGuildCunningReceipt",2,"[\\"nexusGuildCunning\\",3,\\"g\\"]","g",3,5,"cunning",[["a","atreides"],["f","fremen"],["g","guild"]],[true,1,2]]',
|
||||
}));
|
||||
validateNexusGuildCunning(context, clone(legacy));
|
||||
validateNexusGuildCunning(context, clone(counted));
|
||||
assert.equal(nexusGuildCunningMoves(legacy, true), 1);
|
||||
assert.equal(nexusGuildCunningMoves(legacy, true, 1), 0);
|
||||
assert.equal(nexusGuildCunningMoves(counted, 2), 2);
|
||||
assert.equal(nexusGuildCunningMoves(counted, 2, 2), 0);
|
||||
});
|
||||
|
||||
void test('older signed receipts can count genuinely new Ornithopter credits without changing their original snapshots', () => {
|
||||
const receipts = [
|
||||
createNexusGuildCunning(context, 'g', { shipped: true, moved: 1, hajrUsed: false }),
|
||||
createNexusGuildCunning(context, 'g', { shipped: true, moved: 1, hajrPlays: 0 }),
|
||||
];
|
||||
for (const receipt of receipts) {
|
||||
const original = JSON.stringify(receipt);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 1, 2, 1), 0);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, 2, 2, 1), 1);
|
||||
assert.equal(JSON.stringify(receipt), original);
|
||||
validateNexusGuildCunning(context, receipt);
|
||||
}
|
||||
});
|
||||
|
||||
void test('v3 validates exact original movement shapes and safe combined ceilings', () => {
|
||||
const before: NexusGuildCunningMovementBefore = {
|
||||
shipped: true, moved: 2, hajrPlays: 1, ornithopterExtraMoves: 1,
|
||||
};
|
||||
for (const bad of [
|
||||
{ ...before, shipped: 1 },
|
||||
{ ...before, moved: -1 },
|
||||
{ ...before, moved: 0.5 },
|
||||
{ ...before, moved: 4 },
|
||||
{ ...before, moved: Number.MAX_SAFE_INTEGER + 1 },
|
||||
...[-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER, undefined, true].map(
|
||||
ornithopterExtraMoves => ({ ...before, ornithopterExtraMoves })),
|
||||
{ ...before, hajrPlays: -1 },
|
||||
{ ...before, hajrPlays: Number.MAX_SAFE_INTEGER - 1 },
|
||||
{ ...before, hajrUsed: true },
|
||||
{ ...before, privateFlight: {} },
|
||||
]) assert.throws(() => createNexusGuildCunning(context, 'g', bad as NexusGuildCunningMovementBefore));
|
||||
const maximum = movement(1, Number.MAX_SAFE_INTEGER - 2, 1);
|
||||
assert.equal(nexusGuildCunningMoves(maximum, Number.MAX_SAFE_INTEGER - 2, 0, 1), Number.MAX_SAFE_INTEGER - 1);
|
||||
assert.equal(nexusGuildCunningMoves(maximum, Number.MAX_SAFE_INTEGER - 2, Number.MAX_SAFE_INTEGER - 1, 1), 0);
|
||||
});
|
||||
|
||||
void test('current movement counts cannot lose original grants, overflow, or overconsume the signed allowance', () => {
|
||||
const receipt = movement(2, 1, 1);
|
||||
for (const currentExtra of [-1, 0, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER, true, null])
|
||||
assert.throws(() => nexusGuildCunningMoves(receipt, 1, 0, currentExtra as number));
|
||||
for (const currentHajr of [0, false, -1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER - 1])
|
||||
assert.throws(() => nexusGuildCunningMoves(receipt, currentHajr, 0, 1));
|
||||
for (const consumed of [-1, 0.5, NaN, Infinity, 2])
|
||||
assert.throws(() => nexusGuildCunningMoves(receipt, 1, consumed, 1));
|
||||
assert.throws(() => nexusGuildCunningMoves(receipt, 1),
|
||||
'A v3 receipt with prior extra credits requires their actual current count');
|
||||
assert.equal(nexusGuildCunningMoves(receipt, true, 1, 1), 0);
|
||||
});
|
||||
|
||||
void test('v3 signatures bind original card credits and cannot be relabelled as a legacy format', () => {
|
||||
const receipt = movement(2, 1, 1);
|
||||
const mutations: ((value: NexusGuildCunningMovementReceipt) => void)[] = [
|
||||
value => { value.before.ornithopterExtraMoves = 2; },
|
||||
value => { value.before.hajrPlays = 2; },
|
||||
value => { value.before.moved = 1; },
|
||||
value => { value.before.shipped = false; },
|
||||
value => { value.signature = ''; },
|
||||
];
|
||||
for (const change of mutations) {
|
||||
const invalid = clone(receipt); change(invalid);
|
||||
assert.throws(() => validateNexusGuildCunning(context, invalid));
|
||||
}
|
||||
for (const version of [1, 2, 4])
|
||||
assert.throws(() => validateNexusGuildCunning(context, {
|
||||
...receipt, version,
|
||||
} as NexusGuildCunningReceipt));
|
||||
for (const version of [1, 2]) {
|
||||
const original = version === 1
|
||||
? createNexusGuildCunning(context, 'g', { shipped: true, moved: 1, hajrUsed: true })
|
||||
: createNexusGuildCunning(context, 'g', { shipped: true, moved: 1, hajrPlays: 1 });
|
||||
assert.throws(() => validateNexusGuildCunning(context, {
|
||||
...original, before: { ...original.before, ornithopterExtraMoves: undefined },
|
||||
} as NexusGuildCunningReceipt));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user