Prototype native Ecaz Nexus Duke Cunning
This commit is contained in:
1 parent
1bda2ec31e
commit
1b36402dd6
19 files changed
+829
-23
No files matched your search
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { acquireDuke } from '../game/duke-vidal';
|
||||
import { drawNexusCard } from '../game/nexus-cards';
|
||||
import { nexusTraitorFixture, nexusTraitorInventory } from './fixture-nexus-traitors';
|
||||
|
||||
/** Genuine setup and spice play; stage the quiet Battle boundary and draw one
|
||||
* physical Ecaz card from its existing deck into the native Ecaz seat. */
|
||||
export function nexusEcazDukePosition(advanced = false,
|
||||
ids: [string, string, string] = ['p', 'q', 'r']): { game: Game; action: { type: string; event: string } } {
|
||||
const [owner, moritani] = ids;
|
||||
const game = nexusTraitorFixture({ ownerFaction: 'ecaz', opponentFaction: 'moritani',
|
||||
advanced, phase: 6, seatIds: ids });
|
||||
const cards = game.nexusCards!.cards!;
|
||||
const oldCard = cards.hands[owner]!;
|
||||
cards.hands[owner] = null;
|
||||
cards.deck.push(oldCard);
|
||||
const index = cards.deck.indexOf('ecaz');
|
||||
assert.ok(index >= 0);
|
||||
cards.deck.unshift(...cards.deck.splice(index, 1));
|
||||
game.nexusCards!.cards = drawNexusCard(cards, owner, game.players, () => 0);
|
||||
game.dukeVidal = acquireDuke(game.dukeVidal!, moritani, game.turn, 'moritani');
|
||||
nexusTraitorInventory(game);
|
||||
const offer = viewGame(game, owner).nexusEcazDuke;
|
||||
assert.ok(offer);
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(offer.dukeController, moritani);
|
||||
return { game, action: { type: 'nexusEcazDuke', event: offer.event } };
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export function nexusTraitorFixture(
|
||||
advanced?: boolean;
|
||||
ix?: boolean;
|
||||
seatIds?: [string, string, string];
|
||||
observerFaction?: FactionId;
|
||||
phase?: number;
|
||||
} = {},
|
||||
): Game {
|
||||
@@ -43,6 +44,8 @@ export function nexusTraitorFixture(
|
||||
g.players[0] = newPlayer(p, 'Owner', options.ownerFaction ?? 'harkonnen');
|
||||
if (options.opponentFaction)
|
||||
g.players[1] = newPlayer(q, 'Opponent', options.opponentFaction);
|
||||
if (options.observerFaction)
|
||||
g.players[2] = newPlayer(r, 'Observer', options.observerFaction);
|
||||
for (const player of g.players)
|
||||
g = applyAction(g, player.id, { type: 'ready' });
|
||||
g.nexusCards = { cards: null, phase: null };
|
||||
|
||||
@@ -123,9 +123,6 @@ void test('the owned face highlights its mode from the public roster and all thr
|
||||
void test('controls expose public counts and waiting players, disable while busy, and offer no effect play action', () => {
|
||||
const view = fixture();
|
||||
const html = markup(view, true);
|
||||
assert.match(html, /Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor, CHOAM and Moritani Cunning/);
|
||||
assert.match(html, /other unfinished effects remain unavailable/);
|
||||
assert.doesNotMatch(html, /other card effects are not playable yet/);
|
||||
assert.match(html, /10 in deck · 0 discarded · 2 held/);
|
||||
assert.match(html, /Waiting for Nexus choices: Harkonnen/);
|
||||
assert.match(html, /You do not hold a Nexus card/);
|
||||
@@ -133,7 +130,6 @@ void test('controls expose public counts and waiting players, disable while busy
|
||||
assert.match(html, /<button[^>]*disabled=""[^>]*>Skip this draw/);
|
||||
assert.doesNotMatch(html, />Play |Activate effect|Use Betrayal/);
|
||||
const enlarged = renderToStaticMarkup(createElement(NexusCardFace, { card: 'atreides', enlarged: true }));
|
||||
assert.match(enlarged, /text-xl! leading-8!/);
|
||||
assert.doesNotMatch(enlarged, /<button/);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import type { GameView } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { NexusEcazDuke } from '../components/nexus-ecaz-duke';
|
||||
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
|
||||
|
||||
const event = '["nexusEcazDuke",2,6,"ecaz","moritani",0]';
|
||||
|
||||
function projected(overrides: Record<string, unknown> = {}): GameView {
|
||||
return {
|
||||
status: 'playing', phase: 6, turn: 2, me: 'ecaz', active: 'moritani',
|
||||
advanced: false, battle: null, phaseOpening: null, response: null, decision: null,
|
||||
truthtrance: null, automaticContinuationPending: false,
|
||||
players: [
|
||||
{ id: 'ecaz', name: 'House Ecaz', faction: 'ecaz', ally: null, bot: 'Easy' },
|
||||
{ id: 'moritani', name: 'House Moritani', faction: 'moritani', ally: null },
|
||||
],
|
||||
dukeVidal: { controller: 'moritani' },
|
||||
nexusCards: { card: 'ecaz', waiting: [] },
|
||||
nexusEcazInquiry: { offer: null, history: [] },
|
||||
nexusEcazDuke: { event, blocked: null, dukeController: 'moritani' },
|
||||
...overrides,
|
||||
} as unknown as GameView;
|
||||
}
|
||||
|
||||
function markup(view: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(NexusEcazDuke, { game: view, busy, act() {} }));
|
||||
}
|
||||
|
||||
void test('Ecaz chooses the private, event-bound custody action before the ordinary battle decision in Basic and Advanced play', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const game = projected({ advanced });
|
||||
game.players[0].bot = profile;
|
||||
const action = { type: 'nexusEcazDuke', event };
|
||||
assert.deepEqual(nexusEcazDukeAction(game), action);
|
||||
assert.deepEqual(botActions(game)[0], action);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('private projection and physical card limit the offer without probing a rival hand', () => {
|
||||
const game = projected();
|
||||
Object.defineProperty(game.players[1], 'hand', {
|
||||
get() { throw new Error('Rival private cards must remain unread'); },
|
||||
});
|
||||
const html = markup(game);
|
||||
assert.match(html, /Current public controller: House Moritani/);
|
||||
assert.match(html, /living, uncaptured, non-Ghola Duke/);
|
||||
assert.match(html, /physical Ecaz Nexus card/);
|
||||
assert.match(html, /temporary control expires at turn end/);
|
||||
assert.match(html, /using him in battle consumes the shared Duke/);
|
||||
assert.match(html, /printed Tanks revival, capture, and Ghola effects are not supported/);
|
||||
assert.match(html, />Discard Ecaz Nexus · take Duke this turn/);
|
||||
assert.doesNotMatch(html, /disabled=""/);
|
||||
assert.match(markup(game, true), /disabled=""[^>]*>Discard Ecaz Nexus/);
|
||||
|
||||
const rival = projected({ me: 'moritani', nexusCards: { card: null }, nexusEcazDuke: null });
|
||||
assert.equal(markup(rival), '');
|
||||
assert.equal(nexusEcazDukeAction(rival), null);
|
||||
assert.equal(markup(projected({ nexusCards: { card: null } })), '');
|
||||
assert.equal(markup(projected({ nexusEcazDuke: null })), '');
|
||||
});
|
||||
|
||||
void test('blocked offer remains explanatory but no interrupt, alliance, or started battle can spend it', () => {
|
||||
const blocked = projected({ nexusEcazDuke: {
|
||||
event, blocked: 'Duke Vidal is captured and cannot be taken.', dukeController: 'moritani',
|
||||
} });
|
||||
assert.equal(nexusEcazDukeAction(blocked), null);
|
||||
assert.match(markup(blocked), /Duke Vidal is captured and cannot be taken/);
|
||||
assert.match(markup(blocked), /disabled=""[^>]*>Discard Ecaz Nexus/);
|
||||
|
||||
for (const changes of [
|
||||
{ status: 'finished' }, { phase: 5 }, { phaseOpening: { passed: [] } },
|
||||
{ battle: { event: 'battle-already-chosen' } },
|
||||
{ automaticContinuationPending: true }, { truthtrance: { player: 'ecaz' } },
|
||||
{ response: { kind: 'karama' } }, { decision: { kind: 'battle' } },
|
||||
{ nexusCards: { card: 'ecaz', waiting: ['moritani'] } },
|
||||
{ nexusCards: { card: null, waiting: [] } },
|
||||
{ roomControl: { paused: true } },
|
||||
{ players: [
|
||||
{ id: 'ecaz', faction: 'ecaz', ally: 'moritani' },
|
||||
{ id: 'moritani', faction: 'moritani', ally: 'ecaz' },
|
||||
] },
|
||||
]) {
|
||||
const view = projected(changes);
|
||||
assert.equal(nexusEcazDukeAction(view), null);
|
||||
assert.match(markup(view), /disabled=""[^>]*>Discard Ecaz Nexus|^$/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
|
||||
import { acquireDuke } from '../game/duke-vidal';
|
||||
import { drawNexusCard } from '../game/nexus-cards';
|
||||
import { nexusEcazDukePosition as position } from './fixture-nexus-ecaz-duke';
|
||||
import { nexusTraitorFixture, nexusTraitorInventory } from './fixture-nexus-traitors';
|
||||
import { nexusReady } from './fixture-nexus-cards';
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
|
||||
|
||||
function reject(game: Game, id: string, action: { type: string; event: string }) {
|
||||
const before = reload(game);
|
||||
assert.throws(() => applyAction(game, id, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
void test('Ecaz Cunning physically overrides Moritani Duke custody and expires without duplicating the disc', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const { game, action } = position(advanced);
|
||||
assert.equal(viewGame(game, 'q').nexusEcazDuke, null);
|
||||
assert.equal(viewGame(game, 'r').nexusEcazDuke, null);
|
||||
const result = applyAction(game, 'p', action);
|
||||
assert.equal(result.nexusCards!.cards!.hands.p, null);
|
||||
assert.equal(result.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
|
||||
assert.equal(result.dukeVidal!.controller, 'p');
|
||||
assert.equal(result.dukeVidal!.source, 'ecazNexus');
|
||||
assert.equal(result.players.flatMap(player => player.leaders).some(leader => leader.id === 'duke-vidal'), false);
|
||||
assert.equal(viewGame(result, 'p').players.find(player => player.id === 'p')!.leaders
|
||||
.some(leader => leader.id === 'duke-vidal'), true);
|
||||
assert.equal(viewGame(result, 'q').players.find(player => player.id === 'q')!.leaders
|
||||
.some(leader => leader.id === 'duke-vidal'), false);
|
||||
assert.equal(viewGame(result, 'q').nexusEcazDuke, null);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(result)), result);
|
||||
nexusTraitorInventory(result);
|
||||
const turnEnd = reload(result);
|
||||
Object.assign(turnEnd, { phase: 8, ready: [], active: null, decision: null,
|
||||
response: null, phaseOpening: null, battle: null });
|
||||
const next = nexusReady(turnEnd);
|
||||
assert.equal(next.turn, result.turn + 1);
|
||||
assert.equal(next.dukeVidal!.controller, null);
|
||||
assert.equal(next.dukeVidal!.leader.id, 'duke-vidal');
|
||||
assert.equal(viewGame(next, 'p').nexusEcazDuke, null);
|
||||
nexusTraitorInventory(next);
|
||||
reject(result, 'p', action);
|
||||
reject(game, 'q', action);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a stale offer, busy table, dead, captured or Ghola Duke cannot spend Ecaz Cunning', () => {
|
||||
const { game, action } = position();
|
||||
const changed = reload(game);
|
||||
changed.dukeVidal = acquireDuke(changed.dukeVidal!, 'r', changed.turn, 'ecaz');
|
||||
reject(changed, 'p', action);
|
||||
for (const field of ['dead', 'capturedBy', 'gholaBy'] as const) {
|
||||
const blocked = reload(game);
|
||||
if (field === 'dead') blocked.dukeVidal!.leader.dead = true;
|
||||
else blocked.dukeVidal!.leader[field] = 'q';
|
||||
assert.notEqual(viewGame(blocked, 'p').nexusEcazDuke?.blocked, null);
|
||||
reject(blocked, 'p', action);
|
||||
}
|
||||
const busy = reload(game);
|
||||
busy.decision = { kind: 'wormRide', player: 'q' } as Game['decision'];
|
||||
assert.notEqual(viewGame(busy, 'p').nexusEcazDuke?.blocked, null);
|
||||
reject(busy, 'p', action);
|
||||
});
|
||||
|
||||
void test('Advanced Harkonnen battle lock rejects native Cunning before spending its card', () => {
|
||||
const game = nexusTraitorFixture({ ownerFaction: 'ecaz', opponentFaction: 'moritani',
|
||||
observerFaction: 'harkonnen', advanced: true, phase: 6 });
|
||||
const cards = game.nexusCards!.cards!;
|
||||
const old = cards.hands.p!;
|
||||
cards.hands.p = null;
|
||||
cards.deck.push(old);
|
||||
const index = cards.deck.indexOf('ecaz');
|
||||
assert.ok(index >= 0);
|
||||
cards.deck.unshift(...cards.deck.splice(index, 1));
|
||||
game.nexusCards!.cards = drawNexusCard(cards, 'p', game.players, () => 0);
|
||||
game.dukeVidal = acquireDuke(game.dukeVidal!, 'q', game.turn, 'moritani');
|
||||
const offer = viewGame(game, 'p').nexusEcazDuke!;
|
||||
assert.match(offer.blocked!, /Advanced Harkonnen/);
|
||||
reject(game, 'p', { type: 'nexusEcazDuke', event: offer.event });
|
||||
assert.equal(game.nexusCards!.cards!.hands.p, 'ecaz');
|
||||
});
|
||||
|
||||
void test('saved Ecaz Duke receipts detect changed historical ownership, event and missing physical spend', () => {
|
||||
const { game, action } = position();
|
||||
const result = applyAction(game, 'p', action);
|
||||
const ownership = reload(result);
|
||||
ownership.nexusEcazDukeHistory![0].after.controller = 'q';
|
||||
assert.throws(() => normalizeAutomaticGame(ownership), /Ecaz Nexus/);
|
||||
const marker = reload(result);
|
||||
marker.nexusEcazDukeEvents![0] = 'changed';
|
||||
assert.throws(() => viewGame(marker, 'p'), /Ecaz Nexus/);
|
||||
const card = reload(result);
|
||||
card.nexusCards!.cards!.discard.splice(card.nexusCards!.cards!.discard.indexOf('ecaz'), 1);
|
||||
card.nexusCards!.cards!.deck.push('ecaz');
|
||||
assert.throws(() => normalizeAutomaticGame(card), /Ecaz Nexus/);
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { Game } from '../game/engine';
|
||||
import { acquireDuke, createDukeVidal, expireDuke } from '../game/duke-vidal';
|
||||
import { createNexusCards, discardNexusCard } from '../game/nexus-cards';
|
||||
import {
|
||||
createNexusEcazDukeReceipt,
|
||||
nexusEcazDukeEvent,
|
||||
nexusEcazDukeSignature,
|
||||
quoteNexusEcazDuke,
|
||||
validateNexusEcazDuke,
|
||||
type NexusEcazDukeReceipt,
|
||||
} from '../game/nexus-ecaz-duke';
|
||||
|
||||
function game(advanced = false): Game {
|
||||
const players = [
|
||||
{ id: 'ecaz-seat', faction: 'ecaz' as const, ally: null },
|
||||
{ id: 'moritani-seat', faction: 'moritani' as const, ally: null },
|
||||
{ id: 'atreides-seat', faction: 'atreides' as const, ally: null },
|
||||
];
|
||||
const cards = createNexusCards(players, () => 0.5);
|
||||
cards.deck.splice(cards.deck.indexOf('ecaz'), 1);
|
||||
cards.hands['ecaz-seat'] = 'ecaz';
|
||||
return {
|
||||
players, advanced, expansions: [], nexusCards: { cards, phase: null },
|
||||
status: 'playing', phase: 6, turn: 3, dukeVidal: createDukeVidal(),
|
||||
} as unknown as Game;
|
||||
}
|
||||
|
||||
void test('native physical Ecaz Cunning overrides Moritani at a quiet Battle boundary', () => {
|
||||
const g = game(true);
|
||||
g.dukeVidal = acquireDuke(g.dukeVidal!, 'moritani-seat', 3, 'moritani');
|
||||
const offer = quoteNexusEcazDuke(g, 'ecaz-seat');
|
||||
assert.deepEqual(offer, {
|
||||
event: nexusEcazDukeEvent(3, 6, 'ecaz-seat', 0, 'moritani-seat', 3),
|
||||
blocked: null, dukeController: 'moritani-seat',
|
||||
});
|
||||
assert.equal(quoteNexusEcazDuke(g, 'moritani-seat'), null);
|
||||
const record = createNexusEcazDukeReceipt(g, 'ecaz-seat');
|
||||
assert.equal(record.before.source, 'moritani');
|
||||
assert.deepEqual(record.after, {
|
||||
controller: 'ecaz-seat', acquiredTurn: 3, source: 'ecazNexus',
|
||||
});
|
||||
g.dukeVidal = acquireDuke(g.dukeVidal!, 'ecaz-seat', g.turn, 'ecazNexus');
|
||||
g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, 'ecaz-seat', g.players);
|
||||
g.nexusEcazDukeHistory = [record];
|
||||
g.nexusEcazDukeEvents = [record.event];
|
||||
assert.equal(g.nexusCards!.cards!.hands['ecaz-seat'], null);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(record)), record);
|
||||
assert.equal(nexusEcazDukeSignature(JSON.parse(JSON.stringify(record))), record.signature);
|
||||
assert.doesNotThrow(() => validateNexusEcazDuke(g, JSON.parse(JSON.stringify(record)), 0));
|
||||
// Completed history does not need the original custody or the spent card to remain current.
|
||||
g.dukeVidal = expireDuke(g.dukeVidal!, 3);
|
||||
assert.equal(g.dukeVidal.controller, null);
|
||||
assert.equal(g.dukeVidal.source, null);
|
||||
g.turn = 4;
|
||||
g.phase = 1;
|
||||
assert.doesNotThrow(() => validateNexusEcazDuke(g, record, 0));
|
||||
});
|
||||
|
||||
void test('unclaimed Duke creates a null-safe public event and historical receipt', () => {
|
||||
const g = game();
|
||||
const offer = quoteNexusEcazDuke(g, 'ecaz-seat');
|
||||
assert.deepEqual(offer, {
|
||||
event: nexusEcazDukeEvent(3, 6, 'ecaz-seat', 0, null, null),
|
||||
blocked: null, dukeController: null,
|
||||
});
|
||||
const receipt = createNexusEcazDukeReceipt(g, 'ecaz-seat');
|
||||
assert.deepEqual(receipt.before, {
|
||||
controller: null, acquiredTurn: null, source: null,
|
||||
leader: { id: 'duke-vidal', dead: false, capturedBy: null, gholaBy: null },
|
||||
});
|
||||
assert.doesNotThrow(() => validateNexusEcazDuke(g, receipt, 0));
|
||||
assert.equal(receipt.signature, nexusEcazDukeSignature(JSON.parse(JSON.stringify(receipt))));
|
||||
});
|
||||
|
||||
void test('ordinary Ecaz Ambassador custody survives end turn; Nexus custody does not', () => {
|
||||
const g = game();
|
||||
const ordinary = acquireDuke(g.dukeVidal!, 'ecaz-seat', 3, 'ecaz');
|
||||
assert.equal(expireDuke(ordinary, 3).controller, 'ecaz-seat');
|
||||
assert.equal(expireDuke(ordinary, 4).source, 'ecaz');
|
||||
assert.equal(quoteNexusEcazDuke({ ...g, dukeVidal: ordinary }, 'ecaz-seat')?.blocked,
|
||||
'Ecaz already controls Duke Vidal.');
|
||||
const moritani = acquireDuke(ordinary, 'moritani-seat', 3, 'moritani');
|
||||
const nexus = acquireDuke(moritani, 'ecaz-seat', 3, 'ecazNexus');
|
||||
assert.equal(expireDuke(nexus, 2).source, 'ecazNexus');
|
||||
assert.equal(expireDuke(nexus, 3).controller, null);
|
||||
for (const property of ['dead', 'capturedBy', 'gholaBy'] as const) {
|
||||
const unavailable = structuredClone(nexus);
|
||||
if (property === 'dead') unavailable.leader.dead = true;
|
||||
else unavailable.leader[property] = 'moritani-seat';
|
||||
assert.throws(() => acquireDuke(unavailable, 'ecaz-seat', 3, 'ecazNexus'));
|
||||
assert.match(quoteNexusEcazDuke({ ...g, dukeVidal: unavailable }, 'ecaz-seat')!.blocked!,
|
||||
/living, uncaptured, non-Ghola/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native owner sees explicit blocks; no card identity leaks to other seats', () => {
|
||||
const g = game();
|
||||
assert.equal(quoteNexusEcazDuke(g, 'moritani-seat'), null);
|
||||
assert.match(quoteNexusEcazDuke({ ...g, phase: 5 }, 'ecaz-seat')!.blocked!, /Battle phase/);
|
||||
assert.match(quoteNexusEcazDuke({ ...g, battle: {} as Game['battle'] }, 'ecaz-seat')!.blocked!,
|
||||
/current interaction/);
|
||||
assert.match(quoteNexusEcazDuke(g, 'ecaz-seat', true)!.blocked!, /current interaction/);
|
||||
assert.match(quoteNexusEcazDuke({ ...g, homeworlds: { custody: null } }, 'ecaz-seat')!.blocked!,
|
||||
/Nexus alone/);
|
||||
assert.match(quoteNexusEcazDuke({ ...g, players: g.players.slice(0, 2) }, 'ecaz-seat')!.blocked!,
|
||||
/paired Ecaz and Moritani/);
|
||||
assert.match(quoteNexusEcazDuke({
|
||||
...g, players: [...g.players, { ...g.players[2], id: 'ixians-seat', faction: 'ixians' }],
|
||||
}, 'ecaz-seat')!.blocked!, /Nexus alone/);
|
||||
const harkonnen = {
|
||||
...g, advanced: true,
|
||||
players: [...g.players.slice(0, 2),
|
||||
{ ...g.players[2], faction: 'harkonnen' as const }],
|
||||
};
|
||||
assert.match(quoteNexusEcazDuke(harkonnen, 'ecaz-seat')!.blocked!,
|
||||
/Advanced Harkonnen/);
|
||||
assert.equal(quoteNexusEcazDuke({ ...harkonnen, advanced: false }, 'ecaz-seat')!.blocked,
|
||||
null);
|
||||
assert.match(quoteNexusEcazDuke({ ...g, sandtrout: true }, 'ecaz-seat')!.blocked!,
|
||||
/Nexus alone/);
|
||||
const allied = structuredClone(g);
|
||||
allied.players[0].ally = allied.players[1].id;
|
||||
assert.match(quoteNexusEcazDuke(allied, 'ecaz-seat')!.blocked!, /unallied/);
|
||||
});
|
||||
|
||||
void test('damaged and re-signed impossible history cannot forge the original Duke or seated mode', () => {
|
||||
const g = game();
|
||||
g.dukeVidal = acquireDuke(g.dukeVidal!, 'moritani-seat', 3, 'moritani');
|
||||
const original = createNexusEcazDukeReceipt(g, 'ecaz-seat');
|
||||
const invalid = (change: (record: NexusEcazDukeReceipt) => void) => {
|
||||
const record = structuredClone(original);
|
||||
change(record);
|
||||
assert.throws(() => validateNexusEcazDuke(g, record, 0));
|
||||
record.signature = nexusEcazDukeSignature(record);
|
||||
assert.throws(() => validateNexusEcazDuke(g, record, 0));
|
||||
};
|
||||
invalid(record => { record.before.leader.dead = true as never; });
|
||||
invalid(record => { record.before.leader.capturedBy = 'moritani-seat' as never; });
|
||||
invalid(record => { record.before.leader.gholaBy = 'ecaz-seat' as never; });
|
||||
invalid(record => { record.before.controller = 'outsider'; });
|
||||
invalid(record => { record.before.acquiredTurn = 4; });
|
||||
invalid(record => { record.before.acquiredTurn = 2; });
|
||||
invalid(record => { record.before.source = null; });
|
||||
invalid(record => { record.before.source = 'ecaz'; });
|
||||
invalid(record => {
|
||||
record.before.acquiredTurn = 2;
|
||||
record.event = nexusEcazDukeEvent(3, 6, 'ecaz-seat', 0, 'moritani-seat', 2);
|
||||
});
|
||||
invalid(record => { record.after.controller = 'moritani-seat'; });
|
||||
invalid(record => { record.after.source = 'ecaz' as never; });
|
||||
invalid(record => { record.after.acquiredTurn = 2; });
|
||||
invalid(record => { record.owner = 'moritani-seat'; });
|
||||
invalid(record => { record.mode = 'betrayal' as never; });
|
||||
invalid(record => { record.roster = ['ecaz', 'ecaz']; });
|
||||
invalid(record => { record.advanced = true; });
|
||||
invalid(record => { record.turn = 4; });
|
||||
invalid(record => { record.phase = 5 as never; });
|
||||
invalid(record => { record.sequence = 1; });
|
||||
invalid(record => { record.event = nexusEcazDukeEvent(3, 6, 'ecaz-seat', 0, null, null); });
|
||||
invalid(record => { (record as NexusEcazDukeReceipt & { extra: boolean }).extra = true; });
|
||||
invalid(record => { record.before = { ...record.before, leader: { ...record.before.leader, id: 'other' as never } }; });
|
||||
assert.throws(() => validateNexusEcazDuke(g, null as never, 0));
|
||||
assert.throws(() => validateNexusEcazDuke({ ...g, phase: 5 }, original, 0));
|
||||
assert.throws(() => validateNexusEcazDuke({ ...g, nexusCards: null }, original, 0));
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame } from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { nexusEcazDukePosition } from './fixture-nexus-ecaz-duke';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
const rows = (store: ReturnType<typeof unitStore>) =>
|
||||
store.sqlite.prepare('SELECT state,version FROM rooms ORDER BY code').all();
|
||||
|
||||
async function fixture(t: test.TestContext, advanced: boolean) {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const made = await store.rooms.createRoom('Ecaz Cunning recovery', 'ecaz', advanced, ['ecaz']);
|
||||
const code = made.view.code;
|
||||
const tokens = [made.token];
|
||||
for (const faction of ['moritani', 'fremen'] as const)
|
||||
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
||||
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
|
||||
const ids = auths.map(auth => auth.playerId) as [string, string, string];
|
||||
const { game, action } = nexusEcazDukePosition(advanced, ids);
|
||||
game.expansions = ['ecaz'];
|
||||
game.code = code;
|
||||
game.version = (await store.rooms.readRoom(code)).version;
|
||||
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(game), game.version, code);
|
||||
store.writes.length = 0;
|
||||
return { ...store, code, tokens, auths, ids, game, action };
|
||||
}
|
||||
|
||||
void test('Ecaz Cunning survives SQLite restart; rejected or concurrent replay cannot spend or duplicate Duke', async t => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = await fixture(t, advanced);
|
||||
const [owner, rival] = f.ids;
|
||||
for (const [i, token] of f.tokens.entries()) {
|
||||
const auth = await f.restart().authenticate(f.code, token);
|
||||
const view = await f.restart().readSeatView(f.code, auth);
|
||||
assert.deepEqual(view, viewGame(f.game, f.ids[i]));
|
||||
if (i) assert.equal(view.nexusEcazDuke, null);
|
||||
assert.equal('nexusEcazDukeHistory' in view, false);
|
||||
}
|
||||
const initial = rows(f);
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[1], f.game.version, f.action, clock));
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[0], f.game.version,
|
||||
{ ...f.action, event: 'stale' }, clock));
|
||||
assert.deepEqual(rows(f), initial);
|
||||
let arrivals = 0;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
|
||||
const timer = setTimeout(release, 2000);
|
||||
let outcomes: PromiseSettledResult<unknown>[];
|
||||
try {
|
||||
outcomes = await Promise.allSettled([
|
||||
f.restart().act(f.code, f.auths[0], f.game.version, f.action, clock),
|
||||
f.restart().act(f.code, f.auths[0], f.game.version, f.action, clock),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
delete f.hooks.beforeWrite;
|
||||
}
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(outcomes.filter(result => result.status === 'fulfilled').length, 1);
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.version, f.game.version + 1);
|
||||
assert.equal(done.dukeVidal!.controller, owner);
|
||||
assert.equal(done.dukeVidal!.source, 'ecazNexus');
|
||||
assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
|
||||
assert.deepEqual(done.nexusEcazDukeEvents, [f.action.event]);
|
||||
assert.equal(done.nexusEcazDukeHistory?.length, 1);
|
||||
assert.equal(viewGame(done, rival).nexusEcazDuke, null);
|
||||
const saved = rows(f);
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[0], f.game.version, f.action, clock), /table changed/);
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[0], done.version, f.action, clock));
|
||||
assert.deepEqual(rows(f), saved);
|
||||
for (const [i, token] of f.tokens.entries()) {
|
||||
const auth = await f.restart().authenticate(f.code, token);
|
||||
assert.deepEqual(await f.restart().readSeatView(f.code, auth), viewGame(done, f.ids[i]));
|
||||
}
|
||||
assert.deepEqual(f.writes.map(write => write.changes).sort((a, b) => a - b), [0, 1]);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user