Expand Basic Semuta and Truthtrance rules
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
7e6e8c2d12
commit
4f990db52e
13 files changed
+351
-12
No files matched your search
@@ -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'}));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user