Bind Truthtrance alliance promises to actual Nexus closure
This commit is contained in:
1 parent
335b4ce638
commit
5627512d16
19 files changed
+1485
-30
No files matched your search
@@ -0,0 +1,206 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
AlliancePromiseError,
|
||||
allianceClaimText,
|
||||
alliancePromiseModeSupported,
|
||||
alliancePromiseRequirements,
|
||||
alliancePromisesMatch,
|
||||
findAlliancePromisePlan,
|
||||
liveAlliancePromises,
|
||||
parseAllianceClaim,
|
||||
validateAlliancePromises,
|
||||
type AllianceOpportunity,
|
||||
type AlliancePromise,
|
||||
} from '../game/alliance-promises';
|
||||
import { quoteNexusAlliance } from '../game/nexus-alliance';
|
||||
|
||||
const seats = ['respondent', 'partner-a', 'partner-b', 'asker'] as const;
|
||||
const opportunity = (): AllianceOpportunity => ({ turn: 2, source: 'original-worm-nexus' });
|
||||
function promise(event: string, partner = 'partner-a', extra: Partial<AlliancePromise> = {}): AlliancePromise {
|
||||
return { event, turn: 2, source: 'original-worm-nexus', player: 'respondent', asker: 'asker',
|
||||
partner, answer: true, ...extra };
|
||||
}
|
||||
function context() {
|
||||
return { players: seats.map(id => ({ id, ally: null as string | null })), offers: {} as Record<string, string> };
|
||||
}
|
||||
function executePlan(state: ReturnType<typeof context>, actions: { type: 'alliance'; target: string | null }[]) {
|
||||
let current = structuredClone(state);
|
||||
for (const action of actions) {
|
||||
const quote = quoteNexusAlliance({ ...current, actor: 'respondent', target: action.target });
|
||||
current = { players: current.players.map(player => ({ ...player, ally: quote.allies[player.id] })), offers: quote.offers };
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
void test('alliance claims normalize current-turn partner identity without retaining private Nexus sources', () => {
|
||||
const input = { turn: 2, partner: 'partner-a', source: 'private-source', event: 'private-event' };
|
||||
assert.deepEqual(parseAllianceClaim(input), { turn: 2, partner: 'partner-a' });
|
||||
assert.deepEqual(parseAllianceClaim({ turn: Number.MAX_SAFE_INTEGER, partner: 'partner-a' }),
|
||||
{ turn: Number.MAX_SAFE_INTEGER, partner: 'partner-a' });
|
||||
for (const turn of [0, -1, 1.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '2', null, undefined])
|
||||
assert.throws(() => parseAllianceClaim({ ...input, turn }), AlliancePromiseError);
|
||||
for (const partner of ['', ' ', ' partner-a', 'partner-a ', 1, null, undefined])
|
||||
assert.throws(() => parseAllianceClaim({ ...input, partner }), AlliancePromiseError);
|
||||
for (const value of [null, undefined, [], 'partner-a', 2]) assert.throws(() => parseAllianceClaim(value), AlliancePromiseError);
|
||||
const text = allianceClaimText(parseAllianceClaim(input), id => id === 'partner-a' ? 'Atreides' : id);
|
||||
assert.match(text, /be allied with Atreides when this Nexus ends/);
|
||||
assert.doesNotMatch(text, /private-source|private-event/);
|
||||
});
|
||||
|
||||
void test('alliance promises retain bare native Basic and Advanced scope and do not require optional Nexus Cards', () => {
|
||||
const classic = { advanced: false, expansions: [], players: [{ faction: 'atreides' }, { faction: 'emperor' }] };
|
||||
const native = { advanced: true, expansions: ['ix', 'choam', 'ecaz'],
|
||||
players: ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].map(faction => ({ faction })) };
|
||||
for (const game of [classic, { ...classic, advanced: true }, native, { ...native, advanced: false },
|
||||
{ ...classic, expansions: ['ix', 'choam', 'ecaz'] },
|
||||
{ ...classic, advanced: true, expansions: ['ecaz'], players: [{ faction: 'moritani' }, { faction: 'harkonnen' }] },
|
||||
]) assert.equal(alliancePromiseModeSupported(game), true);
|
||||
for (const option of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
|
||||
'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery',
|
||||
'semutaPreview', 'kullPreview', 'nexusKullPreview', 'richeseBetrayalPreview'] as const)
|
||||
assert.equal(alliancePromiseModeSupported({ ...native, [option]: true }), false, option);
|
||||
for (const game of [
|
||||
{ ...native, expansions: ['ix', 'choam'] }, { ...native, expansions: ['ix', 'ix', 'ecaz'] },
|
||||
{ ...classic, expansions: ['unknown'] }, { ...classic, players: [{ faction: 'atreides' }, { faction: 'unknown' }] },
|
||||
{ ...classic, players: [{ faction: 'atreides' }] }, { ...classic, players: [...native.players, { faction: 'atreides' }] },
|
||||
{ ...classic, advanced: 'basic' as unknown as boolean },
|
||||
]) assert.equal(alliancePromiseModeSupported(game), false);
|
||||
});
|
||||
|
||||
void test('joint answers require one distinct Yes partner while independent No partners remain forbidden', () => {
|
||||
const records = [promise('yes-a'), promise('yes-a-again'),
|
||||
promise('no-b', 'partner-b', { answer: false }), promise('no-b-again', 'partner-b', { answer: false }),
|
||||
promise('no-asker', 'asker', { answer: false })];
|
||||
assert.deepEqual(alliancePromiseRequirements(records, 'respondent', opportunity()),
|
||||
{ partner: 'partner-a', forbidden: ['partner-b', 'asker'] });
|
||||
assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), 'partner-a'), true);
|
||||
assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), null), false);
|
||||
assert.equal(alliancePromiseRequirements([...records, promise('yes-b', 'partner-b')], 'respondent', opportunity()), null);
|
||||
assert.equal(alliancePromiseRequirements([...records, promise('no-a', 'partner-a', { answer: false })], 'respondent', opportunity()), null);
|
||||
assert.equal(findAlliancePromisePlan([...records, promise('yes-b', 'partner-b')], 'respondent', opportunity(), context()), null);
|
||||
});
|
||||
|
||||
void test('an unallied Yes target requires actual outgoing consent and never assumes a future foreign break or acceptance', () => {
|
||||
const records = [promise('yes-a')]; const none = context();
|
||||
assert.equal(findAlliancePromisePlan(records, 'respondent', opportunity(), none), null);
|
||||
const ownOffer = { ...none, offers: { respondent: 'partner-a' } };
|
||||
assert.equal(findAlliancePromisePlan(records, 'respondent', opportunity(), ownOffer), null,
|
||||
'An own proposal does not control the other player’s future acceptance');
|
||||
const incoming = { ...none, offers: { 'partner-a': 'respondent' } };
|
||||
const plan = findAlliancePromisePlan(records, 'respondent', opportunity(), incoming);
|
||||
assert.deepEqual(plan, [{ type: 'alliance', target: 'partner-a' }]); assert.ok(plan);
|
||||
const completed = executePlan(incoming, plan);
|
||||
assert.equal(completed.players.find(player => player.id === 'respondent')!.ally, 'partner-a');
|
||||
const occupied = structuredClone(incoming);
|
||||
occupied.players.find(player => player.id === 'partner-a')!.ally = 'partner-b';
|
||||
occupied.players.find(player => player.id === 'partner-b')!.ally = 'partner-a';
|
||||
assert.equal(findAlliancePromisePlan(records, 'respondent', opportunity(), occupied), null,
|
||||
'Even an outstanding offer does not authorize breaking the desired partner’s current alliance');
|
||||
assert.equal(findAlliancePromisePlan(records, 'respondent', opportunity(), { ...none, offers: { 'partner-a': 'asker' } }), null);
|
||||
});
|
||||
|
||||
void test('an owned break can precede acceptance only while the desired partner offer survives the canonical quotes', () => {
|
||||
const state = context();
|
||||
state.players.find(player => player.id === 'respondent')!.ally = 'partner-b';
|
||||
state.players.find(player => player.id === 'partner-b')!.ally = 'respondent';
|
||||
state.offers = { respondent: 'asker', 'partner-a': 'respondent', 'partner-b': 'asker', asker: 'partner-b' };
|
||||
const before = structuredClone(state); state.players.forEach(Object.freeze); Object.freeze(state.players);
|
||||
Object.freeze(state.offers); Object.freeze(state);
|
||||
const records = [promise('yes-a'), promise('no-b', 'partner-b', { answer: false })];
|
||||
const plan = findAlliancePromisePlan(records, 'respondent', opportunity(), state);
|
||||
assert.deepEqual(plan, [{ type: 'alliance', target: null }, { type: 'alliance', target: 'partner-a' }]); assert.ok(plan);
|
||||
const completed = executePlan(state, plan);
|
||||
assert.deepEqual(completed.players.map(player => [player.id, player.ally]), [
|
||||
['respondent', 'partner-a'], ['partner-a', 'respondent'], ['partner-b', null], ['asker', null],
|
||||
]);
|
||||
assert.deepEqual(completed.offers, { 'partner-b': 'asker', asker: 'partner-b' },
|
||||
'An unrelated reciprocal proposal is not executed on either foreign player’s behalf');
|
||||
assert.deepEqual(state, before);
|
||||
});
|
||||
|
||||
void test('No constrains final membership and permits temporary proposals or a currently different alliance', () => {
|
||||
const records = [promise('no-a', 'partner-a', { answer: false })];
|
||||
const unallied = { ...context(), offers: { respondent: 'partner-a' } };
|
||||
assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), null), true);
|
||||
assert.deepEqual(findAlliancePromisePlan(records, 'respondent', opportunity(), unallied), [],
|
||||
'A proposal itself is not final alliance membership');
|
||||
const other = context();
|
||||
other.players.find(player => player.id === 'respondent')!.ally = 'partner-b';
|
||||
other.players.find(player => player.id === 'partner-b')!.ally = 'respondent';
|
||||
assert.deepEqual(findAlliancePromisePlan(records, 'respondent', opportunity(), other), []);
|
||||
const forbidden = context();
|
||||
forbidden.players.find(player => player.id === 'respondent')!.ally = 'partner-a';
|
||||
forbidden.players.find(player => player.id === 'partner-a')!.ally = 'respondent';
|
||||
assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), 'partner-a'), false);
|
||||
const plan = findAlliancePromisePlan(records, 'respondent', opportunity(), forbidden);
|
||||
assert.deepEqual(plan, [{ type: 'alliance', target: null }]); assert.ok(plan);
|
||||
const final = executePlan(forbidden, plan).players.find(player => player.id === 'respondent')!.ally;
|
||||
assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), final), true);
|
||||
});
|
||||
|
||||
void test('an already held required partner needs no action but a foreign break removes the owned completion until renewed consent', () => {
|
||||
const state = context();
|
||||
state.players.find(player => player.id === 'respondent')!.ally = 'partner-a';
|
||||
state.players.find(player => player.id === 'partner-a')!.ally = 'respondent';
|
||||
const records = [promise('yes-a')];
|
||||
assert.deepEqual(findAlliancePromisePlan(records, 'respondent', opportunity(), state), []);
|
||||
const broken = quoteNexusAlliance({ ...state, actor: 'partner-a', target: null });
|
||||
const afterBreak = { players: state.players.map(player => ({ ...player, ally: broken.allies[player.id] })), offers: broken.offers };
|
||||
assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), null), false);
|
||||
assert.equal(findAlliancePromisePlan(records, 'respondent', opportunity(), afterBreak), null);
|
||||
const renewed = quoteNexusAlliance({ ...afterBreak, actor: 'partner-a', target: 'respondent' });
|
||||
const afterOffer = { players: afterBreak.players.map(player => ({ ...player, ally: renewed.allies[player.id] })), offers: renewed.offers };
|
||||
assert.deepEqual(findAlliancePromisePlan(records, 'respondent', opportunity(), afterOffer), [{ type: 'alliance', target: 'partner-a' }]);
|
||||
assert.equal(records[0].fulfilled, undefined); assert.equal(records[0].released, undefined,
|
||||
'Planning never records the real interval’s completion or forced release');
|
||||
});
|
||||
|
||||
void test('turn, Nexus source, respondent and terminal records isolate the current final-membership obligation', () => {
|
||||
const live = promise('live');
|
||||
const records = [live, promise('other-turn', 'partner-b', { turn: 1 }),
|
||||
promise('other-source', 'partner-b', { source: 'different-worm-same-turn' }),
|
||||
promise('other-player', 'partner-b', { player: 'partner-a' }),
|
||||
promise('fulfilled', 'partner-b', { fulfilled: true }), promise('released', 'partner-b', { released: true })];
|
||||
const before = structuredClone(records); records.forEach(Object.freeze); Object.freeze(records);
|
||||
assert.deepEqual(liveAlliancePromises(records, 'respondent', opportunity()), [live]);
|
||||
assert.deepEqual(alliancePromiseRequirements(records, 'respondent', opportunity()), { partner: 'partner-a', forbidden: [] });
|
||||
assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), 'partner-a'), true);
|
||||
assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), 'partner-b'), false);
|
||||
assert.deepEqual(alliancePromiseRequirements(records, 'absent', opportunity()), { partner: null, forbidden: [] });
|
||||
assert.equal(alliancePromisesMatch(records, 'absent', opportunity(), null), true);
|
||||
assert.deepEqual(records, before);
|
||||
});
|
||||
|
||||
void test('no clauses or only terminal answers need no owned change and unknown actors cannot receive a plan', () => {
|
||||
assert.deepEqual(findAlliancePromisePlan([], 'respondent', opportunity(), context()), []);
|
||||
const records = [promise('old-yes', 'partner-a', { fulfilled: true }),
|
||||
promise('released-no', 'partner-b', { answer: false, released: true })];
|
||||
assert.deepEqual(findAlliancePromisePlan(records, 'respondent', opportunity(), context()), []);
|
||||
assert.equal(findAlliancePromisePlan([], 'absent', opportunity(), context()), null);
|
||||
assert.deepEqual(alliancePromiseRequirements([promise('no-a', 'partner-a', { answer: false }),
|
||||
promise('no-b', 'partner-b', { answer: false })], 'respondent', opportunity()),
|
||||
{ partner: null, forbidden: ['partner-a', 'partner-b'] });
|
||||
});
|
||||
|
||||
void test('record validation retains original interval, event and participants without rechecking historical terminal membership', () => {
|
||||
const valid = [promise('live'), promise('fulfilled-yes', 'partner-b', { fulfilled: true }),
|
||||
promise('released-yes', 'partner-a', { released: true }),
|
||||
promise('fulfilled-no', 'partner-b', { answer: false, fulfilled: true }),
|
||||
promise('released-no', 'partner-a', { answer: false, released: true }),
|
||||
promise('asker-is-partner', 'asker')];
|
||||
const before = structuredClone(valid);
|
||||
assert.doesNotThrow(() => validateAlliancePromises(valid, seats)); assert.deepEqual(valid, before);
|
||||
assert.doesNotThrow(() => validateAlliancePromises(JSON.parse(JSON.stringify(valid)) as AlliancePromise[], seats));
|
||||
assert.doesNotThrow(() => validateAlliancePromises([], seats));
|
||||
const base = promise('event');
|
||||
const invalid: unknown[] = [null, {}, [null], [base, { ...base }],
|
||||
...[{ event: '' }, { event: ' ' }, { source: '' }, { source: ' ' },
|
||||
{ player: 'outsider' }, { asker: 'outsider' }, { asker: 'respondent' },
|
||||
{ partner: 'outsider' }, { partner: 'respondent' }, { partner: ' partner-a' },
|
||||
{ answer: 'yes' }, { fulfilled: 1 }, { released: 'true' }, { fulfilled: true, released: true },
|
||||
{ turn: 0 }].map(extra => [{ ...base, ...extra }])];
|
||||
for (const records of invalid)
|
||||
assert.throws(() => validateAlliancePromises(records as AlliancePromise[], seats), AlliancePromiseError);
|
||||
assert.deepEqual(valid, before);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
actTruthBid, createTruthBidFixture, nextTruthBid, truthBidClean, truthBidInventory, truthBidPlayer, type TruthBidStep,
|
||||
} from './fixture-truth-bid';
|
||||
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
|
||||
|
||||
export type TruthAllianceStep = TruthBidStep;
|
||||
export type TruthAllianceTransition = { before: Game; steps: TruthAllianceStep[]; after: Game };
|
||||
export const actTruthAlliance = actTruthBid;
|
||||
export const truthAllianceClean = truthBidClean;
|
||||
export const truthAllianceInventory = truthBidInventory;
|
||||
export const truthAlliancePlayer = truthBidPlayer;
|
||||
export function truthAllianceNexus(game: Game) {
|
||||
return game.phase === 1 && game.nexus && !game.spiceWindow && !game.spiceResolution && truthAllianceClean(game);
|
||||
}
|
||||
export function nextTruthAlliance(game: Game): TruthAllianceStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
for (const player of game.players) {
|
||||
const guide = viewGame(game, player.id).alliancePromiseGuide;
|
||||
if (guide) return { actor: player.id, action: guide.action };
|
||||
}
|
||||
return nextTruthBid(game);
|
||||
}
|
||||
export function advanceTruthAlliance(game: Game, until: (state: Game) => boolean, actions: TruthAllianceStep[] = []): Game {
|
||||
for (let count = 0; count < 1500; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The genuine alliance source ended before its owned control');
|
||||
const next = nextTruthAlliance(game); assert.ok(next, 'The original Truthtrance question, answer or saved card belongs to its caller');
|
||||
game = actTruthAlliance(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The genuine alliance source did not reach its finite boundary');
|
||||
}
|
||||
export function settleTruthAlliance(game: Game, actions: TruthAllianceStep[] = []): Game {
|
||||
return advanceTruthAlliance(game, truthAllianceClean, actions);
|
||||
}
|
||||
export function closeTruthAllianceNexus(game: Game, actions: TruthAllianceStep[] = []): Game {
|
||||
assert.ok(truthAllianceNexus(game)); const turn = game.turn;
|
||||
return advanceTruthAlliance(game, state => state.turn === turn && !state.nexus && truthAllianceClean(state), actions);
|
||||
}
|
||||
export function finishTruthAlliance(game: Game, actions: TruthAllianceStep[] = []): Game {
|
||||
const turn = game.turn;
|
||||
return advanceTruthAlliance(game, state => state.turn === turn && state.phase === 2 && truthAllianceClean(state), actions);
|
||||
}
|
||||
|
||||
/** Original public H/A/E base33, two actual Harkonnen Truthtrances and no
|
||||
* optional Nexus Cards. Finish the original first turn and real later Storm;
|
||||
* select only an unopened original worm plus ordinary replacements, then open
|
||||
* the real ordinary Nexus. Consent is a native partner offer and reciprocal
|
||||
* acceptance, never an installed ally/offer or assumed foreign decision. */
|
||||
export function createTruthAllianceFixture(options: {
|
||||
advanced?: boolean; consent?: 'none' | 'offered' | 'allied'; partner?: 'atreides' | 'harkonnen';
|
||||
} = {}) {
|
||||
const advanced = options.advanced ?? false, consent = options.consent ?? 'offered';
|
||||
const base = createTruthBidFixture({ advanced, window: 'waiting' });
|
||||
const partner = options.partner ?? base.opponent;
|
||||
let game = structuredClone(base.game); const actions = structuredClone(base.sourceActions);
|
||||
const act = (actor: string, action: Action) => { game = actTruthAlliance(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceTruthAlliance(game, until, actions); };
|
||||
const transition = (run: () => void): TruthAllianceTransition => {
|
||||
const before = structuredClone(game), from = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
|
||||
};
|
||||
const nextStorm = transition(() => {
|
||||
reach(state => state.turn === 2 && beforeIxSpiceDraw(state));
|
||||
});
|
||||
assert.ok(game.players.every(player => player.ally === null));
|
||||
const nexusSpiceOrder = orderIxSpiceCards(game, ['worm', 'land', ...(advanced ? ['land' as const] : [])]);
|
||||
const beforeWorm = structuredClone(game);
|
||||
const encounter = transition(() => { reach(truthAllianceNexus); });
|
||||
assert.equal(game.turn, 2); assert.equal(game.phase, 1); assert.equal(game.nexus, true);
|
||||
assert.ok(game.spiceDiscard.flat().some(card => 'worm' in card && !card.suppressed));
|
||||
assert.ok(!game.nexusCards && !game.homeworlds && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const beforeConsent = structuredClone(game);
|
||||
let offer: TruthAllianceTransition | null = null, acceptance: TruthAllianceTransition | null = null;
|
||||
if (consent !== 'none') {
|
||||
offer = transition(() => { act(partner, { type: 'alliance', target: base.target }); });
|
||||
assert.equal(game.allianceOffers[partner], base.target); assert.equal(truthAlliancePlayer(game, base.target).ally, null);
|
||||
}
|
||||
if (consent === 'allied') {
|
||||
acceptance = transition(() => { act(base.target, { type: 'alliance', target: partner }); });
|
||||
assert.equal(truthAlliancePlayer(game, base.target).ally, partner);
|
||||
assert.equal(truthAlliancePlayer(game, partner).ally, base.target);
|
||||
}
|
||||
assert.deepEqual(truthAllianceInventory(game), base.originalCards);
|
||||
assert.equal(truthAlliancePlayer(game, base.target).spice, 10);
|
||||
assert.equal(viewGame(game, base.target).alliancePromiseNexus?.turn, game.turn);
|
||||
const question = { kind: 'alliance' as const, target: base.target, turn: game.turn, partner };
|
||||
return { ...base, game, sourceGame: structuredClone(game), base, advanced, consent, partner,
|
||||
nextStorm, beforeWorm, nexusSpiceOrder, encounter, beforeConsent, offer, acceptance,
|
||||
question, questionAction: { type: 'truthAsk', question } as Action,
|
||||
allianceAction: { type: 'alliance', target: partner } as Action,
|
||||
breakAction: { type: 'alliance', target: null } as Action,
|
||||
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
|
||||
staging: [] as string[], sourceDisclosure: 'Genuine public original H/A/E base33 and native first-turn/later-Storm controls. Only inherited unopened original deal/lot/Spice positions, the later original worm/replacement prefix and scalar lotteries are selected. The actual ordinary worm opens this Nexus; native reciprocal offers earn any existing alliance. No clock, hand, wallet, offer, ally or promise is installed, and no foreign consent is invented.' };
|
||||
}
|
||||
export type TruthAllianceFixture = ReturnType<typeof createTruthAllianceFixture>;
|
||||
export function declareTruthAlliance(f: TruthAllianceFixture, state = f.game, options: { copies?: 1 | 2 } = {}) {
|
||||
let game = state; const before = structuredClone(game), actions: TruthAllianceStep[] = [];
|
||||
const declaration: Action = { type: 'card', card: f.truths[0], ...(options.copies === 2 ? { cards: [f.truths[1]] } : {}) };
|
||||
game = actTruthAlliance(game, f.asker, declaration, actions);
|
||||
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actTruthAlliance(game, actor.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return { game, before, declaration, actions, actionCount: actions.length };
|
||||
}
|
||||
export function askTruthAlliance(game: Game, asker: string, target: string, partner: string,
|
||||
actions: TruthAllianceStep[] = []): Game {
|
||||
return actTruthAlliance(game, asker, { type: 'truthAsk', question: { kind: 'alliance', target, turn: game.turn, partner } }, actions);
|
||||
}
|
||||
export function answerTruthAlliance(game: Game, target: string, answer: 'yes' | 'no' | 'unknown',
|
||||
actions: TruthAllianceStep[] = []): Game {
|
||||
return actTruthAlliance(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
export function bindTruthAlliance(f: TruthAllianceFixture, answer: 'yes' | 'no' = 'yes', partner = f.partner, state = f.game) {
|
||||
const declared = declareTruthAlliance(f, state), actions = [...declared.actions];
|
||||
const asked = askTruthAlliance(declared.game, f.asker, f.target, partner, actions);
|
||||
const game = answerTruthAlliance(asked, f.target, answer, actions);
|
||||
return { game, declared, asked, actions, actionCount: actions.length };
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { AllianceClaimFields, allianceClaimInputError } from '../components/alliance-claim-fields';
|
||||
import { AlliancePromises, AlliancePromiseGuideButton } from '../components/alliance-promises';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import type { AllianceClaim } from '../game/alliance-promises';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import {
|
||||
createTruthAllianceFixture, bindTruthAlliance, actTruthAlliance, settleTruthAlliance,
|
||||
closeTruthAllianceNexus, truthAllianceInventory,
|
||||
} from './fixture-truth-alliance';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
||||
},
|
||||
});
|
||||
const { Truthtrance } = await import('../components/truthtrance');
|
||||
aliases.deregister();
|
||||
|
||||
type ElementProps = { children?: ReactNode; [key: string]: unknown };
|
||||
type Element = ReactElement<ElementProps>;
|
||||
function elements(node: ReactNode): Element[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<ElementProps>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
function partner(props: Parameters<typeof AllianceClaimFields>[0], value: string) {
|
||||
const input = elements(AllianceClaimFields(props)).find(element => element.props.id === 'truth-alliance-partner');
|
||||
assert.ok(input);
|
||||
(input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
|
||||
}
|
||||
function guide(props: Parameters<typeof AlliancePromiseGuideButton>[0]) {
|
||||
const button = AlliancePromiseGuideButton(props); assert.ok(button); return button;
|
||||
}
|
||||
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
|
||||
const markup = (game: GameView, busy = false) => renderToStaticMarkup(createElement(AlliancePromises, { game, busy, send() {} }));
|
||||
|
||||
/** Controlled personalized Nexus projections exercise UI guards only; the
|
||||
* native source below earns its worm, consent, Truthtrance and final close. */
|
||||
function projected(kind: 'accept' | 'break' | 'ready' = 'accept'): GameView {
|
||||
const source = createGame('TRUTHALLIANCECONTROLS', newPlayer('p', 'Respondent', 'emperor'));
|
||||
joinGame(source, newPlayer('a', 'Asker', 'harkonnen'));
|
||||
joinGame(source, newPlayer('r', 'Partner', 'atreides'));
|
||||
const game = viewGame(source, 'p');
|
||||
game.status = 'playing'; game.turn = 2; game.phase = 1; game.nexus = true; game.spiceWindow = null;
|
||||
game.active = 'a'; game.ready = []; game.alliancePromiseNexus = { turn: 2 };
|
||||
game.alliancePromises = [{ event: 'controlled-alliance-event', player: 'p', asker: 'a', turn: 2,
|
||||
partner: 'r', answer: kind !== 'break' }];
|
||||
game.allianceOffers = kind === 'accept' ? { r: 'p' } : {};
|
||||
if (kind !== 'accept') {
|
||||
game.players.find(player => player.id === 'p')!.ally = 'r';
|
||||
game.players.find(player => player.id === 'r')!.ally = 'p';
|
||||
}
|
||||
game.alliancePromiseGuide = { action: { type: kind === 'ready' ? 'ready' : 'alliance',
|
||||
...(kind === 'ready' ? {} : { target: kind === 'accept' ? 'r' : null }), alliancePromiseEvent: 'controlled-alliance-event' } };
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('alliance claim fields fix this Nexus turn and accept only a current named other partner', () => {
|
||||
const value: AllianceClaim = { turn: 2, partner: 'r' }, changes: AllianceClaim[] = [];
|
||||
const partners = [{ id: 'r', name: 'Partner' }, { id: 'a', name: 'Asker' }];
|
||||
const props = { value, partners, onChange(next: AllianceClaim) { changes.push(next); } };
|
||||
const html = renderToStaticMarkup(createElement(AllianceClaimFields, props));
|
||||
assert.match(html, /Turn 2 · Current Nexus/); assert.match(html, /Named alliance partner/);
|
||||
assert.match(html, /actual membership when this Nexus closes/); assert.match(html, /Offers alone are not alliances/);
|
||||
assert.match(html, /cannot promise the other player’s consent/);
|
||||
assert.equal(elements(AllianceClaimFields(props)).filter(element => typeof element.props.onChange === 'function').length, 1);
|
||||
partner(props, 'a'); assert.deepEqual(changes, [{ turn: 2, partner: 'a' }]);
|
||||
for (const invalid of ['missing', '', ' r ']) partner(props, invalid);
|
||||
partner({ ...props, disabled: true }, 'a'); partner({ ...props, partners: [] }, 'a');
|
||||
partner({ ...props, value: { ...value, turn: 0 } }, 'a'); assert.equal(changes.length, 1);
|
||||
assert.ok(allianceClaimInputError(value, []));
|
||||
assert.match(renderToStaticMarkup(createElement(AllianceClaimFields, { ...props, partners: [] })), /role="alert"/);
|
||||
});
|
||||
|
||||
void test('own alliance promises describe final membership and remain live after formation until the actual Nexus close', () => {
|
||||
const game = projected('ready');
|
||||
game.alliancePromises.push(
|
||||
{ event: 'no-event', player: 'p', asker: 'a', turn: 2, partner: 'a', answer: false },
|
||||
{ event: 'fulfilled-event', player: 'p', asker: 'a', turn: 1, partner: 'r', answer: true, fulfilled: true },
|
||||
{ event: 'released-event', player: 'p', asker: 'a', turn: 1, partner: 'a', answer: true, released: true },
|
||||
{ event: 'foreign-event', player: 'a', asker: 'p', turn: 2, partner: 'r', answer: true },
|
||||
);
|
||||
const before = structuredClone(game);
|
||||
for (let render = 0; render < 2; render++) {
|
||||
const html = markup(game);
|
||||
assert.match(html, /Your alliance promises/); assert.match(html, /be allied with Partner when this Nexus ends/);
|
||||
assert.match(html, /Yes remains live after forming an alliance and settles only at the close/);
|
||||
assert.match(html, /a temporary alliance can still be ended before the close/);
|
||||
assert.match(html, /Offers alone are not alliances/); assert.match(html, /Neither answer promises another player’s consent/);
|
||||
assert.match(html, /Honor any earlier promises first/); assert.match(html, /Fulfilled at the Nexus close/);
|
||||
assert.match(html, /href="\/rules#truthtrance-alliance"/);
|
||||
assert.doesNotMatch(html, /foreign-event|controlled-alliance-event/);
|
||||
}
|
||||
assert.deepEqual(game, before);
|
||||
assert.equal(AlliancePromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
|
||||
});
|
||||
|
||||
void test('actual guide callbacks forward reciprocal acceptance, explicit-null break and Ready out of turn', () => {
|
||||
for (const kind of ['accept', 'break', 'ready'] as const) {
|
||||
const game = projected(kind), sent: Action[] = [];
|
||||
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
|
||||
const button = guide(props); assert.equal(button.props.disabled, false); press(button);
|
||||
assert.deepEqual(sent, [game.alliancePromiseGuide!.action]); assert.equal(sent[0], game.alliancePromiseGuide!.action);
|
||||
assert.equal(game.active, 'a'); assert.notEqual(game.active, game.me);
|
||||
if (kind === 'break') assert.equal(sent[0].target, null);
|
||||
press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
void test('stale guides cannot cross Nexus closure, readiness, membership, consent, current event or quiet windows', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 2; },
|
||||
game => { game.status = 'setup'; }, game => { game.nexus = false; }, game => { game.ready = ['p']; },
|
||||
game => { game.spiceWindow = { territory: 'hagga_basin', sector: 12, amount: 6, harvested: false }; },
|
||||
game => { game.alliancePromiseNexus = null; }, game => { game.alliancePromiseNexus!.turn = 1; },
|
||||
game => { game.alliancePromiseGuide = { action: { ...game.alliancePromiseGuide!.action } }; },
|
||||
game => { game.alliancePromiseGuide = null; }, game => { game.alliancePromiseGuide!.action.target = null; },
|
||||
game => { game.alliancePromises[0].event = 'expired'; }, game => { game.alliancePromises[0].player = 'a'; },
|
||||
game => { game.alliancePromises[0].fulfilled = true; }, game => { game.alliancePromises[0].released = true; },
|
||||
game => { game.alliancePromises = []; }, game => { game.allianceOffers = {}; },
|
||||
game => { game.players = game.players.filter(player => player.id !== 'r'); },
|
||||
game => { game.phaseOpening = { passed: [] }; },
|
||||
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||||
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||||
game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
|
||||
game => { game.automaticContinuationPending = true; },
|
||||
game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
|
||||
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||||
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||||
game => { game.techTokens = createTechTokens(); },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected(), sent: Action[] = [];
|
||||
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
|
||||
const button = guide(props); change(game); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
const ready = projected('ready');
|
||||
ready.players.find(player => player.id === 'p')!.ally = null; ready.players.find(player => player.id === 'r')!.ally = null;
|
||||
const sent: Action[] = []; press(guide({ game: ready, busy: false, send(action) { sent.push(action); } })); assert.equal(sent.length, 0);
|
||||
const noReady = projected('break'); noReady.alliancePromiseGuide!.action = { type: 'ready', alliancePromiseEvent: 'controlled-alliance-event' };
|
||||
press(guide({ game: noReady, busy: false, send(action) { sent.push(action); } })); assert.equal(sent.length, 0);
|
||||
const noConsent = projected(); noConsent.allianceOffers = {};
|
||||
press(guide({ game: noConsent, busy: false, send(action) { sent.push(action); } })); assert.equal(sent.length, 0);
|
||||
for (const target of ['p', 'missing']) {
|
||||
const game = projected(); game.alliancePromiseGuide!.action.target = target;
|
||||
press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.equal(sent.length, 0);
|
||||
}
|
||||
const props = { game: projected(), busy: false, send() { assert.fail('A now-busy alliance guide cannot dispatch'); } };
|
||||
const button = guide(props); props.busy = true; press(button);
|
||||
});
|
||||
|
||||
void test('alliance guidance uses public membership and offers without inspecting private sources, supplies or card piles', () => {
|
||||
const game = projected(), sent: Action[] = [];
|
||||
for (const seat of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'bribes', 'reserves', 'tanks'])
|
||||
Object.defineProperty(seat, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||||
for (const key of ['spiceDeck', 'deck', 'discard']) Object.defineProperty(game, key, {
|
||||
get() { throw Error(`No private ${key} inspection`); }, configurable: true,
|
||||
});
|
||||
Object.defineProperty(game.alliancePromises[0], 'source', { get() { throw Error('No private Nexus source inspection'); } });
|
||||
assert.match(markup(game), /Accept Partner’s alliance invitation/);
|
||||
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
|
||||
assert.deepEqual(sent, [game.alliancePromiseGuide!.action]);
|
||||
});
|
||||
|
||||
void test('genuine Basic and Advanced guides accept actual consent and settle Yes only at the original Nexus close', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = createTruthAllianceFixture({ advanced, consent: 'offered' }), bound = bindTruthAlliance(f, 'yes', f.partner);
|
||||
const respondent = viewGame(bound.asked, f.target);
|
||||
assert.ok(respondent.truthAllianceAnswers?.includes('yes'));
|
||||
assert.equal(viewGame(bound.asked, f.asker).truthAllianceAnswers, null);
|
||||
const answers = renderToStaticMarkup(createElement(Truthtrance, { game: respondent, busy: false, act() {} }));
|
||||
assert.match(answers, /Answer Yes/); assert.match(answers, /Answer No/);
|
||||
let game = bound.game;
|
||||
const dispatch = (type: string) => {
|
||||
const view = viewGame(game, f.target), sent: Action[] = [], next = view.alliancePromiseGuide; assert.ok(next);
|
||||
assert.equal(next.action.type, type);
|
||||
press(guide({ game: view, busy: false, send(action) { sent.push(action); } }));
|
||||
assert.deepEqual(sent, [next.action]); game = settleTruthAlliance(actTruthAlliance(game, f.target, sent[0]));
|
||||
};
|
||||
assert.equal(game.allianceOffers[f.partner], f.target);
|
||||
assert.equal(game.players.find(player => player.id === f.target)!.ally, null, 'A real offer alone has not formed the alliance.');
|
||||
dispatch('alliance');
|
||||
assert.equal(game.players.find(player => player.id === f.target)!.ally, f.partner);
|
||||
assert.notEqual(game.alliancePromises!.find(promise => promise.player === f.target)!.fulfilled, true);
|
||||
assert.match(markup(viewGame(game, f.target)), /This answer stays binding until this Nexus closes/);
|
||||
dispatch('ready'); game = closeTruthAllianceNexus(game);
|
||||
assert.equal(game.nexus, false);
|
||||
assert.equal(game.alliancePromises!.find(promise => promise.player === f.target)!.fulfilled, true);
|
||||
assert.deepEqual(truthAllianceInventory(game), f.originalCards);
|
||||
assert.match(markup(viewGame(game, f.target)), /Fulfilled at the Nexus close/);
|
||||
assert.equal(AlliancePromises({ game: viewGame(game, f.asker), busy: false, send() {} }), null);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame } from '../game/engine';
|
||||
import {
|
||||
createTruthAllianceFixture as fixture, bindTruthAlliance as bind,
|
||||
declareTruthAlliance as declare, askTruthAlliance as ask, answerTruthAlliance as answer,
|
||||
actTruthAlliance as act, closeTruthAllianceNexus as close, truthAlliancePlayer as player,
|
||||
truthAllianceInventory as inventory,
|
||||
} from './fixture-truth-alliance';
|
||||
|
||||
for (const answer of ['yes', 'no'] as const) void test(`controlled prior foreign shipment impossibility settles before a still-owned Alliance ${answer}`, () => {
|
||||
const f = fixture(), bound = bind(f, answer);
|
||||
const controlled = structuredClone(bound.game);
|
||||
// Only this prior debt is controlled: it represents the already-lost funded
|
||||
// route after a foreign event. Nexus, cards, invitation and answer are earned.
|
||||
controlled.shipmentPromises = [{ turn: controlled.turn, player: f.target, asker: f.asker,
|
||||
claim: { territory: 'carthag', minimum: 20 }, answer: true }];
|
||||
const game = act(controlled, f.partner, { type: 'bribe', target: f.asker, amount: 1 });
|
||||
assert.equal(game.shipmentPromises![0].released, true);
|
||||
const promise = game.alliancePromises![0];
|
||||
assert.ok(!promise.fulfilled && !promise.released);
|
||||
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, answer === 'yes' ? 'alliance' : 'ready');
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('two original Truth cards preserve an owed native bribe before accepting its named Nexus partner', () => {
|
||||
const f = fixture();
|
||||
let game = declare(f, f.game, { copies: 2 }).game;
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'bribe', target: f.target, turn: game.turn, recipient: f.partner, minimum: 3,
|
||||
} });
|
||||
assert.ok(viewGame(game, f.target).truthBribeAnswers?.includes('yes'));
|
||||
game = answer(game, f.target, 'yes');
|
||||
game = ask(game, f.asker, f.target, f.partner);
|
||||
assert.ok(viewGame(game, f.target).truthAllianceAnswers?.includes('yes'));
|
||||
game = answer(game, f.target, 'yes');
|
||||
assert.equal(viewGame(game, f.target).alliancePromiseGuide, null);
|
||||
const payment = viewGame(game, f.target).bribePromiseGuide; assert.ok(payment);
|
||||
const before = player(game, f.partner).spice;
|
||||
game = act(game, f.target, payment.action);
|
||||
assert.equal(player(game, f.target).spice, 7);
|
||||
assert.equal(player(game, f.partner).spice, before);
|
||||
assert.equal(player(game, f.partner).bribes, 3);
|
||||
assert.ok(game.bribePromises![0].fulfilled);
|
||||
const acceptance = viewGame(game, f.target).alliancePromiseGuide; assert.ok(acceptance);
|
||||
game = act(game, f.target, acceptance.action);
|
||||
assert.equal(player(game, f.target).ally, f.partner);
|
||||
assert.ok(!game.alliancePromises![0].fulfilled);
|
||||
game = close(game);
|
||||
assert.ok(game.alliancePromises![0].fulfilled);
|
||||
assert.equal(player(game, f.partner).bribes, 3);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
actTruthAlliance as act, answerTruthAlliance as answer, askTruthAlliance as ask, bindTruthAlliance as bind,
|
||||
closeTruthAllianceNexus as close, createTruthAllianceFixture as fixture, declareTruthAlliance as declare,
|
||||
finishTruthAlliance as finish, truthAllianceInventory as inventory, truthAllianceNexus as nexus, truthAlliancePlayer as player,
|
||||
} from './fixture-truth-alliance';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
const promise = (game: Game, target: string) => game.alliancePromises!.find(promise => promise.player === target)!;
|
||||
|
||||
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} current Nexus Yes accepts real consent and fulfills only at actual alliance closure`, () => {
|
||||
const f = fixture({ advanced }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
|
||||
assert.equal(f.game.turn, 2); assert.ok(nexus(f.game)); assert.equal(f.originalCards.length, 33);
|
||||
assert.deepEqual(f.staging, []); assert.equal(f.sourceActionCount, f.sourceActions.length);
|
||||
assert.equal(f.offer!.steps[0].actor, f.partner); assert.equal(f.game.allianceOffers[f.partner], f.target);
|
||||
assert.equal(player(f.game, f.target).ally, null);
|
||||
const before = structuredClone(f.game), bound = bind(f); assert.deepEqual(f.game, before);
|
||||
assert.equal(bound.game.discard.filter(card => card.id === f.truth).length, 1);
|
||||
assert.ok(!promise(bound.game, f.target).fulfilled); rejected(bound.game, f.target, { type: 'ready' });
|
||||
const guide = viewGame(bound.game, f.target).alliancePromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, f.partner);
|
||||
let game = act(bound.game, f.target, guide.action);
|
||||
assert.equal(player(game, f.target).ally, f.partner); assert.equal(player(game, f.partner).ally, f.target);
|
||||
assert.ok(!promise(game, f.target).fulfilled, 'Actual acceptance is still provisional until this Nexus closes');
|
||||
game = close(game);
|
||||
assert.equal(game.nexus, false); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.equal(player(game, f.target).ally, f.partner);
|
||||
assert.equal(viewGame(game, f.target).alliancePromiseNexus, null);
|
||||
game = finish(game); assert.equal(game.phase, 2); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native No for an existing reciprocal alliance breaks it before owned readiness and waits for closure', () => {
|
||||
const f = fixture({ consent: 'allied' }); let game = bind(f, 'no').game;
|
||||
assert.equal(player(game, f.target).ally, f.partner); rejected(game, f.target, { type: 'ready' });
|
||||
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, null);
|
||||
game = act(game, f.target, guide.action);
|
||||
assert.equal(player(game, f.target).ally, null); assert.equal(player(game, f.partner).ally, null);
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
game = close(game); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native final No permits an interim newly formed alliance but requires its own break before Ready', () => {
|
||||
const f = fixture({ consent: 'none' }); let game = bind(f, 'no').game;
|
||||
game = act(game, f.partner, { type: 'alliance', target: f.target });
|
||||
game = act(game, f.target, { type: 'alliance', target: f.partner });
|
||||
assert.equal(player(game, f.target).ally, f.partner); assert.ok(!promise(game, f.target).fulfilled);
|
||||
rejected(game, f.target, { type: 'ready' });
|
||||
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); game = act(game, f.target, guide.action);
|
||||
assert.equal(player(game, f.target).ally, null); game = close(game);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native absent foreign consent offers No or unknown without inventing an alliance and preserves the saved card', () => {
|
||||
const f = fixture({ consent: 'none' }), declared = declare(f);
|
||||
let game = ask(declared.game, f.asker, f.target, f.partner);
|
||||
assert.deepEqual(viewGame(game, f.target).truthAllianceAnswers, ['no', 'unknown']);
|
||||
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = answer(game, f.target, 'unknown'); assert.equal(game.truthtrance!.stage, 'unknown');
|
||||
assert.equal(game.discard.filter(card => card.id === f.truth).length, 0);
|
||||
game = act(game, f.asker, { type: 'truthSave' });
|
||||
assert.equal(game.truthtrance, null); assert.equal(player(game, f.asker).hand.filter(card => card.effect === 'truthtrance').length, 2);
|
||||
assert.equal(game.alliancePromises?.length ?? 0, 0);
|
||||
game = act(game, f.partner, { type: 'alliance', target: f.target });
|
||||
game = bind(f, 'yes', f.partner, game).game; game = close(game);
|
||||
assert.equal(player(game, f.target).ally, f.partner); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native binding reopens the respondent own readiness and cannot finish with unmatched membership', () => {
|
||||
const f = fixture(); let game = act(f.game, f.target, { type: 'ready' });
|
||||
assert.ok(game.ready.includes(f.target)); game = bind(f, 'yes', f.partner, game).game;
|
||||
assert.ok(!game.ready.includes(f.target)); rejected(game, f.target, { type: 'ready' });
|
||||
game = close(game); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('two physical Truth copies constrain the same final partner without settling at proposal or acceptance', () => {
|
||||
const f = fixture(), declared = declare(f, f.game, { copies: 2 });
|
||||
let game = ask(declared.game, f.asker, f.target, f.partner); game = answer(game, f.target, 'yes');
|
||||
game = ask(game, f.asker, f.target, f.partner);
|
||||
assert.deepEqual(viewGame(game, f.target).truthAllianceAnswers, ['yes']);
|
||||
rejected(game, f.target, { type: 'truthAnswer', answer: 'no' }); game = answer(game, f.target, 'yes');
|
||||
assert.equal(game.alliancePromises!.length, 2); assert.ok(game.alliancePromises!.every(promise => !promise.fulfilled));
|
||||
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); game = act(game, f.target, guide.action);
|
||||
assert.ok(game.alliancePromises!.every(promise => !promise.fulfilled)); game = close(game);
|
||||
assert.ok(game.alliancePromises!.every(promise => promise.fulfilled));
|
||||
for (const card of f.truths) assert.equal(game.discard.filter(held => held.id === card).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('two actual foreign offers cannot answer Yes to two different final partners in one Nexus', () => {
|
||||
const f = fixture(); let game = act(f.game, f.asker, { type: 'alliance', target: f.target });
|
||||
assert.equal(game.allianceOffers[f.partner], f.target); assert.equal(game.allianceOffers[f.asker], f.target);
|
||||
game = declare(f, game, { copies: 2 }).game;
|
||||
game = ask(game, f.asker, f.target, f.partner); game = answer(game, f.target, 'yes');
|
||||
game = ask(game, f.asker, f.target, f.asker);
|
||||
assert.deepEqual(viewGame(game, f.target).truthAllianceAnswers, ['no']);
|
||||
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no');
|
||||
game = close(game); assert.equal(player(game, f.target).ally, f.partner);
|
||||
assert.ok(game.alliancePromises!.every(promise => promise.fulfilled)); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
for (const consent of ['offered', 'allied'] as const) void test(`native opposing ${consent === 'offered' ? 'offer withdrawal' : 'alliance break'} releases a Yes with no owned acceptance left`, () => {
|
||||
const f = fixture({ consent }); let game = bind(f).game;
|
||||
game = act(game, f.partner, { type: 'alliance', target: null });
|
||||
assert.equal(player(game, f.target).ally, null); assert.equal(game.allianceOffers[f.partner], undefined);
|
||||
assert.equal(promise(game, f.target).released, true); assert.ok(!promise(game, f.target).fulfilled);
|
||||
game = close(game); assert.equal(player(game, f.target).ally, null);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native respondent cannot erase the only final Yes witness by its own actual alliance break', () => {
|
||||
const f = fixture({ consent: 'allied' }), game = bind(f).game;
|
||||
rejected(game, f.target, { type: 'alliance', target: null });
|
||||
assert.equal(player(game, f.target).ally, f.partner); assert.ok(!promise(game, f.target).released);
|
||||
const closed = close(game); assert.equal(promise(closed, f.target).fulfilled, true);
|
||||
});
|
||||
|
||||
void test('native current Nexus question ownership and exact guide event reject foreign and stale callbacks', () => {
|
||||
const f = fixture(), declared = declare(f);
|
||||
rejected(declared.game, f.opponent, f.questionAction);
|
||||
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
|
||||
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, partner: f.target } });
|
||||
let game = ask(declared.game, f.asker, f.target, f.partner);
|
||||
assert.ok(viewGame(game, f.target).truthAllianceAnswers!.includes('yes'));
|
||||
assert.equal(viewGame(game, f.asker).truthAllianceAnswers, null);
|
||||
rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes');
|
||||
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); assert.ok(guide.action.alliancePromiseEvent);
|
||||
assert.ok(viewGame(game, f.target).alliancePromises.every(promise => !Object.hasOwn(promise, 'source')));
|
||||
rejected(game, f.asker, guide.action);
|
||||
rejected(game, f.target, { ...guide.action, alliancePromiseEvent: 'stale-owned-event' });
|
||||
game = act(game, f.partner, { type: 'alliance', target: null });
|
||||
assert.equal(promise(game, f.target).released, true);
|
||||
game = act(game, f.partner, { type: 'alliance', target: f.target });
|
||||
rejected(game, f.target, guide.action);
|
||||
game = act(game, f.target, { type: 'alliance', target: f.partner });
|
||||
assert.equal(player(game, f.target).ally, f.partner); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('all four minimal policies answer and accept a real current foreign Nexus offer legally', () => {
|
||||
const f = fixture(), declared = declare(f), asked = ask(declared.game, f.asker, f.target, f.partner);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(asked, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
|
||||
const action = botActions(view).find(action => action.type === 'truthAnswer'); assert.ok(action, difficulty);
|
||||
assert.ok(view.truthAllianceAnswers!.some(answer => answer === action.answer));
|
||||
const answered = act(structuredClone(asked), f.target, action);
|
||||
assert.equal(answered.discard.filter(card => card.id === f.truth).length, action.answer === 'unknown' ? 0 : 1);
|
||||
assert.deepEqual(inventory(answered), f.originalCards);
|
||||
}
|
||||
const bound = bind(f).game;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(bound, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
|
||||
const action = botActions(view).find(action => action.type === 'alliance' && action.target === f.partner); assert.ok(action, difficulty);
|
||||
const accepted = act(structuredClone(bound), f.target, action);
|
||||
assert.equal(player(accepted, f.target).ally, f.partner); assert.ok(!promise(accepted, f.target).fulfilled);
|
||||
assert.deepEqual(inventory(close(accepted)), f.originalCards);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user