438 lines
26 KiB
TypeScript
438 lines
26 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { baseDeck, leaders, treacheryDeck } from '../game/cards';
|
|
import { NEXUS_FACTIONS, type NexusState } from '../game/nexus-cards';
|
|
import { createTraitorDeclaration } from '../game/traitor-declarations';
|
|
import { traitorDeck } from '../game/traitors';
|
|
import {
|
|
HarkonnenBetrayalError, closeHarkonnenBetrayalSource, createHarkonnenBetrayalReplacement,
|
|
createHarkonnenBetrayalSource, drawHarkonnenBetrayalReplacement,
|
|
harkonnenBetrayalEligible, harkonnenBetrayalResponders, initialHarkonnenBetrayalCursor,
|
|
validateHarkonnenBetrayalFrame, validateHarkonnenBetrayalHistory,
|
|
validateHarkonnenBetrayalReplacement, validateHarkonnenBetrayalReplacements,
|
|
validateHarkonnenBetrayalSource,
|
|
type HarkonnenBetrayalAuthority, type HarkonnenBetrayalContext,
|
|
type HarkonnenBetrayalReceipt, type HarkonnenBetrayalReplacement,
|
|
} from '../game/nexus-harkonnen-betrayal';
|
|
|
|
const parent = 'independently-produced-native-battle-parent';
|
|
function context(remote = false, face: 'harkonnen' | 'richese' = 'harkonnen'): HarkonnenBetrayalContext {
|
|
const players: HarkonnenBetrayalContext['players'] = [
|
|
{ id: 'h', faction: 'harkonnen', ally: remote ? 'f' : null },
|
|
{ id: 'a', faction: 'atreides', ally: null },
|
|
{ id: 'e', faction: 'emperor', ally: null },
|
|
{ id: 'f', faction: 'fremen', ally: remote ? 'h' : null },
|
|
];
|
|
const universe = traitorDeck(players.map(player => ({ leaders: leaders(player.faction) })));
|
|
const custody = [
|
|
{ id: 'h', faction: 'harkonnen' as const, traitors: ['emperor-0', 'emperor-1', 'fremen-0', 'atreides-0'] },
|
|
{ id: 'a', faction: 'atreides' as const, traitors: ['harkonnen-0'] },
|
|
{ id: 'e', faction: 'emperor' as const, traitors: ['harkonnen-1'] },
|
|
{ id: 'f', faction: 'fremen' as const, traitors: ['harkonnen-2'] },
|
|
];
|
|
const held = custody.flatMap(player => player.traitors);
|
|
const otherFace = face === 'harkonnen' ? 'richese' : 'guild';
|
|
return {
|
|
status: 'playing', phase: 6, turn: 2, advanced: false, sequence: 0, players,
|
|
cards: { version: 1, deck: NEXUS_FACTIONS.filter(card => card !== face && card !== otherFace),
|
|
discard: [], hands: { h: null, a: face, e: otherFace, f: null } },
|
|
physicalCards: baseDeck(), universe,
|
|
traitors: { reserve: universe.filter(identity => !held.includes(identity)), players: custody },
|
|
battle: { event: 'native-battle-0', attacker: remote ? 'f' : 'h', defender: 'e', heroLeaderIds: [],
|
|
plans: { [remote ? 'f' : 'h']: { leader: remote ? 'fremen-1' : 'harkonnen-3' },
|
|
e: { leader: 'emperor-0' } } },
|
|
};
|
|
}
|
|
function authority(ctx: HarkonnenBetrayalContext): HarkonnenBetrayalAuthority {
|
|
const remote = ctx.battle.attacker !== 'h';
|
|
const declaration = createTraitorDeclaration({ ...ctx.battle,
|
|
players: ctx.players.map(player => ({ ...player,
|
|
traitors: ctx.traitors.players.find(seat => seat.id === player.id)!.traitors })),
|
|
}, 'h');
|
|
return { provider: 'h', declaration, nativeWindow: remote ? 'harkonnenTraitor' : 'direct',
|
|
nativeContext: `native-allowed:${ctx.battle.event}`,
|
|
nativeRequired: remote ? ['a', 'e'] : [], nativePassed: remote ? ['e', 'a'] : [] };
|
|
}
|
|
function closed(ctx = context(), outcome: 'pass' | 'use' = 'use') {
|
|
const native = authority(ctx);
|
|
const source = createHarkonnenBetrayalSource(ctx, native, parent);
|
|
return closeHarkonnenBetrayalSource(source, outcome, outcome === 'use' ? 'a' : null,
|
|
initialHarkonnenBetrayalCursor());
|
|
}
|
|
function later(ctx: HarkonnenBetrayalContext): HarkonnenBetrayalContext {
|
|
const next = structuredClone(ctx);
|
|
next.sequence++;
|
|
next.battle.event = `native-battle-${next.sequence}`;
|
|
next.battle.plans.e.leader = 'emperor-1';
|
|
const hand = next.traitors.players.find(player => player.id === 'h')!.traitors;
|
|
hand.splice(hand.indexOf('emperor-0'), 1);
|
|
next.traitors.reserve.unshift('emperor-0');
|
|
next.cards.hands.a = null;
|
|
next.cards.discard.push('harkonnen');
|
|
return next;
|
|
}
|
|
function freeze(value: unknown): void {
|
|
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return;
|
|
for (const child of Object.values(value)) freeze(child);
|
|
Object.freeze(value);
|
|
}
|
|
|
|
for (const remote of [false, true]) {
|
|
for (const advanced of [false, true]) {
|
|
void test(`${advanced ? 'Advanced' : 'Basic'} ${remote ? 'allied remote' : 'personal'} call binds its canonical declaration and original allowed native source`, () => {
|
|
const ctx = { ...context(remote), advanced }, native = authority(ctx);
|
|
const source = createHarkonnenBetrayalSource(ctx, native, parent);
|
|
assert.equal(source.declaration.beneficiary, remote ? 'f' : 'h');
|
|
assert.equal(source.declaration.target, 'e');
|
|
assert.equal(source.declaration.identity, 'emperor-0');
|
|
validateHarkonnenBetrayalFrame(ctx, source, native, parent, ['a', 'e'], ['e']);
|
|
const changed: HarkonnenBetrayalAuthority[] = [
|
|
{ ...native, provider: 'a' }, { ...native, nativeContext: 'different-native-attempt' },
|
|
{ ...native, nativeWindow: remote ? 'direct' : 'harkonnenTraitor' },
|
|
{ ...native, declaration: { ...native.declaration, beneficiary: 'e' } },
|
|
{ ...native, declaration: { ...native.declaration, target: 'a' } },
|
|
{ ...native, declaration: { ...native.declaration, leader: 'emperor-1' } },
|
|
{ ...native, declaration: { ...native.declaration, identity: 'emperor-1' } },
|
|
{ ...native, declaration: { ...native.declaration, voter: 'a' } },
|
|
{ ...native, declaration: { ...native.declaration, event: 'another-battle' } },
|
|
];
|
|
for (const invalid of changed) {
|
|
assert.throws(() => validateHarkonnenBetrayalSource(ctx, source, invalid, parent), HarkonnenBetrayalError);
|
|
}
|
|
for (const invalid of [
|
|
{ ...ctx, status: 'setup' }, { ...ctx, phase: 7 }, { ...ctx, turn: 3 },
|
|
{ ...ctx, sequence: 1 }, { ...ctx, advanced: !advanced },
|
|
]) {
|
|
assert.throws(() => validateHarkonnenBetrayalSource(invalid, source, native, parent), HarkonnenBetrayalError);
|
|
}
|
|
assert.throws(() => validateHarkonnenBetrayalSource(ctx, source, native, 'other-parent'), HarkonnenBetrayalError);
|
|
const kh = structuredClone(ctx);
|
|
kh.battle.plans.e.kwisatz = true;
|
|
assert.throws(() => createHarkonnenBetrayalSource(kh, native, parent), HarkonnenBetrayalError);
|
|
});
|
|
}
|
|
}
|
|
|
|
void test('remote calls require all original native passes and correct live alliance roles', () => {
|
|
const ctx = context(true), native = authority(ctx);
|
|
for (const patch of [
|
|
{ nativePassed: ['e'] }, { nativePassed: ['e', 'e', 'a'] },
|
|
{ nativeRequired: ['a', 'e', 'absent'], nativePassed: ['e', 'a', 'absent'] },
|
|
{ nativePassed: ['e', 'a', 'absent'] }, { nativeContext: '' },
|
|
]) {
|
|
assert.throws(() => createHarkonnenBetrayalSource(ctx, { ...native, ...patch }, parent), HarkonnenBetrayalError);
|
|
}
|
|
const unallied = structuredClone(ctx);
|
|
unallied.players = unallied.players.map(player => ({ ...player, ally: null }));
|
|
assert.throws(() => createHarkonnenBetrayalSource(unallied, native, parent), HarkonnenBetrayalError);
|
|
const personal = context(), direct = authority(personal);
|
|
assert.throws(() => createHarkonnenBetrayalSource(personal,
|
|
{ ...direct, nativeRequired: ['a'], nativePassed: ['a'] }, parent), HarkonnenBetrayalError);
|
|
});
|
|
|
|
void test('the matched opposing leader can be a captured classic leader, not only its faction original discs', () => {
|
|
const ctx = context();
|
|
ctx.battle.plans.e.leader = 'fremen-0';
|
|
const native = authority(ctx);
|
|
const source = createHarkonnenBetrayalSource(ctx, native, parent);
|
|
const result = closeHarkonnenBetrayalSource(source, 'use', 'a', initialHarkonnenBetrayalCursor());
|
|
assert.equal(source.declaration.target, 'e');
|
|
assert.equal(source.declaration.identity, 'fremen-0');
|
|
assert.equal(createHarkonnenBetrayalReplacement(result.receipt).identity, 'fremen-0');
|
|
validateHarkonnenBetrayalHistory([result.receipt], result.cursor, ctx.universe);
|
|
});
|
|
|
|
void test('a personal native Harkonnen defender declares the actual attacking leader', () => {
|
|
const ctx = context();
|
|
ctx.battle.attacker = 'e';
|
|
ctx.battle.defender = 'h';
|
|
const native = authority(ctx);
|
|
native.nativeWindow = 'direct';
|
|
native.nativeRequired = [];
|
|
native.nativePassed = [];
|
|
const source = createHarkonnenBetrayalSource(ctx, native, parent);
|
|
assert.equal(source.declaration.beneficiary, 'h');
|
|
assert.equal(source.declaration.target, 'e');
|
|
const result = closeHarkonnenBetrayalSource(source, 'use', 'a', initialHarkonnenBetrayalCursor());
|
|
validateHarkonnenBetrayalHistory([result.receipt], result.cursor, ctx.universe);
|
|
});
|
|
|
|
void test('public acknowledgment membership is face neutral while only the private singleton owner may Use', () => {
|
|
const real = context(), wrong = context(false, 'richese');
|
|
const realSource = createHarkonnenBetrayalSource(real, authority(real), parent);
|
|
const wrongSource = createHarkonnenBetrayalSource(wrong, authority(wrong), parent);
|
|
assert.deepEqual(harkonnenBetrayalResponders(real.cards, real.players), ['a', 'e']);
|
|
assert.deepEqual(harkonnenBetrayalResponders(wrong.cards, wrong.players), ['a', 'e']);
|
|
assert.deepEqual(realSource.required, wrongSource.required);
|
|
assert.equal(realSource.event, wrongSource.event);
|
|
assert.equal(harkonnenBetrayalEligible(real.cards, real.players, 'a'), true);
|
|
assert.equal(harkonnenBetrayalEligible(wrong.cards, wrong.players, 'a'), false);
|
|
for (const holder of ['h', 'e', 'f', 'absent', null]) {
|
|
assert.throws(() => closeHarkonnenBetrayalSource(realSource, 'use', holder,
|
|
initialHarkonnenBetrayalCursor()), HarkonnenBetrayalError);
|
|
}
|
|
assert.throws(() => closeHarkonnenBetrayalSource(wrongSource, 'use', 'a',
|
|
initialHarkonnenBetrayalCursor()), HarkonnenBetrayalError);
|
|
const pass = closeHarkonnenBetrayalSource(wrongSource, 'pass', null, initialHarkonnenBetrayalCursor());
|
|
validateHarkonnenBetrayalHistory([pass.receipt], pass.cursor, wrong.universe);
|
|
const allied = context();
|
|
allied.players = allied.players.map(player => ({ ...player,
|
|
ally: player.id === 'a' ? 'f' : player.id === 'f' ? 'a' : null }));
|
|
assert.deepEqual(harkonnenBetrayalResponders(allied.cards, allied.players), ['e']);
|
|
assert.equal(harkonnenBetrayalEligible(allied.cards, allied.players, 'a'), false);
|
|
const nativeHeld = context();
|
|
nativeHeld.cards.hands.a = null;
|
|
nativeHeld.cards.hands.h = 'harkonnen';
|
|
assert.equal(harkonnenBetrayalEligible(nativeHeld.cards, nativeHeld.players, 'h'), false);
|
|
assert.deepEqual(harkonnenBetrayalResponders(nativeHeld.cards, nativeHeld.players), ['e']);
|
|
});
|
|
|
|
void test('frame rejects missing, reordered, duplicated or foreign responders and passes', () => {
|
|
const ctx = context(), native = authority(ctx);
|
|
const source = createHarkonnenBetrayalSource(ctx, native, parent);
|
|
for (const [required, passed] of [
|
|
[['a'], []], [['e', 'a'], []], [['a', 'e', 'h'], []], [['a', 'e', 'e'], []],
|
|
[['a', 'e'], ['a', 'a']], [['a', 'e'], ['h']], [['a', 'e'], ['absent']],
|
|
]) {
|
|
assert.throws(() => validateHarkonnenBetrayalFrame(ctx, source, native, parent, required, passed), HarkonnenBetrayalError);
|
|
}
|
|
const changed = structuredClone(ctx);
|
|
changed.cards.hands.e = null;
|
|
changed.cards.deck.push('richese');
|
|
assert.throws(() => validateHarkonnenBetrayalFrame(changed, source, native, parent, ['a', 'e'], []), HarkonnenBetrayalError);
|
|
});
|
|
|
|
void test('canonical stock rejects fabricated, duplicate, missing or foreign physical cards and universes', () => {
|
|
const ctx = context(), native = authority(ctx);
|
|
for (const cards of [baseDeck().slice(1), [...baseDeck(), baseDeck()[0]], treacheryDeck(['ix']),
|
|
baseDeck().map((card, index) => index === 0 ? baseDeck()[1] : card)]) {
|
|
assert.throws(() => createHarkonnenBetrayalSource({ ...ctx, physicalCards: cards }, native, parent));
|
|
}
|
|
for (const patch of [{ name: 'Forged' }, { kind: 'special' }, { effect: 'fake' },
|
|
{ id: 'fake' }, { extra: 'private-copy' }]) {
|
|
assert.throws(() => createHarkonnenBetrayalSource({ ...ctx,
|
|
physicalCards: [Object.assign({}, ctx.physicalCards[0], patch), ...ctx.physicalCards.slice(1)] }, native, parent));
|
|
}
|
|
for (const universe of [ctx.universe.slice(1), [...ctx.universe, ctx.universe[0]],
|
|
ctx.universe.map((identity, index) => index === 0 ? 'cheap-hero-traitor' : identity)]) {
|
|
assert.throws(() => createHarkonnenBetrayalSource({ ...ctx, universe }, native, parent));
|
|
}
|
|
const fabricated = structuredClone(ctx);
|
|
fabricated.universe = fabricated.universe.map(identity => identity === 'emperor-0' ? 'forged-leader' : identity);
|
|
fabricated.traitors.players[0].traitors[0] = 'forged-leader';
|
|
assert.throws(() => createHarkonnenBetrayalSource(fabricated, native, parent));
|
|
for (const variant of ['missing', 'duplicate', 'foreign', 'wrongRoster'] as const) {
|
|
const changed = structuredClone(ctx);
|
|
if (variant === 'missing') changed.traitors.reserve.pop();
|
|
if (variant === 'duplicate') changed.traitors.reserve.push('emperor-0');
|
|
if (variant === 'foreign') changed.traitors.reserve[0] = 'foreign-identity';
|
|
if (variant === 'wrongRoster') changed.traitors.players[0].id = 'absent';
|
|
assert.throws(() => createHarkonnenBetrayalSource(changed, native, parent));
|
|
}
|
|
});
|
|
|
|
void test('Nexus census cannot hide missing, duplicate or foreign custody behind public parity', () => {
|
|
const variants: NexusState[] = [];
|
|
const duplicate = context().cards; duplicate.deck.push('harkonnen'); variants.push(duplicate);
|
|
const missing = context().cards; missing.hands.a = null; variants.push(missing);
|
|
const extra = context().cards; extra.hands.foreign = null; variants.push(extra);
|
|
const unknown = context().cards; Object.assign(unknown.deck, { 0: 'unprinted-face' }); variants.push(unknown);
|
|
for (const cards of variants) {
|
|
assert.throws(() => harkonnenBetrayalResponders(cards, context().players));
|
|
assert.throws(() => harkonnenBetrayalEligible(cards, context().players, 'a'));
|
|
}
|
|
const excluded = context();
|
|
excluded.players = excluded.players.map(player => player.id === 'f' ? { ...player, faction: 'ixians' } : player);
|
|
assert.throws(() => createHarkonnenBetrayalSource(excluded, authority(context()), parent));
|
|
});
|
|
|
|
void test('pending source freezes current physical custody and native identity before any cost', () => {
|
|
const ctx = context(), native = authority(ctx);
|
|
const source = createHarkonnenBetrayalSource(ctx, native, parent);
|
|
const transferred = structuredClone(ctx);
|
|
transferred.traitors.players[0].traitors.shift();
|
|
transferred.traitors.players[1].traitors.push('emperor-0');
|
|
assert.throws(() => validateHarkonnenBetrayalSource(transferred, source, native, parent), HarkonnenBetrayalError);
|
|
const reordered = structuredClone(ctx);
|
|
reordered.traitors.reserve.reverse();
|
|
assert.throws(() => validateHarkonnenBetrayalSource(reordered, source, native, parent), HarkonnenBetrayalError);
|
|
const undeclared = structuredClone(ctx);
|
|
undeclared.traitors.players[0].traitors.shift();
|
|
undeclared.traitors.reserve.push('emperor-0');
|
|
assert.throws(() => createHarkonnenBetrayalSource(undeclared, native, parent), HarkonnenBetrayalError);
|
|
for (const patch of [
|
|
{ signature: 'forged' }, { event: 'expired' }, { identity: 'forged' },
|
|
{ provider: 'a' }, { required: ['a'] }, { eligible: 'e' }, { inventory: 'forged' },
|
|
{ parent: 'forged' }, { extra: 'client-selector' },
|
|
]) {
|
|
const corrupt = Object.assign(structuredClone(source), patch);
|
|
assert.throws(() => validateHarkonnenBetrayalSource(ctx, corrupt, native, parent), HarkonnenBetrayalError);
|
|
}
|
|
});
|
|
|
|
void test('quotes and closure preserve frozen inputs and produce independent saved values', () => {
|
|
const ctx = context(true), native = authority(ctx), snapshot = structuredClone({ ctx, native });
|
|
freeze(ctx); freeze(native);
|
|
const source = createHarkonnenBetrayalSource(ctx, native, parent);
|
|
freeze(source);
|
|
const cursor = initialHarkonnenBetrayalCursor(); freeze(cursor);
|
|
const result = closeHarkonnenBetrayalSource(source, 'use', 'a', cursor);
|
|
validateHarkonnenBetrayalFrame(ctx, source, native, parent, source.required, ['e']);
|
|
validateHarkonnenBetrayalHistory([result.receipt], result.cursor, ctx.universe);
|
|
assert.deepEqual({ ctx, native }, snapshot);
|
|
assert.deepEqual(cursor, initialHarkonnenBetrayalCursor());
|
|
assert.notEqual(result.receipt.source, source);
|
|
assert.notEqual(result.receipt.source.declaration, source.declaration);
|
|
assert.notEqual(result.receipt.source.battle, source.battle);
|
|
assert.notEqual(result.receipt.source.required, source.required);
|
|
});
|
|
|
|
void test('closed cursor is independent, exactly once, and rejects forged or missing history', () => {
|
|
const ctx = context(), native = authority(ctx), source = createHarkonnenBetrayalSource(ctx, native, parent);
|
|
const use = closed(ctx), pass = closed(ctx, 'pass');
|
|
assert.throws(() => closeHarkonnenBetrayalSource(source, 'use', 'a', use.cursor), HarkonnenBetrayalError);
|
|
assert.throws(() => closeHarkonnenBetrayalSource(source, 'pass', 'a', initialHarkonnenBetrayalCursor()), HarkonnenBetrayalError);
|
|
const next = later(ctx);
|
|
const nextSource = createHarkonnenBetrayalSource(next, authority(next), 'next-native-parent');
|
|
for (const cursor of [
|
|
{ sequence: 0, signature: 'forged-genesis' },
|
|
{ sequence: 1, signature: 'forged-head' },
|
|
{ sequence: 1, signature: JSON.stringify(['harkonnenBetrayalHead', use.receipt.signature, 'use', 'a']) },
|
|
{ sequence: 2, signature: use.cursor.signature },
|
|
]) {
|
|
assert.throws(() => closeHarkonnenBetrayalSource(nextSource, 'pass', null, cursor), HarkonnenBetrayalError);
|
|
}
|
|
for (const history of [[], [use.receipt, use.receipt], [pass.receipt]]) {
|
|
assert.throws(() => validateHarkonnenBetrayalHistory(history, use.cursor, ctx.universe), HarkonnenBetrayalError);
|
|
}
|
|
for (const patch of [{ previous: 'forged' }, { signature: 'forged' }, { outcome: 'pass' }, { holder: 'e' }]) {
|
|
assert.throws(() => validateHarkonnenBetrayalHistory([Object.assign(structuredClone(use.receipt), patch)], use.cursor), HarkonnenBetrayalError);
|
|
}
|
|
assert.throws(() => validateHarkonnenBetrayalHistory([use.receipt], { ...use.cursor, signature: 'forged' }), HarkonnenBetrayalError);
|
|
assert.throws(() => validateHarkonnenBetrayalHistory([use.receipt], use.cursor, ctx.universe.slice(1)), HarkonnenBetrayalError);
|
|
});
|
|
|
|
void test('historical Use and its pending replacement survive return/recycling and later all-pass native attempts', () => {
|
|
const ctx = context(), first = closed(ctx);
|
|
const replacement = createHarkonnenBetrayalReplacement(first.receipt);
|
|
const next = later(ctx), native = authority(next);
|
|
const source = createHarkonnenBetrayalSource(next, native, 'independent-later-native-parent');
|
|
const pass = closeHarkonnenBetrayalSource(source, 'pass', null, first.cursor);
|
|
const history = [first.receipt, pass.receipt];
|
|
validateHarkonnenBetrayalHistory(history, pass.cursor, next.universe);
|
|
validateHarkonnenBetrayalReplacement(replacement, history, pass.cursor, next.universe);
|
|
validateHarkonnenBetrayalReplacements(history, pass.cursor, [replacement], next.universe);
|
|
assert.equal(replacement.event, first.receipt.source.event);
|
|
assert.notEqual(replacement.event, pass.receipt.source.event);
|
|
// A real shuffle can put the exact returned identity on top again.
|
|
const drawn = drawHarkonnenBetrayalReplacement(replacement, history, pass.cursor,
|
|
next.traitors.reserve[0], next.universe);
|
|
assert.equal(drawn.drawn, replacement.identity);
|
|
next.traitors.players[0].traitors.push(next.traitors.reserve.shift()!);
|
|
next.cards.discard = [];
|
|
next.cards.hands.f = 'harkonnen';
|
|
validateHarkonnenBetrayalHistory(history, pass.cursor, next.universe);
|
|
validateHarkonnenBetrayalReplacements(history, pass.cursor, [drawn], next.universe);
|
|
assert.equal(drawn.status, 'drawn');
|
|
assert.throws(() => drawHarkonnenBetrayalReplacement(drawn, history, pass.cursor, 'emperor-0'), HarkonnenBetrayalError);
|
|
assert.equal(replacement.status, 'due');
|
|
assert.equal(replacement.drawn, null);
|
|
});
|
|
|
|
void test('each successful Use has exactly one due or actual completed canonical private draw', () => {
|
|
const ctx = context(), use = closed(ctx), pass = closed(ctx, 'pass');
|
|
const due = createHarkonnenBetrayalReplacement(use.receipt), history = [use.receipt];
|
|
assert.throws(() => createHarkonnenBetrayalReplacement(pass.receipt), HarkonnenBetrayalError);
|
|
assert.throws(() => validateHarkonnenBetrayalReplacements(history, use.cursor, []), HarkonnenBetrayalError);
|
|
assert.throws(() => validateHarkonnenBetrayalReplacements(history, use.cursor, [due, due]), HarkonnenBetrayalError);
|
|
assert.throws(() => validateHarkonnenBetrayalReplacements([pass.receipt], pass.cursor, [due]), HarkonnenBetrayalError);
|
|
for (const patch of [
|
|
{ provider: 'a' }, { turn: 3 }, { event: 'another-use' }, { identity: 'emperor-1' },
|
|
{ sequence: 1 }, { receipt: pass.cursor.signature }, { signature: 'forged' },
|
|
{ status: 'drawn', drawn: null }, { status: 'due', drawn: 'emperor-0' },
|
|
{ status: 'drawn', drawn: 'forged-identity' }, { extra: 'selector' },
|
|
]) {
|
|
const corrupt = Object.assign(structuredClone(due), patch);
|
|
assert.throws(() => validateHarkonnenBetrayalReplacement(corrupt, history, use.cursor), HarkonnenBetrayalError);
|
|
}
|
|
assert.throws(() => drawHarkonnenBetrayalReplacement(due, history, use.cursor, 'forged-identity'), HarkonnenBetrayalError);
|
|
freeze(due);
|
|
const drawn = drawHarkonnenBetrayalReplacement(due, history, use.cursor, 'atreides-1');
|
|
validateHarkonnenBetrayalReplacements(history, use.cursor, [drawn]);
|
|
assert.equal(drawn.drawn, 'atreides-1');
|
|
});
|
|
|
|
void test('bounded prototype rejects simultaneous replacement obligations but retains multiple completed Uses', () => {
|
|
const ctx = context(), first = closed(ctx), firstDue = createHarkonnenBetrayalReplacement(first.receipt);
|
|
const next = later(ctx);
|
|
next.turn = 3;
|
|
next.cards.discard = [];
|
|
next.cards.hands.a = 'harkonnen';
|
|
const source = createHarkonnenBetrayalSource(next, authority(next), 'next-turn-native-parent');
|
|
const second = closeHarkonnenBetrayalSource(source, 'use', 'a', first.cursor);
|
|
const secondDue = createHarkonnenBetrayalReplacement(second.receipt), history = [first.receipt, second.receipt];
|
|
assert.throws(() => validateHarkonnenBetrayalReplacements(history, second.cursor, [firstDue, secondDue]), HarkonnenBetrayalError);
|
|
const firstDrawn = drawHarkonnenBetrayalReplacement(firstDue, history, second.cursor, 'emperor-0');
|
|
validateHarkonnenBetrayalReplacements(history, second.cursor, [firstDrawn, secondDue]);
|
|
const secondDrawn = drawHarkonnenBetrayalReplacement(secondDue, history, second.cursor, 'emperor-1');
|
|
validateHarkonnenBetrayalReplacements(history, second.cursor, [firstDrawn, secondDrawn]);
|
|
assert.throws(() => validateHarkonnenBetrayalReplacements(history, second.cursor, [secondDrawn]), HarkonnenBetrayalError);
|
|
});
|
|
|
|
void test('permanent set-aside custody survives Betrayal serialization and can never fund its replacement draw', () => {
|
|
const ctx = context();
|
|
const setAside = ctx.traitors.reserve.shift()!;
|
|
ctx.traitors.removed = [setAside];
|
|
const native = authority(ctx);
|
|
const source = createHarkonnenBetrayalSource(ctx, native, parent);
|
|
validateHarkonnenBetrayalSource(ctx, structuredClone(source), native, parent);
|
|
const use = closeHarkonnenBetrayalSource(source, 'use', 'a', initialHarkonnenBetrayalCursor());
|
|
const replacement = createHarkonnenBetrayalReplacement(use.receipt);
|
|
validateHarkonnenBetrayalReplacement(replacement, [use.receipt], use.cursor, ctx.universe);
|
|
assert.throws(() => drawHarkonnenBetrayalReplacement(replacement, [use.receipt], use.cursor,
|
|
setAside, ctx.universe), HarkonnenBetrayalError);
|
|
const next = later(ctx), nextNative = authority(next);
|
|
const nextSource = createHarkonnenBetrayalSource(next, nextNative, 'later-native-set-aside-parent');
|
|
const pass = closeHarkonnenBetrayalSource(nextSource, 'pass', null, use.cursor);
|
|
const history = [use.receipt, pass.receipt];
|
|
validateHarkonnenBetrayalHistory(history, pass.cursor, next.universe);
|
|
validateHarkonnenBetrayalReplacements(history, pass.cursor, [replacement], next.universe);
|
|
const recycled = next.traitors.reserve[0];
|
|
const drawn = drawHarkonnenBetrayalReplacement(replacement, history, pass.cursor, recycled, next.universe);
|
|
assert.equal(drawn.status, 'drawn');
|
|
assert.equal(replacement.status, 'due');
|
|
});
|
|
void test('Betrayal live custody keeps the permanent zone inside one complete canonical census', () => {
|
|
const ctx = context();
|
|
const setAside = ctx.traitors.reserve.shift()!;
|
|
ctx.traitors.removed = [setAside];
|
|
const native = authority(ctx);
|
|
const source = createHarkonnenBetrayalSource(ctx, native, parent);
|
|
assert.deepEqual(source.required, ['a', 'e']);
|
|
for (const variant of ['duplicate', 'missing', 'foreign'] as const) {
|
|
const changed = structuredClone(ctx);
|
|
if (variant === 'duplicate') changed.traitors.reserve.push(setAside);
|
|
if (variant === 'missing') changed.traitors.removed = [];
|
|
if (variant === 'foreign') changed.traitors.removed = ['foreign-identity'];
|
|
assert.throws(() => createHarkonnenBetrayalSource(changed, native, parent));
|
|
}
|
|
});
|
|
void test('native attempt history serialization stays linear with nonrecursive current and previous heads', () => {
|
|
const ctx = context(), history: HarkonnenBetrayalReceipt[] = [];
|
|
let cursor = initialHarkonnenBetrayalCursor(), halfway = 0;
|
|
const replacements: HarkonnenBetrayalReplacement[] = [];
|
|
for (let sequence = 0; sequence < 80; sequence++) {
|
|
ctx.sequence = sequence;
|
|
ctx.battle.event = `native-battle-${sequence}`;
|
|
const source = createHarkonnenBetrayalSource(ctx, authority(ctx), `original-native-parent-${sequence}`);
|
|
const result = closeHarkonnenBetrayalSource(source, 'pass', null, cursor);
|
|
history.push(result.receipt);
|
|
cursor = result.cursor;
|
|
if (sequence === 39) halfway = JSON.stringify(history).length;
|
|
}
|
|
validateHarkonnenBetrayalHistory(history, cursor, ctx.universe);
|
|
validateHarkonnenBetrayalReplacements(history, cursor, replacements, ctx.universe);
|
|
assert.ok(JSON.stringify(history).length < halfway * 2.1);
|
|
assert.ok(cursor.signature.length < 400);
|
|
});
|