Extend Truthtrance facts and Basic Semuta Tech
Verify and publish checkpoint container / container (push) Failing after 10m53s
Verify and publish checkpoint container / container (push) Failing after 10m53s
This commit is contained in:
1 parent
16f10beecf
commit
7a083ac9aa
12 files changed
+361
-6
No files matched your search
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user