Prototype native Ecaz Nexus Duke Cunning

This commit is contained in:
Quintine committed 2026-09-28 14:20:18 +10:00
1 parent 1bda2ec31e
commit 1b36402dd6
19 files changed
+829 -23

No files matched your search

+29
View File
@@ -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 } };
}
+3
View File
@@ -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 };
-4
View File
@@ -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/);
});
+93
View File
@@ -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|^$/);
}
});
+99
View File
@@ -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/);
});
+167
View File
@@ -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));
});
+83
View File
@@ -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]);
}
});