121 lines
6.9 KiB
TypeScript
121 lines
6.9 KiB
TypeScript
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 { knowledgeGame, knowledgePhysical, rejectKnowledge } from './truthtrance-knowledge-fixture';
|
|
import { createTruthtranceFaceDancerFixture, truthFaceDancerInventory,
|
|
truthFaceDancerTraitors, truthFaceDancerPlayer } from './fixture-truthtrance-face-dancer';
|
|
|
|
const player = (g: Game, id: string) => {
|
|
const p = g.players.find(candidate => candidate.id === id); assert.ok(p); return p;
|
|
};
|
|
const publicLeaders = (g: Game) => g.players.flatMap(p => p.leaders);
|
|
function namedLeader(g: Game, predicate: (id: string) => boolean) {
|
|
const roster = publicLeaders(g);
|
|
const leader = roster.find(item => predicate(item.id) && roster.filter(other => other.name.toLowerCase() === item.name.toLowerCase()).length === 1);
|
|
assert.ok(leader, 'An eligible unique public leader name must exist');
|
|
return leader;
|
|
}
|
|
function traitorTarget(g: Game) {
|
|
const roster = publicLeaders(g);
|
|
const target = g.players.find(p => p.id !== 'a' && p.traitors.some(id =>
|
|
roster.some(leader => leader.id === id && roster.filter(other => other.name.toLowerCase() === leader.name.toLowerCase()).length === 1)));
|
|
assert.ok(target, 'A non-asking seat has a uniquely named ordinary Traitor');
|
|
return target;
|
|
}
|
|
function ask(g: Game, asker: string, target: string, text: string, scope: 'fact' | 'currentTurn' = 'fact') {
|
|
const card = player(g, asker).hand.find(candidate => candidate.effect === 'truthtrance'); assert.ok(card);
|
|
let next = applyAction(g, asker, {type:'card', card:card.id});
|
|
while (next.truthtrance?.stage === 'priority') {
|
|
const actor = next.players.find(candidate => !next.truthtrance!.passed.includes(candidate.id)); assert.ok(actor);
|
|
next = applyAction(next, actor.id, {type:'truthPass'});
|
|
}
|
|
return applyAction(next, asker, {type:'truthAsk', question:{kind:'freeform', target, scope, text}});
|
|
}
|
|
function question(g: Game) {
|
|
const q = g.truthtrance?.question; assert.equal(q?.kind, 'freeform');
|
|
if (q?.kind !== 'freeform') throw Error('Expected freeform question');
|
|
return q;
|
|
}
|
|
function verify(g: Game, target: string, answer: 'yes' | 'no' | 'unknown') {
|
|
assert.equal(question(g).interpreted?.version, 9);
|
|
assert.equal(viewGame(g, target).truthAnswer, answer);
|
|
for (const seat of g.players.filter(p => p.id !== target)) {
|
|
const view = viewGame(g, seat.id);
|
|
assert.equal(view.truthAnswer, null);
|
|
assert.equal(view.players.find(p => p.id === target)!.traitors, undefined);
|
|
assert.equal(view.players.find(p => p.id === target)!.faceDancers, undefined);
|
|
}
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const view = viewGame(g, target); view.players.find(p => p.id === target)!.bot = difficulty;
|
|
assert.deepEqual(botActions(view), [{type:'truthAnswer', answer}]);
|
|
}
|
|
for (const wrong of (['yes','no','unknown'] as const).filter(value => value !== answer))
|
|
rejectKnowledge(g, target, {type:'truthAnswer', answer:wrong});
|
|
const restored = JSON.parse(JSON.stringify(g)) as Game;
|
|
const done = applyAction(restored, target, {type:'truthAnswer', answer});
|
|
if (answer === 'unknown') assert.equal(done.truthtrance?.stage, 'unknown');
|
|
else assert.equal(done.truthHistory!.at(-1)!.answer, answer);
|
|
return done;
|
|
}
|
|
|
|
void test('V9 checks the actual held and unheld Traitor privately', () => {
|
|
const initial = knowledgeGame(), target = traitorTarget(initial);
|
|
const held = namedLeader(initial, id => target.traitors.includes(id));
|
|
const unheld = namedLeader(initial, id => !target.traitors.includes(id));
|
|
const inventory = knowledgePhysical(initial);
|
|
for (const [name, answer] of [[held.name,'yes'], [unheld.name,'no']] as const) {
|
|
const pending = ask(structuredClone(initial), 'a', target.id, `Is ${name} your traitor?`);
|
|
const done = verify(pending, target.id, answer);
|
|
assert.deepEqual(knowledgePhysical(done), inventory);
|
|
}
|
|
const compound = ask(structuredClone(initial), 'a', target.id, `Is ${held.name} your traitor and do you have at least 0 spice?`);
|
|
const done = verify(compound, target.id, 'yes');
|
|
assert.deepEqual(knowledgePhysical(done), inventory);
|
|
});
|
|
|
|
void test('V9 preserves unknown for an unresolved physical Traitor choice', () => {
|
|
const initial = knowledgeGame(), target = traitorTarget(initial);
|
|
const held = namedLeader(initial, id => target.traitors.includes(id));
|
|
// Stage an unresolved legacy setup choice without creating a second card.
|
|
target.traitors = target.traitors.filter(id => id !== held.id);
|
|
target.traitorChoices = [held.id];
|
|
const pending = ask(initial, 'a', target.id, `Is ${held.name} your traitor?`);
|
|
const done = verify(pending, target.id, 'unknown');
|
|
assert.deepEqual(player(done,target.id).traitorChoices, [held.id]);
|
|
});
|
|
|
|
void test('V9 checks actual native Face Dancer custody separately from Traitors', () => {
|
|
const f = createTruthtranceFaceDancerFixture();
|
|
const held = namedLeader(f.game, id => f.heldIDs.includes(id));
|
|
const absent = namedLeader(f.game, id => !f.heldIDs.includes(id));
|
|
const original = structuredClone(truthFaceDancerPlayer(f.game, f.respondent).faceDancers);
|
|
for (const [leader, answer] of [[held,'yes'], [absent,'no']] as const) {
|
|
const pending = ask(structuredClone(f.game), f.asker, f.respondent, `Do you hold ${leader.name} as an unrevealed Face Dancer?`);
|
|
const done = verify(pending, f.respondent, answer);
|
|
assert.deepEqual(truthFaceDancerPlayer(done, f.respondent).faceDancers, original);
|
|
assert.deepEqual(truthFaceDancerInventory(done), f.originalInventory);
|
|
assert.deepEqual(truthFaceDancerTraitors(done), f.originalTraitors);
|
|
}
|
|
const different = ask(structuredClone(f.game), f.asker, f.respondent, `Is ${held.name} your traitor?`);
|
|
verify(different, f.respondent, 'no');
|
|
});
|
|
|
|
void test('V9 leaves unstamped and current-turn questions manual and rejects receipt tampering', () => {
|
|
const initial = knowledgeGame(), target = traitorTarget(initial), held = namedLeader(initial, id => target.traitors.includes(id));
|
|
const text = `Is ${held.name} your traitor?`;
|
|
const future = ask(structuredClone(initial), 'a', target.id, text, 'currentTurn');
|
|
assert.equal(question(future).interpreted, undefined);
|
|
const old = ask(structuredClone(initial), 'a', target.id, text);
|
|
delete question(old).interpreted;
|
|
assert.equal(viewGame(old,target.id).truthAnswer, null);
|
|
assert.equal(applyAction(old,target.id,{type:'truthAnswer',answer:'no'}).truthHistory!.at(-1)!.answer,'no');
|
|
const changed = ask(structuredClone(initial), 'a', target.id, text);
|
|
const q = question(changed); assert.equal(q.interpreted?.version, 9);
|
|
if (!q.interpreted) throw Error('Expected interpretation');
|
|
q.interpreted.fact = {kind:'faceDancer',leader:held.id};
|
|
assert.throws(() => viewGame(changed,target.id));
|
|
assert.throws(() => applyAction(changed,target.id,{type:'truthAnswer',answer:'yes'}));
|
|
});
|