Integrate full funded Guild Nexus Betrayal
This commit is contained in:
1 parent
d3bf12fcd8
commit
8a56299155
28 files changed
+3005
-58
No files matched your search
@@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { createGuildBetrayalFixture } from './fixture-nexus-guild-betrayal';
|
||||
|
||||
void test('all four legal bot profiles replace the original payment for every supported shipment adapter', () => {
|
||||
for (const profile of DIFFICULTIES) {
|
||||
const scenarios = [
|
||||
{ source: 'reserve' },
|
||||
{ source: 'guildTransport' },
|
||||
{ source: 'homeworld' },
|
||||
{ source: 'junction' },
|
||||
{ source: 'reserve', homeworlds: true, occupiedJunction: true },
|
||||
] as const;
|
||||
for (const options of scenarios) {
|
||||
const fixture = createGuildBetrayalFixture(options);
|
||||
const game = structuredClone(fixture.game);
|
||||
const own = viewGame(game, fixture.holder);
|
||||
own.players.find(player => player.id === fixture.holder)!.bot = profile;
|
||||
// Bots must not reconstruct eligibility or price from hidden opponents.
|
||||
for (const rival of own.players) {
|
||||
if (rival.id !== own.me) {
|
||||
delete rival.hand;
|
||||
delete rival.spice;
|
||||
}
|
||||
}
|
||||
const reaction = own.guildBetrayalReaction!;
|
||||
assert.equal(reaction.canUse, true);
|
||||
const actions = botActions(own);
|
||||
assert.deepEqual(actions, [{ type: 'guildBetrayalUse', event: fixture.event }]);
|
||||
const result = applyAction(game, fixture.holder, actions[0]);
|
||||
assert.equal(viewGame(result, fixture.holder).guildBetrayalReaction, null);
|
||||
assert.equal(result.players.find(player => player.id === fixture.holder)!.spice,
|
||||
game.players.find(player => player.id === fixture.holder)!.spice + fixture.price);
|
||||
assert.equal(result.players.find(player => player.id === fixture.shipper)!.spice,
|
||||
game.players.find(player => player.id === fixture.shipper)!.spice - fixture.price);
|
||||
assert.equal(result.nexusCards?.cards?.hands[fixture.holder], null);
|
||||
assert.ok(result.nexusCards?.cards?.discard.includes('guild'));
|
||||
assert.deepEqual(result.players.map(player => ({ id: player.id, forces: player.forces, reserves: player.reserves, elites: player.elites, shipped: player.shipped })),
|
||||
fixture.original.players.map(player => ({ id: player.id, forces: player.forces, reserves: player.reserves, elites: player.elites, shipped: player.shipped })));
|
||||
assert.deepEqual(result.homeworlds?.custody, fixture.original.homeworlds?.custody);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('all profiles legally take their own fully funded invoice without treating a refund as payment funding', () => {
|
||||
for (const profile of DIFFICULTIES) {
|
||||
const fixture = createGuildBetrayalFixture({ payer: 'holder' });
|
||||
const own = viewGame(fixture.game, fixture.holder);
|
||||
own.players.find(player => player.id === fixture.holder)!.bot = profile;
|
||||
const actions = botActions(own);
|
||||
assert.deepEqual(actions, [{ type: 'guildBetrayalUse', event: fixture.event }]);
|
||||
const result = applyAction(fixture.game, fixture.holder, actions[0]);
|
||||
assert.equal(result.players.find(player => player.id === fixture.holder)!.spice,
|
||||
fixture.game.players.find(player => player.id === fixture.holder)!.spice);
|
||||
assert.equal(result.players.find(player => player.id === fixture.holder)!.reserves,
|
||||
fixture.game.players.find(player => player.id === fixture.holder)!.reserves - 2);
|
||||
assert.equal(result.players.find(player => player.id === fixture.holder)!.shipped, true);
|
||||
assert.equal(result.nexusCards?.cards?.hands[fixture.holder], null);
|
||||
assert.equal(viewGame(result, fixture.holder).guildBetrayalReaction, null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('every profile acknowledges irrelevant faces until the original settlement completes, without fallthrough', () => {
|
||||
for (const profile of DIFFICULTIES) {
|
||||
for (const payer of ['holder', 'other'] as const) {
|
||||
const fixture = createGuildBetrayalFixture({ payer, nexusFace: 'choam' });
|
||||
let game = structuredClone(fixture.game);
|
||||
for (const player of game.players) {
|
||||
const own = viewGame(game, player.id);
|
||||
own.players.find(seat => seat.id === player.id)!.bot = profile;
|
||||
const reaction = own.guildBetrayalReaction;
|
||||
if (!reaction) break;
|
||||
const actions = botActions(own);
|
||||
if (!reaction.canPass || reaction.hasPassed) {
|
||||
assert.deepEqual(actions, []);
|
||||
continue;
|
||||
}
|
||||
assert.deepEqual(actions, [{ type: 'guildBetrayalPass', event: fixture.event }]);
|
||||
game = applyAction(game, player.id, actions[0]);
|
||||
const after = viewGame(game, player.id);
|
||||
after.players.find(seat => seat.id === player.id)!.bot = profile;
|
||||
if (after.guildBetrayalReaction) assert.deepEqual(botActions(after), []);
|
||||
}
|
||||
assert.equal(viewGame(game, fixture.holder).guildBetrayalReaction, null);
|
||||
assert.deepEqual(game.players.map(player => ({ id: player.id, spice: player.spice, forces: player.forces, reserves: player.reserves, shipped: player.shipped })),
|
||||
fixture.original.players.map(player => ({ id: player.id, spice: player.spice, forces: player.forces, reserves: player.reserves, shipped: player.shipped })));
|
||||
assert.equal(game.nexusCards?.cards?.hands[fixture.holder], 'choam');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('seat-private blocked and response ownership gates override visible held-card hints for all profiles', () => {
|
||||
for (const profile of DIFFICULTIES) {
|
||||
const fixture = createGuildBetrayalFixture();
|
||||
const own = viewGame(fixture.game, fixture.holder);
|
||||
own.players.find(player => player.id === fixture.holder)!.bot = profile;
|
||||
assert.equal(own.nexusCards?.card, 'guild');
|
||||
const reaction = own.guildBetrayalReaction!;
|
||||
reaction.blocked = 'The committed promise prevents spending this Nexus card.';
|
||||
assert.deepEqual(botActions(own), [{ type: 'guildBetrayalPass', event: fixture.event }]);
|
||||
reaction.blocked = null;
|
||||
reaction.canUse = false;
|
||||
assert.deepEqual(botActions(own), [{ type: 'guildBetrayalPass', event: fixture.event }]);
|
||||
reaction.canUse = true;
|
||||
reaction.hasPassed = true;
|
||||
assert.deepEqual(botActions(own), []);
|
||||
reaction.hasPassed = false;
|
||||
reaction.canPass = false;
|
||||
assert.deepEqual(botActions(own), []);
|
||||
reaction.canPass = true;
|
||||
own.status = 'finished';
|
||||
assert.deepEqual(botActions(own), []);
|
||||
own.status = 'playing';
|
||||
reaction.event = '';
|
||||
assert.deepEqual(botActions(own), []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('human seats require delegation while autopilot obeys the same private response offer', () => {
|
||||
const fixture = createGuildBetrayalFixture({ payer: 'holder' });
|
||||
const own = viewGame(fixture.game, fixture.holder);
|
||||
const seat = own.players.find(player => player.id === fixture.holder)!;
|
||||
delete seat.bot;
|
||||
delete seat.autopilot;
|
||||
assert.deepEqual(botActions(own), []);
|
||||
seat.autopilot = 'Easy';
|
||||
const actions = botActions(own);
|
||||
assert.deepEqual(actions, [{ type: 'guildBetrayalUse', event: fixture.event }]);
|
||||
const result = applyAction(fixture.game, fixture.holder, actions[0]);
|
||||
assert.equal(result.nexusCards?.cards?.hands[fixture.holder], null);
|
||||
assert.equal(result.players.find(player => player.id === fixture.holder)!.spice,
|
||||
fixture.game.players.find(player => player.id === fixture.holder)!.spice);
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {applyAction, createGame, initializeGuildBetrayalGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game} from '../game/engine';
|
||||
import {botActions} from '../game/bots';
|
||||
import type {FactionId} from '../game/catalog';
|
||||
import {TERRITORIES, splitLocation} from '../game/board';
|
||||
import {botEntryAllowed, fremenReserveEntry} from '../game/bot-mobility';
|
||||
import {guildTransportQuote} from '../game/transport-quote';
|
||||
import {junctionTransportChoice} from '../game/junction-transport-options';
|
||||
import {homeworldShipmentChoice} from '../game/homeworld-shipment-options';
|
||||
import {drawNexusCard, validateNexusCards} from '../game/nexus-cards';
|
||||
import {homeworldGameIntegrity} from '../game/homeworld-game';
|
||||
import {orderNexusSpice} from './fixture-nexus-cards';
|
||||
|
||||
export type GuildBetrayalFixtureOptions = {
|
||||
/** Newly admitted saved setup; preserve its identities and physical setup. */
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
homeworlds?: boolean;
|
||||
source?: 'reserve' | 'guildTransport' | 'homeworld' | 'junction';
|
||||
payer?: 'holder' | 'guild' | 'other';
|
||||
allyPayment?: number;
|
||||
seatIds?: string[];
|
||||
nexusFace?: 'guild' | 'choam';
|
||||
zero?: boolean;
|
||||
occupiedJunction?: boolean;
|
||||
};
|
||||
export type GuildBetrayalFixture = {
|
||||
game: Game;
|
||||
before: Game;
|
||||
original: Game;
|
||||
declaration: Action;
|
||||
holder: string;
|
||||
guild: string;
|
||||
shipper: string;
|
||||
donor: string | null;
|
||||
event: string;
|
||||
price: number;
|
||||
source: 'reserve' | 'guildTransport' | 'homeworld' | 'junction';
|
||||
};
|
||||
|
||||
/** Complete only already declared native permission, acknowledgement and income
|
||||
* windows. Never creates or replays a shipment action. */
|
||||
export function settleGuildBetrayalFixture(state: Game): Game {
|
||||
let game = state;
|
||||
for (let step = 0; step < 100; step++) {
|
||||
const frame = game.pendingGuildBetrayal;
|
||||
if (frame) {
|
||||
const id = frame.required.find(seat => !frame.passed.includes(seat))!;
|
||||
game = applyAction(game, id, {type: 'guildBetrayalPass', event: frame.invoice.event});
|
||||
} else if (game.decision?.kind === 'guildShipment') {
|
||||
game = applyAction(game, game.decision.player, {type: 'decision', allow: true});
|
||||
} else if (game.decision?.kind === 'homeworldShipmentGuild') {
|
||||
game = applyAction(game, game.decision.player,
|
||||
{type: 'decision', allow: true, event: game.decision.event});
|
||||
} else if (game.response) {
|
||||
const id = game.players.find(p => !game.response!.passed.includes(p.id))!.id;
|
||||
game = applyAction(game, id, {type: 'passResponse'});
|
||||
} else return game;
|
||||
}
|
||||
throw new Error('Native Guild fixture settlement did not complete.');
|
||||
}
|
||||
|
||||
function stepGuildFixture(game: Game): Game {
|
||||
for (const p of game.players) {
|
||||
const view = viewGame(game, p.id);
|
||||
view.players.find(seat => seat.id === p.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (action) return applyAction(game, p.id, action);
|
||||
}
|
||||
throw new Error(`Native fixture has no legal action at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
}
|
||||
function enterGuildFixtureMovement(state: Game, minimumTurn = 1): Game {
|
||||
let game = state;
|
||||
let orderedTurn = 0;
|
||||
for (let step = 0; step < 700; step++) {
|
||||
if (game.status === 'playing' && game.turn >= minimumTurn && game.phase === 5 &&
|
||||
!game.phaseOpening && !game.response && !game.decision) return game;
|
||||
if (game.status === 'playing' && game.phase === 1 && orderedTurn !== game.turn) {
|
||||
orderNexusSpice(game, ['land', 'land']);
|
||||
orderedTurn = game.turn;
|
||||
}
|
||||
game = stepGuildFixture(game);
|
||||
}
|
||||
throw new Error('Genuine Guild fixture did not reach Shipment and Movement.');
|
||||
}
|
||||
function holdGuildFixtureFace(game: Game, holder: string, face: 'guild' | 'choam'): void {
|
||||
// Arrange only the conserved physical Nexus inventory, before any invoice.
|
||||
// Public held-card presence is identical across hidden-face fixture variants.
|
||||
let cards = game.nexusCards!.cards!;
|
||||
for (const p of game.players) {
|
||||
const held = cards.hands[p.id];
|
||||
if (held !== null) {cards.deck.push(held); cards.hands[p.id] = null;}
|
||||
}
|
||||
for (const p of game.players.filter(seat => !seat.ally)) {
|
||||
const desired = p.id === holder ? face : p.faction === 'guild' ? 'atreides' : 'fremen';
|
||||
let index = cards.deck.indexOf(desired);
|
||||
if (index < 0) {
|
||||
index = cards.discard.indexOf(desired);
|
||||
assert.ok(index >= 0, 'The actual singleton face is available.');
|
||||
cards.deck.unshift(cards.discard.splice(index, 1)[0]);
|
||||
} else cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
||||
cards = drawNexusCard(cards, p.id, game.players, () => 0);
|
||||
}
|
||||
game.nexusCards!.cards = cards;
|
||||
validateNexusCards(cards, game.players);
|
||||
}
|
||||
|
||||
function clearGuildFixtureHands(game: Game): void {
|
||||
for (const p of game.players) game.deck.push(...p.hand.splice(0));
|
||||
}
|
||||
|
||||
function reserveDeclaration(game: Game, shipper: string, amount: number, allyPayment = 0): Action {
|
||||
const view = viewGame(game, shipper);
|
||||
const payer = view.players.find(p => p.id === shipper)!;
|
||||
const candidates = TERRITORIES.filter(t => payer.faction === 'fremen' ?
|
||||
fremenReserveEntry(t.id) : t.type === 'stronghold');
|
||||
// Prefer a peaceful destination if this shipment prepares a later turn.
|
||||
candidates.sort((a, b) => Number(game.players.some(p => p.id !== shipper &&
|
||||
Object.keys(p.forces).some(key => splitLocation(key).territory === a.id && p.forces[key] > 0))) -
|
||||
Number(game.players.some(p => p.id !== shipper &&
|
||||
Object.keys(p.forces).some(key => splitLocation(key).territory === b.id && p.forces[key] > 0))));
|
||||
for (const t of candidates) for (const sector of t.sectors) {
|
||||
if (sector !== game.storm && botEntryAllowed(view, payer, t.id, sector, 'ship'))
|
||||
return {type: 'ship', territory: t.id, sector, amount, allyPayment};
|
||||
}
|
||||
throw new Error('No current storm-safe native reserve destination.');
|
||||
}
|
||||
|
||||
function fundGuildFixtureSpice(game: Game, id: string, minimum: number, excluded: string | null = null): void {
|
||||
const recipient = game.players.find(p => p.id === id)!;
|
||||
for (const giver of game.players.filter(p => p.id !== id && p.id !== excluded)) {
|
||||
const amount = Math.min(Math.max(0, minimum - recipient.spice), giver.spice);
|
||||
giver.spice -= amount;
|
||||
recipient.spice += amount;
|
||||
}
|
||||
assert.ok(recipient.spice >= minimum, 'Upfront spice is transferred, not minted.');
|
||||
}
|
||||
|
||||
function transportDeclaration(game: Game, shipper: string, amount: number, allyPayment: number): Action | null {
|
||||
const payer = game.players.find(p => p.id === shipper)!;
|
||||
const view = viewGame(game, shipper);
|
||||
for (const [key, count] of Object.entries(payer.forces)) {
|
||||
if (count < amount || splitLocation(key).sector === game.storm) continue;
|
||||
const forces = {[key]: amount};
|
||||
if (!game.homeworlds && payer.faction === 'guild') {
|
||||
const action: Action = {type: 'guildShip', forces, territory: 'reserves', allyPayment};
|
||||
if (!guildTransportQuote(view, action).unavailableReasons.length) return action;
|
||||
} else for (const t of TERRITORIES.filter(t => t.type !== 'stronghold')) {
|
||||
for (const sector of t.sectors) {
|
||||
const action: Action = {type: 'guildShip', forces, territory: t.id, sector, allyPayment};
|
||||
if (!guildTransportQuote(view, action).unavailableReasons.length) return action;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function prepareGuildFixtureTransport(state: Game, shipper: string, amount: number): Game {
|
||||
let game = state;
|
||||
for (let opportunity = 0; opportunity < 10; opportunity++) {
|
||||
while (game.active !== shipper) game = applyAction(game, game.active!, {type: 'endMovement'});
|
||||
// Native bidding on a later real turn may spend the earlier preparation
|
||||
// budget. Re-establish conserved upfront funds before quoting transport.
|
||||
fundGuildFixtureSpice(game, shipper, amount);
|
||||
if (transportDeclaration(game, shipper, amount, 0)) return game;
|
||||
// The actual storm may cover every initial group. Establish a real source
|
||||
// with an ordinary paid shipment, then advance the actual turn and storm.
|
||||
const turn = game.turn;
|
||||
game = settleGuildBetrayalFixture(applyAction(game, shipper,
|
||||
reserveDeclaration(game, shipper, amount)));
|
||||
while (game.phase === 5) game = applyAction(game, game.active!, {type: 'endMovement'});
|
||||
game = enterGuildFixtureMovement(game, turn + 1);
|
||||
clearGuildFixtureHands(game);
|
||||
}
|
||||
throw new Error('Native turn progression did not produce a legal Guild transport source.');
|
||||
}
|
||||
|
||||
/** Final identities precede actual setup. All source frames below are declared
|
||||
* through native actions; no fee, card, force, signed frame or escrow is minted. */
|
||||
export function createGuildBetrayalFixture(options: GuildBetrayalFixtureOptions = {}): GuildBetrayalFixture {
|
||||
const source = options.source ?? 'reserve';
|
||||
const ids = options.seatIds ?? options.initial?.players.map(p => p.id) ?? ['holder', 'guild', 'other', 'donor'];
|
||||
const guild = options.initial?.players.find(p => p.faction === 'guild')?.id ?? ids[1];
|
||||
const holder = options.initial?.players.find(p => p.id !== guild && (!options.zero || p.faction !== 'fremen'))?.id ??
|
||||
options.initial?.players.find(p => p.id !== guild)?.id ?? ids[0];
|
||||
const other = options.initial ? options.initial.players.find(p => options.zero ? p.faction === 'fremen' :
|
||||
p.id !== guild && p.id !== holder)?.id ?? holder : ids[2];
|
||||
assert.ok(holder && guild && other);
|
||||
const donor = (options.allyPayment ?? 0) > 0 ? options.initial?.players.find(p =>
|
||||
p.id !== holder && p.id !== guild && p.id !== other)?.id ?? ids[3] ?? 'donor' : null;
|
||||
const roster: [string, FactionId][] = options.initial ? options.initial.players.map(p => [p.id, p.faction]) :
|
||||
[[holder, 'atreides'], [guild, 'guild'], [other, options.zero ? 'fremen' : 'emperor'],
|
||||
...(donor ? [[donor, 'harkonnen'] as [string, FactionId]] : [])];
|
||||
let game: Game;
|
||||
if (options.initial) {
|
||||
game = structuredClone(options.initial);
|
||||
assert.equal(game.status, 'setup', 'Only the newly admitted setup can be prepared.');
|
||||
assert.equal(game.guildBetrayalPreview, true);
|
||||
assert.equal(game.guildBetrayal?.sequence, 0);
|
||||
assert.equal(game.pendingGuildBetrayal, null);
|
||||
if (options.seatIds) assert.deepEqual(game.players.map(p => p.id), options.seatIds);
|
||||
if (donor) assert.ok(game.players.some(p => p.id === donor), 'The admitted roster must contain the actual donor.');
|
||||
if (options.zero) assert.equal(game.players.find(p => p.id === other)!.faction, 'fremen');
|
||||
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
|
||||
if (options.homeworlds !== undefined) assert.equal(!!game.homeworlds, options.homeworlds);
|
||||
if (options.occupiedJunction || source === 'homeworld' || source === 'junction')
|
||||
assert.ok(game.homeworlds, 'This adapter needs genuinely seeded Homeworlds.');
|
||||
for (const p of game.players) viewGame(game, p.id);
|
||||
} else {
|
||||
game = createGame('GUILDBETRAYALAUDIT', newPlayer(holder, 'Atreides', 'atreides'),
|
||||
options.advanced ?? false, []);
|
||||
for (const [id, faction] of roster.slice(1)) joinGame(game, newPlayer(id, faction, faction));
|
||||
if (options.homeworlds || options.occupiedJunction || source === 'homeworld' || source === 'junction')
|
||||
game = applyAction(game, holder, {type: 'homeworlds', enabled: true});
|
||||
game.nexusCards = {cards: null, phase: null};
|
||||
for (const p of game.players) game = applyAction(game, p.id, {type: 'ready'});
|
||||
game = initializeGuildBetrayalGameForAudit(game);
|
||||
}
|
||||
game = enterGuildFixtureMovement(game);
|
||||
// Returning dealt physical hands to the same deck removes unrelated optional
|
||||
// card responses, without changing setup, physical identities or deck size.
|
||||
clearGuildFixtureHands(game);
|
||||
|
||||
if (options.occupiedJunction) {
|
||||
assert.notEqual(source, 'junction', 'Low occupied Junction cannot invent a high-population sponsorship.');
|
||||
// Native Guild empties Junction through a real reserve shipment, and the
|
||||
// holder physically enters through a real Homeworld shipment. No preplaced
|
||||
// invader or forged occupation record is used.
|
||||
const guildPlayer = game.players.find(p => p.id === guild)!;
|
||||
const giver = game.players.find(p => p.id === other)!;
|
||||
const needed = Math.max(0, Math.ceil(guildPlayer.reserves / 2) - guildPlayer.spice);
|
||||
assert.ok(giver.spice >= needed);
|
||||
giver.spice -= needed;
|
||||
guildPlayer.spice += needed;
|
||||
const turn = game.turn;
|
||||
while (game.phase === 5) {
|
||||
const actor = game.active!;
|
||||
if (actor === guild) {
|
||||
game = settleGuildBetrayalFixture(applyAction(game, actor,
|
||||
reserveDeclaration(game, actor, guildPlayer.reserves)));
|
||||
} else if (actor === holder) {
|
||||
const invader = game.players.find(p => p.id === actor)!;
|
||||
const choice = homeworldShipmentChoice(viewGame(game, actor), 'homeworld:guild',
|
||||
{[`homeworld:${invader.faction}`]: {normal: 1, elite: 0}});
|
||||
assert.ok(choice.action, choice.blocked ?? 'A genuine Guild invasion must be legal.');
|
||||
game = settleGuildBetrayalFixture(applyAction(game, actor, choice.action));
|
||||
}
|
||||
game = applyAction(game, actor, {type: 'endMovement'});
|
||||
}
|
||||
game = enterGuildFixtureMovement(game, turn + 1);
|
||||
clearGuildFixtureHands(game);
|
||||
assert.equal(game.players.find(p => p.id === guild)!.reserves, 0);
|
||||
assert.equal(game.homeworlds!.custody!.visitors['homeworld:guild'][holder].normal, 1);
|
||||
homeworldGameIntegrity(game);
|
||||
}
|
||||
|
||||
const shipper = source === 'guildTransport' ? options.payer === 'other' ? other : guild : options.zero ? other :
|
||||
options.payer === 'holder' ? holder : options.payer === 'guild' ? guild : other;
|
||||
const amount = options.occupiedJunction ? 3 : 2;
|
||||
if (source === 'guildTransport' && shipper !== guild) {
|
||||
assert.equal(donor, null, 'A native Guild ally uses the actual Guild grant, not a second alliance.');
|
||||
game.players.find(p => p.id === shipper)!.ally = guild;
|
||||
game.players.find(p => p.id === guild)!.ally = shipper;
|
||||
const cards = game.nexusCards!.cards!;
|
||||
for (const id of [shipper, guild]) {
|
||||
const face = cards.hands[id];
|
||||
if (face !== null) {cards.discard.push(face); cards.hands[id] = null;}
|
||||
}
|
||||
}
|
||||
for (const [id, minimum] of [[shipper, options.zero ? 0 : amount],
|
||||
...(donor ? [[donor, options.allyPayment ?? 0]] : [])] as [string, number][])
|
||||
fundGuildFixtureSpice(game, id, minimum, donor);
|
||||
if (source === 'guildTransport') game = prepareGuildFixtureTransport(game, shipper, amount);
|
||||
else while (game.active !== shipper) game = applyAction(game, game.active!, {type: 'endMovement'});
|
||||
if (donor) {
|
||||
assert.notEqual(shipper, holder, 'An allied own payer cannot hold a Nexus card.');
|
||||
// Arrange a conserved reciprocal alliance before creating any source frame.
|
||||
// The native pledge action performs the actual donor debit/escrow custody.
|
||||
const payer = game.players.find(p => p.id === shipper)!;
|
||||
const ally = game.players.find(p => p.id === donor)!;
|
||||
payer.ally = donor; ally.ally = shipper;
|
||||
const cards = game.nexusCards!.cards!;
|
||||
for (const id of [shipper, donor]) {
|
||||
const face = cards.hands[id];
|
||||
if (face !== null) {cards.discard.push(face); cards.hands[id] = null;}
|
||||
}
|
||||
game = applyAction(game, donor, {type: 'pledgeAid', amount: options.allyPayment});
|
||||
}
|
||||
holdGuildFixtureFace(game, holder, options.nexusFace ?? 'guild');
|
||||
if (source === 'junction') {
|
||||
const option = viewGame(game, guild).junctionTransport!;
|
||||
game = applyAction(game, guild, {type: 'offerJunctionTransport', event: option.offerEvent, rate: 'full'});
|
||||
}
|
||||
const payment = options.allyPayment ?? 0;
|
||||
const payer = game.players.find(p => p.id === shipper)!;
|
||||
let declaration: Action;
|
||||
if (source === 'reserve') declaration = reserveDeclaration(game, shipper, amount, payment);
|
||||
else if (source === 'guildTransport') {
|
||||
const action = transportDeclaration(game, shipper, amount, payment);
|
||||
assert.ok(action, 'The current native Guild quote must authorize the exact source and route.');
|
||||
declaration = action;
|
||||
} else if (source === 'homeworld') {
|
||||
const choice = homeworldShipmentChoice(viewGame(game, shipper),
|
||||
shipper === guild ? 'homeworld:atreides' : 'homeworld:guild',
|
||||
{[`homeworld:${payer.faction}`]: {normal: amount, elite: 0}}, payment);
|
||||
assert.ok(choice.action, choice.blocked ?? 'The current paid Homeworld route must be legal.');
|
||||
declaration = choice.action;
|
||||
} else {
|
||||
const view = viewGame(game, shipper);
|
||||
let action: Action | null = null;
|
||||
for (const t of TERRITORIES.filter(t => t.type === 'stronghold')) {
|
||||
for (const sector of t.sectors) {
|
||||
if (sector === game.storm) continue;
|
||||
const choice = junctionTransportChoice(view, `${t.id}:${sector}`,
|
||||
{[`homeworld:${payer.faction}`]: {normal: amount, elite: 0}}, payment);
|
||||
if (choice.action) {action = choice.action; break;}
|
||||
}
|
||||
if (action) break;
|
||||
}
|
||||
assert.ok(action, 'A current storm-safe Junction quote must authorize the route.');
|
||||
declaration = action;
|
||||
}
|
||||
const before = structuredClone(game);
|
||||
game = applyAction(game, shipper, declaration);
|
||||
for (let step = 0; !game.pendingGuildBetrayal && !game.players.find(p => p.id === shipper)!.shipped && step < 20; step++) {
|
||||
const decision = game.decision;
|
||||
assert.ok(decision && ['guildShipment', 'homeworldShipmentGuild'].includes(decision.kind), 'Native permission precedes payment.');
|
||||
game = applyAction(game, decision.player, {type: 'decision', allow: true,
|
||||
...(decision.kind === 'homeworldShipmentGuild' ? {event: decision.event} : {})});
|
||||
}
|
||||
const event = game.pendingGuildBetrayal?.invoice.event ?? '';
|
||||
const price = game.pendingGuildBetrayal?.invoice.price ?? 0;
|
||||
assert.equal(!!game.pendingGuildBetrayal, !options.zero);
|
||||
validateNexusCards(game.nexusCards!.cards!, game.players);
|
||||
if (game.homeworlds) homeworldGameIntegrity(game);
|
||||
const original = settleGuildBetrayalFixture(game);
|
||||
return {game, before, original, declaration, holder, guild, shipper, donor, event, price, source};
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { FACTIONS, type FactionId } from '../game/catalog';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
|
||||
import { NEXUS_CARD_REFERENCE, NEXUS_PANEL_NAMES, nexusCardReference } from '../game/nexus-card-reference';
|
||||
import { NEXUS_CARD_REFERENCE, NEXUS_PANEL_NAMES } from '../game/nexus-card-reference';
|
||||
|
||||
// Match Vinext's actual next/image alias in this isolated Node test process;
|
||||
// render the production image implementation, not a component mock.
|
||||
@@ -38,26 +38,6 @@ void test('the original inventory renders twelve identities, all36 readable pane
|
||||
for (const mode of ['betrayal', 'cunning', 'secretAlly'] as const)
|
||||
assert.equal(html.split(`aria-label="${NEXUS_PANEL_NAMES[mode]}"`).length - 1, 12);
|
||||
assert.equal((html.match(/aria-label="Inspect [^"]+ Nexus card"/g) ?? []).length, 12);
|
||||
for (const card of NEXUS_CARD_REFERENCE) {
|
||||
for (const mode of ['betrayal', 'cunning', 'secretAlly'] as const) {
|
||||
assert.ok(card[mode].length > 30);
|
||||
assert.ok(html.includes(card[mode]));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('reference preserves materially different physical effects and original numerical limits', () => {
|
||||
assert.match(nexusCardReference('guild').betrayal, /whole shipment payment.*own payment.*Junction/);
|
||||
assert.match(nexusCardReference('guild').cunning, /second shipment.*cannot move unless.*Hajr/);
|
||||
assert.match(nexusCardReference('richese').cunning, /two No-Field.*one force.*reveal one/);
|
||||
assert.match(nexusCardReference('tleilaxu').secretAlly, /leader.*free.*five.*one spice each/);
|
||||
assert.match(nexusCardReference('emperor').secretAlly, /three extra forces free beyond/);
|
||||
assert.match(nexusCardReference('ecaz').cunning, /capture.*Tanks.*Ghola.*Moritani.*end of the turn/);
|
||||
assert.match(nexusCardReference('moritani').betrayal, /Return.*without revealing/);
|
||||
assert.match(nexusCardReference('harkonnen').betrayal, /replacement during Mentat Pause/);
|
||||
assert.match(nexusCardReference('choam').secretAlly, /not used in that battle/);
|
||||
assert.match(nexusCardReference('ixians').betrayal, /cannot prevent both/);
|
||||
assert.doesNotMatch(JSON.stringify(NEXUS_CARD_REFERENCE), /INTERNAL AUDIT|ALTERNATE SUPPLY CHAIN|entire Battle Plan/);
|
||||
});
|
||||
|
||||
void test('draw, replacement and keep actions bind the exact offered turn and card without mutation', () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type * as Rooms from '../db/rooms';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { createNexusChoamKullFixture, createNexusKullNativeParent,
|
||||
createNexusKullNestedParent, createNexusKullAuctionParent } from './fixture-nexus-choam-kull';
|
||||
import { takeKullCard } from './fixture-choam-kull';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
const plain = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
@@ -624,9 +625,7 @@ void test('SQLite a new Nexus-profile printed counter without a selection is cor
|
||||
const f = await persisted(t);
|
||||
const { choam, actor, event } = f;
|
||||
const beforeDeclaration = structuredClone(f.initial);
|
||||
const cost = beforeDeclaration.deck.find(card => card.id === 'ix-kull-wahad')!;
|
||||
assert.ok(cost);
|
||||
beforeDeclaration.deck.splice(beforeDeclaration.deck.indexOf(cost), 1);
|
||||
const cost = takeKullCard(beforeDeclaration, 'ix-kull-wahad');
|
||||
player(beforeDeclaration, choam).hand.push(cost);
|
||||
f.save(beforeDeclaration);
|
||||
const declared = await act(f, beforeDeclaration, choam,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Children, createElement, isValidElement } from 'react';
|
||||
import type { ReactNode, ComponentProps } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import type { Action, Game, GameView } from '../game/engine';
|
||||
import { createGuildBetrayalFixture } from './fixture-nexus-guild-betrayal';
|
||||
import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
|
||||
|
||||
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(match => [match[1], match[1]]));
|
||||
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
||||
},
|
||||
});
|
||||
// These UI modules require the Node-only Next/CSS loading hooks above;
|
||||
// static imports would resolve them before the hooks are registered.
|
||||
const { NexusGuildBetrayal } = await import('../components/nexus-guild-betrayal');
|
||||
const { GameTable } = await import('../components/game-table');
|
||||
|
||||
function markup(game: GameView, busy = false): string {
|
||||
return renderToStaticMarkup(createElement(NexusGuildBetrayal, { game, act() {}, busy }));
|
||||
}
|
||||
|
||||
function click(node: ReactNode, prefix: string): boolean {
|
||||
for (const child of Children.toArray(node)) {
|
||||
if (!isValidElement<{ children?: ReactNode; onClick?: () => void }>(child)) continue;
|
||||
if (typeof child.props.children === 'string' && child.props.children.startsWith(prefix) && child.props.onClick) {
|
||||
child.props.onClick();
|
||||
return true;
|
||||
}
|
||||
if (click(child.props.children, prefix)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function select(game: GameView, prefix: string, busy = false): Action | undefined {
|
||||
let selected: Action | undefined;
|
||||
click(NexusGuildBetrayal({ game, busy, act(action) { selected = action; } }), prefix);
|
||||
return selected;
|
||||
}
|
||||
|
||||
function allPass(original: Game): Game {
|
||||
let game = structuredClone(original);
|
||||
for (const player of game.players) {
|
||||
const action = select(viewGame(game, player.id), 'Pass');
|
||||
if (action) game = applyAction(game, player.id, action);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('private eligibility grants Use and own Guild inspector without revealing a rival face or source', () => {
|
||||
const fixture = createGuildBetrayalFixture();
|
||||
const own = viewGame(fixture.game, fixture.holder);
|
||||
assert.match(markup(own), /<button[^>]*>Use Guild Nexus/);
|
||||
assert.match(markup(own), /Inspect Spacing Guild Nexus card/);
|
||||
const nonholder = createGuildBetrayalFixture({ nexusFace: 'choam' });
|
||||
const neutral = viewGame(nonholder.game, nonholder.holder);
|
||||
assert.ok(select(neutral, 'Pass'));
|
||||
assert.equal(select(neutral, 'Use Guild Nexus'), undefined);
|
||||
assert.doesNotMatch(markup(neutral), /Inspect Spacing Guild Nexus card/);
|
||||
const outsider = viewGame(fixture.game, fixture.guild);
|
||||
assert.equal(select(outsider, 'Pass'), undefined);
|
||||
assert.equal(select(outsider, 'Use Guild Nexus'), undefined);
|
||||
assert.doesNotMatch(markup(outsider), /Inspect Spacing Guild Nexus card/);
|
||||
// The response itself gets only the fixed private projection, not a quote or
|
||||
// an extra entitlement to a shipper's hand, balance, source or route.
|
||||
assert.deepEqual(Object.keys(own.guildBetrayalReaction!).sort(), ['blocked', 'canPass', 'canUse', 'event', 'hasPassed', 'shipper']);
|
||||
});
|
||||
|
||||
void test('actual event-only human Use replaces other and own funded reserve payments with identical force delivery', () => {
|
||||
for (const payer of ['other', 'holder'] as const) {
|
||||
const fixture = createGuildBetrayalFixture({ payer });
|
||||
const before = structuredClone(fixture.game);
|
||||
const action = select(viewGame(before, fixture.holder), 'Use Guild Nexus');
|
||||
assert.deepEqual(action, { type: 'guildBetrayalUse', event: fixture.event });
|
||||
assert.ok(action);
|
||||
const result = applyAction(before, fixture.holder, action);
|
||||
const passed = allPass(fixture.game);
|
||||
assert.equal(viewGame(result, fixture.holder).guildBetrayalReaction, null);
|
||||
const holderBefore = fixture.game.players.find(player => player.id === fixture.holder)!;
|
||||
const holderAfter = result.players.find(player => player.id === fixture.holder)!;
|
||||
const shipperBefore = fixture.game.players.find(player => player.id === fixture.shipper)!;
|
||||
const shipperAfter = result.players.find(player => player.id === fixture.shipper)!;
|
||||
assert.equal(holderAfter.spice, holderBefore.spice + (payer === 'holder' ? 0 : fixture.price));
|
||||
assert.equal(shipperAfter.spice, shipperBefore.spice - (payer === 'holder' ? 0 : fixture.price));
|
||||
assert.equal(result.players.find(player => player.id === fixture.guild)!.spice,
|
||||
fixture.game.players.find(player => player.id === fixture.guild)!.spice);
|
||||
assert.equal(shipperAfter.shipped, true);
|
||||
assert.equal(shipperAfter.reserves, shipperBefore.reserves - 2);
|
||||
assert.deepEqual(result.players.map(player => ({ id: player.id, reserves: player.reserves, forces: player.forces, elites: player.elites })),
|
||||
passed.players.map(player => ({ id: player.id, reserves: player.reserves, forces: player.forces, elites: player.elites })));
|
||||
assert.equal(result.nexusCards?.cards?.hands[fixture.holder], null);
|
||||
assert.ok(result.nexusCards?.cards?.discard.includes('guild'));
|
||||
assert.equal(passed.nexusCards?.cards?.hands[fixture.holder], 'guild');
|
||||
assert.equal(passed.players.find(player => player.id === fixture.guild)!.spice,
|
||||
fixture.game.players.find(player => player.id === fixture.guild)!.spice + fixture.price);
|
||||
}
|
||||
});
|
||||
|
||||
void test('busy, stopped, passed, ineligible and privately blocked responses cannot emit Use', () => {
|
||||
const fixture = createGuildBetrayalFixture();
|
||||
const view = viewGame(fixture.game, fixture.holder);
|
||||
assert.equal(select(view, 'Use Guild Nexus', true), undefined);
|
||||
assert.equal(select(view, 'Pass', true), undefined);
|
||||
view.guildBetrayalReaction!.blocked = 'An existing promise reserves this Nexus card.';
|
||||
assert.equal(select(view, 'Use Guild Nexus'), undefined);
|
||||
assert.deepEqual(select(view, 'Pass'), { type: 'guildBetrayalPass', event: fixture.event });
|
||||
assert.match(markup(view), /Inspect Spacing Guild Nexus card/);
|
||||
view.guildBetrayalReaction!.blocked = null;
|
||||
view.guildBetrayalReaction!.canUse = false;
|
||||
assert.equal(select(view, 'Use Guild Nexus'), undefined);
|
||||
view.guildBetrayalReaction!.canUse = true;
|
||||
view.guildBetrayalReaction!.hasPassed = true;
|
||||
assert.equal(select(view, 'Use Guild Nexus'), undefined);
|
||||
assert.equal(select(view, 'Pass'), undefined);
|
||||
view.guildBetrayalReaction!.hasPassed = false;
|
||||
view.guildBetrayalReaction!.canPass = false;
|
||||
assert.equal(select(view, 'Use Guild Nexus'), undefined);
|
||||
view.guildBetrayalReaction!.canPass = true;
|
||||
view.status = 'finished';
|
||||
assert.equal(select(view, 'Use Guild Nexus'), undefined);
|
||||
assert.equal(select(view, 'Pass'), undefined);
|
||||
view.guildBetrayalReaction = null;
|
||||
assert.equal(markup(view), '');
|
||||
});
|
||||
|
||||
void test('saved human actions cannot spend against future, foreign or completed shipment events', () => {
|
||||
const fixture = createGuildBetrayalFixture();
|
||||
const action = select(viewGame(fixture.game, fixture.holder), 'Use Guild Nexus');
|
||||
assert.ok(action);
|
||||
for (const event of ['foreign-shipment', `${fixture.event}:future`]) {
|
||||
const game = structuredClone(fixture.game);
|
||||
const before = JSON.stringify(game);
|
||||
assert.throws(() => applyAction(game, fixture.holder, { ...action, event }));
|
||||
assert.equal(JSON.stringify(game), before);
|
||||
}
|
||||
const settled = applyAction(structuredClone(fixture.game), fixture.holder, action);
|
||||
const before = JSON.stringify(settled);
|
||||
assert.throws(() => applyAction(settled, fixture.holder, action));
|
||||
assert.equal(JSON.stringify(settled), before);
|
||||
});
|
||||
|
||||
void test('actual GameTable reserves underlying shipment, voluntary and hand controls and respects room ownership', () => {
|
||||
const fixture = createGuildBetrayalFixture({ payer: 'holder' });
|
||||
const own = viewGame(fixture.game, fixture.holder);
|
||||
const table = (game: ComponentProps<typeof GameTable>['game'], busy = false) => renderToStaticMarkup(createElement(GameTable, {
|
||||
game, send: async () => {}, busy, onExit() {},
|
||||
}));
|
||||
const html = table(own);
|
||||
assert.match(html, /aria-label="Guild Nexus Betrayal response"/);
|
||||
assert.match(html, /Guild Nexus Betrayal development preview/);
|
||||
assert.match(html, /href="\/rules\?topic=nexus-guild-betrayal#nexus-guild-betrayal"/);
|
||||
assert.doesNotMatch(html, /<button[^>]*>[^<]*(?:Ship |Confirm shipment|End movement|Play Karama|Make a bribe|Pledge|Search)/i);
|
||||
assert.match(table(own, true), /disabled=""[^>]*>Use Guild Nexus/);
|
||||
for (const restriction of ['paused', 'closed', 'autopilot'] as const) {
|
||||
const restricted: ComponentProps<typeof GameTable>['game'] = structuredClone(own);
|
||||
if (restriction === 'autopilot') restricted.players.find(player => player.id === own.me)!.autopilot = 'Easy';
|
||||
else restricted.roomControl = { ...DEFAULT_ROOM_CONTROL, [restriction]: true };
|
||||
assert.match(table(restricted), /disabled=""[^>]*>Use Guild Nexus/);
|
||||
assert.match(table(restricted), /disabled=""[^>]*>Pass/);
|
||||
}
|
||||
const continued = viewGame(allPass(fixture.game), fixture.holder);
|
||||
assert.doesNotMatch(table(continued), /aria-label="Guild Nexus Betrayal response"/);
|
||||
assert.equal(continued.players.find(player => player.id === own.me)!.shipped, true);
|
||||
});
|
||||
|
||||
aliases.deregister();
|
||||
@@ -0,0 +1,368 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {applyAction, initializeGuildBetrayalGameForAudit, normalizeAutomaticGame, viewGame,
|
||||
type Action, type Game} from '../game/engine';
|
||||
import {validateNexusCards} from '../game/nexus-cards';
|
||||
import {homeworldGameIntegrity} from '../game/homeworld-game';
|
||||
import {signGuildBetrayalInvoice} from '../game/nexus-guild-betrayal';
|
||||
import {createGuildBetrayalFixture, settleGuildBetrayalFixture,
|
||||
type GuildBetrayalFixture, type GuildBetrayalFixtureOptions} from './fixture-nexus-guild-betrayal';
|
||||
|
||||
function physicalShipment(game: Game) {
|
||||
return {custody: game.homeworlds?.custody,
|
||||
forces: game.players.map(p => ({id: p.id, forces: p.forces, reserves: p.reserves,
|
||||
tanks: p.tanks, elites: p.elites, advisors: p.advisors, shipped: p.shipped, moved: p.moved})),
|
||||
active: game.active, remaining: game.movementRemaining};
|
||||
}
|
||||
function rejects(game: Game, actor: string, action: Action) {
|
||||
const saved = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, saved, 'Rejected actions cannot alter the saved funded declaration.');
|
||||
}
|
||||
function take(fixture: GuildBetrayalFixture): Game {
|
||||
return applyAction(JSON.parse(JSON.stringify(fixture.game)) as Game, fixture.holder,
|
||||
{type: 'guildBetrayalUse', event: fixture.event});
|
||||
}
|
||||
function proveFullPayment(fixture: GuildBetrayalFixture): void {
|
||||
const done = take(fixture);
|
||||
assert.deepEqual(physicalShipment(done), physicalShipment(fixture.original), 'The same native source/delivery suffix runs exactly once.');
|
||||
assert.equal(done.pendingGuildBetrayal, null);
|
||||
const invoice = fixture.game.pendingGuildBetrayal!.invoice;
|
||||
for (const p of done.players) {
|
||||
const prior = fixture.before.players.find(seat => seat.id === p.id)!;
|
||||
assert.equal(p.spice, prior.spice - (p.id === fixture.shipper ? invoice.ownPayment : 0) +
|
||||
(p.id === fixture.holder ? fixture.price : 0), `Full fee has exactly one receiver: ${p.id}`);
|
||||
}
|
||||
if (fixture.donor) assert.equal(done.aid[fixture.donor].amount,
|
||||
fixture.before.aid[fixture.donor].amount - invoice.allyPayment);
|
||||
validateNexusCards(done.nexusCards!.cards!, done.players);
|
||||
assert.equal(done.nexusCards!.cards!.hands[fixture.holder], null);
|
||||
assert.equal(done.nexusCards!.cards!.discard.at(-1), 'guild');
|
||||
assert.equal(done.guildBetrayal!.status, 'completed');
|
||||
const receipt = done.nexusGuildBetrayalHistory!.at(-1)!;
|
||||
assert.equal(receipt.invoice.price, fixture.price);
|
||||
assert.equal(receipt.recipient, fixture.holder);
|
||||
assert.equal(receipt.mode, 'betrayal');
|
||||
assert.equal(done.guildBetrayalSourceReceipts!.at(-1)!.receipt, receipt.sourceReceipt);
|
||||
if (done.homeworlds) homeworldGameIntegrity(done);
|
||||
rejects(done, fixture.holder, {type: 'guildBetrayalUse', event: fixture.event});
|
||||
rejects(done, fixture.shipper, fixture.declaration);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
for (const source of ['reserve', 'guildTransport', 'homeworld', 'junction'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${source}: full original funded fee replaces native bank/Guild income and preserves native custody`, () => {
|
||||
proveFullPayment(createGuildBetrayalFixture({advanced, source}));
|
||||
});
|
||||
}
|
||||
for (const source of ['reserve', 'homeworld', 'junction'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${source}: own payment is funded upfront and then fully refunded`, () => {
|
||||
const fixture = createGuildBetrayalFixture({advanced, source, payer: 'holder'});
|
||||
proveFullPayment(fixture);
|
||||
const prior = fixture.before.players.find(p => p.id === fixture.holder)!.spice;
|
||||
assert.equal(take(fixture).players.find(p => p.id === fixture.holder)!.spice, prior);
|
||||
});
|
||||
}
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} all-pass preserves the actual default payment and keeps every Nexus card`, () => {
|
||||
const fixture = createGuildBetrayalFixture({advanced, source: 'reserve'});
|
||||
let done = fixture.game;
|
||||
const hands = structuredClone(done.nexusCards!.cards!.hands);
|
||||
for (const id of fixture.game.pendingGuildBetrayal!.required)
|
||||
done = applyAction(done, id, {type: 'guildBetrayalPass', event: fixture.event});
|
||||
done = settleGuildBetrayalFixture(done);
|
||||
assert.deepEqual(physicalShipment(done), physicalShipment(fixture.original));
|
||||
assert.deepEqual(done.players.map(p => p.spice), fixture.original.players.map(p => p.spice));
|
||||
assert.deepEqual(done.nexusCards!.cards!.hands, hands);
|
||||
assert.equal(done.nexusGuildBetrayalHistory!.at(-1)!.recipient, null);
|
||||
assert.equal(done.players.find(p => p.id === fixture.guild)!.spice,
|
||||
fixture.before.players.find(p => p.id === fixture.guild)!.spice + fixture.price);
|
||||
});
|
||||
}
|
||||
|
||||
for (const options of [
|
||||
{source: 'reserve', allyPayment: 1},
|
||||
{source: 'homeworld', allyPayment: 1},
|
||||
{source: 'junction', allyPayment: 1},
|
||||
{source: 'guildTransport', allyPayment: 1},
|
||||
{source: 'reserve', homeworlds: true, occupiedJunction: true},
|
||||
{source: 'reserve', homeworlds: true, occupiedJunction: true, allyPayment: 1},
|
||||
{source: 'guildTransport', homeworlds: true},
|
||||
] satisfies GuildBetrayalFixtureOptions[]) {
|
||||
void test(`Funded native ${options.source} preserves ally escrow/low-Junction full fee ${JSON.stringify(options)}`, () => {
|
||||
const fixture = createGuildBetrayalFixture(options);
|
||||
proveFullPayment(fixture);
|
||||
if ('occupiedJunction' in options) {
|
||||
const oldGuild = fixture.before.players.find(p => p.id === fixture.guild)!.spice;
|
||||
assert.equal(fixture.original.players.find(p => p.id === fixture.guild)!.spice - oldGuild,
|
||||
Math.ceil(fixture.price / 2));
|
||||
assert.equal(fixture.game.homeworlds!.custody!.visitors['homeworld:guild'][fixture.holder].normal, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('Own refund cannot supply missing upfront spice; zero Fremen reinforcement has no payment gate or Nexus cost', () => {
|
||||
const own = createGuildBetrayalFixture({payer: 'holder'});
|
||||
const unfunded = structuredClone(own.before);
|
||||
unfunded.players.find(p => p.id === own.holder)!.spice = 0;
|
||||
rejects(unfunded, own.holder, own.declaration);
|
||||
assert.equal(unfunded.nexusCards!.cards!.hands[own.holder], 'guild');
|
||||
for (const advanced of [false, true]) {
|
||||
const free = createGuildBetrayalFixture({advanced, zero: true});
|
||||
assert.equal(free.price, 0);
|
||||
assert.equal(free.game.pendingGuildBetrayal, null);
|
||||
assert.equal(free.game.nexusCards!.cards!.hands[free.holder], 'guild');
|
||||
assert.equal(free.game.players.find(p => p.id === free.shipper)!.spice,
|
||||
free.before.players.find(p => p.id === free.shipper)!.spice);
|
||||
const destination = `${String(free.declaration.territory)}:${Number(free.declaration.sector)}`;
|
||||
assert.notEqual(Number(free.declaration.sector), free.before.storm);
|
||||
assert.equal(free.game.players.find(p => p.id === free.shipper)!.forces[destination],
|
||||
(free.before.players.find(p => p.id === free.shipper)!.forces[destination] ?? 0) + 2);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Advanced Guild special Karama stops before any positive invoice, refund or Nexus cost', () => {
|
||||
const fixture = createGuildBetrayalFixture({advanced: true});
|
||||
let game = structuredClone(fixture.before);
|
||||
const index = game.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(index >= 0);
|
||||
const card = game.deck.splice(index, 1)[0];
|
||||
game.players.find(p => p.id === fixture.guild)!.hand.push(card);
|
||||
const originalSpice = game.players.map(p => p.spice);
|
||||
const originalForces = game.players.map(p => ({forces: p.forces, reserves: p.reserves}));
|
||||
game = applyAction(game, fixture.shipper, fixture.declaration);
|
||||
assert.equal(game.decision?.kind, 'guildShipment');
|
||||
game = applyAction(game, fixture.guild, {type: 'card', mode: 'special', card: card.id});
|
||||
game = settleGuildBetrayalFixture(game);
|
||||
assert.equal(game.pendingGuildBetrayal, null);
|
||||
assert.equal(game.nexusCards!.cards!.hands[fixture.holder], 'guild');
|
||||
assert.deepEqual(game.players.map(p => p.spice), originalSpice);
|
||||
assert.deepEqual(game.players.map(p => ({forces: p.forces, reserves: p.reserves})), originalForces);
|
||||
});
|
||||
|
||||
void test('A purchased native Karama rate binds its actual bank-funded fee, not the ordinary tariff', () => {
|
||||
const fixture = createGuildBetrayalFixture({payer: 'other'});
|
||||
let game = structuredClone(fixture.before);
|
||||
const index = game.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(index >= 0);
|
||||
const card = game.deck.splice(index, 1)[0];
|
||||
game.players.find(p => p.id === fixture.shipper)!.hand.push(card);
|
||||
game = applyAction(game, fixture.shipper, {type: 'card', mode: 'shipment', card: card.id, target: fixture.shipper});
|
||||
game = settleGuildBetrayalFixture(game);
|
||||
const before = structuredClone(game);
|
||||
game = applyAction(game, fixture.shipper, {...fixture.declaration, amount: 3});
|
||||
game = applyAction(game, fixture.holder,
|
||||
{type: 'guildBetrayalUse', event: game.pendingGuildBetrayal!.invoice.event});
|
||||
assert.equal(game.players.find(p => p.id === fixture.shipper)!.spice,
|
||||
before.players.find(p => p.id === fixture.shipper)!.spice - 2);
|
||||
assert.equal(game.players.find(p => p.id === fixture.holder)!.spice,
|
||||
before.players.find(p => p.id === fixture.holder)!.spice + 2);
|
||||
assert.equal(game.players.find(p => p.id === fixture.guild)!.spice,
|
||||
before.players.find(p => p.id === fixture.guild)!.spice);
|
||||
assert.equal(game.karamaShipping, null);
|
||||
});
|
||||
|
||||
void test('The fixed neutral projection exposes only event/shipper and private legal choices; pending payment preempts every underlying action', () => {
|
||||
const fixture = createGuildBetrayalFixture({payer: 'holder'});
|
||||
const otherFace = structuredClone(fixture.before);
|
||||
const cards = otherFace.nexusCards!.cards!;
|
||||
const index = cards.deck.indexOf('choam');
|
||||
assert.ok(index >= 0);
|
||||
cards.deck[index] = 'guild'; cards.hands[fixture.holder] = 'choam';
|
||||
const hidden = applyAction(otherFace, fixture.shipper, fixture.declaration);
|
||||
for (const p of fixture.game.players) {
|
||||
const left = viewGame(fixture.game, p.id).guildBetrayalReaction!;
|
||||
const right = viewGame(hidden, p.id).guildBetrayalReaction!;
|
||||
assert.deepEqual(Object.keys(left).sort(), ['blocked', 'canPass', 'canUse', 'event', 'hasPassed', 'shipper']);
|
||||
assert.deepEqual({event: left.event, shipper: left.shipper, canPass: left.canPass, hasPassed: left.hasPassed},
|
||||
{event: right.event, shipper: right.shipper, canPass: right.canPass, hasPassed: right.hasPassed});
|
||||
if (p.id !== fixture.holder) assert.deepEqual(left, right);
|
||||
}
|
||||
assert.equal(viewGame(fixture.game, fixture.holder).guildBetrayalReaction!.canUse, true);
|
||||
assert.equal(viewGame(hidden, fixture.holder).guildBetrayalReaction!.canUse, false);
|
||||
for (const action of [fixture.declaration, {type: 'endMovement'}, {type: 'ready'},
|
||||
{type: 'pledgeAid', amount: 0}, {type: 'nexusGuildCunning'}, {type: 'guildBetrayalUse', event: 'old'},
|
||||
{type: 'guildBetrayalUse', event: fixture.event, recipient: fixture.holder},
|
||||
{type: 'guildBetrayalUse', event: fixture.event, amount: fixture.price}])
|
||||
rejects(fixture.game, fixture.holder, action);
|
||||
const passed = applyAction(fixture.game, fixture.holder, {type: 'guildBetrayalPass', event: fixture.event});
|
||||
assert.equal(viewGame(passed, fixture.holder).guildBetrayalReaction!.hasPassed, true);
|
||||
rejects(passed, fixture.holder, {type: 'guildBetrayalUse', event: fixture.event});
|
||||
assert.deepEqual(normalizeAutomaticGame(passed), passed);
|
||||
});
|
||||
|
||||
void test('Malformed, stale, orphaned and suspended native parents reject before apply, projection or automatic normalization', () => {
|
||||
const fixture = createGuildBetrayalFixture();
|
||||
const mutations: ((game: Game) => void)[] = [
|
||||
game => {delete game.pendingGuildBetrayal;},
|
||||
game => {delete game.guildBetrayal;},
|
||||
game => {game.guildBetrayal!.event = 'foreign';},
|
||||
game => {game.pendingGuildBetrayal!.invoice.price += 1;},
|
||||
game => {game.pendingGuildBetrayal!.invoice.ownPayment = 0;},
|
||||
game => {game.pendingGuildBetrayal!.passed.push(fixture.holder);},
|
||||
game => {game.pendingGuildBetrayal!.required = [fixture.holder];},
|
||||
game => {game.pendingGuildBetrayal!.signature = 'forged';},
|
||||
game => {game.players.find(p => p.id === fixture.shipper)!.spice = 0;},
|
||||
game => {game.players.find(p => p.id === fixture.shipper)!.reserves -= 1;},
|
||||
game => {game.active = fixture.guild;},
|
||||
game => {game.turn += 1;},
|
||||
game => {game.decision = {kind: 'guildShipment', player: fixture.guild,
|
||||
shipper: fixture.shipper, territory: 'arrakeen', sector: 10, amount: 2};},
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const game = structuredClone(fixture.game);
|
||||
mutate(game);
|
||||
const saved = structuredClone(game);
|
||||
assert.throws(() => viewGame(game, fixture.holder));
|
||||
assert.throws(() => normalizeAutomaticGame(game));
|
||||
rejects(game, fixture.holder, {type: 'guildBetrayalUse', event: fixture.event});
|
||||
assert.deepEqual(game, saved);
|
||||
}
|
||||
const completed = take(fixture);
|
||||
for (const mutate of [
|
||||
(game: Game) => {game.nexusGuildBetrayalHistory!.pop();},
|
||||
(game: Game) => {game.guildBetrayalSourceReceipts!.pop();},
|
||||
(game: Game) => {game.nexusGuildBetrayalHistory![0].signature = 'forged';},
|
||||
(game: Game) => {game.players.find(p => p.id === fixture.shipper)!.shipped = false;},
|
||||
]) {
|
||||
const game = structuredClone(completed); mutate(game);
|
||||
assert.throws(() => viewGame(game, fixture.holder));
|
||||
assert.throws(() => normalizeAutomaticGame(game));
|
||||
}
|
||||
assert.throws(() => initializeGuildBetrayalGameForAudit(fixture.game), /fresh/);
|
||||
});
|
||||
|
||||
void test('No publicly possible holder auto-settles the native fee without a fabricated Nexus payment window', () => {
|
||||
const fixture = createGuildBetrayalFixture();
|
||||
const before = structuredClone(fixture.before);
|
||||
const cards = before.nexusCards!.cards!;
|
||||
for (const p of before.players) {
|
||||
const face = cards.hands[p.id];
|
||||
if (face !== null) {cards.deck.push(face); cards.hands[p.id] = null;}
|
||||
}
|
||||
const done = settleGuildBetrayalFixture(applyAction(before, fixture.shipper, fixture.declaration));
|
||||
assert.equal(done.pendingGuildBetrayal, null);
|
||||
assert.equal(done.players.find(p => p.id === fixture.guild)!.spice,
|
||||
before.players.find(p => p.id === fixture.guild)!.spice + fixture.price);
|
||||
assert.equal(done.players.find(p => p.id === fixture.holder)!.spice,
|
||||
before.players.find(p => p.id === fixture.holder)!.spice);
|
||||
assert.deepEqual(physicalShipment(done), physicalShipment(fixture.original));
|
||||
validateNexusCards(done.nexusCards!.cards!, done.players);
|
||||
});
|
||||
|
||||
void test('Unsupported Cunning producer is visibly blocked before its singleton is spent in the bounded payment profile', () => {
|
||||
const fixture = createGuildBetrayalFixture({payer: 'guild'});
|
||||
const before = structuredClone(fixture.before);
|
||||
const cards = before.nexusCards!.cards!;
|
||||
cards.hands[fixture.holder] = cards.hands[fixture.guild];
|
||||
cards.hands[fixture.guild] = 'guild';
|
||||
const offer = viewGame(before, fixture.guild).nexusGuildCunning!.offer!;
|
||||
assert.match(offer.blocked!, /second-shipment overlay/);
|
||||
rejects(before, fixture.guild, {type: 'endMovement', nexus: offer.event});
|
||||
assert.equal(before.nexusCards!.cards!.hands[fixture.guild], 'guild');
|
||||
assert.equal(before.nexusGuildCunningHistory, undefined);
|
||||
});
|
||||
|
||||
function outsidePaymentProfile(game: Game): Game {
|
||||
const native = structuredClone(game);
|
||||
delete native.guildBetrayalPreview;
|
||||
delete native.guildBetrayal;
|
||||
delete native.pendingGuildBetrayal;
|
||||
delete native.nexusGuildBetrayalHistory;
|
||||
delete native.guildBetrayalSourceReceipts;
|
||||
return native;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Richese overlay rejects before spend while ordinary and outside-profile shipments finish`, () => {
|
||||
const fixture = createGuildBetrayalFixture({advanced, payer: 'holder'});
|
||||
const before = structuredClone(fixture.before);
|
||||
const cards = before.nexusCards!.cards!;
|
||||
const index = cards.deck.indexOf('richese');
|
||||
assert.ok(index >= 0);
|
||||
cards.deck[index] = cards.hands[fixture.holder]!;
|
||||
cards.hands[fixture.holder] = 'richese';
|
||||
validateNexusCards(cards, before.players);
|
||||
const offer = viewGame(before, fixture.holder).nexusRichese!;
|
||||
assert.match(offer.blocked!, /Richese Secret Ally shipment overlay/);
|
||||
const action: Action = {...fixture.declaration, amount: 3, nexus: offer.event};
|
||||
const saved = structuredClone(before);
|
||||
assert.throws(() => applyAction(before, fixture.holder, action), /Richese Secret Ally shipment overlay/);
|
||||
assert.deepEqual(before, saved, 'No singleton spend, parent, history, fee or forces change on rejection.');
|
||||
assert.equal(before.nexusRicheseHistory, undefined);
|
||||
|
||||
const ordinary = settleGuildBetrayalFixture(applyAction(before, fixture.holder,
|
||||
{...fixture.declaration, amount: 3}));
|
||||
const prior = before.players.find(p => p.id === fixture.holder)!;
|
||||
const shipped = ordinary.players.find(p => p.id === fixture.holder)!;
|
||||
assert.equal(shipped.reserves, prior.reserves - 3);
|
||||
assert.equal(shipped.spice, prior.spice - 3);
|
||||
assert.equal(shipped.shipped, true);
|
||||
assert.equal(ordinary.nexusCards!.cards!.hands[fixture.holder], 'richese');
|
||||
|
||||
const native = outsidePaymentProfile(before);
|
||||
const nativeOffer = viewGame(native, fixture.holder).nexusRichese!;
|
||||
assert.equal(nativeOffer.blocked, null);
|
||||
const done = settleGuildBetrayalFixture(applyAction(native, fixture.holder,
|
||||
{...action, nexus: nativeOffer.event}));
|
||||
const recipient = done.players.find(p => p.id === fixture.holder)!;
|
||||
assert.equal(recipient.reserves, prior.reserves - 3);
|
||||
assert.equal(recipient.spice, prior.spice - 1);
|
||||
assert.equal(recipient.shipped, true);
|
||||
assert.equal(done.nexusCards!.cards!.hands[fixture.holder], null);
|
||||
assert.equal(done.nexusRicheseHistory!.at(-1)!.stage, 'shipped');
|
||||
assert.equal(done.nexusCards!.cards!.discard.filter(face => face === 'richese').length, 1);
|
||||
});
|
||||
}
|
||||
|
||||
void test('Saved native cross permission is independently required even for a self-consistent paid frame or purchased Karama rate', () => {
|
||||
for (const purchasedRate of [false, true]) {
|
||||
const fixture = createGuildBetrayalFixture({source: 'guildTransport', payer: 'other'});
|
||||
let game = structuredClone(fixture.game);
|
||||
if (purchasedRate) {
|
||||
game = structuredClone(fixture.before);
|
||||
const index = game.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(index >= 0);
|
||||
const card = game.deck.splice(index, 1)[0];
|
||||
game.players.find(p => p.id === fixture.shipper)!.hand.push(card);
|
||||
game = settleGuildBetrayalFixture(applyAction(game, fixture.shipper,
|
||||
{type: 'card', mode: 'shipment', card: card.id, target: fixture.shipper}));
|
||||
game = applyAction(game, fixture.shipper, fixture.declaration);
|
||||
}
|
||||
// Re-sign only the changed saved alliance facts, not a copied native quote.
|
||||
// All counters, source, destination, tariff and full funding remain genuine.
|
||||
game.players.find(p => p.id === fixture.shipper)!.ally = null;
|
||||
game.players.find(p => p.id === fixture.guild)!.ally = null;
|
||||
const frame = game.pendingGuildBetrayal!;
|
||||
const proof = JSON.parse(frame.invoice.sourceSignature) as {players: {id: string; ally: string | null}[]};
|
||||
for (const seat of proof.players) seat.ally = game.players.find(p => p.id === seat.id)!.ally;
|
||||
frame.invoice.sourceSignature = JSON.stringify(proof);
|
||||
frame.invoice.originalReceiver = purchasedRate ? null : fixture.guild;
|
||||
frame.invoice.signature = signGuildBetrayalInvoice(frame.invoice);
|
||||
frame.signature = JSON.stringify([frame.invoice.signature, frame.invoice.sourceSignature, frame.required, frame.passed]);
|
||||
game.guildBetrayal!.sourceSignature = frame.invoice.sourceSignature;
|
||||
const saved = structuredClone(game);
|
||||
for (const operation of [
|
||||
() => viewGame(game, fixture.holder),
|
||||
() => normalizeAutomaticGame(game),
|
||||
() => applyAction(game, fixture.holder, {type: 'guildBetrayalUse', event: frame.invoice.event}),
|
||||
]) {
|
||||
assert.throws(operation, /Native Guild transport requires the Guild or its reciprocal ally/);
|
||||
assert.deepEqual(game, saved, 'A saved paid rate cannot self-authorize native cross transport or move resources.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('Native Guild and reciprocal Guild ally cross shipments remain legal outside the bounded profile', () => {
|
||||
for (const payer of ['guild', 'other'] as const) {
|
||||
const fixture = createGuildBetrayalFixture({source: 'guildTransport', payer});
|
||||
proveFullPayment(fixture);
|
||||
const before = outsidePaymentProfile(fixture.before);
|
||||
const done = settleGuildBetrayalFixture(applyAction(before, fixture.shipper, fixture.declaration));
|
||||
const prior = before.players.find(p => p.id === fixture.shipper)!;
|
||||
const shipper = done.players.find(p => p.id === fixture.shipper)!;
|
||||
assert.equal(shipper.shipped, true);
|
||||
assert.deepEqual(physicalShipment(done), physicalShipment(fixture.original));
|
||||
assert.equal(shipper.spice, prior.spice - fixture.price);
|
||||
assert.equal(done.nexusCards!.cards!.hands[fixture.holder], 'guild');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,715 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { DatabaseSync, SQLOutputValue } from 'node:sqlite';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game, type GameView } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { NEXUS_FACTIONS, replaceNexusCard, drawNexusCard, validateNexusCards } from '../game/nexus-cards';
|
||||
import { guildBetrayalResponders, signGuildBetrayalInvoice } from '../game/nexus-guild-betrayal';
|
||||
import { guildShipmentIncome } from '../game/shipment-price';
|
||||
import type { RoomsClock, SeatAuth } from '../db/rooms';
|
||||
import type * as Rooms from '../db/rooms';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { createGuildBetrayalFixture, type GuildBetrayalFixture, type GuildBetrayalFixtureOptions } from './fixture-nexus-guild-betrayal';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
const plain = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
type SqlRow = Record<string, SQLOutputValue>;
|
||||
const use = (event: string): Action => ({ type: 'guildBetrayalUse', event });
|
||||
const pass = (event: string): Action => ({ type: 'guildBetrayalPass', event });
|
||||
const forged = (payload: object): Action => payload as Action;
|
||||
const sum = (counts: Record<string, number>) => Object.values(counts).reduce((a, b) => a + b, 0);
|
||||
function player(g: Game, id: string) {
|
||||
const found = g.players.find(p => p.id === id);
|
||||
assert.ok(found, `missing authenticated seat ${id}`);
|
||||
return found;
|
||||
}
|
||||
function credentials(sqlite: DatabaseSync) {
|
||||
return {
|
||||
seats: sqlite.prepare('SELECT * FROM seats ORDER BY room_code,player_id,token_hash').all(),
|
||||
entries: sqlite.prepare('SELECT * FROM room_entry_receipts ORDER BY room_code,player_id,operation_hash').all(),
|
||||
keys: sqlite.prepare('SELECT * FROM seat_recovery_keys ORDER BY room_code,player_id').all(),
|
||||
recoveries: sqlite.prepare('SELECT * FROM seat_recovery_receipts ORDER BY room_code,player_id,operation_hash').all(),
|
||||
};
|
||||
}
|
||||
function physical(g: Game) {
|
||||
const ids = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand),
|
||||
...(g.auction?.cards.slice(g.auction.index) ?? [])].map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'every physical Treachery card has one custodian');
|
||||
return ids;
|
||||
}
|
||||
function forceInventory(g: Game) {
|
||||
homeworldGameIntegrity(g);
|
||||
const homes = g.homeworlds?.custody ? homeworldForceGroups(homeworldContext(g), g.homeworlds.custody) : [];
|
||||
return g.players.map(p => {
|
||||
const visitors = homes.filter(home => home.native !== p.id)
|
||||
.map(home => home.forces[p.id] ?? { normal: 0, elite: 0 });
|
||||
return {
|
||||
id: p.id,
|
||||
total: p.reserves + p.tanks + sum(p.forces) + visitors.reduce((n, pool) => n + pool.normal + pool.elite, 0),
|
||||
elite: p.elites ? p.elites.reserves + p.elites.tanks + sum(p.elites.forces) +
|
||||
visitors.reduce((n, pool) => n + pool.elite, 0) : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
function delivery(g: Game) {
|
||||
return {
|
||||
players: g.players.map(p => ({ id: p.id, reserves: p.reserves, tanks: p.tanks,
|
||||
forces: p.forces, elites: p.elites ?? null, advisors: p.advisors ?? null,
|
||||
shipped: p.shipped, moved: p.moved })),
|
||||
custody: g.homeworlds?.custody ?? null,
|
||||
};
|
||||
}
|
||||
interface Fixture extends GuildBetrayalFixture {
|
||||
sqlite: DatabaseSync;
|
||||
restart(): typeof Rooms;
|
||||
hooks: { beforeWrite?: () => Promise<void> };
|
||||
writes: { expected: number; changes: number }[];
|
||||
code: string;
|
||||
initial: Game;
|
||||
tokens: string[];
|
||||
auths: SeatAuth[];
|
||||
stock: string[];
|
||||
forceStock: { id: string; total: number; elite: number }[];
|
||||
credentialRows: { seats: SqlRow[]; entries: SqlRow[]; keys: SqlRow[]; recoveries: SqlRow[] };
|
||||
unrelatedCode: string;
|
||||
unrelatedToken: string;
|
||||
unrelatedRow: SqlRow | undefined;
|
||||
save(this: void, g: Game): void;
|
||||
}
|
||||
async function fixture(t: test.TestContext, options: GuildBetrayalFixtureOptions = {}): Promise<Fixture> {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const made = await store.rooms.createRoom('Guild Betrayal SQL', 'atreides', options.advanced ?? false, []);
|
||||
const code = made.view.code;
|
||||
const tokens = [made.token];
|
||||
const factions: FactionId[] = ['guild', options.zero ? 'fremen' : 'emperor', ...(options.allyPayment ? ['harkonnen' as const] : [])];
|
||||
for (const faction of factions)
|
||||
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
||||
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
|
||||
const madeGame = createGuildBetrayalFixture({ ...options, seatIds: auths.map(auth => auth.playerId) });
|
||||
const initial = madeGame.game;
|
||||
initial.code = code;
|
||||
initial.host = auths[0].playerId;
|
||||
initial.version = (await store.rooms.readRoom(code)).version;
|
||||
const save = (g: Game) => {
|
||||
assert.equal(store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(g), g.version, code).changes, 1);
|
||||
};
|
||||
save(initial);
|
||||
const unrelated = await store.rooms.createRoom('Unrelated preserved room', 'harkonnen', false, []);
|
||||
const unrelatedRow = store.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(unrelated.view.code);
|
||||
const credentialRows = credentials(store.sqlite);
|
||||
const stock = physical(initial);
|
||||
const forceStock = forceInventory(initial);
|
||||
return { ...store, ...madeGame, code, initial, tokens, auths, save, stock, forceStock, credentialRows,
|
||||
unrelatedCode: unrelated.view.code, unrelatedToken: unrelated.token, unrelatedRow };
|
||||
}
|
||||
const row = (f: Fixture) => f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.code);
|
||||
function totalChanges(f: Fixture) {
|
||||
const changes = f.sqlite.prepare('SELECT total_changes() AS changes').get()?.changes;
|
||||
assert.ok(typeof changes === 'number');
|
||||
return changes;
|
||||
}
|
||||
function protectedRows(f: Fixture) {
|
||||
assert.deepEqual(credentials(f.sqlite), f.credentialRows, 'saved authorization hashes and recovery records stay unchanged');
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.unrelatedCode), f.unrelatedRow);
|
||||
}
|
||||
function inventory(f: Fixture, g: Game) {
|
||||
assert.deepEqual(physical(g), f.stock);
|
||||
assert.deepEqual(forceInventory(g), f.forceStock, 'normal and elite physical force inventory is conserved');
|
||||
const cards = g.nexusCards?.cards;
|
||||
assert.ok(cards);
|
||||
validateNexusCards(cards, g.players);
|
||||
assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort(),
|
||||
[...NEXUS_FACTIONS].sort(), 'one singleton of every Nexus face stays in custody');
|
||||
protectedRows(f);
|
||||
}
|
||||
async function authenticate(f: Fixture, id: string) {
|
||||
const index = f.auths.findIndex(auth => auth.playerId === id);
|
||||
assert.ok(index >= 0);
|
||||
const auth = await f.restart().authenticate(f.code, f.tokens[index]);
|
||||
assert.equal(auth.playerId, id);
|
||||
assert.equal(auth.tokenHash, f.auths[index].tokenHash);
|
||||
return auth;
|
||||
}
|
||||
async function act(f: Fixture, g: Game, id: string, action: Action) {
|
||||
await f.restart().act(f.code, await authenticate(f, id), g.version, action, clock);
|
||||
return f.restart().readRoom(f.code);
|
||||
}
|
||||
async function restored(f: Fixture, g: Game) {
|
||||
const before = row(f), changes = totalChanges(f);
|
||||
assert.deepEqual(plain(await f.restart().readRoom(f.code)), plain(g));
|
||||
assert.deepEqual(plain(normalizeAutomaticGame(g)), plain(g), 'normalization cannot answer a human fee choice');
|
||||
const views: GameView[] = [];
|
||||
for (const auth of f.auths) {
|
||||
const view = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId));
|
||||
assert.deepEqual(plain(view), plain(viewGame(g, auth.playerId)));
|
||||
for (const key of ['pendingGuildBetrayal', 'guildBetrayal', 'nexusGuildBetrayalHistory', 'guildBetrayalSourceReceipts'])
|
||||
assert.equal(Object.hasOwn(view, key), false, 'source, invoice and durable receipt remain private');
|
||||
for (const rival of view.players.filter(p => p.id !== auth.playerId)) {
|
||||
for (const key of ['hand', 'traitors', 'spice']) assert.equal(Object.hasOwn(rival, key), false);
|
||||
}
|
||||
const reaction = view.guildBetrayalReaction;
|
||||
if (reaction) {
|
||||
assert.deepEqual(Object.keys(reaction).sort(), ['event', 'shipper', 'canPass', 'hasPassed', 'canUse', 'blocked'].sort());
|
||||
const first = views[0]?.guildBetrayalReaction;
|
||||
if (first) assert.deepEqual({ event: reaction.event, shipper: reaction.shipper },
|
||||
{ event: first.event, shipper: first.shipper });
|
||||
assert.ok(g.players.some(p => p.id === reaction.shipper));
|
||||
assert.equal(reaction.canUse, auth.playerId === f.holder &&
|
||||
g.nexusCards!.cards!.hands[f.holder] === 'guild' && !reaction.hasPassed);
|
||||
if (auth.playerId !== f.holder) assert.equal(reaction.blocked, null);
|
||||
}
|
||||
views.push(view);
|
||||
}
|
||||
await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
|
||||
assert.deepEqual(row(f), before, 'refresh/restart cannot consume or settle a held human opportunity');
|
||||
assert.equal(totalChanges(f), changes, 'refresh/restart does not write any durable table');
|
||||
inventory(f, g);
|
||||
return views;
|
||||
}
|
||||
async function rejected(
|
||||
f: Fixture, g: Game, auth: SeatAuth, action: Action, version = g.version, expected?: RegExp | { message: string },
|
||||
) {
|
||||
const before = row(f), changes = totalChanges(f), state = plain(g);
|
||||
const attempt = f.restart().act(f.code, auth, version, action, clock);
|
||||
if (expected) await assert.rejects(attempt, expected);
|
||||
else await assert.rejects(attempt);
|
||||
assert.equal(totalChanges(f), changes, 'denied mutations cannot change any durable table, including a zero-row credential CAS');
|
||||
assert.deepEqual(row(f), before, 'saved room state, version and timestamps stay unchanged');
|
||||
assert.deepEqual(plain(g), state, 'rejected mutation does not alter the supplied source state');
|
||||
protectedRows(f);
|
||||
}
|
||||
async function passOthers(f: Fixture, g: Game) {
|
||||
for (const p of g.players) {
|
||||
if (p.id === f.holder) continue;
|
||||
const reaction = viewGame(g, p.id).guildBetrayalReaction;
|
||||
if (reaction?.canPass) g = await act(f, g, p.id, pass(reaction.event));
|
||||
}
|
||||
assert.equal(viewGame(g, f.holder).guildBetrayalReaction?.canPass, true);
|
||||
return g;
|
||||
}
|
||||
async function settleNativeResponses(f: Fixture, g: Game) {
|
||||
const originalDelivery = structuredClone(delivery(g));
|
||||
for (let step = 0; g.response && step < 16; step++) {
|
||||
const response = g.response;
|
||||
const responder = g.players.find(p => !response.passed.includes(p.id));
|
||||
assert.ok(responder);
|
||||
g = await act(f, g, responder.id, { type: 'passResponse' });
|
||||
assert.deepEqual(delivery(g), originalDelivery, 'native income allowance does not replay already delivered forces');
|
||||
await restored(f, g);
|
||||
}
|
||||
assert.equal(g.response, null, 'all original native response suffixes finish');
|
||||
return g;
|
||||
}
|
||||
async function allPass(f: Fixture, g: Game) {
|
||||
g = await passOthers(f, g);
|
||||
await restored(f, g);
|
||||
return settleNativeResponses(f, await act(f, g, f.holder, pass(f.event)));
|
||||
}
|
||||
function diverted(f: Fixture, g: Game, allyPayment = 0) {
|
||||
assert.ok(f.price > 0, 'only an already-funded positive fee can be taken');
|
||||
for (const p of g.players) {
|
||||
const debit = p.id === f.shipper ? f.price - allyPayment : 0;
|
||||
const credit = p.id === f.holder ? f.price : 0;
|
||||
assert.equal(p.spice, player(f.initial, p.id).spice - debit + credit,
|
||||
'replace the full original receiver, without a second payer or native-income credit');
|
||||
}
|
||||
if (allyPayment) {
|
||||
const donor = f.donor;
|
||||
assert.ok(donor, 'the split quote retains its authorized donor');
|
||||
assert.equal(player(g, donor).spice, player(f.initial, donor).spice, 'pledged escrow is not withdrawn twice');
|
||||
assert.equal(g.aid[donor].amount, f.initial.aid[donor].amount - allyPayment);
|
||||
}
|
||||
const budget = (state: Game) => state.players.reduce((total, p) => total + p.spice, 0) +
|
||||
Object.values(state.aid).reduce((total, aid) => total + aid.amount, 0);
|
||||
assert.equal(budget(g), budget(f.initial), 'full bank/default recipient replacement conserves paid spice plus escrow');
|
||||
assert.deepEqual(delivery(g), delivery(f.original), 'the exact original cohort is delivered once with native turn flags');
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.holder], null);
|
||||
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'guild').length, 1);
|
||||
assert.equal(g.nexusGuildBetrayalHistory!.length, 1);
|
||||
assert.equal(g.nexusGuildBetrayalHistory![0].recipient, f.holder);
|
||||
assert.equal(g.guildBetrayalSourceReceipts!.length, 1);
|
||||
assert.equal(g.guildBetrayal!.status, 'completed');
|
||||
assert.equal(viewGame(g, f.holder).guildBetrayalReaction, null);
|
||||
inventory(f, g);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
for (const scenario of [
|
||||
{ source: 'reserve', payer: 'holder' },
|
||||
{ source: 'reserve', payer: 'other' },
|
||||
{ source: 'reserve', payer: 'guild', allyPayment: 1 },
|
||||
{ source: 'guildTransport', payer: 'guild' },
|
||||
{ source: 'guildTransport', payer: 'guild', homeworlds: true },
|
||||
{ source: 'homeworld', payer: 'holder', homeworlds: true },
|
||||
{ source: 'homeworld', payer: 'other', homeworlds: true, allyPayment: 1 },
|
||||
{ source: 'junction', payer: 'other', homeworlds: true },
|
||||
{ source: 'reserve', payer: 'other', homeworlds: true, occupiedJunction: true },
|
||||
] as const) {
|
||||
void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} ${scenario.source} ${scenario.payer}${'homeworlds' in scenario ? ' Homeworld' : ''} funded full fee replacement${'occupiedJunction' in scenario ? ' overrides occupied Junction' : ''} survives authenticated restart`, async t => {
|
||||
const f = await fixture(t, { ...scenario, advanced });
|
||||
await restored(f, f.initial);
|
||||
const done = await act(f, f.initial, f.holder, use(f.event));
|
||||
assert.equal(done.version, f.initial.version + 1);
|
||||
diverted(f, done, 'allyPayment' in scenario ? scenario.allyPayment : 0);
|
||||
if ('occupiedJunction' in scenario) {
|
||||
assert.equal(f.initial.homeworlds!.custody!.visitors['homeworld:guild'][f.holder].normal, 1);
|
||||
const nativeIncome = player(f.original, f.guild).spice - player(f.initial, f.guild).spice;
|
||||
assert.ok(nativeIncome > 0 && nativeIncome < f.price, 'the genuine low-Junction native quote only receives part of this fee');
|
||||
assert.equal(player(f.original, f.holder).spice, player(f.initial, f.holder).spice);
|
||||
assert.equal(player(done, f.guild).spice, player(f.initial, f.guild).spice, 'Betrayal suppresses native low-Junction income');
|
||||
assert.equal(done.response?.kind === 'guildIncome', false, 'no second income allowance survives the replacement');
|
||||
}
|
||||
await restored(f, done);
|
||||
const auth = await authenticate(f, f.holder);
|
||||
for (const action of [use(f.event), pass(f.event)]) await rejected(f, done, auth, action);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const source of ['reserve', 'guildTransport', 'homeworld', 'junction'] as const) {
|
||||
void test(`SQLite restored ${source} all-pass retains the Nexus and commits the original native payment and custody once`, async t => {
|
||||
const f = await fixture(t, { source, payer: source === 'guildTransport' ? 'guild' : 'other',
|
||||
homeworlds: source === 'homeworld' || source === 'junction' });
|
||||
await restored(f, f.initial);
|
||||
const done = await allPass(f, f.initial);
|
||||
assert.deepEqual(done.players.map(p => [p.id, p.spice]), f.original.players.map(p => [p.id, p.spice]));
|
||||
assert.deepEqual(done.aid, f.original.aid);
|
||||
assert.deepEqual(delivery(done), delivery(f.original));
|
||||
assert.equal(done.nexusCards!.cards!.hands[f.holder], 'guild');
|
||||
assert.equal(done.nexusCards!.cards!.discard.includes('guild'), false);
|
||||
assert.equal(viewGame(done, f.holder).guildBetrayalReaction, null);
|
||||
assert.equal(done.nexusGuildBetrayalHistory!.length, 1);
|
||||
assert.equal(done.nexusGuildBetrayalHistory![0].recipient, null);
|
||||
assert.equal(done.guildBetrayalSourceReceipts!.length, 1);
|
||||
assert.equal(done.guildBetrayal!.status, 'completed');
|
||||
await restored(f, done);
|
||||
await rejected(f, done, await authenticate(f, f.holder), pass(f.event));
|
||||
});
|
||||
}
|
||||
|
||||
for (const source of ['reserve', 'guildTransport', 'homeworld', 'junction'] as const) {
|
||||
void test(`SQLite simultaneous ${source} Use/final Pass commits one fee and one physical delivery through CAS`, async t => {
|
||||
const f = await fixture(t, { source, payer: source === 'guildTransport' ? 'guild' : 'other',
|
||||
homeworlds: source === 'homeworld' || source === 'junction' });
|
||||
const pending = await passOthers(f, f.initial);
|
||||
await restored(f, pending);
|
||||
f.writes.length = 0;
|
||||
let arrivals = 0;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
f.hooks.beforeWrite = async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await gate;
|
||||
};
|
||||
let results: PromiseSettledResult<unknown>[];
|
||||
try {
|
||||
const auth = await authenticate(f, f.holder);
|
||||
results = await Promise.allSettled([use(f.event), pass(f.event)].map(action =>
|
||||
f.restart().act(f.code, auth, pending.version, action, clock).finally(release)));
|
||||
} finally { delete f.hooks.beforeWrite; }
|
||||
assert.equal(arrivals, 2, 'both authenticated mutations reached the actual SQL version fence');
|
||||
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
|
||||
assert.deepEqual(f.writes.map(write => write.changes).sort((a, b) => a - b), [0, 1]);
|
||||
let done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.version, pending.version + 1);
|
||||
if (results[0].status === 'fulfilled') diverted(f, done);
|
||||
else {
|
||||
done = await settleNativeResponses(f, done);
|
||||
assert.deepEqual(done.players.map(p => [p.id, p.spice]), f.original.players.map(p => [p.id, p.spice]));
|
||||
assert.deepEqual(delivery(done), delivery(f.original));
|
||||
assert.equal(done.nexusCards!.cards!.hands[f.holder], 'guild');
|
||||
}
|
||||
await restored(f, done);
|
||||
});
|
||||
}
|
||||
|
||||
void test('SQLite credential reauthentication rejects stale, foreign, unsupported and client-owned fee choices without a write', async t => {
|
||||
const f = await fixture(t);
|
||||
const holder = await authenticate(f, f.holder);
|
||||
const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken);
|
||||
const before = row(f), changes = totalChanges(f);
|
||||
await assert.rejects(f.restart().authenticate(f.code, f.unrelatedToken));
|
||||
await assert.rejects(f.restart().readSeatView(f.code, foreign));
|
||||
await assert.rejects(f.restart().readSeatView(f.code, { playerId: f.holder, tokenHash: foreign.tokenHash }));
|
||||
assert.equal(totalChanges(f), changes, 'denied authentication and private reads write no durable table');
|
||||
assert.deepEqual(row(f), before);
|
||||
protectedRows(f);
|
||||
await rejected(f, f.initial, foreign, use(f.event));
|
||||
await rejected(f, f.initial, { playerId: f.holder, tokenHash: foreign.tokenHash }, use(f.event));
|
||||
await rejected(f, f.initial, holder, use(f.event), f.initial.version - 1);
|
||||
await rejected(f, f.initial, await authenticate(f, f.guild), use(f.event));
|
||||
for (const action of [
|
||||
use(`${f.event}:foreign`), pass(`${f.event}:foreign`),
|
||||
forged({ ...use(f.event), amount: f.price }), forged({ ...use(f.event), recipient: f.holder }),
|
||||
forged({ ...use(f.event), price: 0 }), forged({ ...use(f.event), source: f.source }),
|
||||
forged({ ...pass(f.event), card: 'guild' }), forged({ ...pass(f.event), ignored: true }),
|
||||
{ type: 'pledgeAid', amount: 1 } as Action,
|
||||
f.declaration,
|
||||
]) await rejected(f, f.initial, holder, action);
|
||||
const pending = await passOthers(f, f.initial);
|
||||
if (pending.version !== f.initial.version)
|
||||
await rejected(f, pending, holder, use(f.event), f.initial.version);
|
||||
for (const p of pending.players) {
|
||||
const reaction = viewGame(pending, p.id).guildBetrayalReaction;
|
||||
if (reaction?.hasPassed) await rejected(f, pending, await authenticate(f, p.id), pass(f.event));
|
||||
}
|
||||
await restored(f, pending);
|
||||
});
|
||||
|
||||
void test('SQLite public fee acknowledgement does not identify the held Nexus face or reveal source, route or cost', async t => {
|
||||
const f = await fixture(t, { source: 'homeworld', payer: 'other', homeworlds: true });
|
||||
const originalViews = await restored(f, f.initial);
|
||||
const before = structuredClone(f.before);
|
||||
before.code = f.code;
|
||||
before.host = f.initial.host;
|
||||
before.version = f.initial.version;
|
||||
const cards = before.nexusCards!.cards!;
|
||||
const index = cards.deck.indexOf('choam');
|
||||
assert.ok(index >= 0);
|
||||
assert.equal(cards.hands[f.holder], 'guild');
|
||||
cards.deck[index] = 'guild';
|
||||
cards.hands[f.holder] = 'choam';
|
||||
validateNexusCards(cards, before.players);
|
||||
// Change only the private face in the same funded source, not another
|
||||
// randomly played setup with a different payer balance or force history.
|
||||
let alternative = applyAction(before, f.shipper, f.declaration);
|
||||
for (let step = 0; !alternative.pendingGuildBetrayal && step < 20; step++) {
|
||||
const decision = alternative.decision;
|
||||
assert.ok(decision && ['guildShipment', 'homeworldShipmentGuild'].includes(decision.kind));
|
||||
alternative = applyAction(alternative, decision.player, {
|
||||
type: 'decision', allow: true,
|
||||
...(decision.kind === 'homeworldShipmentGuild' ? { event: decision.event } : {}),
|
||||
});
|
||||
}
|
||||
assert.ok(alternative.pendingGuildBetrayal);
|
||||
f.save(alternative);
|
||||
const changedViews = await restored(f, alternative);
|
||||
for (const [index, auth] of f.auths.entries()) {
|
||||
const original = originalViews[index].guildBetrayalReaction;
|
||||
const changed = changedViews[index].guildBetrayalReaction;
|
||||
assert.ok(original);
|
||||
assert.ok(changed);
|
||||
assert.equal(changed.event, original.event, 'the event is selected from the public producer, not the private face');
|
||||
assert.equal(changed.shipper, original.shipper);
|
||||
assert.equal(changed.canPass, original.canPass);
|
||||
assert.equal(changed.hasPassed, original.hasPassed);
|
||||
assert.equal(changed.canUse, false);
|
||||
if (auth.playerId !== f.holder) assert.deepEqual(plain(changed), plain(original));
|
||||
}
|
||||
await rejected(f, alternative, await authenticate(f, f.holder), use(f.event));
|
||||
const done = await allPass(f, alternative);
|
||||
assert.deepEqual(delivery(done), delivery(f.original));
|
||||
assert.deepEqual(done.players.map(p => [p.id, p.spice]), f.original.players.map(p => [p.id, p.spice]));
|
||||
assert.equal(done.nexusCards!.cards!.hands[f.holder], 'choam');
|
||||
await restored(f, done);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} a re-signed paid cross-planet source cannot replace the native Guild grant`, async t => {
|
||||
const f = await fixture(t, { advanced, source: 'guildTransport', payer: 'other' });
|
||||
assert.notEqual(f.shipper, f.guild);
|
||||
assert.equal(player(f.initial, f.shipper).ally, f.guild);
|
||||
assert.equal(player(f.initial, f.guild).ally, f.shipper);
|
||||
await restored(f, f.initial);
|
||||
|
||||
const corrupt = structuredClone(f.initial);
|
||||
player(corrupt, f.shipper).ally = null;
|
||||
player(corrupt, f.guild).ally = null;
|
||||
const pending = corrupt.pendingGuildBetrayal;
|
||||
assert.ok(pending?.continuation.source === 'guildTransport');
|
||||
assert.ok(pending.invoice.price > 0);
|
||||
assert.equal(pending.continuation.fromReserves, false);
|
||||
assert.notEqual(pending.continuation.frame.to, 'reserves');
|
||||
assert.deepEqual(pending.continuation, f.initial.pendingGuildBetrayal!.continuation,
|
||||
'the physically available paid native group, route and tariff are unchanged');
|
||||
assert.deepEqual(guildBetrayalResponders(corrupt.nexusCards!.cards!, corrupt.players), pending.required);
|
||||
|
||||
// Amend the saved consistency proof itself, rather than leaving a stale
|
||||
// signature that would reject even without an independent native grant.
|
||||
// No private validator or reconstruction of its source quote is involved.
|
||||
const proof: { players: { id: string; ally: string | null }[] } =
|
||||
JSON.parse(pending.invoice.sourceSignature);
|
||||
for (const seat of proof.players) seat.ally = player(corrupt, seat.id).ally;
|
||||
pending.invoice.sourceSignature = JSON.stringify(proof);
|
||||
const native = pending.continuation.frame;
|
||||
pending.invoice.originalReceiver = guildShipmentIncome({
|
||||
guild: f.guild, shipper: f.shipper, ally: player(corrupt, f.shipper).ally,
|
||||
cost: native.cost, allyPayment: native.allyPayment,
|
||||
bankOnly: corrupt.karamaShipping?.player === f.shipper,
|
||||
}) > 0 ? f.guild : null;
|
||||
pending.invoice.signature = signGuildBetrayalInvoice(pending.invoice);
|
||||
corrupt.guildBetrayal!.sourceSignature = pending.invoice.sourceSignature;
|
||||
const binding: unknown[] = JSON.parse(pending.signature);
|
||||
binding[0] = pending.invoice.signature;
|
||||
binding[1] = pending.invoice.sourceSignature;
|
||||
pending.signature = JSON.stringify(binding);
|
||||
f.save(corrupt);
|
||||
|
||||
const before = row(f), changes = totalChanges(f), state = plain(corrupt);
|
||||
const denied = { message: 'Native Guild transport requires the Guild or its reciprocal ally.' };
|
||||
assert.deepEqual(plain(await f.restart().readRoom(f.code)), state, 'raw restore preserves the damaged source for diagnosis');
|
||||
assert.throws(() => normalizeAutomaticGame(corrupt), denied);
|
||||
for (const auth of f.auths)
|
||||
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)), denied);
|
||||
for (const action of [pass(f.event), use(f.event)])
|
||||
await rejected(f, corrupt, await authenticate(f, f.holder), action, corrupt.version, denied);
|
||||
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]);
|
||||
assert.equal(totalChanges(f), changes, 'failed read, normalization and authenticated settlement write no durable table');
|
||||
assert.deepEqual(row(f), before);
|
||||
const saved = await f.restart().readRoom(f.code);
|
||||
assert.deepEqual(plain(saved), state, 'fee, forces, held cards, escrow, histories and room version remain untouched');
|
||||
assert.deepEqual(plain(corrupt), state);
|
||||
inventory(f, saved);
|
||||
});
|
||||
}
|
||||
|
||||
for (const source of ['reserve', 'guildTransport', 'homeworld', 'junction'] as const) {
|
||||
void test(`SQLite malformed or detached ${source} invoice/source rejects all-seat disclosure, normalization and settlement without writes`, async t => {
|
||||
const f = await fixture(t, { source, payer: source === 'guildTransport' ? 'guild' : 'other',
|
||||
homeworlds: source === 'homeworld' || source === 'junction' });
|
||||
const mutations: { name: string; change(this: void, g: Game): void }[] = [
|
||||
{ name: 'deleted continuation with pending cursor', change: g => { delete g.pendingGuildBetrayal; } },
|
||||
{ name: 'deleted cursor with orphan invoice', change: g => { delete g.guildBetrayal; } },
|
||||
{ name: 'deleted native source receipt collection', change: g => { delete g.guildBetrayalSourceReceipts; } },
|
||||
{ name: 'deleted independent receipt history', change: g => { delete g.nexusGuildBetrayalHistory; } },
|
||||
{ name: 'detached event', change: g => { g.guildBetrayal!.event = null; } },
|
||||
{ name: 'lost pending sequence', change: g => { g.guildBetrayal!.sequence = 0; } },
|
||||
{ name: 'false completion', change: g => { g.guildBetrayal!.status = 'completed'; } },
|
||||
{ name: 'changed charged price', change: g => { g.pendingGuildBetrayal!.invoice.price++; } },
|
||||
{ name: 'changed payer split', change: g => { g.pendingGuildBetrayal!.invoice.ownPayment++; } },
|
||||
{ name: 'foreign payer', change: g => { g.pendingGuildBetrayal!.invoice.shipper = f.holder; } },
|
||||
{ name: 'changed original receiver', change: g => { g.pendingGuildBetrayal!.invoice.originalReceiver = f.shipper; } },
|
||||
{ name: 'changed source signature', change: g => { g.pendingGuildBetrayal!.invoice.sourceSignature += ':forged'; } },
|
||||
{ name: 'changed producer parent', change: g => { g.pendingGuildBetrayal!.signature += ':forged'; } },
|
||||
{ name: 'extra invoice key', change: g => { Object.assign(g.pendingGuildBetrayal!.invoice, { recipient: f.holder }); } },
|
||||
{ name: 'extra continuation key', change: g => { Object.assign(g.pendingGuildBetrayal!.continuation, { action: f.declaration }); } },
|
||||
{ name: 'foreign acknowledgement', change: g => { g.pendingGuildBetrayal!.required.push(f.guild); } },
|
||||
{ name: 'fabricated saved pass', change: g => { g.pendingGuildBetrayal!.passed.push(f.holder); } },
|
||||
{ name: 'missing original upfront funding', change: g => { player(g, f.shipper).spice = 0; } },
|
||||
{ name: 'source custody moved without delivery', change: g => {
|
||||
const p = player(g, f.shipper);
|
||||
if (source === 'guildTransport') {
|
||||
const location = Object.keys(p.forces).find(key => p.forces[key] > 0);
|
||||
assert.ok(location);
|
||||
p.forces[location]--;
|
||||
p.tanks++;
|
||||
} else {
|
||||
assert.ok(p.reserves > 0);
|
||||
p.reserves--;
|
||||
p.tanks++;
|
||||
}
|
||||
} },
|
||||
{ name: 'changed native quoted route', change: g => {
|
||||
const continuation = g.pendingGuildBetrayal!.continuation;
|
||||
switch (continuation.source) {
|
||||
case 'reserve': continuation.shipment.amount++; break;
|
||||
case 'guildTransport': continuation.frame.amount++; break;
|
||||
case 'homeworld': continuation.shipment.amount++; break;
|
||||
case 'junction': continuation.transport.destination += ':foreign'; break;
|
||||
}
|
||||
} },
|
||||
];
|
||||
for (const { name, change } of mutations) {
|
||||
const corrupt = structuredClone(f.initial);
|
||||
change(corrupt);
|
||||
f.save(corrupt);
|
||||
const before = row(f), changes = totalChanges(f);
|
||||
const stock = physical(corrupt);
|
||||
assert.deepEqual(plain(await f.restart().readRoom(f.code)), plain(corrupt), 'raw read does not rewrite a damaged save');
|
||||
assert.throws(() => normalizeAutomaticGame(corrupt), name);
|
||||
for (const auth of f.auths)
|
||||
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)), name);
|
||||
for (const action of [pass(f.event), use(f.event)])
|
||||
await rejected(f, corrupt, await authenticate(f, f.holder), action);
|
||||
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]);
|
||||
assert.deepEqual(row(f), before, name);
|
||||
assert.equal(totalChanges(f), changes, name);
|
||||
const saved = await f.restart().readRoom(f.code);
|
||||
assert.deepEqual(physical(saved), stock, name);
|
||||
assert.deepEqual(saved.players.map(p => [p.id, p.reserves, p.forces, p.tanks]), corrupt.players.map(p => [p.id, p.reserves, p.forces, p.tanks]), name);
|
||||
protectedRows(f);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('SQLite own-payment refund requires already available original funds and cannot bootstrap its upfront debit', async t => {
|
||||
const f = await fixture(t, { payer: 'holder' });
|
||||
assert.equal(f.shipper, f.holder);
|
||||
assert.ok(player(f.initial, f.holder).spice >= f.price);
|
||||
const corrupt = structuredClone(f.initial);
|
||||
player(corrupt, f.holder).spice = f.price - 1;
|
||||
f.save(corrupt);
|
||||
const before = row(f), changes = totalChanges(f);
|
||||
await rejected(f, corrupt, await authenticate(f, f.holder), use(f.event));
|
||||
for (const auth of f.auths)
|
||||
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)));
|
||||
assert.deepEqual(row(f), before);
|
||||
assert.equal(totalChanges(f), changes, 'failed upfront funding cannot write any durable table');
|
||||
protectedRows(f);
|
||||
});
|
||||
|
||||
void test('SQLite altered authorized aid escrow cannot charge its donor again or settle an unfunded original invoice', async t => {
|
||||
const f = await fixture(t, { payer: 'guild', allyPayment: 1 });
|
||||
const donor = f.donor;
|
||||
assert.ok(donor);
|
||||
const mutations: { name: string; change(this: void, g: Game): void }[] = [
|
||||
{ name: 'deleted escrow', change: g => { delete g.aid[donor]; } },
|
||||
{ name: 'empty escrow', change: g => { g.aid[donor].amount = 0; } },
|
||||
{ name: 'foreign donor', change: g => { g.pendingGuildBetrayal!.invoice.donor = f.holder; } },
|
||||
{ name: 'lost authorization', change: g => { player(g, f.shipper).ally = null; } },
|
||||
];
|
||||
for (const { name, change } of mutations) {
|
||||
const corrupt = structuredClone(f.initial);
|
||||
change(corrupt);
|
||||
f.save(corrupt);
|
||||
const before = row(f), changes = totalChanges(f);
|
||||
await rejected(f, corrupt, await authenticate(f, f.holder), use(f.event));
|
||||
assert.deepEqual(row(f), before, name);
|
||||
assert.equal(totalChanges(f), changes, name);
|
||||
const saved = await f.restart().readRoom(f.code);
|
||||
assert.equal(player(saved, donor).spice, player(corrupt, donor).spice, name);
|
||||
protectedRows(f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('SQLite independent completed receipt survives a later actual shipment and conserved Nexus recycling, but cannot be deleted or detached', async t => {
|
||||
const f = await fixture(t, { advanced: false, payer: 'holder' });
|
||||
let done = await act(f, f.initial, f.holder, use(f.event));
|
||||
diverted(f, done);
|
||||
await restored(f, done);
|
||||
const firstReceipt = structuredClone(done.nexusGuildBetrayalHistory![0]);
|
||||
const firstSource = structuredClone(done.guildBetrayalSourceReceipts![0]);
|
||||
|
||||
// Exercise the physical component's real draw/replacement lifecycle, not a
|
||||
// fabricated card or amended invoice. Earlier receipts must not retain a
|
||||
// permanent claim to the present discard position or held face.
|
||||
const recycled = structuredClone(done);
|
||||
assert.ok(recycled.nexusCards?.cards);
|
||||
recycled.nexusCards.cards = drawNexusCard(recycled.nexusCards.cards, f.holder, recycled.players, () => 0);
|
||||
for (let step = 0; step < 48 && recycled.nexusCards.cards.hands[f.holder] !== 'guild'; step++)
|
||||
recycled.nexusCards.cards = replaceNexusCard(recycled.nexusCards.cards, f.holder, recycled.players, () => 0);
|
||||
assert.equal(recycled.nexusCards.cards.hands[f.holder], 'guild');
|
||||
assert.equal(recycled.nexusCards.cards.discard.includes('guild'), false);
|
||||
f.save(recycled);
|
||||
await restored(f, recycled);
|
||||
await rejected(f, recycled, await authenticate(f, f.holder), use(f.event));
|
||||
|
||||
done = await act(f, recycled, f.holder, { type: 'endMovement' });
|
||||
for (let step = 0; step < 500; step++) {
|
||||
const actor = done.active ? player(done, done.active) : null;
|
||||
const cleanMovement = done.phase === 5 && !done.phaseOpening && !done.response && !done.decision;
|
||||
if (cleanMovement && actor && actor.id !== f.holder && !actor.shipped && actor.reserves > 0 && actor.spice >= 2) break;
|
||||
if (cleanMovement && actor) {
|
||||
done = await act(f, done, actor.id, { type: 'endMovement' });
|
||||
continue;
|
||||
}
|
||||
let request: { id: string; action: Action } | null = null;
|
||||
for (const p of done.players) {
|
||||
const view = viewGame(done, p.id);
|
||||
const own = view.players.find(seat => seat.id === p.id);
|
||||
assert.ok(own);
|
||||
own.bot = 'Easy';
|
||||
let action = botActions(view)[0];
|
||||
if (p.id === f.holder && action?.type === 'nexusCardChoice' && view.nexusCards?.choices.includes('keep'))
|
||||
action = { type: 'nexusCardChoice', turn: view.turn, card: view.nexusCards.card, choice: 'keep', ownRedraws: 0 };
|
||||
if (action) { request = { id: p.id, action }; break; }
|
||||
}
|
||||
assert.ok(request, 'genuine intervening phases retain a legal authenticated continuation');
|
||||
done = await act(f, done, request.id, request.action);
|
||||
}
|
||||
assert.equal(done.phase, 5);
|
||||
assert.ok(!done.phaseOpening && !done.response && !done.decision,
|
||||
'the later producer starts only after genuine intervening opportunities finish');
|
||||
const laterShipper = done.active;
|
||||
assert.ok(laterShipper, 'the next real movement turn retains its seated actor');
|
||||
assert.notEqual(laterShipper, f.holder);
|
||||
assert.equal(player(done, laterShipper).shipped, false);
|
||||
const beforeSecond = structuredClone(done);
|
||||
done = await act(f, done, laterShipper, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1 });
|
||||
for (let step = 0; !done.pendingGuildBetrayal && step < 16; step++) {
|
||||
const decision = done.decision;
|
||||
if (decision?.kind === 'guildShipment') {
|
||||
done = await act(f, done, decision.player, { type: 'decision', allow: true });
|
||||
} else {
|
||||
const response = done.response;
|
||||
assert.ok(response, 'the later declaration retains its native permission/rate opportunity');
|
||||
const responder = done.players.find(p => !response.passed.includes(p.id));
|
||||
assert.ok(responder);
|
||||
done = await act(f, done, responder.id, { type: 'passResponse' });
|
||||
}
|
||||
}
|
||||
const reaction = viewGame(done, f.holder).guildBetrayalReaction;
|
||||
assert.ok(reaction, 'a later genuinely declared funded shipment opens its own distinct invoice');
|
||||
assert.notEqual(reaction.event, f.event);
|
||||
assert.equal(reaction.shipper, laterShipper);
|
||||
assert.equal(done.guildBetrayal!.sequence, 2);
|
||||
assert.deepEqual(done.nexusGuildBetrayalHistory, [firstReceipt]);
|
||||
assert.deepEqual(done.guildBetrayalSourceReceipts, [firstSource]);
|
||||
await restored(f, done);
|
||||
await rejected(f, done, await authenticate(f, f.holder), use(f.event));
|
||||
const price = done.pendingGuildBetrayal!.invoice.price;
|
||||
const later = await act(f, done, f.holder, use(reaction.event));
|
||||
assert.equal(later.nexusGuildBetrayalHistory!.length, 2);
|
||||
assert.equal(later.guildBetrayalSourceReceipts!.length, 2);
|
||||
assert.deepEqual(later.nexusGuildBetrayalHistory![0], firstReceipt);
|
||||
assert.deepEqual(later.guildBetrayalSourceReceipts![0], firstSource);
|
||||
assert.equal(player(later, laterShipper).spice, player(beforeSecond, laterShipper).spice - price);
|
||||
assert.equal(player(later, f.holder).spice, player(beforeSecond, f.holder).spice + price);
|
||||
assert.equal(player(later, laterShipper).reserves, player(beforeSecond, laterShipper).reserves - 1);
|
||||
assert.equal(player(later, laterShipper).forces['polar_sink:0'],
|
||||
(player(beforeSecond, laterShipper).forces['polar_sink:0'] ?? 0) + 1);
|
||||
await restored(f, later);
|
||||
for (const event of [f.event, reaction.event])
|
||||
await rejected(f, later, await authenticate(f, f.holder), use(event));
|
||||
|
||||
const mutations: { name: string; change(this: void, g: Game): void }[] = [
|
||||
{ name: 'deleted completed receipt', change: g => { g.nexusGuildBetrayalHistory!.shift(); } },
|
||||
{ name: 'deleted independent source receipt', change: g => { g.guildBetrayalSourceReceipts!.shift(); } },
|
||||
{ name: 'deleted receipt cursor', change: g => { delete g.guildBetrayal; } },
|
||||
{ name: 'deleted receipt collection', change: g => { delete g.nexusGuildBetrayalHistory; } },
|
||||
{ name: 'altered historical charged fee', change: g => { g.nexusGuildBetrayalHistory![0].invoice.price++; } },
|
||||
{ name: 'altered historical recipient', change: g => { g.nexusGuildBetrayalHistory![0].recipient = f.guild; } },
|
||||
{ name: 'detached native source event', change: g => { g.guildBetrayalSourceReceipts![0].event += ':foreign'; } },
|
||||
{ name: 'detached native source binding', change: g => { g.guildBetrayalSourceReceipts![0].receipt += ':foreign'; } },
|
||||
{ name: 'reopened completed physical delivery', change: g => { player(g, laterShipper).shipped = false; } },
|
||||
];
|
||||
for (const { name, change } of mutations) {
|
||||
const corrupt = structuredClone(later);
|
||||
change(corrupt);
|
||||
f.save(corrupt);
|
||||
const before = row(f), changes = totalChanges(f);
|
||||
assert.throws(() => normalizeAutomaticGame(corrupt), name);
|
||||
for (const auth of f.auths)
|
||||
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)), name);
|
||||
await rejected(f, corrupt, await authenticate(f, f.holder), use(reaction.event));
|
||||
assert.deepEqual(row(f), before, name);
|
||||
assert.equal(totalChanges(f), changes, name);
|
||||
protectedRows(f);
|
||||
}
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} actual free Fremen shipment has no fee opportunity, award or Nexus consumption`, async t => {
|
||||
const f = await fixture(t, { advanced, zero: true, payer: 'other' });
|
||||
const before = structuredClone(f.before);
|
||||
before.code = f.code;
|
||||
before.host = f.initial.host;
|
||||
before.version = f.initial.version;
|
||||
f.save(before);
|
||||
const done = await act(f, before, f.shipper, f.declaration);
|
||||
assert.equal(done.pendingGuildBetrayal, null);
|
||||
assert.equal(done.guildBetrayal!.sequence, before.guildBetrayal!.sequence);
|
||||
assert.deepEqual(done.players.map(p => [p.id, p.spice]), before.players.map(p => [p.id, p.spice]));
|
||||
assert.deepEqual(done.nexusCards!.cards, before.nexusCards!.cards);
|
||||
assert.deepEqual(delivery(done), delivery(f.original));
|
||||
assert.equal(player(done, f.shipper).shipped, true);
|
||||
assert.equal(player(done, f.shipper).reserves, player(before, f.shipper).reserves - 2);
|
||||
await restored(f, done);
|
||||
await rejected(f, done, await authenticate(f, f.holder), use(f.event));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createNexusCards, discardNexusCard, type NexusPlayer, type NexusState,
|
||||
} from '../game/nexus-cards';
|
||||
import {
|
||||
createGuildBetrayalInvoice, createGuildBetrayalReceipt, guildBetrayalEligible,
|
||||
guildBetrayalResponders, nexusGuildBetrayalEvent, nexusGuildBetrayalSourceReceipt,
|
||||
quoteGuildBetrayalFunding, signGuildBetrayalInvoice, signGuildBetrayalReceipt,
|
||||
validateGuildBetrayalHistory, validateGuildBetrayalInvoice,
|
||||
type GuildBetrayalAuthority, type GuildBetrayalContext, type GuildBetrayalCursor,
|
||||
type GuildBetrayalInvoice, type GuildBetrayalReceipt,
|
||||
} from '../game/nexus-guild-betrayal';
|
||||
|
||||
const players: NexusPlayer[] = [
|
||||
{ id: 'holder', faction: 'atreides' },
|
||||
{ id: 'guild', faction: 'guild' },
|
||||
{ id: 'shipper', faction: 'emperor', ally: 'donor' },
|
||||
{ id: 'donor', faction: 'harkonnen', ally: 'shipper' },
|
||||
];
|
||||
const context: GuildBetrayalContext = { turn: 2, players };
|
||||
function cards(face: 'guild' | 'choam' = 'guild'): NexusState {
|
||||
const inventory = createNexusCards(players, () => 0.5);
|
||||
inventory.deck.splice(inventory.deck.indexOf(face), 1);
|
||||
inventory.hands.holder = face;
|
||||
return inventory;
|
||||
}
|
||||
function authority(patch: Partial<GuildBetrayalAuthority> = {}): GuildBetrayalAuthority {
|
||||
return {
|
||||
shipper: 'shipper', price: 8, ownSpice: 5, allyPayment: 3, allyEscrow: 6,
|
||||
donor: 'donor', source: 'reserve', sourceSignature: 'native-force-group-and-quote',
|
||||
originalReceiver: 'guild', ...patch,
|
||||
};
|
||||
}
|
||||
function cursor(invoice: GuildBetrayalInvoice, status: 'pending' | 'completed'): GuildBetrayalCursor {
|
||||
return { sequence: invoice.sequence, event: invoice.event, turn: invoice.turn,
|
||||
sourceSignature: invoice.sourceSignature, status };
|
||||
}
|
||||
function completed(invoice: GuildBetrayalInvoice, used = true): GuildBetrayalReceipt {
|
||||
return createGuildBetrayalReceipt(context, invoice, {
|
||||
recipient: used ? 'holder' : null, holderFaction: used ? 'atreides' : null,
|
||||
holderUnallied: used ? true : null, nexusDiscardIndex: used ? 4 : null,
|
||||
sourceReceipt: nexusGuildBetrayalSourceReceipt(invoice),
|
||||
});
|
||||
}
|
||||
|
||||
void test('only the canonical unallied non-native holder of the singleton Guild card is eligible', () => {
|
||||
const inventory = cards();
|
||||
assert.equal(guildBetrayalEligible(inventory, players, 'holder'), true);
|
||||
assert.equal(guildBetrayalEligible(inventory, players, 'guild'), false);
|
||||
assert.equal(guildBetrayalEligible(inventory, players, 'outsider'), false);
|
||||
assert.equal(guildBetrayalEligible(inventory, players,
|
||||
{ id: 'holder', faction: 'atreides', ally: null } as never), false);
|
||||
assert.equal(guildBetrayalEligible(cards('choam'), players, 'holder'), false);
|
||||
const absentGuild = players.map(player => player.id === 'guild'
|
||||
? { ...player, faction: 'fremen' as const } : player);
|
||||
assert.equal(guildBetrayalEligible(inventory, absentGuild, 'holder'), false);
|
||||
const allied = players.map(player => player.id === 'holder' ? { ...player, ally: 'guild' }
|
||||
: player.id === 'guild' ? { ...player, ally: 'holder' } : player);
|
||||
assert.equal(guildBetrayalEligible(inventory, allied, 'holder'), false);
|
||||
const counterfeit = structuredClone(inventory);
|
||||
counterfeit.hands.shipper = 'guild';
|
||||
assert.throws(() => guildBetrayalEligible(counterfeit, players, 'holder'));
|
||||
assert.deepEqual(guildBetrayalResponders(inventory, players), ['holder']);
|
||||
assert.deepEqual(guildBetrayalResponders(cards('choam'), players), ['holder']);
|
||||
assert.equal(guildBetrayalEligible(discardNexusCard(inventory, 'holder', players), players, 'holder'), false);
|
||||
});
|
||||
|
||||
void test('full original fee requires existing own funds or authorized prepaid ally escrow, never a later refund', () => {
|
||||
quoteGuildBetrayalFunding({ ...authority(), players });
|
||||
quoteGuildBetrayalFunding({ ...authority({ ownSpice: 0, allyPayment: 8, allyEscrow: 8 }), players });
|
||||
quoteGuildBetrayalFunding({ ...authority({ price: Number.MAX_SAFE_INTEGER,
|
||||
ownSpice: Number.MAX_SAFE_INTEGER, allyPayment: 0, allyEscrow: 0, donor: null }), players });
|
||||
const invalid: Partial<GuildBetrayalAuthority>[] = [
|
||||
{ price: 0 }, { price: -1 }, { price: 0.5 }, { price: NaN }, { price: Infinity },
|
||||
{ price: Number.MAX_SAFE_INTEGER + 1 }, { ownSpice: 4 }, { ownSpice: -1 },
|
||||
{ ownSpice: Infinity }, { allyEscrow: 2 }, { allyEscrow: -1 },
|
||||
{ allyPayment: 9 }, { allyPayment: -1 }, { allyPayment: 1.5 },
|
||||
{ donor: 'holder' }, { donor: 'shipper' }, { donor: 'outsider' }, { donor: null },
|
||||
{ allyPayment: 0 },
|
||||
];
|
||||
for (const patch of invalid) {
|
||||
const quote = { ...authority(patch), players };
|
||||
const before = JSON.stringify(quote);
|
||||
assert.throws(() => quoteGuildBetrayalFunding(quote));
|
||||
assert.equal(JSON.stringify(quote), before);
|
||||
}
|
||||
// An unallied holder paying its own invoice cannot borrow the future receipt.
|
||||
const own = authority({ shipper: 'holder', ownSpice: 7, allyPayment: 0, allyEscrow: 0, donor: null });
|
||||
assert.throws(() => createGuildBetrayalInvoice(context, own, 1));
|
||||
const funded = createGuildBetrayalInvoice(context, { ...own, ownSpice: 8 }, 1);
|
||||
assert.equal(funded.ownPayment, 8);
|
||||
assert.equal(completed(funded).invoice.price, 8);
|
||||
});
|
||||
|
||||
void test('a re-signed saved invoice cannot authorize its own stale price, route, payer or escrow', () => {
|
||||
for (const source of ['reserve', 'guildTransport', 'homeworld', 'junction'] as const) {
|
||||
const native = authority({ source });
|
||||
const invoice = createGuildBetrayalInvoice(context, native, 1);
|
||||
validateGuildBetrayalInvoice(context, invoice, native, 1);
|
||||
for (const patch of [
|
||||
{ price: 7, ownPayment: 4 }, { sourceSignature: 'different-native-custody' },
|
||||
{ source: source === 'homeworld' ? 'reserve' as const : 'homeworld' as const },
|
||||
{ ownSpice: 8 }, { allyEscrow: 7 }, { originalReceiver: null },
|
||||
{ shipper: 'donor' },
|
||||
]) {
|
||||
const changed = { ...invoice, ...patch };
|
||||
changed.event = nexusGuildBetrayalEvent(changed.turn, changed.sequence, changed.shipper);
|
||||
changed.signature = signGuildBetrayalInvoice(changed);
|
||||
const before = JSON.stringify(changed);
|
||||
assert.throws(() => validateGuildBetrayalInvoice(context, changed, native, 1));
|
||||
assert.equal(JSON.stringify(changed), before);
|
||||
}
|
||||
assert.throws(() => validateGuildBetrayalInvoice({ ...context, turn: 3 }, invoice, native, 1));
|
||||
assert.throws(() => validateGuildBetrayalInvoice(context, invoice, native, 2));
|
||||
assert.throws(() => validateGuildBetrayalInvoice(context, { ...invoice, extra: true } as never, native, 1));
|
||||
const brokenAlliance = players.map(player => ({ ...player, ally: null }));
|
||||
assert.throws(() => validateGuildBetrayalInvoice({ ...context, players: brokenAlliance }, invoice, native, 1));
|
||||
}
|
||||
});
|
||||
|
||||
void test('independent cursor rejects incomplete, orphan, stale, duplicate and foreign completed sources immutably', () => {
|
||||
const invoice = createGuildBetrayalInvoice(context, authority(), 1);
|
||||
const receipt = completed(invoice);
|
||||
const pending = cursor(invoice, 'pending');
|
||||
const settled = cursor(invoice, 'completed');
|
||||
validateGuildBetrayalHistory(context, [], pending, invoice);
|
||||
validateGuildBetrayalHistory(context, [receipt], settled, null);
|
||||
const invalid: [GuildBetrayalReceipt[], GuildBetrayalCursor, GuildBetrayalInvoice | null][] = [
|
||||
[[], pending, null], [[], settled, null], [[receipt], pending, invoice],
|
||||
[[receipt], settled, invoice], [[receipt, receipt], settled, null],
|
||||
[[receipt], { ...settled, sourceSignature: 'foreign-source' }, null],
|
||||
[[receipt], { ...settled, turn: 1 }, null],
|
||||
[[receipt], { ...settled, event: 'other-event' }, null],
|
||||
[[receipt], { ...settled, sequence: 2 }, null],
|
||||
[[receipt], { ...settled, extra: true } as never, null],
|
||||
[[], { sequence: 0, status: 'completed', event: null, turn: null, sourceSignature: null }, invoice],
|
||||
];
|
||||
for (const [history, savedCursor, frame] of invalid) {
|
||||
const before = JSON.stringify([history, savedCursor, frame]);
|
||||
assert.throws(() => validateGuildBetrayalHistory(context, history, savedCursor, frame));
|
||||
assert.equal(JSON.stringify([history, savedCursor, frame]), before);
|
||||
}
|
||||
assert.throws(() => createGuildBetrayalReceipt({ ...context, turn: 3 }, invoice, {
|
||||
recipient: null, holderFaction: null, holderUnallied: null, nexusDiscardIndex: null,
|
||||
sourceReceipt: nexusGuildBetrayalSourceReceipt(invoice),
|
||||
}));
|
||||
for (const patch of [
|
||||
{ recipient: 'guild', holderFaction: 'guild' as const },
|
||||
{ recipient: 'outsider' }, { holderFaction: 'emperor' as const },
|
||||
{ holderUnallied: false }, { card: null }, { nexusDiscardIndex: -1 },
|
||||
{ sourceReceipt: '' }, { sourceReceipt: 'foreign-completed-source' },
|
||||
]) {
|
||||
const corrupt = { ...receipt, ...patch };
|
||||
corrupt.signature = signGuildBetrayalReceipt(corrupt);
|
||||
assert.throws(() => validateGuildBetrayalHistory(context, [corrupt], settled, null));
|
||||
}
|
||||
assert.throws(() => validateGuildBetrayalHistory(context, [receipt], null as never, null));
|
||||
});
|
||||
|
||||
void test('completed source facts survive later legitimate alliances, Nexus recycling and a second native invoice', () => {
|
||||
const first = createGuildBetrayalInvoice(context, authority(), 1);
|
||||
const receipt = completed(first);
|
||||
const laterPlayers = players.map(player => player.id === 'holder' ? { ...player, ally: 'guild' }
|
||||
: player.id === 'guild' ? { ...player, ally: 'holder' }
|
||||
: { ...player, ally: null });
|
||||
const later = { turn: 4, players: laterPlayers };
|
||||
const ownNative = authority({ shipper: 'shipper', price: 2, ownSpice: 2,
|
||||
allyPayment: 0, allyEscrow: 0, donor: null, source: 'homeworld',
|
||||
sourceSignature: 'later-physical-source', originalReceiver: null });
|
||||
const second = createGuildBetrayalInvoice(later, ownNative, 2);
|
||||
validateGuildBetrayalHistory(later, [receipt], cursor(second, 'pending'), second);
|
||||
const pass = createGuildBetrayalReceipt(later, second, {
|
||||
recipient: null, holderFaction: null, holderUnallied: null, nexusDiscardIndex: null,
|
||||
sourceReceipt: nexusGuildBetrayalSourceReceipt(second),
|
||||
});
|
||||
validateGuildBetrayalHistory(later, [receipt, pass], cursor(second, 'completed'), null);
|
||||
// Recycled discard position may shrink; original historic holder need not remain unallied.
|
||||
const recycled = createGuildBetrayalReceipt({ turn: 4, players }, second, {
|
||||
recipient: 'holder', holderFaction: 'atreides', holderUnallied: true, nexusDiscardIndex: 0,
|
||||
sourceReceipt: nexusGuildBetrayalSourceReceipt(second),
|
||||
});
|
||||
validateGuildBetrayalHistory(later, [receipt, recycled], cursor(second, 'completed'), null);
|
||||
assert.equal(guildBetrayalEligible(cards(), laterPlayers, 'holder'), false);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
@@ -329,3 +330,38 @@ for (const advanced of [false, true]) {
|
||||
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
for (const homeworlds of [false, true]) {
|
||||
void test(`Guild Betrayal ${advanced ? 'Advanced' : 'Basic'} ${homeworlds ? 'Homeworld' : 'base'} entry preserves explicit modules and rejects redealing without touching other saves`, (t) => {
|
||||
const { db } = fixture(t);
|
||||
let game = createGame('PROTOTYP', newPlayer('g', 'Guild', 'guild'), advanced);
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
if (homeworlds)
|
||||
game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
|
||||
for (const p of game.players) p.ready = true;
|
||||
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
|
||||
db.prepare('UPDATE seats SET player_id=? WHERE room_code=?').run('g', game.code);
|
||||
db.prepare('INSERT INTO seats VALUES(?,?,?)').run(game.code, 'e', 'unchanged-emperor-session');
|
||||
const seats = db.prepare('SELECT * FROM seats').all();
|
||||
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
|
||||
const result = startPrototypeRoom(db, game.code, 7, 'guild-betrayal');
|
||||
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
|
||||
assert.equal(result.version, 8);
|
||||
assert.equal(saved.status, 'setup');
|
||||
assert.equal(saved.guildBetrayalPreview, true);
|
||||
assert.equal(!!saved.homeworlds, homeworlds);
|
||||
assert.deepEqual([...saved.nexusCards!.cards!.deck].sort((a,b) => a.localeCompare(b)),
|
||||
[...NEXUS_FACTIONS].sort((a,b) => a.localeCompare(b)));
|
||||
assert.deepEqual(saved.expansions, []);
|
||||
for (const p of game.players)
|
||||
assert.equal(saved.players.find(seat => seat.id === p.id)!.name, p.name);
|
||||
const rows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||
assert.throws(() => startPrototypeRoom(db, game.code, 7, 'guild-betrayal'));
|
||||
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'guild-betrayal'));
|
||||
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rows);
|
||||
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
|
||||
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { tableActionOwner } from '../game/table-turn';
|
||||
import { choamKullGame, kullShipmentAttempt } from './fixture-choam-kull';
|
||||
import { createRicheseBetrayalFixture } from './fixture-richese-betrayal';
|
||||
import { createGuildBetrayalFixture } from './fixture-nexus-guild-betrayal';
|
||||
|
||||
function battle(advanced = false) {
|
||||
let g = createGame(
|
||||
@@ -235,3 +236,19 @@ void test('neutral Richese Betrayal acknowledgement preempts an underlying buyer
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('neutral Guild Betrayal acknowledgement has no sole turn owner for own or another payer', () => {
|
||||
for (const payer of ['holder', 'other'] as const) {
|
||||
const fixture = createGuildBetrayalFixture({ payer });
|
||||
for (const player of fixture.game.players) {
|
||||
const view = viewGame(fixture.game, player.id);
|
||||
assert.ok(view.guildBetrayalReaction);
|
||||
assert.equal(tableActionOwner(view), null);
|
||||
}
|
||||
const settled = applyAction(fixture.game, fixture.holder, {
|
||||
type: 'guildBetrayalUse', event: fixture.event,
|
||||
});
|
||||
for (const player of settled.players)
|
||||
assert.equal(tableActionOwner(viewGame(settled, player.id)), fixture.shipper);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user