Interpret bounded Truthtrance fact questions
Verify and publish checkpoint container / container (push) Successful in 10m38s
Verify and publish checkpoint container / container (push) Successful in 10m38s
This commit is contained in:
1 parent
88c0ab1547
commit
8ae275f598
16 files changed
+431
-31
No files matched your search
@@ -0,0 +1,100 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { newPlayer, viewGame } from '../game/engine';
|
||||
import { TRUTH_CARD_NAMES } from '../game/truthtrance-card-names';
|
||||
import { knowledgeFactAnswer, truthKnowledgeOf } from '../game/truthtrance-knowledge';
|
||||
import {
|
||||
actTruthtranceRichesePromises as act,
|
||||
advanceTruthtranceRichesePromises as advance,
|
||||
askTruthtranceRichesePromises as ask,
|
||||
createTruthtranceRichesePromisesFixture as fixture,
|
||||
passTruthRicheseFreshDiscard,
|
||||
truthtranceRichesePromisesClean as clean,
|
||||
truthtranceRichesePromisesInventory as inventory,
|
||||
} from './fixture-truthtrance-richese-promises';
|
||||
|
||||
/** Public Advanced Richese setup and actual cache auction; the shared fixture
|
||||
* orders unopened deal and Spice cards but does not forge the cache lot. */
|
||||
function liveCache() {
|
||||
const f = fixture({ advanced: true });
|
||||
const game = advance(f.earlySource, state => clean(state) && state.phase === 3 &&
|
||||
state.richeseAuction?.source === 'cache' && !state.richeseAuction.outcome);
|
||||
const card = game.richeseCache!.find(item => item.id === game.richeseAuction!.cardId);
|
||||
assert.ok(card);
|
||||
assert.equal(game.advanced, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
return { f, game, card };
|
||||
}
|
||||
|
||||
for (const expected of ['yes', 'no'] as const) {
|
||||
void test(`Advanced public cache question answers ${expected} and all bots continue`, () => {
|
||||
const { f, game, card } = liveCache();
|
||||
for (const seat of game.players) assert.equal(viewGame(game, seat.id).richeseAuction?.card?.id, card.id);
|
||||
const name = expected === 'yes' ? card.name : TRUTH_CARD_NAMES.find(face => face !== card.name);
|
||||
assert.ok(name);
|
||||
const lot = structuredClone(game.richeseAuction);
|
||||
const asked = ask(structuredClone(game), f, {kind:'fact', target:f.richese,
|
||||
fact:{kind:'cacheLot', name}});
|
||||
assert.equal(viewGame(asked, f.richese).truthAnswer, expected);
|
||||
assert.equal(viewGame(asked, f.asker).truthAnswer, null);
|
||||
const before = structuredClone(asked);
|
||||
assert.throws(() => act(asked, f.richese, {type:'truthAnswer', answer:expected === 'yes' ? 'no' : 'yes'}));
|
||||
assert.deepEqual(asked, before);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(asked, f.richese);
|
||||
view.players.find(player => player.id === f.richese)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(view), [{type:'truthAnswer', answer:expected}]);
|
||||
const done = passTruthRicheseFreshDiscard(act(structuredClone(asked), f.richese,
|
||||
{type:'truthAnswer', answer:expected}));
|
||||
assert.equal(done.truthtrance, null);
|
||||
assert.equal(done.truthHistory!.at(-1)!.answer, expected);
|
||||
assert.deepEqual(done.richeseAuction, lot);
|
||||
assert.deepEqual(inventory(done), f.originalCards);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('Advanced cache question is unknown outside the active public lot or for an outsider', () => {
|
||||
const {game, card} = liveCache();
|
||||
const fact = {kind:'cacheLot', name:card.name} as const;
|
||||
const target = game.players.find(player => player.id === 'richese')!;
|
||||
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(game, target)), 'yes');
|
||||
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(game, newPlayer('outside','Outside','guild'))), 'unknown');
|
||||
for (const change of [
|
||||
(state: typeof game) => { state.phase = 4; },
|
||||
(state: typeof game) => { state.richeseAuction = null; },
|
||||
(state: typeof game) => { state.richeseAuction!.outcome = {kind:'unbid'}; },
|
||||
(state: typeof game) => { state.richeseCache = state.richeseCache!.filter(item => item.id !== card.id); },
|
||||
]) {
|
||||
const staged = structuredClone(game); change(staged);
|
||||
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(staged, target)), 'unknown');
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual Advanced Black Market remains unknown to the public cache question', () => {
|
||||
const f = fixture({advanced:true});
|
||||
let game = advance(f.earlySource, state => state.decision?.kind === 'richeseBlackMarket');
|
||||
const card = game.players.find(player => player.id === f.richese)!.hand[0];
|
||||
assert.ok(card);
|
||||
game = act(game, f.richese, {type:'decision', event:game.richeseBidding!.event, card:card.id, method:'silent'});
|
||||
for (let count = 0; game.response && count < game.players.length; count++) {
|
||||
const respondent = game.players.find(player => !game.response!.passed.includes(player.id));
|
||||
assert.ok(respondent); game = act(game, respondent.id, {type:'passResponse'});
|
||||
}
|
||||
assert.equal(game.richeseAuction?.source, 'blackMarket');
|
||||
assert.equal(knowledgeFactAnswer({kind:'cacheLot',name:card.name},
|
||||
truthKnowledgeOf(game, game.players.find(player => player.id === f.richese)!)), 'unknown');
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
const asked = ask(game, f, {kind:'fact', target:f.richese, fact:{kind:'cacheLot', name:card.name}});
|
||||
assert.equal(viewGame(asked, f.richese).truthAnswer, 'unknown');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(asked, f.richese);
|
||||
view.players.find(player => player.id === f.richese)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(view), [{type:'truthAnswer', answer:'unknown'}]);
|
||||
const answered = act(structuredClone(asked), f.richese, {type:'truthAnswer', answer:'unknown'});
|
||||
assert.equal(answered.truthtrance?.stage, 'unknown');
|
||||
assert.deepEqual(inventory(answered), f.originalCards);
|
||||
}
|
||||
});
|
||||
@@ -32,7 +32,7 @@ void test('single and compound current facts expose the public cache auction sel
|
||||
value: fact, game: view, target: 'target', onChange() {},
|
||||
}));
|
||||
assert.match(single, /value="cacheLot" selected/);
|
||||
assert.match(single, /Public Basic Richese cache auction card/);
|
||||
assert.match(single, /Public Richese cache auction card/);
|
||||
assert.match(single, /value="Shield" selected/);
|
||||
assert.match(single, /If that auction is unavailable/);
|
||||
const compound: TruthFact = { kind: 'or', terms: [fact, { kind: 'spice', compare: 'gte', value: 3 }] };
|
||||
@@ -40,7 +40,7 @@ void test('single and compound current facts expose the public cache auction sel
|
||||
value: compound, game: view, target: 'target', disabled: false, onChange() {},
|
||||
}));
|
||||
assert.match(grouped, /value="cacheLot" selected/);
|
||||
assert.match(grouped, /Public Basic Richese cache auction card/);
|
||||
assert.match(grouped, /Public Richese cache auction card/);
|
||||
assert.match(grouped, /value="Shield" selected/);
|
||||
assert.equal(invalidTruthFact(fact, view, 'target'), false);
|
||||
assert.equal(invalidTruthFact({ kind: 'cacheLot', name: 'not a printed card' }, view, 'target'), true);
|
||||
|
||||
@@ -33,12 +33,11 @@ void test('public cache question validates printed faces and answers from the ac
|
||||
}
|
||||
});
|
||||
|
||||
void test('cache lot question becomes unknown outside the supported active Basic public lot', () => {
|
||||
void test('cache lot question becomes unknown outside a supported active public lot', () => {
|
||||
const { game, card } = liveCache();
|
||||
const fact = { kind: 'cacheLot', name: card.name } as const;
|
||||
const outside = [
|
||||
(copy: typeof game) => { copy.phase = 4; },
|
||||
(copy: typeof game) => { copy.advanced = true; },
|
||||
(copy: typeof game) => { copy.richeseAuction = null; },
|
||||
(copy: typeof game) => { copy.richeseAuction!.source = 'blackMarket'; },
|
||||
(copy: typeof game) => { copy.richeseAuction!.outcome = { kind: 'unbid' }; },
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { Truthtrance } from '../components/truthtrance';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { knowledgeGame } from './truthtrance-knowledge-fixture';
|
||||
|
||||
/** Actual native game and Truthtrance actions, with the target's scalar
|
||||
* spice staged to show both personalized result branches. */
|
||||
function pending(spice: number): Game {
|
||||
let game = knowledgeGame();
|
||||
game.players.find(player => player.id === 'b')!.spice = spice;
|
||||
const card = game.players[0].hand.find(item => item.effect === 'truthtrance');
|
||||
assert.ok(card);
|
||||
game = applyAction(game,'a',{type:'card',card:card.id});
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const voter = game.players.find(player => !game.truthtrance!.passed.includes(player.id));
|
||||
assert.ok(voter); game = applyAction(game,voter.id,{type:'truthPass'});
|
||||
}
|
||||
return applyAction(game,'a',{type:'truthAsk',question:{kind:'freeform',target:'b',
|
||||
scope:'fact',text:'Do you have at least 5 spice?'}});
|
||||
}
|
||||
function markup(game: Game, seat: string) {
|
||||
return renderToStaticMarkup(createElement(Truthtrance, {game:viewGame(game,seat), busy:false, act() {}}));
|
||||
}
|
||||
|
||||
for (const [spice, answer] of [[5,'Yes'],[4,'No']] as const) {
|
||||
void test(`recognized freeform shows checked meaning to the table and private ${answer} to the target`, () => {
|
||||
const game = pending(spice);
|
||||
const target = markup(game,'b');
|
||||
const observer = markup(game,'o');
|
||||
for (const html of [target, observer]) {
|
||||
assert.match(html, /Do you have at least 5 spice\?/);
|
||||
assert.match(html, /Checked as:/);
|
||||
assert.match(html, /you currently hold at least 5 spice/);
|
||||
}
|
||||
assert.match(target, /Your current game information establishes:/);
|
||||
assert.match(target, new RegExp(`<strong>${answer}</strong>`));
|
||||
assert.match(target, /Only you see this until you answer/);
|
||||
assert.match(target, /Publish my answer/);
|
||||
assert.doesNotMatch(observer, /Your current game information establishes:/);
|
||||
assert.doesNotMatch(observer, /Publish my answer/);
|
||||
assert.match(observer, /Waiting for .* to answer/);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseFreeformFactV1 } from '../game/truthtrance-freeform-facts';
|
||||
import { TRUTH_CARD_NAMES } from '../game/truthtrance-card-names';
|
||||
|
||||
void test('freeform v1 compiles exact current personal spice comparisons', () => {
|
||||
assert.deepEqual(parseFreeformFactV1('Do you have at least 5 spice?'), {kind:'spice', compare:'gte', value:5});
|
||||
assert.deepEqual(parseFreeformFactV1(' DO YOU HAVE EXACTLY 0 SPICE '), {kind:'spice', compare:'eq', value:0});
|
||||
assert.deepEqual(parseFreeformFactV1('do you have at most 7 spice'), {kind:'spice', compare:'lte', value:7});
|
||||
assert.deepEqual(parseFreeformFactV1('Do\tyou have at\tleast 0002 spice?'), {kind:'spice', compare:'gte', value:2});
|
||||
assert.equal(parseFreeformFactV1('Do you have exactly 9007199254740992 spice?'), null);
|
||||
});
|
||||
|
||||
void test('freeform v1 resolves only exact printed card names regardless of case', () => {
|
||||
for (const name of TRUTH_CARD_NAMES) {
|
||||
assert.deepEqual(parseFreeformFactV1(`do you have a ${name.toUpperCase()}?`), {kind:'hand', name});
|
||||
}
|
||||
assert.deepEqual(parseFreeformFactV1('Do you have an Artillery Strike?'), {kind:'hand', name:'Artillery Strike'});
|
||||
assert.equal(parseFreeformFactV1('Do you have a shield card?'), null);
|
||||
assert.equal(parseFreeformFactV1('Do you have a secret weapon?'), null);
|
||||
});
|
||||
|
||||
void test('freeform v1 declines ambiguous, compound, negative and future wording', () => {
|
||||
for (const phrase of [
|
||||
'', 'Do you have 5 spice?', 'Do you have more than 5 spice?',
|
||||
'Do you have at least -1 spice?', 'Do you have at least 2.5 spice?',
|
||||
'Do you have at least 5 spice and a Shield?',
|
||||
'Do you not have a Shield?', 'Do you have a Shield or a Snooper?',
|
||||
'Do you have a Shield this turn?', 'Will you have at least 5 spice?',
|
||||
'Do you have a Shield??', 'Do you have a Shield? Please answer.',
|
||||
'Do you have at least 5 spice?\nOr a Shield?',
|
||||
'Do you have a Shield?'.padEnd(501, ' '),
|
||||
]) assert.equal(parseFreeformFactV1(phrase), null, phrase);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, 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';
|
||||
|
||||
type Stamp = { version: 1; fact: unknown };
|
||||
type Freeform = {kind: 'freeform'; target: string; text: string; scope: 'fact' | 'currentTurn'; interpreted?: Stamp};
|
||||
|
||||
/** Actual Truthtrance declaration, priority, question and response actions.
|
||||
* The shared fixture starts the native game and stages original card custody. */
|
||||
function ask(state: Game, question: Freeform): Game {
|
||||
const owner = state.players[0];
|
||||
const card = owner.hand.find(item => item.effect === 'truthtrance'); assert.ok(card);
|
||||
let game = applyAction(state, owner.id, {type:'card', card:card.id});
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const player = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id));
|
||||
assert.ok(player); game = applyAction(game, player.id, {type:'truthPass'});
|
||||
}
|
||||
return applyAction(game, owner.id, {type:'truthAsk', question} as Action);
|
||||
}
|
||||
function freeform(game: Game): Freeform {
|
||||
const question = game.truthtrance?.question;
|
||||
assert.equal(question?.kind, 'freeform');
|
||||
return question as Freeform;
|
||||
}
|
||||
|
||||
for (const expected of ['yes','no'] as const) for (const kind of ['spice','hand'] as const) {
|
||||
void test(`recognized ${kind} freeform yields verified ${expected} for humans and all bots`, () => {
|
||||
const initial = knowledgeGame();
|
||||
const target = initial.players.find(player => player.id === 'b')!;
|
||||
let text: string, fact: Stamp['fact'];
|
||||
if (kind === 'spice') {
|
||||
target.spice = expected === 'yes' ? 5 : 4;
|
||||
text = 'Do you have at least 5 spice?';
|
||||
fact = {kind:'spice', compare:'gte', value:5};
|
||||
} else {
|
||||
const shield = [...initial.deck, ...initial.players.flatMap(player => player.hand)]
|
||||
.find(item => item.name === 'Shield'); assert.ok(shield);
|
||||
moveSemutaEmperorCard(initial, shield.id, expected === 'yes' ? target.id : 'f');
|
||||
// No duplicated physical cards and no other Shield face in this target's hand.
|
||||
if (expected === 'no') {
|
||||
for (const card of [...target.hand].filter(item => item.name === 'Shield'))
|
||||
moveSemutaEmperorCard(initial, card.id, 'f');
|
||||
}
|
||||
text = 'Do you have a Shield?';
|
||||
fact = {kind:'hand', name:'Shield'};
|
||||
}
|
||||
const physical = knowledgePhysical(initial);
|
||||
const card = initial.players[0].hand.find(item => item.effect === 'truthtrance')!;
|
||||
const pending = ask(initial, {kind:'freeform', target:target.id, scope:'fact', text});
|
||||
assert.deepEqual(freeform(pending).interpreted, {version:1, fact});
|
||||
assert.equal(viewGame(pending, target.id).truthAnswer, expected);
|
||||
for (const id of ['a','f','o']) assert.equal(viewGame(pending,id).truthAnswer, null);
|
||||
rejectKnowledge(pending, target.id, {type:'truthAnswer', answer:expected === 'yes' ? 'no' : 'yes'});
|
||||
rejectKnowledge(pending, target.id, {type:'truthAnswer', answer:'unknown'});
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(pending,target.id);
|
||||
view.players.find(player => player.id === target.id)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(view), [{type:'truthAnswer', answer:expected}]);
|
||||
const done = applyAction(structuredClone(pending), target.id, {type:'truthAnswer', answer:expected});
|
||||
assert.equal(done.truthtrance, null);
|
||||
assert.equal(done.truthHistory!.at(-1)!.answer, expected);
|
||||
assert.equal(done.discard.filter(item => item.id === card.id).length,1);
|
||||
assert.deepEqual(knowledgePhysical(done), physical);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('only server interpretation is used when a client forges a stamp', () => {
|
||||
const initial = knowledgeGame();
|
||||
initial.players.find(player => player.id === 'b')!.spice = 4;
|
||||
const asked = ask(initial, {kind:'freeform', target:'b', scope:'fact',
|
||||
text:'Do you have at least 5 spice?', interpreted:{version:1, fact:{kind:'spice', compare:'gte', value:0}}});
|
||||
assert.deepEqual(freeform(asked).interpreted, {version:1, fact:{kind:'spice',compare:'gte',value:5}});
|
||||
assert.equal(viewGame(asked,'b').truthAnswer,'no');
|
||||
rejectKnowledge(asked,'b',{type:'truthAnswer',answer:'yes'});
|
||||
const unsupported = ask(initial, {kind:'freeform', target:'b', scope:'fact',
|
||||
text:'Will you win two battles?', interpreted:{version:1, fact:{kind:'spice', compare:'gte', value:0}}});
|
||||
assert.equal(freeform(unsupported).interpreted, undefined);
|
||||
});
|
||||
|
||||
void test('unsupported and current-turn prose retain previous freeform behavior', () => {
|
||||
for (const question of [
|
||||
{kind:'freeform', target:'b', scope:'fact', text:'Will you win two battles?'},
|
||||
{kind:'freeform', target:'b', scope:'currentTurn', text:'Do you have at least 5 spice?'},
|
||||
] as Freeform[]) {
|
||||
const initial = knowledgeGame();
|
||||
const pending = ask(initial, question);
|
||||
assert.equal(freeform(pending).interpreted, undefined);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(pending,'b');
|
||||
view.players.find(player => player.id === 'b')!.bot = difficulty;
|
||||
assert.deepEqual(botActions(view), [{type:'truthAnswer',answer:'unknown'}]);
|
||||
}
|
||||
const human = applyAction(pending,'b',{type:'truthAnswer',answer:'yes'});
|
||||
assert.equal(human.truthHistory!.at(-1)!.answer,'yes');
|
||||
assert.equal(human.truthtrance,null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('already-pending unstamped legacy freeform is not reinterpreted on answer', () => {
|
||||
const initial = knowledgeGame();
|
||||
initial.players.find(player => player.id === 'b')!.spice = 4;
|
||||
const legacy = ask(initial, {kind:'freeform', target:'b', scope:'fact', text:'Do you have at least 5 spice?'});
|
||||
const pendingQuestion = freeform(legacy);
|
||||
delete pendingQuestion.interpreted;
|
||||
assert.equal(viewGame(legacy,'b').truthAnswer, null);
|
||||
const answered = applyAction(legacy,'b',{type:'truthAnswer',answer:'yes'});
|
||||
const recorded = answered.truthHistory!.at(-1)!.question as Freeform;
|
||||
assert.equal(recorded.interpreted,undefined);
|
||||
assert.equal(answered.truthHistory!.at(-1)!.answer,'yes');
|
||||
});
|
||||
Reference in new issue
Block a user