Connect CHOAM Nexus Betrayal random discard

This commit is contained in:
Quintine committed 2026-09-27 21:54:55 +10:00
1 parent 90c5c7af25
commit 0bf61c8067
12 files changed
+709

No files matched your search

@@ -0,0 +1,121 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { GameView } from '../game/engine';
import {
nexusChoamBetrayalAction,
nexusChoamBetrayalBotActions,
} from '../game/nexus-choam-betrayal-options';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
});
// Register the Next alias hook before loading the component under test.
const { NexusChoamBetrayal } = await import('../components/nexus-choam-betrayal');
function ownerView(overrides: Record<string, unknown> = {}): GameView {
return {
me: 'owner',
players: [
{ id: 'owner', name: 'House Rival', faction: 'atreides', ally: null, bot: 'Easy' },
{ id: 'choam', name: 'CHOAM', faction: 'choam', ally: null },
{ id: 'observer', name: 'Observer', faction: 'guild', ally: null },
],
status: 'playing',
phase: 3,
automaticContinuationPending: false,
nexusCards: { card: 'choam' },
nexusChoamBetrayal: {
event: '["nexusChoamBetrayal",1,3,"owner","choam"]',
blocked: null,
target: { id: 'choam', name: 'CHOAM', handSize: 3 },
},
...overrides,
} as unknown as GameView;
}
function markup(game: GameView, busy = false): string {
return renderToStaticMarkup(createElement(NexusChoamBetrayal, { game, act() {}, busy }));
}
void test('unallied CHOAM card owner offers one server-random discard action at every bot profile', () => {
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = ownerView();
view.players[0].bot = profile;
const action = { type: 'nexusChoamBetrayal', event: view.nexusChoamBetrayal!.event };
assert.deepEqual(nexusChoamBetrayalAction(view), action);
assert.deepEqual(nexusChoamBetrayalBotActions(view), [action]);
}
const projected = ownerView();
Object.defineProperty(projected.players[1], 'hand', {
get() { throw new Error('Rival hand identity read'); },
});
const html = markup(projected);
assert.match(html, /CHOAM Nexus Betrayal/);
assert.match(html, /CHOAM discard one/);
assert.match(html, /CHOAM holds 3 cards during Bidding/);
assert.match(html, /uniformly random/);
assert.match(html, /CHOAM receives no spice/);
assert.match(html, /Spend CHOAM Nexus for random discard/);
assert.doesNotMatch(html, /disabled=""/);
});
void test('blocked and busy private offers explain the restriction and disable the action', () => {
const blocked = ownerView({ nexusChoamBetrayal: {
event: 'offer', blocked: 'Finish the current response first.',
target: { id: 'choam', name: 'CHOAM', handSize: 1 },
} });
assert.equal(nexusChoamBetrayalAction(blocked), null);
assert.deepEqual(nexusChoamBetrayalBotActions(blocked), []);
assert.match(markup(blocked), /Finish the current response first/);
assert.match(markup(blocked), /CHOAM holds 1 card during Bidding/);
assert.match(markup(blocked), /disabled=""[^>]*>Spend CHOAM Nexus/);
assert.match(markup(ownerView(), true), /disabled=""[^>]*>Spend CHOAM Nexus/);
for (const changes of [
{ status: 'finished' },
{ automaticContinuationPending: true },
{ nexusCards: { card: null } },
{ players: [
{ id: 'owner', faction: 'atreides', ally: 'observer' },
{ id: 'choam', faction: 'choam', ally: null },
] },
]) {
const view = ownerView(changes);
assert.equal(nexusChoamBetrayalAction(view), null);
assert.deepEqual(nexusChoamBetrayalBotActions(view), []);
}
assert.match(markup(ownerView({ automaticContinuationPending: true })), /disabled=""[^>]*>Spend CHOAM Nexus/);
const concealed = ownerView({
phase: 2,
nexusChoamBetrayal: {
event: '["nexusChoamBetrayal",1,2,"owner","choam"]',
blocked: null,
target: { id: 'choam', name: 'CHOAM', handSize: null },
},
});
assert.ok(nexusChoamBetrayalAction(concealed));
assert.deepEqual(nexusChoamBetrayalBotActions(concealed), []);
assert.doesNotMatch(markup(concealed), /holds [0-9]+ cards/);
});
void test('nonowner projection exposes neither the offer nor the rival hand or target control', () => {
const observer = ownerView({
me: 'observer',
nexusCards: { card: null },
nexusChoamBetrayal: null,
});
Object.defineProperty(observer.players[1], 'hand', {
get() { throw new Error('Opponent hand read'); },
});
assert.equal(nexusChoamBetrayalAction(observer), null);
assert.deepEqual(nexusChoamBetrayalBotActions(observer), []);
assert.equal(markup(observer), '');
assert.equal(markup(ownerView({ nexusChoamBetrayal: null })), '');
assert.equal(markup(ownerView({ nexusCards: { card: null } })), '');
});
aliases.deregister();
+147
View File
@@ -0,0 +1,147 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { drawNexusCard } from '../game/nexus-cards';
import { nexusChoamBetrayalAction } from '../game/nexus-choam-betrayal-options';
import { nexusChoamBetrayalSignature } from '../game/nexus-choam-betrayal';
import { nexusTraitorFixture, nexusTraitorInventory } from './fixture-nexus-traitors';
function position(advanced = false, phase = 2) {
const g = nexusTraitorFixture({ ownerFaction: 'atreides', opponentFaction: 'choam', advanced, phase });
const owner = 'p', target = 'q', observer = 'r';
const cards = g.nexusCards!.cards!;
const index = cards.deck.indexOf('choam');
assert.ok(index >= 0);
cards.deck[index] = cards.hands[owner]!;
cards.hands[owner] = 'choam';
const victim = g.players.find(player => player.id === target)!;
victim.hand.push(...g.deck.splice(0, 3));
nexusTraitorInventory(g);
const action = nexusChoamBetrayalAction(viewGame(g, owner));
assert.ok(action);
return { g, owner, target, observer, action };
}
function reload(g: Game): Game {
return JSON.parse(JSON.stringify(g)) as Game;
}
function reject(g: Game, owner: string, action: { type: string; [key: string]: unknown }) {
const before = reload(g);
assert.throws(() => applyAction(g, owner, action));
assert.deepEqual(g, before);
}
void test('CHOAM Betrayal spends one Nexus card and randomly discards one actual held card without spice in Basic and Advanced', () => {
for (const advanced of [false, true]) {
const { g, owner, target, observer, action } = position(advanced);
const before = reload(g);
const beforeLog = g.log.length;
const done = applyAction(g, owner, action);
assert.deepEqual(g, before);
const victim = done.players.find(player => player.id === target)!;
const original = before.players.find(player => player.id === target)!;
const removed = original.hand.filter(card => !victim.hand.some(held => held.id === card.id));
assert.equal(removed.length, 1);
assert.deepEqual(victim.hand, original.hand.filter(card => card.id !== removed[0].id));
assert.equal(done.discard.filter(card => card.id === removed[0].id).length, 1);
assert.equal(victim.spice, original.spice);
assert.equal(done.nexusCards!.cards!.hands[owner], null);
assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'choam').length, 1);
assert.equal(done.nexusChoamBetrayals?.at(-1)?.stage, 'complete');
assert.equal(done.nexusChoamBetrayals?.at(-1)?.card, removed[0].id);
assert.equal(done.pendingTreacheryDiscard, null);
assert.equal(done.log.slice(beforeLog).some(row => row.text.includes(removed[0].name)), false);
assert.equal(viewGame(done, observer).nexusChoamBetrayal, null);
assert.equal(Object.hasOwn(viewGame(done, observer), 'nexusChoamBetrayals'), false);
nexusTraitorInventory(done);
reject(done, owner, action);
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
}
});
void test('CHOAM Betrayal hides non-Bidding hand count; all bot levels take a legal action during public-count Bidding', () => {
const { g, owner, target, observer } = position();
const offer = viewGame(g, owner).nexusChoamBetrayal!;
assert.equal(offer.blocked, null);
assert.equal(offer.target.id, target);
assert.equal(offer.target.handSize, null);
assert.equal(viewGame(g, target).nexusChoamBetrayal, null);
assert.equal(viewGame(g, observer).nexusChoamBetrayal, null);
for (const card of g.players.find(player => player.id === target)!.hand)
assert.equal(JSON.stringify(offer).includes(card.id), false);
const publicCount = position(false, 3);
const biddingOffer = viewGame(publicCount.g, owner).nexusChoamBetrayal!;
assert.equal(biddingOffer.target.handSize, publicCount.g.players.find(player => player.id === target)!.hand.length);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(publicCount.g, owner);
view.players.find(player => player.id === owner)!.bot = difficulty;
assert.deepEqual(botActions(view)[0], publicCount.action);
}
const botPlayed = applyAction(publicCount.g, owner, publicCount.action);
assert.equal(botPlayed.nexusChoamBetrayals?.at(-1)?.stage, 'complete');
nexusTraitorInventory(botPlayed);
});
void test('CHOAM Betrayal rejects forged, stale, foreign and interrupted plays without spending the card', () => {
const { g, owner, target, observer, action } = position();
reject(g, target, action);
reject(g, observer, action);
reject(g, owner, { ...action, event: 'stale' });
reject(g, owner, { ...action, card: 'forged' });
const pending = reload(g);
pending.decision = { kind: 'choamMarket', player: target };
assert.ok(viewGame(pending, owner).nexusChoamBetrayal?.blocked);
reject(pending, owner, action);
const empty = reload(g);
empty.deck.push(...empty.players.find(player => player.id === target)!.hand.splice(0));
assert.equal(viewGame(empty, owner).nexusChoamBetrayal?.blocked, null);
reject(empty, owner, action);
assert.equal(g.nexusCards!.cards!.hands[owner], 'choam');
});
void test('a committed random discard resumes exactly once after JSON restore and rejects damaged saved proof', () => {
const { g, owner, target, action } = position();
const done = applyAction(g, owner, action);
const paused = reload(done), record = paused.nexusChoamBetrayals!.at(-1)!;
record.stage = 'discard'; record.signature = nexusChoamBetrayalSignature(record);
paused.nexusChoamBetrayalLast = { event: record.event, stage: record.stage };
const sequence = paused.treacheryDiscardSequence!;
paused.resolvedTreacheryDiscardSequence = sequence - 1;
paused.pendingTreacheryDiscard = {
sequence,
batch: {
event: `discard:${paused.turn}:${paused.phase}:${sequence}`,
turn: paused.turn,
phase: paused.phase,
cause: 'nexus:choamBetrayal',
entries: [{ card: structuredClone(paused.discard.find(card => card.id === record.card)!), discardedBy: target, publicFace: false }],
},
continuation: { kind: 'nexusChoamBetrayal', event: record.event, owner, target, card: record.card, handBefore: record.handBefore },
};
assert.equal(viewGame(paused, owner).automaticContinuationPending, true);
assert.deepEqual(normalizeAutomaticGame(reload(paused)), done);
reject(paused, owner, action);
for (const damage of [
(state: Game) => { state.nexusChoamBetrayals![0].card = 'forged'; },
(state: Game) => { state.pendingTreacheryDiscard!.continuation = { kind: 'nexusChoamBetrayal', event: record.event, owner, target, card: 'forged', handBefore: record.handBefore }; },
(state: Game) => { state.pendingTreacheryDiscard = null; state.resolvedTreacheryDiscardSequence = sequence; },
]) {
const bad = reload(paused); damage(bad); const before = reload(bad);
assert.throws(() => viewGame(bad, owner));
assert.deepEqual(bad, before);
}
});
void test('a completed Betrayal remains valid when its Nexus discard is recycled by a later draw in the same turn', () => {
const { g, owner, action } = position(false, 0);
const done = applyAction(g, owner, action);
const recycled = reload(done), cards = recycled.nexusCards!.cards!;
cards.discard.push(...cards.deck.splice(0));
recycled.nexusCards!.cards = drawNexusCard(cards, owner, recycled.players, () => 0);
assert.equal(recycled.nexusCards!.cards!.discard.includes('choam'), false);
nexusTraitorInventory(recycled);
assert.doesNotThrow(() => viewGame(recycled, owner));
assert.deepEqual(normalizeAutomaticGame(reload(recycled)), recycled);
});
+144
View File
@@ -0,0 +1,144 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { Card } from '../game/cards';
import type { Game } from '../game/engine';
import {
nexusChoamBetrayalSignature, quoteNexusChoamBetrayal,
validateNexusChoamBetrayal, type NexusChoamBetrayal,
} from '../game/nexus-choam-betrayal';
import { nexusTraitorFixture } from './fixture-nexus-traitors';
import { nexusReload } from './fixture-nexus-cards';
function position(phase = 5) {
const g = nexusTraitorFixture({ ownerFaction: 'atreides', opponentFaction: 'choam', phase });
const owner = g.players[0].id, target = g.players[1].id;
const cards = g.nexusCards!.cards!;
const index = cards.deck.indexOf('choam');
assert.ok(index >= 0);
cards.deck[index] = cards.hands[owner]!;
cards.hands[owner] = 'choam';
const selected = g.deck.splice(0, 2);
assert.equal(selected.length, 2);
g.players[1].hand.push(...selected);
return { g, owner, target, selected };
}
void test('outside Bidding, a CHOAM Betrayal offer reads neither hidden hand count nor identities', () => {
const { g, owner, target, selected } = position();
const offer = quoteNexusChoamBetrayal(g, owner)!;
assert.deepEqual(offer.target, { id: target, name: g.players[1].name, handSize: null });
assert.equal(offer.blocked, null);
assert.deepEqual(JSON.parse(offer.event), ['nexusChoamBetrayal', g.turn, g.phase, owner, target]);
for (const card of selected) assert.ok(!JSON.stringify(offer).includes(card.id));
for (const count of [0, 1, 3]) {
const alternate = nexusReload(g);
alternate.deck.push(...alternate.players[1].hand.splice(0));
alternate.players[1].hand.push(...alternate.deck.splice(0, count));
assert.deepEqual(quoteNexusChoamBetrayal(alternate, owner), offer);
}
g.players[1].hand = new Proxy(g.players[1].hand, {
get() { throw new Error('Private CHOAM hand was read'); },
});
assert.deepEqual(quoteNexusChoamBetrayal(g, owner), offer);
const bidding = position(3);
assert.equal(quoteNexusChoamBetrayal(bidding.g, bidding.owner)!.target.handSize, 2);
bidding.g.deck.push(...bidding.g.players[1].hand.splice(0));
assert.match(quoteNexusChoamBetrayal(bidding.g, bidding.owner)!.blocked!, /no Treachery/);
});
void test('the same clean boundary is available across printed phases, but not through live interactions', () => {
for (const phase of [0, 2, 5, 7, 8]) {
const { g, owner } = position(phase);
assert.equal(quoteNexusChoamBetrayal(g, owner)!.blocked, null);
assert.match(quoteNexusChoamBetrayal(g, owner, true)!.blocked!, /current interaction/);
}
const { g, owner, target } = position();
for (const change of [
(copy: Game) => { copy.response = { kind: 'card' } as never; },
(copy: Game) => { copy.decision = { kind: 'guildTiming' } as never; },
(copy: Game) => { copy.pendingTreacheryDiscard = { sequence: 1 } as never; },
(copy: Game) => { copy.nexusCards!.phase = { stage: 'drawing' } as never; },
(copy: Game) => { copy.battle = { event: 'ongoing' } as never; },
(copy: Game) => { copy.pendingExchange = { response: null, decision: null }; },
]) {
const copy = nexusReload(g); change(copy);
assert.match(quoteNexusChoamBetrayal(copy, owner)!.blocked!, /current interaction/);
}
const empty = nexusReload(g);
empty.deck.push(...empty.players[1].hand.splice(0));
assert.equal(quoteNexusChoamBetrayal(empty, owner)!.blocked, null);
const allied = nexusReload(g);
allied.players[0].ally = allied.players[2].id;
allied.players[2].ally = owner;
assert.match(quoteNexusChoamBetrayal(allied, owner)!.blocked!, /unallied/);
assert.equal(quoteNexusChoamBetrayal(g, target), null);
assert.equal(quoteNexusChoamBetrayal(g, 'foreign'), null);
const noCard = nexusReload(g);
noCard.nexusCards!.cards!.hands[owner] = null;
assert.equal(quoteNexusChoamBetrayal(noCard, owner), null);
const absent = nexusReload(g);
absent.players[1].faction = 'guild';
assert.equal(quoteNexusChoamBetrayal(absent, owner), null);
});
void test('signed sampled-card receipt validates after discard and JSON reload without future hand custody', () => {
const { g, owner, target, selected } = position();
const offer = quoteNexusChoamBetrayal(g, owner)!;
const record: NexusChoamBetrayal = {
event: offer.event, turn: g.turn, phase: g.phase, owner, target,
card: selected[0].id, handBefore: 2, stage: 'discard', signature: '',
};
record.signature = nexusChoamBetrayalSignature(record);
const physical = [...g.deck, ...g.players.flatMap(player => player.hand), ...g.discard];
assert.doesNotThrow(() => validateNexusChoamBetrayal(g, record, physical));
const spent = nexusReload(g);
spent.nexusCards!.cards!.hands[owner] = null;
spent.nexusCards!.cards!.discard.push('choam');
const discarded = spent.players[1].hand.shift()!;
spent.discard.push(discarded);
spent.players[1].hand.push(spent.deck.shift()!);
const restored = nexusReload(spent), receipt = JSON.parse(JSON.stringify(record)) as NexusChoamBetrayal;
assert.doesNotThrow(() => validateNexusChoamBetrayal(restored, receipt,
[...restored.deck, ...restored.discard, ...restored.players.flatMap(player => player.hand)]));
receipt.stage = 'complete'; receipt.signature = nexusChoamBetrayalSignature(receipt);
restored.turn++;
assert.doesNotThrow(() => validateNexusChoamBetrayal(restored, receipt,
[...restored.deck, ...restored.discard, ...restored.players.flatMap(player => player.hand)]));
assert.equal(restored.players[1].hand.length, record.handBefore);
});
void test('malformed, stale or forged source/selection receipts cannot become historical evidence', () => {
const { g, owner, target, selected } = position();
const event = quoteNexusChoamBetrayal(g, owner)!.event;
const receipt: NexusChoamBetrayal = {
event, turn: g.turn, phase: g.phase, owner, target, card: selected[0].id,
handBefore: 2, stage: 'discard', signature: '',
};
receipt.signature = nexusChoamBetrayalSignature(receipt);
const physical = [...g.deck, ...g.players.flatMap(player => player.hand), ...g.discard];
const invalid = (patch: Partial<NexusChoamBetrayal>, cards: readonly Card[] = physical) =>
assert.throws(() => validateNexusChoamBetrayal(g, { ...receipt, ...patch }, cards));
invalid({ event: 'forged' });
invalid({ card: selected[1].id });
invalid({ owner: target });
invalid({ target: g.players[2].id });
invalid({ phase: receipt.phase + 1 });
invalid({ turn: receipt.turn + 1 });
invalid({ handBefore: 1 });
invalid({ handBefore: 0 });
invalid({ handBefore: Number.MAX_SAFE_INTEGER + 1 });
invalid({ stage: 'pending' as never });
invalid({ card: 'invented', signature: nexusChoamBetrayalSignature({ ...receipt, card: 'invented' }) });
invalid({}, physical.filter(card => card.id !== receipt.card));
invalid({}, [...physical, selected[0]]);
invalid({ extra: 'hidden' } as never);
const changedRoster = nexusReload(g);
changedRoster.players[1].faction = 'guild';
assert.throws(() => validateNexusChoamBetrayal(changedRoster, receipt, physical));
const foreignOwner = nexusReload(g);
foreignOwner.players[0].faction = 'choam';
assert.throws(() => validateNexusChoamBetrayal(foreignOwner, receipt, physical));
const future = nexusReload(g);
future.phase = 2;
assert.throws(() => validateNexusChoamBetrayal(future, receipt, physical));
});