Expand Basic Semuta and Truthtrance rules
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 05:57:46 +11:00
1 parent 7e6e8c2d12
commit 4f990db52e
13 files changed
+351 -12

No files matched your search

+135
View File
@@ -0,0 +1,135 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { basicSemutaAmbassadorInventory as inventory } from './fixture-basic-semuta-ambassador';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
const seat = (game: Game, id: string) => {
const found = game.players.find(player => player.id === id); assert.ok(found); return found;
};
/** Public Basic start with the Ix deck and independent Ecaz cards. After real
* setup, stage original card custody and the specific phase boundary below. */
function fixture(effect: 'thumper' | 'amal') {
let game = createGame('SEMUTAWINDOW', newPlayer('r', 'Richese', 'richese'), false, ['choam', 'ix']);
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
game = act(game, 'r', {type:'moduleOption', module:'ecazTreachery', enabled:true});
for (const player of game.players) game = act(game, player.id, {type:'ready'});
game = act(game, 'r', {type:'start'});
assert.equal(game.status, 'setup');
assert.equal(game.semutaRecruitsPreview, true);
assert.equal(game.semutaPreview, undefined);
for (let step = 0; game.status === 'setup' && step < 100; step++) {
const next = nextRicheseStrongholdsNativeStep(game);
assert.ok(next, 'Native setup must offer a legal action');
game = act(game, next.actor, next.action);
}
assert.equal(game.status, 'playing');
const original = inventory(game);
for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; }
const take = (id: string) => {
for (const zone of [game.deck, game.discard, game.richeseCache!]) {
const index = zone.findIndex(card => card.id === id || card.effect === id);
if (index >= 0) return zone.splice(index, 1)[0];
}
throw new Error(`Missing physical ${id}`);
};
seat(game, 'r').hand.push(take(SEMUTA_DRUG_ID));
const played = take(effect); seat(game, 'a').hand.push(played);
assert.deepEqual(inventory(game), original);
return {game, played, original};
}
function finishOffer(game: Game, claim: boolean) {
const frame = game.pendingTreacheryDiscard; assert.ok(frame?.reaction);
const event = frame.batch.event;
if (claim) {
game = act(game, 'r', {type:'semutaCommit', event});
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = act(game, 'r', {type:'semutaSelect', event, card:frame.batch.entries[0].card.id});
} else {
for (const player of game.players) game = act(game, player.id, {type:'semutaPass', event});
}
assert.equal(game.pendingTreacheryDiscard, null);
assert.throws(() => act(game, 'r', {type:'semutaCommit', event}));
return game;
}
function checkBots(pending: Game) {
for (const difficulty of DIFFICULTIES) {
const game = structuredClone(pending); seat(game, 'r').bot = difficulty;
const action = botActions(viewGame(game, 'r')).find(item =>
item.type === 'semutaCommit' || item.type === 'semutaPass');
assert.ok(action);
assert.doesNotThrow(() => act(game, 'r', action));
}
}
for (const claim of [false, true]) {
void test(`Basic independent Ix Thumper offers Semuta ${claim ? 'claim' : 'pass'} before one Spice Blow`, () => {
const f = fixture('thumper'), game = f.game;
const lands = game.spiceDeck.filter(card => 'territory' in card && !card.discovery && card.sector !== 18);
const prior = lands[0], next = lands[1];
assert.ok(prior && next && 'territory' in prior && 'territory' in next);
const priorKey = `${prior.territory}:${prior.sector}`;
const remaining = game.spiceDeck.filter(card => card !== prior && card !== next);
game.spiceDeck = [next, ...remaining]; game.spiceDiscard = [[prior], []];
seat(game, 'e').forces = {[priorKey]:3}; seat(game, 'e').reserves = 17;
game.spice = {[priorKey]:8};
Object.assign(game, {turn:2, phase:1, active:null, ready:[], storm:18,
decision:null, response:null, phaseOpening:null, stormPending:null,
spiceWindow:null, spiceResolution:null, spiceSequence:null, nexus:false});
const beforeDeck = structuredClone(game.spiceDeck);
const pending = act(game, 'a', {type:'card', card:f.played.id});
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'thumperDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(pending.spiceDeck, beforeDeck);
assert.equal(pending.spice[priorKey], 8);
assert.equal(seat(pending, 'e').tanks, 0);
assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true);
checkBots(pending);
const resumed = finishOffer(pending, claim);
assert.equal(seat(resumed, 'e').tanks, 3);
assert.equal(resumed.spice[priorKey], undefined);
assert.equal(resumed.spiceWindow?.territory, next.territory);
assert.equal(seat(resumed, 'r').hand.some(card => card.id === f.played.id), claim);
assert.equal(resumed.discard.filter(card => card.id === f.played.id).length, Number(!claim));
assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
assert.deepEqual(inventory(resumed), f.original);
});
void test(`Basic independent Ix Amal offers Semuta ${claim ? 'claim' : 'pass'} after one halving`, () => {
const f = fixture('amal'); let game = f.game;
seat(game, 'r').spice = 11; seat(game, 'a').spice = 9; seat(game, 'e').spice = 7;
Object.assign(game, {turn:2, phase:2, active:null, ready:[], decision:null,
response:null, phaseOpening:null, auction:null, stormPending:null});
for (const id of ['a', 'e', 'r']) game = act(game, id, {type:'ready'});
assert.equal(game.phase, 3); assert.ok(game.phaseOpening);
game = act(game, 'e', {type:'ready'});
assert.deepEqual(game.phaseOpening?.passed, ['e']);
const deck = structuredClone(game.deck);
const pending = act(game, 'a', {type:'card', card:f.played.id});
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'amalDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.phaseOpening, null);
assert.deepEqual(pending.deck, deck);
assert.deepEqual(pending.players.map(player => player.spice), [5, 4, 3]);
assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true);
checkBots(pending);
let resumed = finishOffer(pending, claim);
assert.deepEqual(resumed.phaseOpening?.passed, []);
assert.deepEqual(resumed.players.map(player => player.spice), [5, 4, 3]);
assert.equal(seat(resumed, 'r').hand.some(card => card.id === f.played.id), claim);
assert.equal(resumed.discard.filter(card => card.id === f.played.id).length, Number(!claim));
assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
for (const id of ['a', 'e', 'r']) resumed = act(resumed, id, {type:'ready'});
assert.equal(resumed.phaseOpening, null);
assert.deepEqual(resumed.players.map(player => player.spice), [5, 4, 3]);
assert.deepEqual(inventory(resumed), f.original);
});
}
@@ -68,3 +68,14 @@ void test('a category freeform displays its primary-role meaning without exposin
assert.match(target, /Your current game information establishes:/);
assert.doesNotMatch(observer, /Your current game information establishes:/);
});
void test('a negative freeform displays the checked zero-card meaning without exposing the target answer', () => {
const game = pending(5, 'Do you have no Shield?');
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 exactly 0 cards named Shield/);
}
assert.match(target, /Your current game information establishes:/);
assert.doesNotMatch(observer, /Your current game information establishes:/);
});
@@ -0,0 +1,76 @@
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';
/** Use the real card, priority queue and answer actions with conserved hand staging. */
function pending(present: boolean, category: boolean, scope: 'fact' | 'currentTurn' = 'fact') {
let g = knowledgeGame();
const target = g.players.find(p => p.id === 'b')!;
for (const card of target.hand.slice()) moveSemutaEmperorCard(g, card.id, 'f');
if (present) {
const card = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)]
.find(c => category ? c.kind === 'projectile' : c.name === 'Shield');
assert.ok(card); moveSemutaEmperorCard(g, card.id, target.id);
}
const truth = g.players[0].hand.find(card => card.effect === 'truthtrance');
assert.ok(truth);
g = applyAction(g, 'a', {type:'card', card:truth.id});
while (g.truthtrance?.stage === 'priority') {
const p = g.players.find(row => !g.truthtrance!.passed.includes(row.id));
assert.ok(p); g = applyAction(g, p.id, {type:'truthPass'});
}
const text = category ? 'Do you have no weapons?' : 'Do you not have a Shield?';
return applyAction(g, 'a', {type:'truthAsk', question:{kind:'freeform', target:'b', scope, text}});
}
function freeform(g: Game) {
const q = g.truthtrance?.question;
assert.equal(q?.kind, 'freeform');
if (q?.kind !== 'freeform') throw Error('Expected freeform question');
return q;
}
for (const present of [true, false]) for (const category of [true, false]) {
void test(`v4 ${category ? 'category' : 'named card'} negation enforces the truthful ${present ? 'no' : 'yes'} answer`, () => {
const g = pending(present, category);
const answer = present ? 'no' : 'yes';
const fact = category
? {kind:'handInventory', category:'weapon', compare:'eq', value:0}
: {kind:'handCount', name:'Shield', compare:'eq', value:0};
assert.deepEqual(freeform(g).interpreted, {version:4, fact});
assert.equal(viewGame(g, 'b').truthAnswer, answer);
for (const observer of ['a', 'f', 'o']) assert.equal(viewGame(g, observer).truthAnswer, null);
rejectKnowledge(g, 'b', {type:'truthAnswer', answer:present ? 'yes' : 'no'});
rejectKnowledge(g, 'b', {type:'truthAnswer', answer:'unknown'});
for (const difficulty of DIFFICULTIES) {
const view = viewGame(g, 'b');
view.players.find(p => p.id === 'b')!.bot = difficulty;
assert.deepEqual(botActions(view), [{type:'truthAnswer', answer}]);
}
const physical = knowledgePhysical(g);
const restored = JSON.parse(JSON.stringify(g)) 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 negative questions and current-turn text retain manual semantics', () => {
const current = pending(true, false, 'currentTurn');
assert.equal(freeform(current).interpreted, undefined);
const old = pending(true, false);
delete freeform(old).interpreted;
assert.equal(viewGame(old, 'b').truthAnswer, null);
assert.equal(applyAction(old, 'b', {type:'truthAnswer', answer:'yes'}).truthHistory!.at(-1)!.answer, 'yes');
});
void test('a tampered version-4 receipt cannot change the recognized negative fact', () => {
const g = pending(true, false);
const q = freeform(g);
q.interpreted = {version:4, fact:{kind:'handCount', name:'Snooper', compare:'eq', value:0}};
assert.throws(() => viewGame(g, 'b'));
assert.throws(() => applyAction(g, 'b', {type:'truthAnswer', answer:'yes'}));
});
+29
View File
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseFreeformFactV3 } from '../game/truthtrance-freeform-facts-v3';
import { parseFreeformFactV4 } from '../game/truthtrance-freeform-facts-v4';
void test('v4 parses exact negative named cards and primary hand categories', () => {
const cases = [
['Do you have no Shield?', {kind:'handCount', name:'Shield', compare:'eq', value:0}],
['DO YOU NOT HAVE A SHIELD IN YOUR HAND?', {kind:'handCount', name:'Shield', compare:'eq', value:0}],
['Do you have no poison weapons?', {kind:'handInventory', category:'poisonWeapon', compare:'eq', value:0}],
['Do you not have any projectile defenses in your hand?', {kind:'handInventory', category:'projectileDefense', compare:'eq', value:0}],
['Do you have no cards in your hand?', {kind:'handInventory', category:'all', compare:'eq', value:0}],
['Do you not have a worthless card?', {kind:'handInventory', category:'worthless', compare:'eq', value:0}],
] as const;
for (const [text, expected] of cases) {
assert.equal(parseFreeformFactV3(text), null, text);
assert.deepEqual(parseFreeformFactV4(text), expected, text);
}
});
void test('v4 rejects ambiguous, future, malformed and compound language', () => {
for (const text of [
'Do you have no special cards?', 'Do you not have a weapons?', 'Do you have no constructor?',
'Do you have no Shield or Snooper?', 'Will you have no Shield?',
'Do you have no Shield this turn?', 'Do you have no Shield??',
'Do you have no Shield?\nPlease answer.', 'Do you have no Shield?'.padEnd(501, ' '),
'Do you have at most 0 poison weapons?',
]) assert.equal(parseFreeformFactV4(text), null, text);
});