Extend Truthtrance facts and Basic Semuta Tech
Verify and publish checkpoint container / container (push) Failing after 10m53s

This commit is contained in:
Quintine committed 2026-10-29 05:15:04 +11:00
1 parent 16f10beecf
commit 7a083ac9aa
12 files changed
+361 -6

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { ownedTech, TECH_TOKENS } from '../game/tech-tokens';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextNativeTechFactionsStep } from './fixture-native-tech-factions';
import { basicSemutaAmbassadorInventory as inventory } from './fixture-basic-semuta-ambassador';
const seat = (game: Game, id: string) => {
const player = game.players.find(candidate => candidate.id === id);
assert.ok(player);
return player;
};
const clean = (game: Game) => !game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
/** Run original setup and first Storm; select only safe unopened Spice draws.
* Once the real movement opportunity is reached, relocate the physical Semuta
* and Hajr to their users' hands and return those hands' other cards to the
* deck. No Tech ownership, force, resource or turn state is staged. */
function fixture() {
let game = createGame('SEMUTATECHPUBLIC', newPlayer('r', 'Richese', 'richese'), false, ['choam']);
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('g', 'Guild', 'guild'));
game = applyAction(game, 'r', { type: 'techTokens', enabled: true });
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = applyAction(game, 'r', { type: 'start' });
assert.equal(game.status, 'setup');
assert.equal(game.semutaPreview, true);
const original = inventory(game);
for (let count = 0; game.status === 'setup' && count < 250; count++) {
const step = nextNativeTechFactionsStep(game); assert.ok(step);
game = applyAction(game, step.actor, step.action);
}
assert.equal(game.status, 'playing');
for (let position = 0; position < 4; position++) {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position);
const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card);
}
for (let count = 0; !(game.phase === 5 && game.active === 'a' && clean(game)) && count < 1600; count++) {
assert.notEqual(game.status, 'finished');
const step = nextNativeTechFactionsStep(game); assert.ok(step);
game = applyAction(game, step.actor, step.action);
}
assert.equal(game.phase, 5);
assert.equal(game.active, 'a');
assert.ok(clean(game));
const owners = TECH_TOKENS.map(token => game.techTokens![token.id].owner);
assert.equal(new Set(owners).size, 3);
assert.ok(owners.every(owner => game.players.some(player => player.id === owner)));
for (const id of ['r', 'a']) game.deck.push(...seat(game, id).hand.splice(0));
const zones = [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [], ...game.players.map(player => player.hand)];
const take = (match: (card: Game['deck'][number]) => boolean) => {
for (const zone of zones) {
const index = zone.findIndex(match);
if (index >= 0) return zone.splice(index, 1)[0];
}
throw Error('The original physical card was not found');
};
const semuta = take(card => card.id === SEMUTA_DRUG_ID);
const hajr = take(card => card.effect === 'hajr');
seat(game, 'r').hand.push(semuta);
seat(game, 'a').hand.push(hajr);
assert.deepEqual(inventory(game), original);
return { game, original, hajr: hajr.id, tech: structuredClone(game.techTokens!) };
}
for (const claim of [false, true]) {
void test(`Basic public Tech Semuta ${claim ? 'claims' : 'passes'} a real Hajr discard and continues the shipment`, () => {
const f = fixture();
let game = applyAction(f.game, 'a', { type: 'card', card: f.hajr });
const pending = game.pendingTreacheryDiscard;
assert.ok(pending?.reaction?.stage === 'offer');
assert.equal(pending.continuation.kind, 'ordinaryCardDiscard');
assert.equal(viewGame(game, 'r').semutaReaction?.canCommit, true);
assert.deepEqual(game.techTokens, f.tech);
const event = pending.batch.event;
if (claim) game = applyAction(game, 'r', { type: 'semutaCommit', event });
else for (const player of game.players) game = applyAction(game, player.id, { type: 'semutaPass', event });
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = applyAction(game, 'r', { type: 'semutaSelect', event, card: f.hajr });
assert.equal(game.pendingTreacheryDiscard, null);
assert.ok(game.hajr.includes('a'));
assert.equal(seat(game, 'r').hand.some(card => card.id === f.hajr), claim);
assert.equal(game.discard.some(card => card.id === SEMUTA_DRUG_ID), claim);
const owner = game.techTokens!.heighliners.owner!;
const already = game.techTokens!.heighliners.triggeredTurn === game.turn;
const spice = already ? game.techTokens!.heighliners.spice : ownedTech(game.techTokens, owner).length;
const reserves = seat(game, 'a').reserves;
game = applyAction(game, 'a', { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1 });
for (let count = 0; !clean(game) && count < 30; count++) {
const step = nextNativeTechFactionsStep(game); assert.ok(step);
game = applyAction(game, step.actor, step.action);
}
assert.ok(clean(game));
assert.equal(seat(game, 'a').reserves, reserves - 1);
assert.equal(game.techTokens!.heighliners.owner, owner);
assert.equal(game.techTokens!.heighliners.triggeredTurn, game.turn);
assert.equal(game.techTokens!.heighliners.spice, spice);
assert.deepEqual(inventory(game), f.original);
});
}
void test('all four bot profiles can legally respond to the Basic Tech Semuta offer', () => {
const f = fixture();
const pending = applyAction(f.game, 'a', { type: 'card', card: f.hajr });
for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending, 'r');
const player = view.players.find(candidate => candidate.id === 'r');
assert.ok(player);
player.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action, difficulty);
assert.doesNotThrow(() => applyAction(pending, 'r', action));
}
});
@@ -57,3 +57,14 @@ void test('a compound freeform shows its aggregate interpretation to the table',
assert.match(target, /<strong>Yes<\/strong>/);
assert.doesNotMatch(observer, /Your current game information establishes:/);
});
void test('a category freeform displays its primary-role meaning without exposing the target answer', () => {
const game = pending(5, 'Do you have a weapon?');
const target = markup(game, 'b'), observer = markup(game, 'o');
for (const html of [target, observer]) {
assert.match(html, /Checked as:/);
assert.match(html, /you currently hold at least 1 card whose primary role is a weapon/);
}
assert.match(target, /Your current game information establishes:/);
assert.doesNotMatch(observer, /Your current game information establishes:/);
});
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
import { knowledgeGame, knowledgePhysical, rejectKnowledge } from './truthtrance-knowledge-fixture';
/** Actual Truthtrance actions after conserving the target's staged hand. */
function pending(hasWeapon: boolean, scope: 'fact' | 'currentTurn' = 'fact'): Game {
let game = knowledgeGame();
const target = game.players.find(seat => seat.id === 'b')!;
for (const card of target.hand.slice()) moveSemutaEmperorCard(game, card.id, 'f');
if (hasWeapon) {
const weapon = [...game.deck, ...game.discard, ...game.players.flatMap(seat => seat.hand)]
.find(card => card.kind === 'projectile');
assert.ok(weapon); moveSemutaEmperorCard(game, weapon.id, target.id);
}
const truth = game.players[0].hand.find(card => card.effect === 'truthtrance');
assert.ok(truth);
game = applyAction(game, 'a', { type: 'card', card: truth.id });
while (game.truthtrance?.stage === 'priority') {
const seat = game.players.find(row => !game.truthtrance!.passed.includes(row.id));
assert.ok(seat); game = applyAction(game, seat.id, { type: 'truthPass' });
}
return applyAction(game, 'a', { type: 'truthAsk', question: {
kind: 'freeform', target: 'b', scope, text: 'Do you have a weapon?',
} });
}
for (const hasWeapon of [true, false]) {
void test(`a category question checks the target's current primary weapon role: ${hasWeapon}`, () => {
const game = pending(hasWeapon), answer = hasWeapon ? 'yes' : 'no';
const question = game.truthtrance!.question;
assert.equal(question?.kind, 'freeform');
if (question?.kind !== 'freeform') throw Error('Expected freeform question');
assert.deepEqual(question.interpreted, { version: 3,
fact: { kind: 'handInventory', category: 'weapon', compare: 'gte', value: 1 } });
assert.equal(viewGame(game, 'b').truthAnswer, answer);
for (const observer of ['a', 'f', 'o']) assert.equal(viewGame(game, observer).truthAnswer, null);
rejectKnowledge(game, 'b', { type: 'truthAnswer', answer: hasWeapon ? 'no' : 'yes' });
const physical = knowledgePhysical(game);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'b');
view.players.find(seat => seat.id === 'b')!.bot = difficulty;
assert.deepEqual(botActions(view), [{ type: 'truthAnswer', answer }]);
}
const restored = JSON.parse(JSON.stringify(game)) as Game;
const done = applyAction(restored, 'b', { type: 'truthAnswer', answer });
assert.equal(done.truthHistory!.at(-1)!.answer, answer);
assert.deepEqual(knowledgePhysical(done), physical);
});
}
void test('unstamped and current-turn category questions keep their prior manual semantics', () => {
const currentTurn = pending(true, 'currentTurn');
assert.equal(currentTurn.truthtrance?.question?.kind, 'freeform');
assert.equal(currentTurn.truthtrance?.question?.kind === 'freeform' && currentTurn.truthtrance.question.interpreted, undefined);
const legacy = pending(true);
const question = legacy.truthtrance!.question;
assert.equal(question?.kind, 'freeform');
if (question?.kind !== 'freeform') throw Error('Expected freeform question');
delete question.interpreted;
assert.equal(viewGame(legacy, 'b').truthAnswer, null);
assert.equal(applyAction(legacy, 'b', { type: 'truthAnswer', answer: 'no' }).truthHistory!.at(-1)!.answer, 'no');
});
+60
View File
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { parseFreeformFactV2 } from '../game/truthtrance-freeform-facts-v2';
import { parseFreeformFactV3 } from '../game/truthtrance-freeform-facts-v3';
import { handInventoryFactMatches, type HandInventoryFact } from '../game/truthtrance-hand-inventory';
void test('freeform v3 recognizes current primary-role existence and count questions', () => {
const cases: Array<[string, HandInventoryFact]> = [
['Do you have a weapon?', {kind:'handInventory', category:'weapon', compare:'gte', value:1}],
['DO YOU HAVE ANY DEFENSES IN YOUR HAND?', {kind:'handInventory', category:'defense', compare:'gte', value:1}],
['Do you have at least 02 worthless cards?', {kind:'handInventory', category:'worthless', compare:'gte', value:2}],
['Do you have exactly 0 poison weapons?', {kind:'handInventory', category:'poisonWeapon', compare:'eq', value:0}],
['Do you have at most 3 projectile weapons in your hand?', {kind:'handInventory', category:'projectileWeapon', compare:'lte', value:3}],
['Do you have any poison defenses?', {kind:'handInventory', category:'poisonDefense', compare:'gte', value:1}],
['Do you have a projectile defense?', {kind:'handInventory', category:'projectileDefense', compare:'gte', value:1}],
];
for (const [text, fact] of cases) assert.deepEqual(parseFreeformFactV3(text), fact, text);
});
void test('freeform v3 uses the existing physical hand category semantics', () => {
const deck = baseDeck();
const weapon = deck.find(card => card.kind === 'projectile')!;
const defense = deck.find(card => card.kind === 'shield')!;
const worthless = deck.find(card => card.kind === 'worthless')!;
assert.ok(weapon && defense && worthless);
const hand = [weapon, defense, worthless];
for (const text of ['Do you have a weapon?', 'Do you have a defense?',
'Do you have exactly 1 worthless card?', 'Do you have exactly 0 poison weapons?']) {
const fact = parseFreeformFactV3(text);
assert.ok(fact && fact.kind === 'handInventory', text);
assert.equal(handInventoryFactMatches(hand, fact), true, text);
}
const absent = parseFreeformFactV3('Do you have any poison defenses?');
assert.ok(absent && absent.kind === 'handInventory');
assert.equal(handInventoryFactMatches(hand, absent), false);
});
void test('freeform v3 preserves v2 named-card, spice, hand-size and compound interpretations', () => {
for (const text of [
'Do you have a Shield?', 'Do you have a Cheap Hero?', 'Do you have at least 4 spice?',
'Do you have at most 3 cards in your hand?', 'Do you have a Shield and a Snooper?',
]) {
const v2 = parseFreeformFactV2(text);
assert.ok(v2, text);
assert.deepEqual(parseFreeformFactV3(text), v2, text);
}
});
void test('freeform v3 leaves ambiguous, negative, compound and future wording manual', () => {
for (const text of [
'Do you have a weapons?', 'Do you have an at least?', 'Do you have a constructor?', 'Do you have a special card?',
'Do you have exactly -1 weapons?', 'Do you have exactly 1.5 weapons?',
'Do you have at least 9007199254740992 weapons?',
'Do you have a weapon or a Shield?', 'Do you have a weapon and a defense?',
'Do you not have a weapon?', 'Will you have a weapon?',
'Do you have a weapon this turn?', 'Do you have a weapon??',
'Do you have a weapon?\nPlease answer.', 'Do you have a weapon?'.padEnd(501, ' '),
]) assert.equal(parseFreeformFactV3(text), null, text);
});