Prototype named Ghola leader Truthtrance promises
Verify and publish checkpoint container / container (push) Successful in 10m45s

This commit is contained in:
Quintine committed 2026-10-29 17:34:55 +11:00
1 parent f8078ed5e2
commit c1ffa1c28a
16 files changed
+689 -9

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { GholaLeaderClaimFields, gholaLeaderClaimInputError } from '../components/ghola-leader-claim-fields';
import type { GholaLeaderClaim } from '../game/ghola-leader-promises';
type ElementProps = { children?: ReactNode; [key: string]: unknown };
function elements(node: ReactNode): ReactElement<ElementProps>[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function choose(props: Parameters<typeof GholaLeaderClaimFields>[0], value: string) {
const select = elements(GholaLeaderClaimFields(props)).find(element => element.props.id === 'truth-ghola-leader');
assert.ok(select);
(select.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
}
void test('Ghola named leader field submits only a current-turn listed leader', () => {
const value: GholaLeaderClaim = { turn: 2, leader: 'leader-a' }, changes: GholaLeaderClaim[] = [];
const leaders = [{ id: 'leader-a', name: 'Commander A' }, { id: 'kwisatz', name: 'Kwisatz Haderach' }];
const props = { value, leaders, onChange(next: GholaLeaderClaim) { changes.push(next); } };
const html = renderToStaticMarkup(createElement(GholaLeaderClaimFields, props));
assert.match(html, /Turn 2/);
assert.match(html, /Commander A/);
assert.match(html, /Kwisatz Haderach/);
assert.match(html, /return using Tleilaxu Ghola/);
assert.match(html, /Playing Ghola to return forces, ordinary revival/);
choose(props, 'kwisatz');
assert.deepEqual(changes, [{ turn: 2, leader: 'kwisatz' }]);
for (const invalid of ['', 'missing', ' leader-a ']) choose(props, invalid);
choose({ ...props, disabled: true }, 'kwisatz');
choose({ ...props, leaders: [] }, 'kwisatz');
choose({ ...props, value: { ...value, turn: 0 } }, 'kwisatz');
assert.equal(changes.length, 1);
for (const invalid of [null, {}, { turn: 0, leader: 'leader-a' }, { turn: 2, leader: '' }])
assert.ok(gholaLeaderClaimInputError(invalid));
assert.ok(gholaLeaderClaimInputError(value, []));
assert.match(renderToStaticMarkup(createElement(GholaLeaderClaimFields, { ...props, leaders: [] })), /role="alert"/);
});
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { GholaLeaderPromises, GholaLeaderPromiseGuideButton } from '../components/ghola-leader-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action } from '../game/engine';
function projected() {
const source = createGame('GHOLAGUIDECONTROLS', newPlayer('p', 'Player', 'emperor'), false);
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
const game = viewGame(source, 'p');
game.status = 'playing'; game.turn = 2; game.phase = 4;
game.allLeaders = [{ id: 'named', name: 'Named Commander', faction: 'emperor', strength: 3 }];
const me = game.players.find(p => p.id === 'p')!;
me.hand = [{ id: 'ghola', name: 'Ghola', kind: 'special', effect: 'ghola' }];
game.ghola = { cards: ['ghola'], leaders: [{ id: 'named', name: 'Named Commander', strength: 3 }],
kwisatz: false, maxForces: 0, eliteRemaining: 0, eliteBlock: null, available: true, reason: null };
game.gholaLeaderPromises = [{ event: 'event', turn: 2, player: 'p', asker: 'a', leader: 'named', answer: true }];
game.gholaLeaderPromiseGuide = { action: { type: 'card', card: 'ghola', leader: 'named' } };
return game;
}
void test('private Ghola promises describe the named actual return and statuses', () => {
const game = projected();
game.gholaLeaderPromises.push({ event: 'foreign', turn: 2, player: 'a', asker: 'p', leader: 'secret', answer: false });
const html = renderToStaticMarkup(createElement(GholaLeaderPromises, { game, busy: false, send() {} }));
assert.match(html, /Named Commander/);
assert.match(html, /ordinary return, a force return, or an earlier Ghola play does not fulfill it/);
assert.doesNotMatch(html, /secret|foreign/);
});
void test('the Ghola guide forwards only the current legal card and leader', () => {
const game = projected(), sent: Action[] = [];
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
const button = GholaLeaderPromiseGuideButton(props); assert.ok(button);
assert.equal(button.props.disabled, false); button.props.onClick();
assert.deepEqual(sent, [game.gholaLeaderPromiseGuide!.action]);
game.ghola.available = false; button.props.onClick();
assert.equal(sent.length, 1);
game.ghola.available = true; game.truthtrance = { stage: 'priority', queue: [], passed: [], question: null } as typeof game.truthtrance;
button.props.onClick(); assert.equal(sent.length, 1);
});
+51
View File
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { GholaLeaderPromiseError, gholaLeaderClaimText, gholaLeaderRequirements,
liveGholaLeaderPromises, parseGholaLeaderClaim, recordPromisedGholaLeader,
validateGholaLeaderPromises, type GholaLeaderPromise } from '../game/ghola-leader-promises';
const promise = (event: string, leader = 'leader-a', changes: Partial<GholaLeaderPromise> = {}): GholaLeaderPromise =>
({ event, player: 'target', asker: 'asker', turn: 2, leader, answer: true, ...changes });
void test('named Ghola claims require a current turn and exact identity without coercion', () => {
assert.deepEqual(parseGholaLeaderClaim({ kind: 'gholaLeader', target: 'target', turn: 2, leader: 'kwisatz' }),
{ turn: 2, leader: 'kwisatz' });
assert.match(gholaLeaderClaimText({ turn: 2, leader: 'kwisatz' }), /Kwisatz Haderach.*Ghola/);
for (const value of [null, [], {}, { turn: 0, leader: 'a' }, { turn: 2.5, leader: 'a' },
{ turn: '2', leader: 'a' }, { turn: 2, leader: '' }, { turn: 2, leader: ' a' }])
assert.throws(() => parseGholaLeaderClaim(value), GholaLeaderPromiseError);
});
void test('live obligations combine duplicates, flag conflicts, and do not grant extra Ghola uses', () => {
const records = [promise('a'), promise('a-again'), promise('no', 'leader-b', { answer: false }),
promise('old', 'leader-c', { turn: 1 }), promise('other', 'leader-d', { player: 'other' }),
promise('done', 'leader-e', { fulfilled: true }), promise('released', 'leader-f', { released: true })];
assert.deepEqual(liveGholaLeaderPromises(records, 'target', 2), records.slice(0, 3));
assert.deepEqual(gholaLeaderRequirements(records, 'target', 2), { required: ['leader-a'], forbidden: ['leader-b'] });
assert.equal(gholaLeaderRequirements([...records, promise('contradiction', 'leader-a', { answer: false })], 'target', 2), null);
});
void test('only an actual matching return fulfills Yes, and No rejects without mutation', () => {
const records = [promise('a'), promise('a-again'), promise('no', 'leader-b', { answer: false }),
promise('other', 'leader-a', { player: 'other' }), promise('earlier', 'leader-a', { fulfilled: true })];
const before = structuredClone(records);
assert.throws(() => recordPromisedGholaLeader(records, 'target', 2, 'leader-b'), GholaLeaderPromiseError);
assert.deepEqual(records, before);
const after = recordPromisedGholaLeader(records, 'target', 2, 'leader-a');
assert.equal(after[0].fulfilled, true); assert.equal(after[1].fulfilled, true);
assert.equal(after[2], records[2]); assert.equal(after[3], records[3]); assert.equal(after[4], records[4]);
assert.deepEqual(records, before);
const unrelated = recordPromisedGholaLeader(records, 'target', 2, 'kwisatz');
assert.equal(unrelated[0], records[0]);
assert.throws(() => recordPromisedGholaLeader(records, 'target', 2, ' leader-a'), GholaLeaderPromiseError);
});
void test('ledger validation enforces participants, unique events and terminal state', () => {
const valid = [promise('a'), promise('b', 'kwisatz', { answer: false }), promise('c', 'leader-c', { released: true })];
assert.doesNotThrow(() => validateGholaLeaderPromises(JSON.parse(JSON.stringify(valid)), ['target', 'asker']));
for (const invalid of [null, [null], [promise('a'), promise('a')],
[promise('bad', 'a', { asker: 'target' })], [promise('bad', 'a', { player: 'absent' })],
[promise('bad', 'a', { fulfilled: true, released: true })],
[{ ...promise('bad'), answer: 'yes' }]])
assert.throws(() => validateGholaLeaderPromises(invalid as GholaLeaderPromise[], ['target', 'asker']), GholaLeaderPromiseError);
});
+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, type Game } from '../game/engine';
import { truthQuestionText, validateTruthQuestionReceipt, type TruthQuestion } from '../game/truthtrance';
/** Parser-only game; this does not claim a legal Ghola completion or bind an answer. */
function gameAt(phase: number): Game {
const g = createGame('GHOLATRUTHPARSER', newPlayer('a', 'Asker', 'atreides'));
joinGame(g, newPlayer('p', 'Respondent', 'harkonnen'));
g.status = 'playing'; g.turn = 2; g.phase = phase;
return g;
}
const question = { kind: 'gholaLeader', target: 'p', turn: 2, leader: 'named-leader' } as const;
void test('Ghola named-leader questions parse through quiet phases until turn end', () => {
for (const phase of [0, 4, 7]) {
const game = gameAt(phase);
assert.doesNotThrow(() => validateTruthQuestionReceipt(game, 'a', question));
}
assert.match(truthQuestionText(question, id => id === 'named-leader' ? 'Named Commander' : id, id => id),
/revive Named Commander with Ghola.*end of this turn/);
assert.match(truthQuestionText({ ...question, leader: 'kwisatz' }, id => id, id => id), /Kwisatz Haderach/);
assert.throws(() => validateTruthQuestionReceipt(gameAt(8), 'a', question));
});
void test('Ghola question rejects stale, self-targeted and noncanonical receipts', () => {
const game = gameAt(4);
for (const malformed of [
{ ...question, turn: 1 }, { ...question, target: 'a' }, { ...question, target: 'absent' },
{ ...question, leader: '' }, { ...question, leader: ' named-leader' },
{ ...question, extra: true },
]) assert.throws(() => validateTruthQuestionReceipt(game, 'a', malformed as TruthQuestion));
const busy = gameAt(4);
busy.phaseOpening = { passed: [], initialize: false };
assert.throws(() => validateTruthQuestionReceipt(busy, 'a', question));
});
+120
View File
@@ -0,0 +1,120 @@
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 {
actTruthLeaderRevival as act, answerTruthLeaderRevival as answer,
createTruthLeaderRevivalFixture as fixture, declareTruthLeaderRevival as declare,
settleTruthLeaderRevival as settle, truthLeaderRevivalInventory as inventory,
truthLeaderRevivalPlayer as player,
} from './fixture-truth-leader-revival';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before);
}
function askGhola(f: ReturnType<typeof fixture>) {
const declared = declare(f);
const game = act(declared.game, f.asker, { type: 'truthAsk', question: {
kind: 'gholaLeader', target: f.target, turn: declared.game.turn, leader: f.leader,
} });
return game;
}
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} named Ghola Yes is fulfilled by an actual Ghola leader return`, () => {
const f = fixture({ advanced, ghola: true });
assert.ok(f.ghola);
const asked = askGhola(f);
assert.ok(viewGame(asked, f.target).truthGholaLeaderAnswers?.includes('yes'));
let game = answer(asked, f.target, 'yes');
const promise = game.gholaLeaderPromises!.find(row => row.player === f.target)!;
assert.equal(promise.leader, f.leader);
assert.ok(!promise.fulfilled);
// Spending the only dealt Ghola on a force return cannot satisfy the named leader.
rejected(game, f.target, { type: 'card', card: f.ghola, amount: 1, elite: 0 });
game = settle(act(game, f.target, { type: 'card', card: f.ghola, leader: f.leader }));
assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead, false);
assert.equal(player(game, f.target).leaderRevived, false);
assert.equal(game.gholaLeaderPromises!.find(row => row.event === promise.event)!.fulfilled, true);
assert.equal(game.discard.filter(card => card.id === f.ghola).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
}
void test('named Ghola No rejects the matching leader return but allows its independent force return', () => {
const f = fixture({ ghola: true });
assert.ok(f.ghola);
const asked = askGhola(f);
assert.ok(viewGame(asked, f.target).truthGholaLeaderAnswers?.includes('no'));
let game = answer(asked, f.target, 'no');
const promise = game.gholaLeaderPromises!.find(row => row.player === f.target)!;
rejected(game, f.target, { type: 'card', card: f.ghola, leader: f.leader });
game = settle(act(game, f.target, { type: 'card', card: f.ghola, amount: 1, elite: 0 }));
assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead, true);
assert.ok(!game.gholaLeaderPromises!.find(row => row.event === promise.event)!.fulfilled);
assert.equal(game.discard.filter(card => card.id === f.ghola).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('two Truthtrances cannot bind contradictory answers about the same Ghola leader', () => {
const f = fixture({ ghola: true });
let game = declare(f, f.game, { copies: 2 }).game;
const question = { kind: 'gholaLeader', target: f.target, turn: game.turn, leader: f.leader };
game = act(game, f.asker, { type: 'truthAsk', question });
game = answer(game, f.target, 'yes');
game = act(game, f.asker, { type: 'truthAsk', question });
assert.ok(viewGame(game, f.target).truthGholaLeaderAnswers?.includes('yes'));
assert.ok(!viewGame(game, f.target).truthGholaLeaderAnswers?.includes('no'));
rejected(game, f.target, { type: 'truthAnswer', answer: 'no' });
game = answer(game, f.target, 'yes');
assert.equal(game.gholaLeaderPromises!.filter(row => row.player === f.target).length, 2);
});
void test('prior ordinary-revival Yes prevents a conflicting Ghola leader Yes', () => {
const f = fixture({ ghola: true });
let game = declare(f, f.game, { copies: 2 }).game;
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'leaderRevival', target: f.target, turn: game.turn, leader: f.leader,
} });
game = answer(game, f.target, 'yes');
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'gholaLeader', target: f.target, turn: game.turn, leader: f.leader,
} });
assert.ok(!viewGame(game, f.target).truthGholaLeaderAnswers?.includes('yes'));
assert.ok(viewGame(game, f.target).truthGholaLeaderAnswers?.includes('no'));
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = answer(game, f.target, 'no');
assert.ok(game.leaderRevivalPromises!.some(row => row.player === f.target && row.answer));
assert.ok(game.gholaLeaderPromises!.some(row => row.player === f.target && !row.answer));
});
void test('all bot profiles choose the native named Ghola guide', () => {
const f = fixture({ ghola: true });
const game = answer(askGhola(f), f.target, 'yes');
const view = viewGame(game, f.target);
const guide = view.gholaLeaderPromiseGuide;
assert.ok(guide);
assert.equal(guide.action.type, 'card');
assert.equal(guide.action.leader, f.leader);
for (const difficulty of DIFFICULTIES) {
const botView = viewGame(game, f.target);
botView.players.find(seat => seat.id === f.target)!.bot = difficulty;
assert.deepEqual(botActions(botView)[0], guide.action);
}
const after = settle(act(game, f.target, guide.action));
assert.ok(after.gholaLeaderPromises!.find(row => row.player === f.target)?.fulfilled);
});
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} ordinary revival cannot consume the leader promised for Ghola`, () => {
const f = fixture({ advanced, ghola: true });
const game = answer(askGhola(f), f.target, 'yes');
assert.ok(game.gholaLeaderPromises!.some(row => row.player === f.target && row.answer && !row.fulfilled));
rejected(game, f.target, f.revivalAction);
assert.ok(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead);
});
}