Implement Guild Nexus Cunning second shipment and recovery
This commit is contained in:
1 parent
6ba1050a92
commit
90bf699627
27 files changed
+2657
-43
No files matched your search
@@ -0,0 +1,161 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game, type Action } from '../game/engine';
|
||||
import {
|
||||
nexusRicheseFixture,
|
||||
holdNexusRicheseCard,
|
||||
type NexusRicheseFixtureOptions,
|
||||
} from './fixture-nexus-richese';
|
||||
import { nexusTraitorInventory } from './fixture-nexus-traitors';
|
||||
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { nexusAllow, nexusReload } from './fixture-nexus-cards';
|
||||
import { orderNexusSpice } from './fixture-nexus-cards';
|
||||
import { botActions } from '../game/bots';
|
||||
|
||||
export type NexusGuildCunningFixture = {
|
||||
g: Game;
|
||||
owner: string;
|
||||
target: string;
|
||||
observer: string;
|
||||
karama?: string;
|
||||
};
|
||||
/** Reuse genuine final native Guild setup, then exchange two physical Nexus
|
||||
* identities before any Cunning declaration; no grant or progress is invented. */
|
||||
export function nexusGuildCunningFixture(
|
||||
options: Pick<
|
||||
NexusRicheseFixtureOptions,
|
||||
| 'seatIds'
|
||||
| 'advanced'
|
||||
| 'homeworlds'
|
||||
| 'spice'
|
||||
| 'karama'
|
||||
| 'opponentFaction'
|
||||
> = {},
|
||||
): NexusGuildCunningFixture {
|
||||
const f = nexusRicheseFixture({ ...options, ownerFaction: 'guild' });
|
||||
const cards = f.g.nexusCards!.cards!,
|
||||
index = cards.deck.indexOf('guild');
|
||||
assert.ok(index >= 0);
|
||||
assert.equal(cards.hands[f.owner], 'richese');
|
||||
cards.deck[index] = 'richese';
|
||||
cards.hands[f.owner] = 'guild';
|
||||
nexusGuildCunningInventory(f.g);
|
||||
return f;
|
||||
}
|
||||
/** Allow only the already declared shipment and its ordinary response chain. */
|
||||
export function settleGuildCunningShipment(g: Game): Game {
|
||||
if (g.decision?.kind === 'guildShipment')
|
||||
g = applyAction(g, g.decision.player, { type: 'decision', allow: true });
|
||||
return nexusAllow(g);
|
||||
}
|
||||
export function originalGuildCunningTurn(
|
||||
f: NexusGuildCunningFixture,
|
||||
move = true,
|
||||
g = f.g,
|
||||
): Game {
|
||||
g = settleGuildCunningShipment(
|
||||
applyAction(g, f.owner, {
|
||||
type: 'ship',
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
amount: 5,
|
||||
}),
|
||||
);
|
||||
if (move)
|
||||
g = applyAction(g, f.owner, {
|
||||
type: 'move',
|
||||
from: 'arrakeen:10',
|
||||
territory: 'hagga_basin',
|
||||
sector: 12,
|
||||
amount: 5,
|
||||
});
|
||||
nexusGuildCunningInventory(g);
|
||||
return g;
|
||||
}
|
||||
export function nexusGuildCunningRequest(
|
||||
f: NexusGuildCunningFixture,
|
||||
g = f.g,
|
||||
): Action {
|
||||
return {
|
||||
type: 'endMovement',
|
||||
nexus: viewGame(g, f.owner).nexusGuildCunning!.offer!.event,
|
||||
};
|
||||
}
|
||||
export const holdGuildCunningCard = holdNexusRicheseCard;
|
||||
export function nexusGuildCunningInventory(g: Game) {
|
||||
// The shared card census also checks a pre-Homeworld aggregate force total.
|
||||
// Count foreign visitors in that detached census; validate actual typed
|
||||
// Homeworld custody against the unchanged game separately.
|
||||
const census = structuredClone(g);
|
||||
for (const p of census.players)
|
||||
p.reserves += Object.values(g.homeworlds?.custody?.visitors ?? {}).reduce(
|
||||
(sum, visitors) => {
|
||||
const group = visitors[p.id];
|
||||
return sum + (group?.normal ?? 0) + (group?.elite ?? 0);
|
||||
},
|
||||
0,
|
||||
);
|
||||
nexusTraitorInventory(census);
|
||||
if (g.homeworlds) homeworldGameIntegrity(g);
|
||||
}
|
||||
export const nexusGuildCunningReload = nexusReload;
|
||||
export const nexusGuildCunningAllow = nexusAllow;
|
||||
|
||||
/** Place an actual native Terror token at the first Mentat, then complete the
|
||||
* next Storm/Spice/auction/Revival before returning the Guild's real turn. */
|
||||
export function nexusGuildCunningAllianceFixture(
|
||||
seatIds?: [string, string, string],
|
||||
): NexusGuildCunningFixture {
|
||||
const f = nexusGuildCunningFixture({ seatIds, opponentFaction: 'moritani' });
|
||||
let g = f.g;
|
||||
while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' });
|
||||
assert.equal(g.phase, 7);
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
assert.equal(g.decision?.kind, 'moritaniPlacement');
|
||||
const token = g.moritaniTerror!.tokens.find((t) => t.kind === 'robbery')!;
|
||||
g = nexusAllow(
|
||||
applyAction(g, f.target, {
|
||||
type: 'decision',
|
||||
token: token.id,
|
||||
territory: 'carthag',
|
||||
}),
|
||||
);
|
||||
let ordered = false;
|
||||
for (
|
||||
let step = 0;
|
||||
!(
|
||||
g.turn === 2 &&
|
||||
g.phase === 5 &&
|
||||
!g.response &&
|
||||
!g.decision &&
|
||||
!g.phaseOpening
|
||||
) && step < 180;
|
||||
step++
|
||||
) {
|
||||
if (g.turn === 2 && g.phase === 1 && !ordered) {
|
||||
orderNexusSpice(g, ['land', 'land']);
|
||||
ordered = true;
|
||||
}
|
||||
let next: Game | undefined;
|
||||
for (const p of g.players) {
|
||||
const v = viewGame(g, p.id);
|
||||
v.players.find((s) => s.id === p.id)!.bot = 'Easy';
|
||||
const a = botActions(v)[0];
|
||||
if (a) {
|
||||
next = applyAction(g, p.id, a);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
next,
|
||||
`Guild alliance fixture stalled at ${g.turn}/${g.phase}/${g.decision?.kind}`,
|
||||
);
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.turn, 2);
|
||||
assert.equal(g.phase, 5);
|
||||
while (g.active !== f.owner)
|
||||
g = applyAction(g, g.active!, { type: 'endMovement' });
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.owner], 'guild');
|
||||
nexusGuildCunningInventory(g);
|
||||
return { ...f, g };
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
nexusGuildCunningFixture,
|
||||
originalGuildCunningTurn,
|
||||
nexusGuildCunningAllow,
|
||||
settleGuildCunningShipment,
|
||||
nexusGuildCunningInventory,
|
||||
holdGuildCunningCard,
|
||||
} from './fixture-nexus-guild-cunning';
|
||||
|
||||
void test('all Guild profiles finish the first turn through Cunning then propose a second shipment without ordinary movement', () => {
|
||||
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const f = nexusGuildCunningFixture();
|
||||
const first = originalGuildCunningTurn(f);
|
||||
const v = viewGame(first, f.owner);
|
||||
v.players.find((p) => p.id === f.owner)!.bot = profile;
|
||||
const action = botActions(v)[0];
|
||||
assert.equal(action.type, 'endMovement');
|
||||
assert.equal(action.nexus, v.nexusGuildCunning!.offer!.event);
|
||||
const g = nexusGuildCunningAllow(applyAction(first, f.owner, action));
|
||||
const next = viewGame(g, f.owner);
|
||||
next.players.find((p) => p.id === f.owner)!.bot = profile;
|
||||
const candidates = botActions(next);
|
||||
assert.ok(
|
||||
candidates.some((a) => a.type === 'ship' || a.type === 'guildShip'),
|
||||
);
|
||||
assert.ok(candidates.every((a) => a.type !== 'move'));
|
||||
const shipment = candidates.find(
|
||||
(a) => a.type === 'ship' || a.type === 'guildShip',
|
||||
)!;
|
||||
nexusGuildCunningInventory(
|
||||
settleGuildCunningShipment(applyAction(g, f.owner, shipment)),
|
||||
);
|
||||
}
|
||||
});
|
||||
void test('all Guild profiles use only an offered unused Hajr after the second shipment and preserve blocked ordinary fallback', () => {
|
||||
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const f = nexusGuildCunningFixture();
|
||||
const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
|
||||
const first = originalGuildCunningTurn(f);
|
||||
const v = viewGame(first, f.owner);
|
||||
v.players.find((p) => p.id === f.owner)!.bot = profile;
|
||||
const declared = botActions(v)[0];
|
||||
v.nexusGuildCunning!.offer!.blocked = 'Unavailable';
|
||||
assert.equal(botActions(v)[0].nexus, undefined);
|
||||
let g = nexusGuildCunningAllow(applyAction(first, f.owner, declared));
|
||||
g = settleGuildCunningShipment(
|
||||
applyAction(g, f.owner, {
|
||||
type: 'ship',
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
amount: 3,
|
||||
}),
|
||||
);
|
||||
const after = viewGame(g, f.owner);
|
||||
after.players.find((p) => p.id === f.owner)!.bot = profile;
|
||||
assert.deepEqual(botActions(after)[0], { type: 'card', card: hajr.id });
|
||||
const prepared = applyAction(g, f.owner, botActions(after)[0]);
|
||||
const pv = viewGame(prepared, f.owner);
|
||||
pv.players.find((p) => p.id === f.owner)!.bot = profile;
|
||||
const move = botActions(pv).find((a) => a.type === 'move');
|
||||
assert.ok(move);
|
||||
nexusGuildCunningInventory(applyAction(prepared, f.owner, move));
|
||||
}
|
||||
});
|
||||
|
||||
void test('all Guild profiles skip an unaffordable second shipment to retain their owned unused Hajr', () => {
|
||||
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const f = nexusGuildCunningFixture({ spice: 3 }),
|
||||
hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
|
||||
const first = originalGuildCunningTurn(f),
|
||||
fv = viewGame(first, f.owner);
|
||||
fv.players[0].bot = profile;
|
||||
const g = nexusGuildCunningAllow(
|
||||
applyAction(first, f.owner, botActions(fv)[0]),
|
||||
);
|
||||
const v = viewGame(g, f.owner);
|
||||
v.players[0].bot = profile;
|
||||
const skip = botActions(v)[0];
|
||||
assert.equal(skip.type, 'nexusGuildSkipShipment');
|
||||
const next = applyAction(g, f.owner, skip),
|
||||
nv = viewGame(next, f.owner);
|
||||
nv.players[0].bot = profile;
|
||||
assert.deepEqual(botActions(nv)[0], { type: 'card', card: hajr.id });
|
||||
nexusGuildCunningInventory(applyAction(next, f.owner, botActions(nv)[0]));
|
||||
}
|
||||
});
|
||||
|
||||
void test('a new answered Truthtrance binds the second shipment and all profiles honor its six-force witness despite shipped true', () => {
|
||||
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const f = nexusGuildCunningFixture();
|
||||
const truth = holdGuildCunningCard(f.g, f.target, 'truthtrance');
|
||||
const first = originalGuildCunningTurn(f);
|
||||
const fv = viewGame(first, f.owner);
|
||||
fv.players[0].bot = profile;
|
||||
let g = nexusGuildCunningAllow(
|
||||
applyAction(first, f.owner, botActions(fv)[0]),
|
||||
);
|
||||
g = applyAction(g, f.target, { type: 'card', card: truth.id });
|
||||
for (let n = 0; n < 10 && g.truthtrance?.stage === 'priority'; n++)
|
||||
g = applyAction(
|
||||
g,
|
||||
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
|
||||
{ type: 'truthPass' },
|
||||
);
|
||||
g = applyAction(g, f.target, {
|
||||
type: 'truthAsk',
|
||||
question: {
|
||||
kind: 'shipment',
|
||||
target: f.owner,
|
||||
territory: 'carthag',
|
||||
minimum: 6,
|
||||
},
|
||||
});
|
||||
assert.ok(viewGame(g, f.owner).truthShipmentAnswers!.includes('yes'));
|
||||
g = applyAction(g, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
const v = viewGame(g, f.owner);
|
||||
v.players[0].bot = profile;
|
||||
assert.equal(v.players[0].shipped, true);
|
||||
const actions = botActions(v);
|
||||
assert.ok(actions.length);
|
||||
assert.ok(
|
||||
actions.every(
|
||||
(a) =>
|
||||
a.type === 'ship' &&
|
||||
a.territory === 'carthag' &&
|
||||
Number(a.amount) >= 6,
|
||||
),
|
||||
);
|
||||
const done = settleGuildCunningShipment(
|
||||
applyAction(g, f.owner, actions[0]),
|
||||
);
|
||||
assert.ok(
|
||||
done.players.find((p) => p.id === f.owner)!.forces['carthag:11'] >= 6,
|
||||
);
|
||||
assert.ok(done.shipmentPromises!.every((p) => p.fulfilled && !p.released));
|
||||
nexusGuildCunningInventory(done);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { applyAction, viewGame, type GameView } from '../game/engine';
|
||||
import {
|
||||
nexusGuildCunningAction,
|
||||
nexusGuildShipmentAvailable,
|
||||
nexusGuildMovementAvailable,
|
||||
nexusGuildSkipShipmentAction,
|
||||
} from '../game/nexus-guild-cunning-options';
|
||||
import { ordinaryCardAvailability } from '../game/card-availability';
|
||||
import { guildTransportQuote } from '../game/transport-quote';
|
||||
import { guildHomeworldShipmentChoice } from '../game/guild-homeworld-shipment-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import {
|
||||
nexusGuildCunningFixture,
|
||||
originalGuildCunningTurn,
|
||||
nexusGuildCunningRequest,
|
||||
nexusGuildCunningAllow,
|
||||
settleGuildCunningShipment,
|
||||
nexusGuildCunningInventory,
|
||||
holdGuildCunningCard,
|
||||
} from './fixture-nexus-guild-cunning';
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(
|
||||
specifier === 'next/image'
|
||||
? 'vinext/shims/image'
|
||||
: specifier === 'next/link'
|
||||
? 'vinext/shims/link'
|
||||
: specifier,
|
||||
context,
|
||||
);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries(
|
||||
[...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map((m) => [m[1], m[1]]),
|
||||
);
|
||||
return {
|
||||
format: 'module',
|
||||
source: `export default ${JSON.stringify(classes)}`,
|
||||
shortCircuit: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
const { GameTable } = await import('../components/game-table');
|
||||
aliases.deregister();
|
||||
|
||||
const html = (game: GameView, busy = false) =>
|
||||
renderToStaticMarkup(
|
||||
createElement(GameTable, {
|
||||
game,
|
||||
busy,
|
||||
send: async () => {},
|
||||
onExit() {},
|
||||
}),
|
||||
);
|
||||
function second(homeworlds = false) {
|
||||
const f = nexusGuildCunningFixture({ homeworlds });
|
||||
const original = originalGuildCunningTurn(f);
|
||||
const g = nexusGuildCunningAllow(
|
||||
applyAction(original, f.owner, nexusGuildCunningRequest(f, original)),
|
||||
);
|
||||
return { f, original, g };
|
||||
}
|
||||
|
||||
void test('Guild declaration controls bind the owned current ending opportunity, leave first-turn history intact and protect observers', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = nexusGuildCunningFixture({ advanced, karama: true });
|
||||
const original = originalGuildCunningTurn(f);
|
||||
const v = viewGame(original, f.owner),
|
||||
event = v.nexusGuildCunning!.offer!.event;
|
||||
const before = structuredClone(v);
|
||||
const action = nexusGuildCunningAction(v, event)!;
|
||||
assert.deepEqual(action, { type: 'endMovement', nexus: event });
|
||||
assert.equal(nexusGuildCunningAction(v, 'stale'), null);
|
||||
assert.match(html(v), /Finish ordinary turn and declare Guild Cunning/);
|
||||
assert.match(html(v, true), /disabled=""/);
|
||||
const pending = applyAction(original, f.owner, action);
|
||||
assert.equal(pending.response?.kind, 'nexusGuildCunning');
|
||||
const pv = viewGame(pending, f.owner);
|
||||
assert.match(
|
||||
html(pv),
|
||||
/Allow one second Guild shipment at its normal price/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
html(pv),
|
||||
/"nexusGuildCunning"|\["nexusGuildCunning"/,
|
||||
);
|
||||
assert.equal(nexusGuildShipmentAvailable(pv), false);
|
||||
assert.equal(nexusGuildMovementAvailable(pv), false);
|
||||
assert.equal(pv.players.find((p) => p.id === f.owner)!.shipped, true);
|
||||
assert.equal(pv.players.find((p) => p.id === f.owner)!.moved, 1);
|
||||
for (const id of [f.target, f.observer]) {
|
||||
const other = viewGame(original, id);
|
||||
assert.equal(other.nexusGuildCunning, null);
|
||||
Object.defineProperty(other, 'nexusGuildCunning', {
|
||||
get() {
|
||||
throw Error('Private Guild grant read');
|
||||
},
|
||||
});
|
||||
assert.equal(nexusGuildCunningAction(other, event), null);
|
||||
}
|
||||
assert.deepEqual(v, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('second shipment controls and ordinary transport quote reopen only the new shipment despite shipped true', () => {
|
||||
const { f, g } = second();
|
||||
holdGuildCunningCard(g, f.owner, 'karama');
|
||||
const v = viewGame(g, f.owner);
|
||||
const before = structuredClone(v);
|
||||
assert.equal(v.players.find((p) => p.id === f.owner)!.shipped, true);
|
||||
assert.equal(nexusGuildShipmentAvailable(v), true);
|
||||
assert.equal(nexusGuildMovementAvailable(v), false);
|
||||
const markup = html(v);
|
||||
assert.match(markup, /Ship from reserves/);
|
||||
assert.match(markup, /Guild rates for/);
|
||||
assert.match(markup, /Decline second shipment and finish/);
|
||||
assert.match(markup, /This does not reopen ordinary movement/);
|
||||
const action = {
|
||||
type: 'guildShip',
|
||||
from: 'hagga_basin:12',
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
amount: 3,
|
||||
};
|
||||
const same = { ...action, territory: 'hagga_basin', sector: 12 };
|
||||
assert.ok(
|
||||
guildTransportQuote(v, same).unavailableReasons.some((reason) =>
|
||||
/another territory/.test(reason),
|
||||
),
|
||||
);
|
||||
const originalState = structuredClone(g);
|
||||
assert.throws(
|
||||
() => applyAction(g, f.owner, same),
|
||||
/another territory|same territory|different destination territory/,
|
||||
);
|
||||
assert.deepEqual(g, originalState);
|
||||
const ordinary = nexusGuildCunningFixture();
|
||||
// Conserved ordinary board source before any declaration, not a fabricated grant.
|
||||
ordinary.g.players[0].reserves -= 3;
|
||||
ordinary.g.players[0].forces['hagga_basin:12'] = 3;
|
||||
const ordinaryView = viewGame(ordinary.g, ordinary.owner);
|
||||
assert.equal(nexusGuildShipmentAvailable(ordinaryView), true);
|
||||
assert.ok(
|
||||
guildTransportQuote(ordinaryView, same).unavailableReasons.some((reason) =>
|
||||
/another territory/.test(reason),
|
||||
),
|
||||
);
|
||||
const ordinaryBefore = structuredClone(ordinary.g);
|
||||
assert.throws(
|
||||
() => applyAction(ordinary.g, ordinary.owner, same),
|
||||
/another territory|same territory|different destination territory/,
|
||||
);
|
||||
assert.deepEqual(ordinary.g, ordinaryBefore);
|
||||
const quote = guildTransportQuote(v, action);
|
||||
assert.deepEqual(quote.unavailableReasons, []);
|
||||
const next = settleGuildCunningShipment(applyAction(g, f.owner, action));
|
||||
assert.equal(
|
||||
next.players.find((p) => p.id === f.owner)!.forces['carthag:11'],
|
||||
3,
|
||||
);
|
||||
nexusGuildCunningInventory(next);
|
||||
const stale = structuredClone(v);
|
||||
stale.nexusGuildCunning!.active!.event = 'stale';
|
||||
assert.equal(nexusGuildShipmentAvailable(stale), false);
|
||||
assert.deepEqual(v, before);
|
||||
});
|
||||
|
||||
void test('both native Homeworld shipment controls consume the second grant with typed source quotes', () => {
|
||||
const { f, g } = second(true),
|
||||
v = viewGame(g, f.owner);
|
||||
assert.equal(nexusGuildShipmentAvailable(v), true);
|
||||
const outward = homeworldShipmentChoice(v, 'homeworld:harkonnen', {
|
||||
'homeworld:guild': { normal: 2, elite: 0 },
|
||||
});
|
||||
assert.equal(outward.blocked, null);
|
||||
assert.ok(outward.action);
|
||||
nexusGuildCunningInventory(
|
||||
settleGuildCunningShipment(applyAction(g, f.owner, outward.action)),
|
||||
);
|
||||
const back = guildHomeworldShipmentChoice(v, 'homeworld:guild', {
|
||||
'hagga_basin:12': { normal: 3, elite: 0 },
|
||||
});
|
||||
assert.equal(back.blocked, null);
|
||||
assert.ok(back.action);
|
||||
const restored = settleGuildCunningShipment(
|
||||
applyAction(g, f.owner, back.action),
|
||||
);
|
||||
assert.equal(restored.players.find((p) => p.id === f.owner)!.reserves, 18);
|
||||
nexusGuildCunningInventory(restored);
|
||||
assert.match(html(v), /Ship to another Homeworld/);
|
||||
});
|
||||
|
||||
void test('Hajr availability uses the explicit unused-card permission rather than the derived movement ceiling', () => {
|
||||
const f = nexusGuildCunningFixture();
|
||||
const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
|
||||
const original = originalGuildCunningTurn(f);
|
||||
let g = nexusGuildCunningAllow(
|
||||
applyAction(original, f.owner, nexusGuildCunningRequest(f, original)),
|
||||
);
|
||||
g = settleGuildCunningShipment(
|
||||
applyAction(g, f.owner, {
|
||||
type: 'ship',
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
amount: 2,
|
||||
}),
|
||||
);
|
||||
const v = viewGame(g, f.owner);
|
||||
assert.equal(v.nexusGuildCunning!.active!.stage, 'extraMove');
|
||||
assert.equal(ordinaryCardAvailability(v, hajr.id)?.available, true);
|
||||
const played = applyAction(g, f.owner, { type: 'card', card: hajr.id });
|
||||
const pv = viewGame(played, f.owner);
|
||||
assert.equal(nexusGuildMovementAvailable(pv), true);
|
||||
assert.equal(pv.nexusGuildCunning!.active!.hajrAvailable, false);
|
||||
// Consumer-only alias of a retained physical card tests the explicit flag, without engine evidence claims.
|
||||
pv.players.find((p) => p.id === f.owner)!.hand!.push(hajr);
|
||||
pv.players.find((p) => p.id === f.owner)!.movesAllowed = 1;
|
||||
assert.equal(ordinaryCardAvailability(pv, hajr.id)?.available, false);
|
||||
nexusGuildCunningInventory(played);
|
||||
});
|
||||
|
||||
void test('skipping only the second shipment retains a real Hajr choice with no shipment payment or force movement', () => {
|
||||
const f = nexusGuildCunningFixture({ spice: 3 });
|
||||
const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
|
||||
const first = originalGuildCunningTurn(f);
|
||||
const g = nexusGuildCunningAllow(
|
||||
applyAction(first, f.owner, nexusGuildCunningRequest(f, first)),
|
||||
);
|
||||
const v = viewGame(g, f.owner),
|
||||
event = v.nexusGuildCunning!.active!.event;
|
||||
assert.match(html(v), /Skip second shipment/);
|
||||
assert.match(html(v), /Decline second shipment and finish/);
|
||||
assert.equal(nexusGuildSkipShipmentAction(v, 'stale'), null);
|
||||
const before = g.players.find((p) => p.id === f.owner)!;
|
||||
const skipped = applyAction(
|
||||
g,
|
||||
f.owner,
|
||||
nexusGuildSkipShipmentAction(v, event)!,
|
||||
);
|
||||
const sv = viewGame(skipped, f.owner),
|
||||
after = skipped.players.find((p) => p.id === f.owner)!;
|
||||
assert.equal(sv.nexusGuildCunning!.active!.stage, 'extraMove');
|
||||
assert.equal(nexusGuildShipmentAvailable(sv), false);
|
||||
assert.equal(nexusGuildMovementAvailable(sv), false);
|
||||
assert.equal(ordinaryCardAvailability(sv, hajr.id)?.available, true);
|
||||
assert.equal(after.spice, before.spice);
|
||||
assert.equal(after.reserves, before.reserves);
|
||||
assert.deepEqual(after.forces, before.forces);
|
||||
assert.equal(after.shipped, before.shipped);
|
||||
assert.equal(nexusGuildSkipShipmentAction(sv, event), null);
|
||||
const prepared = applyAction(skipped, f.owner, {
|
||||
type: 'card',
|
||||
card: hajr.id,
|
||||
});
|
||||
assert.equal(nexusGuildMovementAvailable(viewGame(prepared, f.owner)), true);
|
||||
nexusGuildCunningInventory(prepared);
|
||||
});
|
||||
@@ -0,0 +1,482 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Game,
|
||||
type Action,
|
||||
} from '../game/engine';
|
||||
import {
|
||||
nexusGuildCunningFixture,
|
||||
originalGuildCunningTurn,
|
||||
nexusGuildCunningRequest,
|
||||
nexusGuildCunningAllow,
|
||||
nexusGuildCunningReload,
|
||||
nexusGuildCunningInventory,
|
||||
settleGuildCunningShipment,
|
||||
holdGuildCunningCard,
|
||||
nexusGuildCunningAllianceFixture,
|
||||
} from './fixture-nexus-guild-cunning';
|
||||
function rejected(g: Game, id: string, action: Action) {
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, id, action));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
function stable(g: Game) {
|
||||
nexusGuildCunningInventory(g);
|
||||
for (const p of g.players)
|
||||
assert.deepEqual(
|
||||
viewGame(nexusGuildCunningReload(g), p.id),
|
||||
viewGame(g, p.id),
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeAutomaticGame(nexusGuildCunningReload(g)),
|
||||
nexusGuildCunningReload(g),
|
||||
);
|
||||
}
|
||||
|
||||
void test('an actual Moritani alliance formed on the second arrival preserves the earned Guild Hajr continuation', () => {
|
||||
const f = nexusGuildCunningAllianceFixture();
|
||||
const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
|
||||
let g = originalGuildCunningTurn(f);
|
||||
g = nexusGuildCunningAllow(
|
||||
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
|
||||
);
|
||||
g = settleGuildCunningShipment(
|
||||
applyAction(g, f.owner, {
|
||||
type: 'ship',
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
amount: 3,
|
||||
}),
|
||||
);
|
||||
assert.equal(g.pendingTerrorEntry?.stage, 'offer');
|
||||
g = nexusGuildCunningAllow(
|
||||
applyAction(g, f.target, { type: 'decision', alliance: true }),
|
||||
);
|
||||
stable(g);
|
||||
const before = structuredClone(g.players[0]);
|
||||
g = applyAction(nexusGuildCunningReload(g), f.owner, {
|
||||
type: 'decision',
|
||||
accept: true,
|
||||
});
|
||||
assert.equal(g.players[0].ally, f.target);
|
||||
assert.equal(g.players[1].ally, f.owner);
|
||||
assert.equal(g.players[0].spice, before.spice);
|
||||
assert.equal(g.players[0].reserves, before.reserves);
|
||||
stable(g);
|
||||
g = applyAction(g, f.owner, { type: 'card', card: hajr.id });
|
||||
g = applyAction(g, f.owner, {
|
||||
type: 'move',
|
||||
from: 'carthag:11',
|
||||
territory: 'hagga_basin',
|
||||
sector: 12,
|
||||
amount: 3,
|
||||
});
|
||||
assert.equal(g.players[0].forces['hagga_basin:12'], 8);
|
||||
assert.equal(g.players[0].spice, before.spice);
|
||||
if (g.active === f.owner)
|
||||
g = applyAction(g, f.owner, { type: 'endMovement' });
|
||||
assert.notEqual(g.active, f.owner);
|
||||
stable(g);
|
||||
});
|
||||
|
||||
void test('Guild special Karama stops its own second shipment without payment or arrival and preserves only the unused Hajr move', () => {
|
||||
for (const homeworlds of [false, true]) {
|
||||
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);
|
||||
g = nexusGuildCunningAllow(
|
||||
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
|
||||
);
|
||||
const before = structuredClone(g.players[0]);
|
||||
const shipment: Action = homeworlds
|
||||
? {
|
||||
type: 'homeworldShip',
|
||||
event: viewGame(g, f.owner).homeworldShipment!.event,
|
||||
destination: 'homeworld:harkonnen',
|
||||
sources: { 'homeworld:guild': { normal: 3, elite: 0 } },
|
||||
}
|
||||
: { type: 'ship', territory: 'carthag', sector: 11, amount: 3 };
|
||||
g = applyAction(g, f.owner, shipment);
|
||||
assert.equal(
|
||||
g.decision?.kind,
|
||||
homeworlds ? 'homeworldShipmentGuild' : 'guildShipment',
|
||||
);
|
||||
stable(g);
|
||||
g = applyAction(nexusGuildCunningReload(g), f.owner, {
|
||||
type: 'card',
|
||||
mode: 'special',
|
||||
card: karama.id,
|
||||
});
|
||||
assert.equal(
|
||||
g.nexusGuildCunningHistory!.at(-1)!.shipment!.stage,
|
||||
'stopped',
|
||||
);
|
||||
assert.equal(g.players[0].spice, before.spice);
|
||||
assert.equal(g.players[0].reserves, before.reserves);
|
||||
assert.deepEqual(g.players[0].forces, before.forces);
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(
|
||||
g.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
|
||||
1,
|
||||
);
|
||||
if (homeworlds)
|
||||
assert.equal(
|
||||
g.homeworlds!.custody!.visitors['homeworld:harkonnen']?.[f.owner],
|
||||
undefined,
|
||||
);
|
||||
const move: Action = {
|
||||
type: 'move',
|
||||
from: 'arrakeen:10',
|
||||
territory: 'hagga_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,
|
||||
});
|
||||
g = applyAction(g, f.owner, move);
|
||||
rejected(g, f.owner, {
|
||||
type: 'move',
|
||||
from: 'hagga_basin:12',
|
||||
territory: 'arsunt',
|
||||
sector: 12,
|
||||
amount: 5,
|
||||
});
|
||||
assert.equal(g.players[0].spice, before.spice);
|
||||
stable(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native and Cunning cross-shipments cannot manufacture a shipment within the same territory', () => {
|
||||
const f = nexusGuildCunningFixture();
|
||||
const native = nexusGuildCunningFixture();
|
||||
native.g.players[0].reserves -= 5;
|
||||
native.g.players[0].forces['arrakeen:10'] = 5;
|
||||
nexusGuildCunningInventory(native.g);
|
||||
let g = originalGuildCunningTurn(f, false);
|
||||
const same: Action = {
|
||||
type: 'guildShip',
|
||||
from: 'arrakeen:10',
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
amount: 1,
|
||||
};
|
||||
rejected(native.g, native.owner, same);
|
||||
g = nexusGuildCunningAllow(
|
||||
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
|
||||
);
|
||||
rejected(g, f.owner, same);
|
||||
stable(g);
|
||||
});
|
||||
|
||||
void test('Guild Cunning adds a paid native reserve, cross or return shipment after the actual original movement without resetting flags', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const route of ['reserve', 'cross', 'return'] as const) {
|
||||
const f = nexusGuildCunningFixture({ advanced, karama: true });
|
||||
let g = originalGuildCunningTurn(f);
|
||||
const before = nexusGuildCunningReload(g);
|
||||
const request = nexusGuildCunningRequest(f, g);
|
||||
g = applyAction(g, f.owner, request);
|
||||
assert.equal(g.response?.kind, 'nexusGuildCunning');
|
||||
assert.equal(g.players[0].shipped, true);
|
||||
assert.equal(g.players[0].moved, 1);
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
|
||||
stable(g);
|
||||
g = nexusGuildCunningAllow(nexusGuildCunningReload(g));
|
||||
assert.equal(
|
||||
viewGame(g, f.owner).nexusGuildCunning!.active!.stage,
|
||||
'secondShipment',
|
||||
);
|
||||
const action: Action =
|
||||
route === 'reserve'
|
||||
? { type: 'ship', territory: 'carthag', sector: 11, amount: 5 }
|
||||
: {
|
||||
type: 'guildShip',
|
||||
from: 'hagga_basin:12',
|
||||
territory: route === 'return' ? 'reserves' : 'carthag',
|
||||
sector: route === 'return' ? 0 : 11,
|
||||
amount: 5,
|
||||
};
|
||||
g = settleGuildCunningShipment(applyAction(g, f.owner, action));
|
||||
assert.equal(g.players[0].spice, before.players[0].spice - 3);
|
||||
assert.equal(g.players[0].moved, 1);
|
||||
assert.equal(g.players[0].shipped, true);
|
||||
assert.equal(
|
||||
g.players[0].reserves,
|
||||
route === 'reserve' ? 10 : route === 'return' ? 20 : 15,
|
||||
);
|
||||
if (route !== 'return')
|
||||
assert.equal(g.players[0].forces['carthag:11'], 5);
|
||||
rejected(g, f.owner, action);
|
||||
rejected(g, f.owner, {
|
||||
type: 'move',
|
||||
from: 'carthag:11',
|
||||
territory: 'hagga_basin',
|
||||
sector: 12,
|
||||
amount: 1,
|
||||
});
|
||||
assert.equal(
|
||||
g.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
|
||||
1,
|
||||
);
|
||||
stable(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('canceling Guild Cunning preserves the first paid shipment and movement and does not reset the real turn queue', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = nexusGuildCunningFixture({ advanced, karama: true });
|
||||
let g = originalGuildCunningTurn(f);
|
||||
const before = nexusGuildCunningReload(g),
|
||||
request = nexusGuildCunningRequest(f, g);
|
||||
g = applyAction(g, f.owner, request);
|
||||
g = applyAction(nexusGuildCunningReload(g), f.target, {
|
||||
type: 'card',
|
||||
mode: 'cancel',
|
||||
card: f.karama,
|
||||
});
|
||||
assert.deepEqual(g.players[0].forces, before.players[0].forces);
|
||||
assert.equal(g.players[0].spice, before.players[0].spice);
|
||||
assert.equal(g.players[0].moved, 1);
|
||||
assert.equal(g.players[0].shipped, true);
|
||||
assert.equal(g.movementRemaining!.includes(f.owner), false);
|
||||
assert.notEqual(g.active, f.owner);
|
||||
assert.equal(g.discard.filter((c) => c.id === f.karama).length, 1);
|
||||
rejected(g, f.owner, request);
|
||||
rejected(g, f.owner, {
|
||||
type: 'ship',
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
amount: 1,
|
||||
});
|
||||
stable(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('declining ordinary movement does not leave a free movement after the second Guild shipment', () => {
|
||||
for (const originalShip of [false, true]) {
|
||||
const f = nexusGuildCunningFixture();
|
||||
let g = originalShip ? originalGuildCunningTurn(f, false) : f.g;
|
||||
g = nexusGuildCunningAllow(
|
||||
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
|
||||
);
|
||||
g = settleGuildCunningShipment(
|
||||
applyAction(g, f.owner, {
|
||||
type: 'ship',
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
amount: 3,
|
||||
}),
|
||||
);
|
||||
assert.equal(g.players[0].moved, 0);
|
||||
rejected(g, f.owner, {
|
||||
type: 'move',
|
||||
from: 'carthag:11',
|
||||
territory: 'hagga_basin',
|
||||
sector: 12,
|
||||
amount: 3,
|
||||
});
|
||||
stable(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Hajr grants only its remaining extra movement before or after the second shipment and cannot create a third move', () => {
|
||||
for (const mode of ['late', 'early-unspent', 'early-used'] as const) {
|
||||
const f = nexusGuildCunningFixture();
|
||||
const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
|
||||
let g = originalGuildCunningTurn(f);
|
||||
if (mode !== 'late')
|
||||
g = applyAction(g, f.owner, { type: 'card', card: hajr.id });
|
||||
if (mode === 'early-used')
|
||||
g = applyAction(g, f.owner, {
|
||||
type: 'move',
|
||||
from: 'hagga_basin:12',
|
||||
territory: 'arsunt',
|
||||
sector: 12,
|
||||
amount: 5,
|
||||
});
|
||||
g = nexusGuildCunningAllow(
|
||||
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
|
||||
);
|
||||
g = settleGuildCunningShipment(
|
||||
applyAction(g, f.owner, {
|
||||
type: 'ship',
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
amount: 3,
|
||||
}),
|
||||
);
|
||||
if (mode === 'late')
|
||||
g = applyAction(g, f.owner, { type: 'card', card: hajr.id });
|
||||
const move: Action = {
|
||||
type: 'move',
|
||||
from: 'carthag:11',
|
||||
territory: 'hagga_basin',
|
||||
sector: 12,
|
||||
amount: 3,
|
||||
};
|
||||
if (mode === 'early-used') rejected(g, f.owner, move);
|
||||
else {
|
||||
g = applyAction(g, f.owner, move);
|
||||
assert.equal(g.players[0].moved, 2);
|
||||
rejected(g, f.owner, {
|
||||
type: 'move',
|
||||
from: 'hagga_basin:12',
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
amount: 1,
|
||||
});
|
||||
}
|
||||
assert.equal(g.discard.filter((c) => c.id === hajr.id).length, 1);
|
||||
stable(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('declining the second shipment preserves paid first forces and permits only an unused Hajr move after declining ordinary movement', () => {
|
||||
const f = nexusGuildCunningFixture();
|
||||
const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
|
||||
let g = originalGuildCunningTurn(f, false);
|
||||
g = nexusGuildCunningAllow(
|
||||
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
|
||||
);
|
||||
const skip: Action = {
|
||||
type: 'nexusGuildSkipShipment',
|
||||
event: viewGame(g, f.owner).nexusGuildCunning!.active!.event,
|
||||
};
|
||||
rejected(g, f.target, skip);
|
||||
rejected(g, f.owner, { ...skip, event: 'stale' });
|
||||
g = applyAction(g, f.owner, skip);
|
||||
assert.equal(g.players[0].spice, 17);
|
||||
assert.equal(g.players[0].reserves, 15);
|
||||
assert.equal(g.players[0].shipped, true);
|
||||
assert.equal(g.players[0].moved, 0);
|
||||
stable(g);
|
||||
const move: Action = {
|
||||
type: 'move',
|
||||
from: 'arrakeen:10',
|
||||
territory: 'hagga_basin',
|
||||
sector: 12,
|
||||
amount: 5,
|
||||
};
|
||||
rejected(g, f.owner, move);
|
||||
rejected(g, f.owner, skip);
|
||||
g = applyAction(nexusGuildCunningReload(g), f.owner, {
|
||||
type: 'card',
|
||||
card: hajr.id,
|
||||
});
|
||||
g = applyAction(g, f.owner, move);
|
||||
assert.equal(g.players[0].forces['hagga_basin:12'], 5);
|
||||
assert.equal(g.players[0].spice, 17);
|
||||
rejected(g, f.owner, {
|
||||
type: 'move',
|
||||
from: 'hagga_basin:12',
|
||||
territory: 'arsunt',
|
||||
sector: 12,
|
||||
amount: 5,
|
||||
});
|
||||
assert.equal(g.discard.filter((c) => c.id === hajr.id).length, 1);
|
||||
stable(g);
|
||||
});
|
||||
|
||||
void test('Guild Cunning keeps declared ownership private before play and rejects stale, foreign or duplicate declarations', () => {
|
||||
const f = nexusGuildCunningFixture();
|
||||
let g = originalGuildCunningTurn(f);
|
||||
const request = nexusGuildCunningRequest(f, g);
|
||||
for (const id of [f.target, f.observer])
|
||||
assert.equal(viewGame(g, id).nexusGuildCunning, null);
|
||||
rejected(g, f.owner, { ...request, nexus: 'old' });
|
||||
rejected(g, f.target, request);
|
||||
g = nexusGuildCunningAllow(applyAction(g, f.owner, request));
|
||||
rejected(g, f.owner, request);
|
||||
for (const p of g.players)
|
||||
assert.equal(
|
||||
Object.hasOwn(viewGame(g, p.id), 'nexusGuildCunningHistory'),
|
||||
false,
|
||||
);
|
||||
stable(g);
|
||||
});
|
||||
|
||||
void test('a second Guild shipment can return actual Arrakis counters to Junction through the typed native Homeworld route', () => {
|
||||
const f = nexusGuildCunningFixture({ homeworlds: true });
|
||||
let g = originalGuildCunningTurn(f);
|
||||
g = nexusGuildCunningAllow(
|
||||
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
|
||||
);
|
||||
const option = viewGame(g, f.owner).guildHomeworldShipment!;
|
||||
assert.equal(option.blocked, null);
|
||||
g = applyAction(g, f.owner, {
|
||||
type: 'guildHomeworldShip',
|
||||
event: option.event,
|
||||
destination: 'homeworld:guild',
|
||||
sources: { 'hagga_basin:12': { normal: 5, elite: 0 } },
|
||||
});
|
||||
assert.equal(g.players[0].reserves, 20);
|
||||
assert.equal(g.players[0].forces['hagga_basin:12'], undefined);
|
||||
assert.equal(g.players[0].spice, 14);
|
||||
stable(g);
|
||||
const outward = nexusGuildCunningFixture({ homeworlds: true });
|
||||
let next = originalGuildCunningTurn(outward);
|
||||
next = nexusGuildCunningAllow(
|
||||
applyAction(next, outward.owner, nexusGuildCunningRequest(outward, next)),
|
||||
);
|
||||
const outwardOption = viewGame(next, outward.owner).homeworldShipment!;
|
||||
assert.equal(outwardOption.blocked, null);
|
||||
next = applyAction(next, outward.owner, {
|
||||
type: 'homeworldShip',
|
||||
event: outwardOption.event,
|
||||
destination: 'homeworld:harkonnen',
|
||||
sources: { 'homeworld:guild': { normal: 2, elite: 0 } },
|
||||
});
|
||||
assert.equal(next.players[0].reserves, 13);
|
||||
assert.equal(
|
||||
next.homeworlds!.custody!.visitors['homeworld:harkonnen'][outward.owner]
|
||||
.normal,
|
||||
2,
|
||||
);
|
||||
assert.equal(next.players[0].spice, 16);
|
||||
stable(next);
|
||||
});
|
||||
|
||||
void test('Advanced Guild Cunning finishes within a genuinely granted out-of-order combined turn', () => {
|
||||
const f = nexusGuildCunningFixture({ advanced: true, karama: true });
|
||||
let g = f.g;
|
||||
// The first phase-five position is only fixture staging. Enter its native
|
||||
// turn queue through an actual completed Revival boundary before acting.
|
||||
Object.assign(g, {
|
||||
phase: 4,
|
||||
active: null,
|
||||
ready: [],
|
||||
order: [f.target, f.observer, f.owner],
|
||||
});
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
assert.equal(g.decision?.kind, 'guildTiming');
|
||||
g = applyAction(g, f.owner, { type: 'decision', take: true });
|
||||
g = nexusGuildCunningAllow(g);
|
||||
assert.equal(g.active, f.owner);
|
||||
assert.equal(g.guildTimingGranted, true);
|
||||
g = originalGuildCunningTurn(f, true, g);
|
||||
g = nexusGuildCunningAllow(
|
||||
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
|
||||
);
|
||||
g = settleGuildCunningShipment(
|
||||
applyAction(g, f.owner, {
|
||||
type: 'ship',
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
amount: 3,
|
||||
}),
|
||||
);
|
||||
if (g.active === f.owner)
|
||||
g = applyAction(g, f.owner, { type: 'endMovement' });
|
||||
assert.equal(g.movementRemaining!.includes(f.owner), false);
|
||||
assert.equal(g.active, f.target);
|
||||
assert.equal(new Set(g.movementRemaining).size, g.movementRemaining!.length);
|
||||
stable(g);
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
viewGame,
|
||||
normalizeAutomaticGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import {
|
||||
nexusGuildCunningFixture,
|
||||
originalGuildCunningTurn,
|
||||
nexusGuildCunningRequest,
|
||||
holdGuildCunningCard,
|
||||
nexusGuildCunningAllow,
|
||||
nexusGuildCunningInventory,
|
||||
nexusGuildCunningReload,
|
||||
} from './fixture-nexus-guild-cunning';
|
||||
|
||||
function stable(g: Game) {
|
||||
nexusGuildCunningInventory(g);
|
||||
for (const p of g.players)
|
||||
assert.deepEqual(
|
||||
viewGame(nexusGuildCunningReload(g), p.id),
|
||||
viewGame(g, p.id),
|
||||
);
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusGuildCunningReload(g)), g);
|
||||
}
|
||||
function nested() {
|
||||
const f = nexusGuildCunningFixture({
|
||||
advanced: true,
|
||||
opponentFaction: 'beneGesserit',
|
||||
});
|
||||
const worthless = holdGuildCunningCard(f.g, f.target, 'worthless');
|
||||
const karama = holdGuildCunningCard(f.g, f.observer, 'karama');
|
||||
let g = originalGuildCunningTurn(f, false);
|
||||
assert.equal(g.decision?.kind, 'advisor');
|
||||
g = applyAction(g, f.target, { type: 'decision', accept: false });
|
||||
g = applyAction(g, f.owner, {
|
||||
type: 'move',
|
||||
from: 'arrakeen:10',
|
||||
territory: 'hagga_basin',
|
||||
sector: 12,
|
||||
amount: 5,
|
||||
});
|
||||
const original = nexusGuildCunningReload(g);
|
||||
g = applyAction(g, f.owner, nexusGuildCunningRequest(f, g));
|
||||
assert.equal(g.response?.kind, 'nexusGuildCunning');
|
||||
const response = structuredClone(g.response);
|
||||
g = applyAction(g, f.target, {
|
||||
type: 'card',
|
||||
card: worthless.id,
|
||||
mode: 'cancel',
|
||||
});
|
||||
assert.equal(g.response?.kind, 'worthlessKarama');
|
||||
assert.equal(g.pendingKarama?.use.kind, 'cancel');
|
||||
if (g.pendingKarama?.use.kind === 'cancel')
|
||||
assert.deepEqual(g.pendingKarama.use.response, response);
|
||||
assert.equal(g.discard.filter((c) => c.id === worthless.id).length, 1);
|
||||
stable(g);
|
||||
return { f, g, original, worthless, karama };
|
||||
}
|
||||
|
||||
void test('Guild Cunning survives an actual paid BG Worthless cancellation and its printed Karama counter with exact original movement and custody', () => {
|
||||
for (const counter of [false, true]) {
|
||||
const { f, original, worthless, karama, g: pending } = nested();
|
||||
let g = counter
|
||||
? applyAction(nexusGuildCunningReload(pending), f.observer, {
|
||||
type: 'card',
|
||||
card: karama.id,
|
||||
mode: 'cancel',
|
||||
})
|
||||
: nexusGuildCunningAllow(nexusGuildCunningReload(pending));
|
||||
assert.equal(
|
||||
g.nexusGuildCunningHistory!.at(-1)!.stage,
|
||||
counter ? 'secondShipment' : 'canceled',
|
||||
);
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(
|
||||
g.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(g.players[0].forces, original.players[0].forces);
|
||||
assert.equal(g.players[0].spice, original.players[0].spice);
|
||||
assert.equal(g.players[0].moved, 1);
|
||||
assert.equal(g.players[0].shipped, true);
|
||||
assert.equal(g.discard.filter((c) => c.id === worthless.id).length, 1);
|
||||
assert.equal(
|
||||
g.discard.filter((c) => c.id === karama.id).length,
|
||||
counter ? 1 : 0,
|
||||
);
|
||||
assert.equal(g.pendingKarama, null);
|
||||
assert.equal(g.active === f.owner, counter);
|
||||
assert.equal(g.movementRemaining!.includes(f.owner), counter);
|
||||
stable(g);
|
||||
if (counter) {
|
||||
g = applyAction(g, f.owner, {
|
||||
type: 'ship',
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
amount: 2,
|
||||
});
|
||||
if (g.decision?.kind === 'guildShipment')
|
||||
g = applyAction(g, f.owner, { type: 'decision', allow: true });
|
||||
if (g.decision?.kind === 'advisor')
|
||||
g = applyAction(g, f.target, { type: 'decision', accept: false });
|
||||
g = nexusGuildCunningAllow(g);
|
||||
assert.equal(g.players[0].forces['carthag:11'], 2);
|
||||
assert.equal(g.players[0].spice, original.players[0].spice - 1);
|
||||
assert.equal(g.nexusGuildCunningHistory!.at(-1)!.stage, 'extraMove');
|
||||
stable(g);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('nested Guild cancellation rejects altered parent, last marker and saved native source without mutation', () => {
|
||||
const { f, g: pending, karama } = nested();
|
||||
const changes: ((g: Game) => void)[] = [
|
||||
(g) => {
|
||||
g.nexusGuildCunningHistory![0].parent = '[]';
|
||||
},
|
||||
(g) => {
|
||||
g.nexusGuildCunningLast!.stage = 'secondShipment';
|
||||
},
|
||||
(g) => {
|
||||
delete g.nexusGuildCunningLast;
|
||||
},
|
||||
(g) => {
|
||||
delete g.nexusGuildCunningHistory;
|
||||
},
|
||||
(g) => {
|
||||
if (g.pendingKarama!.use.kind === 'cancel')
|
||||
g.pendingKarama!.use.response.intent = 'old';
|
||||
},
|
||||
(g) => {
|
||||
if (g.pendingKarama!.use.kind === 'cancel')
|
||||
g.pendingKarama!.use.response.owner = f.observer;
|
||||
},
|
||||
(g) => {
|
||||
g.players[0].moved = 0;
|
||||
},
|
||||
(g) => {
|
||||
g.movementRemaining = g.movementRemaining!.filter((id) => id !== f.owner);
|
||||
},
|
||||
];
|
||||
for (const change of changes) {
|
||||
const bad = nexusGuildCunningReload(pending);
|
||||
change(bad);
|
||||
const original = nexusGuildCunningReload(bad);
|
||||
for (const p of bad.players) assert.throws(() => viewGame(bad, p.id));
|
||||
assert.throws(() => normalizeAutomaticGame(bad));
|
||||
assert.throws(() =>
|
||||
applyAction(bad, f.observer, {
|
||||
type: 'card',
|
||||
card: karama.id,
|
||||
mode: 'cancel',
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(bad, original);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,566 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import {
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Game,
|
||||
type Action,
|
||||
} from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import {
|
||||
nexusGuildCunningFixture,
|
||||
originalGuildCunningTurn,
|
||||
nexusGuildCunningRequest,
|
||||
nexusGuildCunningInventory,
|
||||
holdGuildCunningCard,
|
||||
nexusGuildCunningAllianceFixture,
|
||||
} from './fixture-nexus-guild-cunning';
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
async function fixture(
|
||||
hajr = false,
|
||||
originalMove = true,
|
||||
selfStop = false,
|
||||
homeworlds = false,
|
||||
alliance = false,
|
||||
) {
|
||||
const store = unitStore(),
|
||||
made = await store.rooms.createRoom(
|
||||
'Guild Cunning SQL',
|
||||
'guild',
|
||||
false,
|
||||
[],
|
||||
),
|
||||
code = made.view.code,
|
||||
tokens = [made.token];
|
||||
for (const faction of ['harkonnen', 'fremen'] as const)
|
||||
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
||||
const auths = await Promise.all(
|
||||
tokens.map((t) => store.rooms.authenticate(code, t)),
|
||||
),
|
||||
ids = auths.map((a) => a.playerId) as [string, string, string];
|
||||
const f = alliance
|
||||
? nexusGuildCunningAllianceFixture(ids)
|
||||
: nexusGuildCunningFixture({
|
||||
seatIds: ids,
|
||||
karama: true,
|
||||
advanced: selfStop,
|
||||
homeworlds,
|
||||
});
|
||||
if (alliance) f.karama = holdGuildCunningCard(f.g, f.target, 'karama').id;
|
||||
const ownKarama = selfStop
|
||||
? holdGuildCunningCard(f.g, f.owner, 'karama').id
|
||||
: undefined;
|
||||
const card = hajr ? holdGuildCunningCard(f.g, f.owner, 'hajr').id : undefined;
|
||||
f.g = originalGuildCunningTurn(f, originalMove);
|
||||
f.g.code = code;
|
||||
f.g.version = (await store.rooms.readRoom(code)).version;
|
||||
const save = (g: Game) =>
|
||||
store.sqlite
|
||||
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(g), g.version, code);
|
||||
save(f.g);
|
||||
store.writes.length = 0;
|
||||
return {
|
||||
...store,
|
||||
...f,
|
||||
code,
|
||||
tokens,
|
||||
auths,
|
||||
ids,
|
||||
save,
|
||||
hajr: card,
|
||||
ownKarama,
|
||||
};
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof fixture>>;
|
||||
const row = (f: Fixture) =>
|
||||
f.sqlite
|
||||
.prepare('SELECT state,version FROM rooms WHERE code = ?')
|
||||
.get(f.code);
|
||||
async function act(f: Fixture, seat: number, action: Action) {
|
||||
const g = await f.restart().readRoom(f.code);
|
||||
await f.restart().act(f.code, f.auths[seat], g.version, action, clock);
|
||||
return f.restart().readRoom(f.code);
|
||||
}
|
||||
async function restored(f: Fixture, g: Game) {
|
||||
const before = row(f),
|
||||
rooms = f.restart();
|
||||
for (const [i, t] of f.tokens.entries()) {
|
||||
const auth = await rooms.authenticate(f.code, t),
|
||||
v = await rooms.readSeatView(f.code, auth);
|
||||
assert.deepEqual(v, viewGame(g, f.ids[i]));
|
||||
assert.equal(Object.hasOwn(v, 'nexusGuildCunningHistory'), false);
|
||||
if (i !== 0) assert.equal(v.nexusGuildCunning, null);
|
||||
}
|
||||
await rooms.continueRoomAutomatic(f.code, clock);
|
||||
assert.deepEqual(row(f), before);
|
||||
nexusGuildCunningInventory(g);
|
||||
}
|
||||
async function compete(f: Fixture, version: number, action: Action) {
|
||||
let arrivals = 0,
|
||||
release!: () => void;
|
||||
const gate = new Promise<void>((r) => {
|
||||
release = r;
|
||||
});
|
||||
f.hooks.beforeWrite = async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await gate;
|
||||
};
|
||||
const timer = setTimeout(release, 2000);
|
||||
const results = await Promise.allSettled(
|
||||
[0, 1].map(() =>
|
||||
f.restart().act(f.code, f.auths[0], version, action, clock),
|
||||
),
|
||||
);
|
||||
clearTimeout(timer);
|
||||
delete f.hooks.beforeWrite;
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
|
||||
assert.deepEqual(
|
||||
f.writes.map((w) => w.changes).sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
);
|
||||
}
|
||||
|
||||
void test('SQLite restores the real second-arrival Moritani alliance reply and continues the spent Guild Nexus through Hajr once', async () => {
|
||||
const f = await fixture(true, true, false, false, true);
|
||||
try {
|
||||
await act(f, 0, nexusGuildCunningRequest(f));
|
||||
let g = await f.restart().readRoom(f.code);
|
||||
while (g.response) {
|
||||
const voter = g.players.find(
|
||||
(p) => !g.response!.passed.includes(p.id),
|
||||
)!.id;
|
||||
g = await act(f, f.ids.indexOf(voter), { type: 'passResponse' });
|
||||
}
|
||||
g = await act(f, 0, {
|
||||
type: 'ship',
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
amount: 3,
|
||||
});
|
||||
assert.equal(g.pendingTerrorEntry?.stage, 'offer');
|
||||
g = await act(f, 1, { type: 'decision', alliance: true });
|
||||
while (g.response) {
|
||||
const voter = g.players.find(
|
||||
(p) => !g.response!.passed.includes(p.id),
|
||||
)!.id;
|
||||
g = await act(f, f.ids.indexOf(voter), { type: 'passResponse' });
|
||||
}
|
||||
await restored(f, g);
|
||||
const spice = g.players[0].spice,
|
||||
reserves = g.players[0].reserves;
|
||||
const accepted = await act(f, 0, { type: 'decision', accept: true });
|
||||
assert.equal(accepted.players[0].ally, f.target);
|
||||
assert.equal(accepted.players[1].ally, f.owner);
|
||||
assert.equal(accepted.players[0].spice, spice);
|
||||
assert.equal(accepted.players[0].reserves, reserves);
|
||||
await restored(f, accepted);
|
||||
const before = row(f);
|
||||
await assert.rejects(
|
||||
f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.auths[0],
|
||||
accepted.version,
|
||||
{ type: 'decision', accept: true },
|
||||
clock,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(row(f), before);
|
||||
await act(f, 0, { type: 'card', card: f.hajr });
|
||||
let done = await act(f, 0, {
|
||||
type: 'move',
|
||||
from: 'carthag:11',
|
||||
territory: 'hagga_basin',
|
||||
sector: 12,
|
||||
amount: 3,
|
||||
});
|
||||
assert.equal(done.players[0].forces['hagga_basin:12'], 8);
|
||||
assert.equal(done.players[0].spice, spice);
|
||||
assert.equal(done.players[0].reserves, reserves);
|
||||
if (done.active === f.owner)
|
||||
done = await act(f, 0, { type: 'endMovement' });
|
||||
assert.notEqual(done.active, f.owner);
|
||||
await restored(f, done);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('SQLite restores self-stopped second Guild shipment with no second cost or arrival and only one Hajr movement', async () => {
|
||||
const f = await fixture(true, false, true);
|
||||
try {
|
||||
await act(f, 0, nexusGuildCunningRequest(f));
|
||||
await act(f, 1, { type: 'passResponse' });
|
||||
const pending = await act(f, 0, {
|
||||
type: 'ship',
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
amount: 3,
|
||||
});
|
||||
assert.equal(pending.decision?.kind, 'guildShipment');
|
||||
await restored(f, pending);
|
||||
const stopped = await act(f, 0, {
|
||||
type: 'card',
|
||||
mode: 'special',
|
||||
card: f.ownKarama,
|
||||
});
|
||||
assert.equal(
|
||||
stopped.nexusGuildCunningHistory!.at(-1)!.shipment!.stage,
|
||||
'stopped',
|
||||
);
|
||||
assert.equal(stopped.players[0].spice, 17);
|
||||
assert.equal(stopped.players[0].reserves, 15);
|
||||
assert.equal(stopped.players[0].forces['carthag:11'], undefined);
|
||||
assert.equal(
|
||||
stopped.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
|
||||
1,
|
||||
);
|
||||
await restored(f, stopped);
|
||||
const move: Action = {
|
||||
type: 'move',
|
||||
from: 'arrakeen:10',
|
||||
territory: 'hagga_basin',
|
||||
sector: 12,
|
||||
amount: 5,
|
||||
};
|
||||
const before = row(f);
|
||||
await assert.rejects(
|
||||
f.restart().act(f.code, f.auths[0], stopped.version, move, clock),
|
||||
);
|
||||
assert.deepEqual(row(f), before);
|
||||
await act(f, 0, { type: 'card', card: f.hajr });
|
||||
const done = await act(f, 0, move);
|
||||
assert.equal(done.players[0].spice, 17);
|
||||
assert.equal(done.players[0].reserves, 15);
|
||||
assert.equal(done.players[0].forces['hagga_basin:12'], 5);
|
||||
assert.equal(done.discard.filter((c) => c.id === f.ownKarama).length, 1);
|
||||
await restored(f, done);
|
||||
const completed = row(f);
|
||||
await assert.rejects(
|
||||
f.restart().act(
|
||||
f.code,
|
||||
f.auths[0],
|
||||
done.version,
|
||||
{
|
||||
type: 'move',
|
||||
from: 'hagga_basin:12',
|
||||
territory: 'arsunt',
|
||||
sector: 12,
|
||||
amount: 5,
|
||||
},
|
||||
clock,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(row(f), completed);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('SQLite rejects lost or changed second-shipment source, paid frame and Guild interception with zero writes', async () => {
|
||||
const f = await fixture(false, true, true, true);
|
||||
try {
|
||||
await act(f, 0, nexusGuildCunningRequest(f));
|
||||
await act(f, 1, { type: 'passResponse' });
|
||||
const pending = await act(f, 0, {
|
||||
type: 'ship',
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
amount: 3,
|
||||
homeworldSources: { 'homeworld:guild': { normal: 3, elite: 0 } },
|
||||
});
|
||||
assert.equal(pending.decision?.kind, 'guildShipment');
|
||||
await restored(f, pending);
|
||||
for (const edit of [
|
||||
(g: Game) => {
|
||||
delete g.pendingShipment!.guildNexusEvent;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.pendingShipment!.guildNexusEvent = 'other';
|
||||
},
|
||||
(g: Game) => {
|
||||
g.pendingShipment!.amount = 2;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.pendingShipment!.cost = 1;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.pendingShipment!.homeworldSources!['homeworld:guild'].normal = 2;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.pendingShipment!.territory = 'arrakeen';
|
||||
g.pendingShipment!.sector = 10;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.nexusGuildCunningHistory!.at(-1)!.shipment = {
|
||||
kind: 'reserve',
|
||||
stage: 'pending',
|
||||
frame: '{}',
|
||||
};
|
||||
},
|
||||
(g: Game) => {
|
||||
g.decision = null;
|
||||
},
|
||||
(g: Game) => {
|
||||
if (g.decision?.kind === 'guildShipment') g.decision.amount = 2;
|
||||
},
|
||||
(g: Game) => {
|
||||
if (g.decision?.kind === 'guildShipment') g.decision.shipper = f.target;
|
||||
},
|
||||
]) {
|
||||
const bad = structuredClone(pending);
|
||||
edit(bad);
|
||||
f.save(bad);
|
||||
f.writes.length = 0;
|
||||
const before = row(f),
|
||||
rooms = f.restart();
|
||||
for (const auth of f.auths)
|
||||
await assert.rejects(rooms.readSeatView(f.code, auth));
|
||||
assert.throws(() => normalizeAutomaticGame(bad));
|
||||
await Promise.allSettled([rooms.continueRoomAutomatic(f.code, clock)]);
|
||||
await assert.rejects(
|
||||
rooms.act(
|
||||
f.code,
|
||||
f.auths[0],
|
||||
bad.version,
|
||||
{ type: 'decision', allow: true },
|
||||
clock,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(row(f), before);
|
||||
assert.equal(f.writes.length, 0);
|
||||
}
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
void test('SQLite restores the first Guild turn and lost Cunning declaration then commits exactly one raced second shipment', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const other = await f.rooms.createRoom(
|
||||
'Unrelated original room',
|
||||
'atreides',
|
||||
false,
|
||||
[],
|
||||
),
|
||||
otherBefore = f.sqlite
|
||||
.prepare('SELECT state,version FROM rooms WHERE code = ?')
|
||||
.get(other.view.code);
|
||||
const pending = await act(f, 0, nexusGuildCunningRequest(f));
|
||||
await restored(f, pending);
|
||||
assert.equal(pending.players[0].spice, 17);
|
||||
assert.equal(pending.players[0].moved, 1);
|
||||
assert.equal(pending.players[0].forces['hagga_basin:12'], 5);
|
||||
const allowed = await act(f, 1, { type: 'passResponse' });
|
||||
await restored(f, allowed);
|
||||
f.writes.length = 0;
|
||||
const shipment: Action = {
|
||||
type: 'ship',
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
amount: 3,
|
||||
};
|
||||
await compete(f, allowed.version, shipment);
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.players[0].spice, 15);
|
||||
assert.equal(done.players[0].reserves, 12);
|
||||
assert.equal(done.players[0].forces['carthag:11'], 3);
|
||||
assert.equal(done.players[0].moved, 1);
|
||||
assert.equal(
|
||||
done.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
|
||||
1,
|
||||
);
|
||||
await restored(f, done);
|
||||
const before = row(f);
|
||||
await assert.rejects(
|
||||
f.restart().act(f.code, f.auths[0], done.version, shipment, clock),
|
||||
);
|
||||
assert.deepEqual(row(f), before);
|
||||
assert.deepEqual(
|
||||
f.sqlite
|
||||
.prepare('SELECT state,version FROM rooms WHERE code = ?')
|
||||
.get(other.view.code),
|
||||
otherBefore,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
void test('SQLite canceled Guild Cunning preserves the already moved army and first payment without granting another turn', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await act(f, 0, nexusGuildCunningRequest(f));
|
||||
const canceled = await act(f, 1, {
|
||||
type: 'card',
|
||||
mode: 'cancel',
|
||||
card: f.karama,
|
||||
});
|
||||
assert.equal(canceled.players[0].spice, 17);
|
||||
assert.equal(canceled.players[0].reserves, 15);
|
||||
assert.equal(canceled.players[0].forces['hagga_basin:12'], 5);
|
||||
assert.equal(canceled.players[0].moved, 1);
|
||||
assert.equal(canceled.movementRemaining!.includes(f.owner), false);
|
||||
assert.equal(canceled.discard.filter((c) => c.id === f.karama).length, 1);
|
||||
await restored(f, canceled);
|
||||
const before = row(f);
|
||||
await assert.rejects(
|
||||
f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.auths[0],
|
||||
canceled.version,
|
||||
nexusGuildCunningRequest(f),
|
||||
clock,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(row(f), before);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
void test('SQLite late Hajr restores one extra move after the second shipment without replaying either shipment', async () => {
|
||||
const f = await fixture(true);
|
||||
try {
|
||||
await act(f, 0, nexusGuildCunningRequest(f));
|
||||
await act(f, 1, { type: 'passResponse' });
|
||||
const shipped = await act(f, 0, {
|
||||
type: 'ship',
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
amount: 3,
|
||||
});
|
||||
await restored(f, shipped);
|
||||
const extra = await act(f, 0, { type: 'card', card: f.hajr });
|
||||
await restored(f, extra);
|
||||
const done = await act(f, 0, {
|
||||
type: 'move',
|
||||
from: 'carthag:11',
|
||||
territory: 'hagga_basin',
|
||||
sector: 12,
|
||||
amount: 3,
|
||||
});
|
||||
assert.equal(done.players[0].spice, 15);
|
||||
assert.equal(done.players[0].reserves, 12);
|
||||
assert.equal(done.players[0].forces['hagga_basin:12'], 8);
|
||||
assert.equal(done.players[0].moved, 2);
|
||||
assert.equal(done.discard.filter((c) => c.id === f.hajr).length, 1);
|
||||
await restored(f, done);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
void test('SQLite changed Guild Cunning history or original movement proof cannot project, normalize or write a continuation', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const pending = await act(f, 0, nexusGuildCunningRequest(f));
|
||||
for (const edit of [
|
||||
(g: Game) => {
|
||||
delete g.nexusGuildCunningHistory;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.nexusGuildCunningHistory![0].signature = 'changed';
|
||||
},
|
||||
(g: Game) => {
|
||||
g.response = null;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.players[0].moved = 0;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.players[0].shipped = false;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.response!.owner = f.target;
|
||||
},
|
||||
]) {
|
||||
const bad = structuredClone(pending);
|
||||
edit(bad);
|
||||
f.save(bad);
|
||||
f.writes.length = 0;
|
||||
const before = row(f),
|
||||
rooms = f.restart();
|
||||
for (const auth of f.auths)
|
||||
await assert.rejects(rooms.readSeatView(f.code, auth));
|
||||
assert.throws(() => normalizeAutomaticGame(bad));
|
||||
await Promise.allSettled([rooms.continueRoomAutomatic(f.code, clock)]);
|
||||
await assert.rejects(
|
||||
rooms.act(
|
||||
f.code,
|
||||
f.auths[1],
|
||||
bad.version,
|
||||
{ type: 'passResponse' },
|
||||
clock,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(row(f), before);
|
||||
assert.equal(f.writes.length, 0);
|
||||
}
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('SQLite restores a declined second shipment and races exactly one Hajr move after the original movement was declined', async () => {
|
||||
const f = await fixture(true, false);
|
||||
try {
|
||||
await act(f, 0, nexusGuildCunningRequest(f));
|
||||
const allowed = await act(f, 1, { type: 'passResponse' });
|
||||
const skip: Action = {
|
||||
type: 'nexusGuildSkipShipment',
|
||||
event: viewGame(allowed, f.owner).nexusGuildCunning!.active!.event,
|
||||
};
|
||||
const skipped = await act(f, 0, skip);
|
||||
assert.equal(skipped.players[0].spice, 17);
|
||||
assert.equal(skipped.players[0].reserves, 15);
|
||||
assert.equal(skipped.players[0].moved, 0);
|
||||
assert.equal(skipped.players[0].shipped, true);
|
||||
await restored(f, skipped);
|
||||
const move: Action = {
|
||||
type: 'move',
|
||||
from: 'arrakeen:10',
|
||||
territory: 'hagga_basin',
|
||||
sector: 12,
|
||||
amount: 5,
|
||||
};
|
||||
const before = row(f);
|
||||
for (const action of [move, skip])
|
||||
await assert.rejects(
|
||||
f.restart().act(f.code, f.auths[0], skipped.version, action, clock),
|
||||
);
|
||||
assert.deepEqual(row(f), before);
|
||||
const extra = await act(f, 0, { type: 'card', card: f.hajr });
|
||||
await restored(f, extra);
|
||||
f.writes.length = 0;
|
||||
await compete(f, extra.version, move);
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.players[0].forces['hagga_basin:12'], 5);
|
||||
assert.equal(done.players[0].spice, 17);
|
||||
assert.equal(done.players[0].reserves, 15);
|
||||
assert.equal(done.discard.filter((c) => c.id === f.hajr).length, 1);
|
||||
await restored(f, done);
|
||||
const completed = row(f);
|
||||
await assert.rejects(
|
||||
f.restart().act(
|
||||
f.code,
|
||||
f.auths[0],
|
||||
done.version,
|
||||
{
|
||||
type: 'move',
|
||||
from: 'hagga_basin:12',
|
||||
territory: 'arsunt',
|
||||
sector: 12,
|
||||
amount: 5,
|
||||
},
|
||||
clock,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(row(f), completed);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createNexusGuildCunning,
|
||||
validateNexusGuildCunning,
|
||||
nexusGuildCunningMoves,
|
||||
type NexusGuildCunningContext,
|
||||
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 make = (moved = 0, hajrUsed = false, shipped = false) =>
|
||||
createNexusGuildCunning(context, 'g', { shipped, moved, hajrUsed });
|
||||
|
||||
void test('Guild Cunning records the ended original combined turn whether its shipment and movement were used or declined', () => {
|
||||
for (const shipped of [false, true])
|
||||
for (const hajrUsed of [false, true])
|
||||
for (let moved = 0; moved <= (hajrUsed ? 2 : 1); moved++) {
|
||||
const before = { shipped, moved, hajrUsed },
|
||||
original = clone(before);
|
||||
const receipt = createNexusGuildCunning(context, 'g', before);
|
||||
assert.equal(
|
||||
receipt.event,
|
||||
JSON.stringify(['nexusGuildCunning', 3, 'g']),
|
||||
);
|
||||
assert.deepEqual(receipt.before, original);
|
||||
assert.deepEqual(before, original);
|
||||
assert.notEqual(receipt.before, before);
|
||||
assert.equal(receipt.mode, 'cunning');
|
||||
validateNexusGuildCunning(context, clone(receipt));
|
||||
}
|
||||
});
|
||||
|
||||
void test('second shipment never supplies an ordinary move, including a declined first movement', () => {
|
||||
for (const moved of [0, 1]) {
|
||||
const receipt = make(moved, false, true);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, false), 0);
|
||||
assert.throws(() => nexusGuildCunningMoves(receipt, false, 1));
|
||||
assert.equal(
|
||||
nexusGuildCunningMoves(receipt, true),
|
||||
1,
|
||||
'later Hajr grants exactly its extra slot',
|
||||
);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, true, 1), 0);
|
||||
}
|
||||
});
|
||||
|
||||
void test('previously played Hajr keeps an unused extra slot after decline, but an already consumed Hajr grants no third move', () => {
|
||||
for (const moved of [0, 1]) {
|
||||
const receipt = make(moved, true);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, true), 1);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, true, 1), 0);
|
||||
assert.throws(() => nexusGuildCunningMoves(receipt, false));
|
||||
}
|
||||
const consumed = make(2, true);
|
||||
assert.equal(nexusGuildCunningMoves(consumed, true), 0);
|
||||
assert.throws(() => nexusGuildCunningMoves(consumed, true, 1));
|
||||
for (const count of [-1, 0.5, 2, NaN, Infinity])
|
||||
assert.throws(() => nexusGuildCunningMoves(make(1, true), true, count));
|
||||
});
|
||||
|
||||
void test('only native unallied Guild can create the receipt at its own phase-five boundary', () => {
|
||||
const before = { shipped: false, moved: 0, hajrUsed: false };
|
||||
for (const phase of [0, 4, 6, 8])
|
||||
assert.throws(() =>
|
||||
createNexusGuildCunning({ ...context, phase }, 'g', before),
|
||||
);
|
||||
assert.throws(() =>
|
||||
createNexusGuildCunning({ ...context, active: 'a' }, 'g', before),
|
||||
);
|
||||
assert.throws(() =>
|
||||
createNexusGuildCunning({ ...context, active: 'a' }, 'a', before),
|
||||
);
|
||||
assert.throws(() =>
|
||||
createNexusGuildCunning(
|
||||
{
|
||||
...context,
|
||||
players: context.players.map((p) => ({
|
||||
...p,
|
||||
ally: p.id === 'g' ? 'a' : null,
|
||||
})),
|
||||
},
|
||||
'g',
|
||||
before,
|
||||
),
|
||||
);
|
||||
for (const bad of [
|
||||
{ ...before, moved: 2 },
|
||||
{ ...before, moved: 3, hajrUsed: true },
|
||||
{ ...before, moved: -1 },
|
||||
{ ...before, moved: 0.5 },
|
||||
{ ...before, moved: NaN },
|
||||
{ ...before, shipped: 0 },
|
||||
{ ...before, hajrUsed: 1 },
|
||||
{ ...before, extra: 1 },
|
||||
])
|
||||
assert.throws(() =>
|
||||
createNexusGuildCunning(context, 'g', bad as typeof before),
|
||||
);
|
||||
});
|
||||
|
||||
void test('completed original receipts survive later alliances, turns and resource changes without reading private fields', () => {
|
||||
const receipt = make(1, true, true);
|
||||
const trap = () => {
|
||||
throw new Error('private field was read');
|
||||
};
|
||||
const later: NexusGuildCunningContext = {
|
||||
turn: 7,
|
||||
players: context.players.map((p) => ({
|
||||
...p,
|
||||
get ally() {
|
||||
return trap();
|
||||
},
|
||||
get hand() {
|
||||
return trap();
|
||||
},
|
||||
get spice() {
|
||||
return trap();
|
||||
},
|
||||
get shipped() {
|
||||
return trap();
|
||||
},
|
||||
get moved() {
|
||||
return trap();
|
||||
},
|
||||
get reserves() {
|
||||
return trap();
|
||||
},
|
||||
})),
|
||||
};
|
||||
validateNexusGuildCunning(later, receipt);
|
||||
assert.equal(nexusGuildCunningMoves(receipt, true), 1);
|
||||
assert.throws(() =>
|
||||
validateNexusGuildCunning({ ...later, turn: 2 }, receipt),
|
||||
);
|
||||
assert.throws(() =>
|
||||
validateNexusGuildCunning(
|
||||
{ ...later, players: later.players.slice(0, 2) },
|
||||
receipt,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
void test('creation reads only public roster, owner alliance, timing and the supplied original allowance', () => {
|
||||
const trap = () => {
|
||||
throw new Error('private field was read');
|
||||
};
|
||||
const publicContext = {
|
||||
...context,
|
||||
players: context.players.map((p) =>
|
||||
Object.freeze({
|
||||
...p,
|
||||
ally: null,
|
||||
get hand() {
|
||||
return trap();
|
||||
},
|
||||
get spice() {
|
||||
return trap();
|
||||
},
|
||||
get forces() {
|
||||
return trap();
|
||||
},
|
||||
}),
|
||||
),
|
||||
};
|
||||
Object.freeze(publicContext.players);
|
||||
Object.freeze(publicContext);
|
||||
const before = Object.freeze({ shipped: true, moved: 1, hajrUsed: true });
|
||||
const receipt = createNexusGuildCunning(publicContext, 'g', before);
|
||||
assert.deepEqual(Object.keys(receipt).sort(), [
|
||||
'before',
|
||||
'event',
|
||||
'mode',
|
||||
'owner',
|
||||
'phase',
|
||||
'roster',
|
||||
'signature',
|
||||
'turn',
|
||||
'version',
|
||||
]);
|
||||
assert.equal(JSON.stringify(receipt).includes('hand'), false);
|
||||
assert.equal(nexusGuildCunningMoves(Object.freeze(receipt), true, 1), 0);
|
||||
});
|
||||
|
||||
void test('changed original facts, foreign fields, deleted proofs and malformed roster identities reject immutably', () => {
|
||||
const receipt = make(1, true, true);
|
||||
const mutations: ((r: NexusGuildCunningReceipt) => void)[] = [
|
||||
(r) => {
|
||||
r.before.moved = 0;
|
||||
},
|
||||
(r) => {
|
||||
r.before.shipped = false;
|
||||
},
|
||||
(r) => {
|
||||
r.before.hajrUsed = false;
|
||||
},
|
||||
(r) => {
|
||||
r.event = 'stale';
|
||||
},
|
||||
(r) => {
|
||||
r.owner = 'a';
|
||||
},
|
||||
(r) => {
|
||||
r.turn = 2;
|
||||
},
|
||||
(r) => {
|
||||
r.signature = '';
|
||||
},
|
||||
(r) => {
|
||||
r.roster.reverse();
|
||||
},
|
||||
(r) => {
|
||||
r.roster[0].id = 'changed';
|
||||
},
|
||||
(r) => {
|
||||
delete (r as Partial<NexusGuildCunningReceipt>).before;
|
||||
},
|
||||
(r) => {
|
||||
Object.assign(r, { stage: 'secondShipment' });
|
||||
},
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const bad = clone(receipt);
|
||||
mutate(bad);
|
||||
const original = clone(bad);
|
||||
assert.throws(() => validateNexusGuildCunning(context, bad));
|
||||
assert.deepEqual(bad, original);
|
||||
}
|
||||
for (const players of [
|
||||
[...context.players, context.players[0]],
|
||||
context.players.map((p) => ({ ...p, id: 'same' })),
|
||||
context.players.map((p) => ({ ...p, faction: 'guild' as const })),
|
||||
context.players.map((p) => ({
|
||||
...p,
|
||||
id: p.id === 'a' ? '__proto__' : p.id,
|
||||
})),
|
||||
])
|
||||
assert.throws(() =>
|
||||
validateNexusGuildCunning({ ...context, players }, receipt),
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user