Prototype CHOAM Nexus Collection trade and inventory Discoveries
An unallied player holding CHOAM Nexus while CHOAM is absent can now discard one Worthless card during Spice Collection for two bank spice. The server spends both physical cards once, exposes only private legal card choices, and persists an already-paid discard receipt. Human controls and all four AI profiles share the action. The after-victory inspection alternative remains missing; normal unfinished modes and publication stay gated. Independent review caught and fixed a queued Grumman Collection lock mismatch. Focused tests cover Basic/Advanced, actual card custody, private controls, all AI profiles, stale and wrong-mode rejection, Truthtrance and Grumman locks, JSON corruption/replay, competing production SQLite writes and private seat restoration. A separate bounded acquisition helper recorded all seven Discovery Spice Cards, eight tokens and exact physical card placements from publisher-authored sources. Discovery runtime remains missing. Validation: types, lint, 4,217 offline tests, production build and 40 HTTP tests passed. Durations: check 139298 ms; build 6617 ms; HTTP 24329 ms. Source fingerprint dfdaaf8bafe69acd31c9d0afc13f7a0d070da43deb544f091e95fe840b399493; private report under ~/.local/share/dune/checkpoints/20260913-choam-nexus-trade/verification/report.json. Browser reference search, Prototyped label and related Collection link checked. All 95 opening saved rooms unchanged; 30 HTTP test rooms added, 125 total. No server restart or database reset. Efficiency counter: 59,982,405 at batch start; 60,581,959 after broad verification, delta 599,554 aggregate tokens before final preservation/Git/usage checks. One newly playable function plus the acquired Discovery inventory; this is not full CHOAM or Nexus compliance.
This commit is contained in:
1 parent
5a76ae0410
commit
4af6e08b3a
18 files changed
+663
-3
No files matched your search
@@ -3,6 +3,7 @@ import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeHomeworldGameForAudit,
|
||||
initializeNexusGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
@@ -13,6 +14,11 @@ import { baseDeck, type Card } from '../game/cards';
|
||||
import { botActions } from '../game/bots';
|
||||
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { placeTerror, type TerrorKind } from '../game/moritani-terror';
|
||||
import {
|
||||
enterNexusSpice,
|
||||
finishNexusSpice,
|
||||
orderNexusSpice,
|
||||
} from './fixture-nexus-cards';
|
||||
|
||||
export const grummanPlayer = (g: Game, id: string) =>
|
||||
g.players.find((p) => p.id === id)!;
|
||||
@@ -25,6 +31,7 @@ export function grummanCollectionFixture(
|
||||
options: {
|
||||
native?: number;
|
||||
advanced?: boolean;
|
||||
nexus?: boolean;
|
||||
seatIds?: [string, string, string];
|
||||
} = {},
|
||||
): Game {
|
||||
@@ -38,8 +45,11 @@ export function grummanCollectionFixture(
|
||||
joinGame(g, newPlayer(a, 'Atreides', 'atreides'));
|
||||
joinGame(g, newPlayer(h, 'Harkonnen', 'harkonnen'));
|
||||
g = applyAction(g, m, { type: 'homeworlds', enabled: true });
|
||||
if (options.nexus) g.nexusCards = { cards: null, phase: null };
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
g = initializeHomeworldGameForAudit(g);
|
||||
g = options.nexus
|
||||
? initializeNexusGameForAudit(g)
|
||||
: initializeHomeworldGameForAudit(g);
|
||||
for (let n = 0; g.status === 'setup' && n < 60; n++) {
|
||||
let next: Game | undefined;
|
||||
for (const p of g.players) {
|
||||
@@ -66,6 +76,11 @@ export function grummanCollectionFixture(
|
||||
moved: 0,
|
||||
});
|
||||
}
|
||||
if (options.nexus) {
|
||||
g = enterNexusSpice(g);
|
||||
orderNexusSpice(g, ['land', 'land']);
|
||||
g = finishNexusSpice(g);
|
||||
}
|
||||
const owner = grummanPlayer(g, m);
|
||||
owner.reserves = options.native ?? 8;
|
||||
owner.forces = { 'polar_sink:0': 20 - owner.reserves };
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Game } from '../game/engine';
|
||||
import { nexusChoamTradeAction } from '../game/nexus-choam-trade-options';
|
||||
import { nexusChoamTradeSignature } from '../game/nexus-choam-trade';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { nexusTraitorFixture, nexusTraitorInventory } from './fixture-nexus-traitors';
|
||||
import { holdNexusChoamCard } from './fixture-nexus-choam';
|
||||
|
||||
export function nexusChoamTradeFixture(advanced = false, seatIds: [string, string, string] = ['p', 'q', 'r']) {
|
||||
const g = nexusTraitorFixture({ ownerFaction: 'atreides', advanced, phase: 7, seatIds });
|
||||
const [owner, target, observer] = seatIds;
|
||||
const cards = g.nexusCards!.cards!, index = cards.deck.indexOf('choam');
|
||||
assert.ok(index >= 0);
|
||||
cards.deck[index] = cards.hands[owner]!;
|
||||
cards.hands[owner] = 'choam';
|
||||
const cost = holdNexusChoamCard(g, owner, 'worthless');
|
||||
const spare = holdNexusChoamCard(g, owner, 'worthless');
|
||||
const other = holdNexusChoamCard(g, owner, 'projectile');
|
||||
nexusTraitorInventory(g);
|
||||
const action = nexusChoamTradeAction(viewGame(g, owner), cost.id)!;
|
||||
assert.ok(action);
|
||||
return { g, owner, target, observer, cost, spare, other, action };
|
||||
}
|
||||
/** Restore the exact already-paid semantic discard boundary. The normal live
|
||||
* action drains it automatically; recovery must never repeat its payment. */
|
||||
export function pausedNexusChoamTrade(completed: Game): Game {
|
||||
const g = structuredClone(completed), record = g.nexusChoamTrades!.at(-1)!;
|
||||
record.stage = 'discard'; record.signature = nexusChoamTradeSignature(record);
|
||||
g.nexusChoamTradeLast = {event: record.event, stage: record.stage};
|
||||
const sequence = g.treacheryDiscardSequence!;
|
||||
g.resolvedTreacheryDiscardSequence = sequence - 1;
|
||||
g.pendingTreacheryDiscard = { sequence, batch: {
|
||||
event: `discard:${g.turn}:${g.phase}:${sequence}`, turn: g.turn, phase: g.phase,
|
||||
cause: 'nexus:choamTrade', entries: [{card: structuredClone(g.discard.find(card => card.id === record.card)!), discardedBy: record.owner, publicFace: true}],
|
||||
}, continuation: {kind: 'nexusChoamTrade', event: record.event, owner: record.owner, card: record.card, spiceAfter: record.spiceBefore + 2} };
|
||||
return g;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame } from '../game/engine';
|
||||
import { nexusChoamTradeAction } from '../game/nexus-choam-trade-options';
|
||||
import {
|
||||
enterGrummanCollection,
|
||||
grummanCollectionFixture,
|
||||
grummanPlayer,
|
||||
grummanReload,
|
||||
holdGrummanCard,
|
||||
} from './fixture-grumman-collection';
|
||||
|
||||
void test('CHOAM Secret Ally trade waits for the queued high-Grumman Collection continuation', () => {
|
||||
let g = grummanCollectionFixture({ native: 7, nexus: true });
|
||||
const owner = g.players.find((player) => player.faction === 'atreides')!.id;
|
||||
const nexus = g.nexusCards!.cards!;
|
||||
const previous = nexus.hands[owner];
|
||||
const heldBy = g.players.find((player) => nexus.hands[player.id] === 'choam');
|
||||
if (heldBy) nexus.hands[heldBy.id] = previous;
|
||||
else {
|
||||
const deck = nexus.deck.indexOf('choam');
|
||||
const discard = nexus.discard.indexOf('choam');
|
||||
assert.ok(deck >= 0 || discard >= 0);
|
||||
if (deck >= 0) {
|
||||
if (previous) nexus.deck[deck] = previous;
|
||||
else nexus.deck.splice(deck, 1);
|
||||
} else if (previous) nexus.discard[discard] = previous;
|
||||
else nexus.discard.splice(discard, 1);
|
||||
}
|
||||
nexus.hands[owner] = 'choam';
|
||||
const worthless = holdGrummanCard(g, owner, 'worthless');
|
||||
|
||||
g = enterGrummanCollection(g);
|
||||
assert.equal(g.grummanCollection?.stage, 'waiting');
|
||||
assert.equal(g.decision, null);
|
||||
const moritani = grummanPlayer(g, g.grummanCollection!.player);
|
||||
moritani.reserves++;
|
||||
moritani.forces['polar_sink:0']--;
|
||||
|
||||
const view = viewGame(g, owner);
|
||||
assert.equal(view.automaticContinuationPending, true);
|
||||
assert.match(view.nexusChoamTrade!.blocked!, /current interaction/);
|
||||
const action = {
|
||||
type: 'nexusChoamTrade',
|
||||
event: view.nexusChoamTrade!.event,
|
||||
card: worthless.id,
|
||||
};
|
||||
assert.equal(nexusChoamTradeAction(view, worthless.id), null);
|
||||
const before = grummanReload(g);
|
||||
assert.throws(() => applyAction(g, owner, action), /current interaction/);
|
||||
assert.deepEqual(g, before);
|
||||
|
||||
const opened = normalizeAutomaticGame(grummanReload(g));
|
||||
assert.equal(opened.grummanCollection?.stage, 'choice');
|
||||
assert.equal(opened.decision?.kind, 'grummanCollection');
|
||||
assert.equal(opened.decision?.player, moritani.id);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { nexusChoamTradeFixture, pausedNexusChoamTrade } from './fixture-nexus-choam-trade';
|
||||
import { viewGame } from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { nexusTraitorInventory } from './fixture-nexus-traitors';
|
||||
|
||||
const clock: RoomsClock = {now: () => 10000, sleep: async () => {}};
|
||||
void test('SQLite competing trades settle once, restore private seats and resume an already-paid discard without another payout', async () => {
|
||||
const store = unitStore();
|
||||
try {
|
||||
const made = await store.rooms.createRoom('Nexus trade SQL', 'atreides', true, []);
|
||||
const code = made.view.code;
|
||||
const tokens = [made.token];
|
||||
for (const faction of ['guild', '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 f = nexusChoamTradeFixture(true, ids);
|
||||
f.g.code = code; f.g.version = (await store.rooms.readRoom(code)).version;
|
||||
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?').run(JSON.stringify(f.g), f.g.version, code);
|
||||
const seats = store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all();
|
||||
let arrivals = 0, release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
store.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
|
||||
const timer = setTimeout(release, 2000);
|
||||
let results: PromiseSettledResult<unknown>[];
|
||||
try {
|
||||
results = await Promise.allSettled([f.cost, f.spare].map(card =>
|
||||
store.restart().act(code, auths[0], f.g.version, {...f.action, card: card.id}, clock)));
|
||||
} finally { clearTimeout(timer); delete store.hooks.beforeWrite; }
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||
const done = await store.restart().readRoom(code);
|
||||
assert.equal(done.players[0].spice, f.g.players[0].spice + 2);
|
||||
assert.equal(done.nexusChoamTrades!.length, 1);
|
||||
assert.equal(done.players[0].hand.length, f.g.players[0].hand.length - 1);
|
||||
assert.equal(done.version, f.g.version + 1);
|
||||
const before = JSON.stringify(done);
|
||||
await assert.rejects(store.restart().act(code, auths[0], done.version, f.action, clock));
|
||||
assert.equal(JSON.stringify(await store.restart().readRoom(code)), before);
|
||||
for (const [i, token] of tokens.entries()) {
|
||||
const rooms = store.restart(), auth = await rooms.authenticate(code, token);
|
||||
assert.deepEqual(await rooms.readSeatView(code, auth), viewGame(done, ids[i]));
|
||||
}
|
||||
const paused = pausedNexusChoamTrade(done);
|
||||
store.sqlite.prepare('UPDATE rooms SET state = ? WHERE code = ?').run(JSON.stringify(paused), code);
|
||||
await store.restart().continueRoomAutomatic(code, clock);
|
||||
const resumed = await store.restart().readRoom(code);
|
||||
assert.equal(resumed.players[0].spice, done.players[0].spice);
|
||||
assert.equal(resumed.nexusChoamTrades![0].stage, 'complete');
|
||||
assert.equal(resumed.pendingTreacheryDiscard, null);
|
||||
const settled = JSON.stringify(resumed);
|
||||
await store.restart().continueRoomAutomatic(code, clock);
|
||||
assert.equal(JSON.stringify(await store.restart().readRoom(code)), settled);
|
||||
assert.deepEqual(store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
|
||||
nexusTraitorInventory(resumed);
|
||||
} finally { store.sqlite.close(); }
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { quoteNexusChoamTrade } from '../game/nexus-choam-trade';
|
||||
import { NexusChoamTrade } from '../components/nexus-choam-trade';
|
||||
import { nexusChoamTradeAction } from '../game/nexus-choam-trade-options';
|
||||
import { holdNexusChoamCard } from './fixture-nexus-choam';
|
||||
import { nexusChoamTradeFixture, pausedNexusChoamTrade } from './fixture-nexus-choam-trade';
|
||||
import { nexusTraitorFixture, nexusTraitorInventory } from './fixture-nexus-traitors';
|
||||
import { nexusReject, nexusReload, nexusReady } from './fixture-nexus-cards';
|
||||
|
||||
void test('Basic and Advanced Collection trade spends both physical cards and pays exactly two bank spice once', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = nexusChoamTradeFixture(advanced), before = nexusReload(f.g);
|
||||
const g = applyAction(f.g, f.owner, f.action);
|
||||
assert.deepEqual(f.g, before);
|
||||
assert.equal(g.players[0].spice, before.players[0].spice + 2);
|
||||
assert.deepEqual(g.players.slice(1), before.players.slice(1));
|
||||
assert.deepEqual(g.players[0].hand, before.players[0].hand.filter(card => card.id !== f.cost.id));
|
||||
assert.equal(g.discard.filter(card => card.id === f.cost.id).length, 1);
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.ok(g.nexusCards!.cards!.discard.includes('choam'));
|
||||
assert.equal(g.nexusChoamTrades!.length, 1);
|
||||
assert.equal(g.nexusChoamTrades![0].stage, 'complete');
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.pendingTreacheryDiscard, null);
|
||||
nexusReject(g, f.owner, f.action);
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
|
||||
assert.equal(nexusReady(g).phase, 8);
|
||||
nexusTraitorInventory(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('private trade choices stay hidden and all four AI profiles use the same legal physical-card action', () => {
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const f = nexusChoamTradeFixture(true);
|
||||
const v = viewGame(f.g, f.owner);
|
||||
v.players.find(p => p.id === f.owner)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(v)[0], f.action);
|
||||
assert.equal(nexusChoamTradeAction(v, f.other.id), null);
|
||||
const opponent = viewGame(f.g, f.target);
|
||||
assert.equal(opponent.nexusChoamTrade, null);
|
||||
assert.equal(opponent.nexusCards?.card, null);
|
||||
const ownHtml = renderToStaticMarkup(createElement(NexusChoamTrade, {game: v, busy: false, act() {}}));
|
||||
assert.match(ownHtml, /Worthless card to trade/);
|
||||
assert.ok(ownHtml.includes(f.cost.name) && ownHtml.includes(f.spare.name));
|
||||
assert.match(ownHtml, /Inspect card/);
|
||||
assert.match(ownHtml, /for 2 spice/);
|
||||
assert.equal(renderToStaticMarkup(createElement(NexusChoamTrade, {game: opponent, busy: false, act() {}})), '');
|
||||
assert.match(renderToStaticMarkup(createElement(NexusChoamTrade, {game: v, busy: true, act() {}})), /disabled=""/);
|
||||
const done = applyAction(f.g, f.owner, botActions(v)[0]);
|
||||
assert.equal(viewGame(done, f.target).nexusChoamTrade, null);
|
||||
assert.equal(Object.hasOwn(viewGame(done, f.target), 'nexusChoamTrades'), false);
|
||||
nexusTraitorInventory(done);
|
||||
}
|
||||
});
|
||||
|
||||
void test('trade rejects stale/forged/foreign/wrong-role requests and does not interrupt Truthtrance', () => {
|
||||
const f = nexusChoamTradeFixture();
|
||||
for (const action of [{...f.action, event: 'stale'}, {...f.action, card: f.other.id},
|
||||
{...f.action, amount: 99}, {...f.action, card: 'missing'}]) nexusReject(f.g, f.owner, action);
|
||||
nexusReject(f.g, f.target, f.action);
|
||||
const phase = nexusReload(f.g); phase.phase = 8;
|
||||
nexusReject(phase, f.owner, f.action, /Collection/);
|
||||
const truth = holdNexusChoamCard(f.g, f.target, 'truthtrance');
|
||||
const pending = applyAction(f.g, f.target, {type: 'card', card: truth.id});
|
||||
assert.ok(pending.truthtrance);
|
||||
assert.match(viewGame(pending, f.owner).nexusChoamTrade!.blocked!, /current interaction/);
|
||||
nexusReject(pending, f.owner, f.action, /current interaction/);
|
||||
const native = nexusTraitorFixture({ownerFaction: 'choam', phase: 7});
|
||||
const deck = native.nexusCards!.cards!;
|
||||
deck.deck[deck.deck.indexOf('choam')] = deck.hands.p!; deck.hands.p = 'choam';
|
||||
assert.equal(quoteNexusChoamTrade(native, 'p', false), null);
|
||||
const betrayal = nexusTraitorFixture({ownerFaction: 'atreides', opponentFaction: 'choam', phase: 7});
|
||||
const second = betrayal.nexusCards!.cards!;
|
||||
second.deck[second.deck.indexOf('choam')] = second.hands.p!; second.hands.p = 'choam';
|
||||
assert.equal(quoteNexusChoamTrade(betrayal, 'p', false), null);
|
||||
});
|
||||
|
||||
void test('paid discard resumes after JSON reload without replay and rejects damaged custody/payment/history', () => {
|
||||
const f = nexusChoamTradeFixture(), done = applyAction(f.g, f.owner, f.action);
|
||||
const paused = pausedNexusChoamTrade(done);
|
||||
assert.ok(viewGame(paused, f.owner).automaticContinuationPending);
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(paused)), done);
|
||||
nexusReject(paused, f.owner, f.action, /committed discard/);
|
||||
for (const damage of [
|
||||
(g: typeof done) => { g.players[0].spice++; },
|
||||
(g: typeof done) => { g.players[0].hand.push(f.cost); },
|
||||
(g: typeof done) => { g.nexusChoamTrades![0].card = f.spare.id; },
|
||||
(g: typeof done) => { delete g.nexusChoamTrades; },
|
||||
(g: typeof done) => { g.pendingTreacheryDiscard = null; g.resolvedTreacheryDiscardSequence = g.treacheryDiscardSequence; },
|
||||
]) {
|
||||
const bad = nexusReload(paused); damage(bad); const before = nexusReload(bad);
|
||||
for (const work of [() => viewGame(bad, f.owner), () => normalizeAutomaticGame(bad), () => applyAction(bad, f.owner, {type: 'advanceBots'})])
|
||||
assert.throws(work, /trade|custody|history|discard/i);
|
||||
assert.deepEqual(bad, before);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user