Support Truthtrance in ordered Charity
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
31c6e79c80
commit
68c7434a20
11 files changed
+425
-19
No files matched your search
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { actAdvancedCharityOrder as act, advancedCharityOrderInventory as inventory,
|
||||
advancedCharityOrderPlayer as player, allowAdvancedCharityOpening as allow,
|
||||
createAdvancedCharityOrderFixture, passAdvancedCharityResponses as pass } from './fixture-advanced-charity-order';
|
||||
|
||||
let cached: ReturnType<typeof createAdvancedCharityOrderFixture> | undefined;
|
||||
const source = () => cached ??= createAdvancedCharityOrderFixture();
|
||||
|
||||
/** Genuine Advanced CHOAM/Bene Gesserit setup, phase opening and original
|
||||
* ordered Charity. Exchange one conserved physical Truthtrance into CHOAM's
|
||||
* hand for an existing card; no wallet, order, response or promise is staged. */
|
||||
function beforeAnswer() {
|
||||
const f = source(); let game = allow(structuredClone(f.game));
|
||||
const asker = player(game,'choam'), target = 'beneGesserit';
|
||||
const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
|
||||
assert.ok(asker.hand.length > 0);
|
||||
[asker.hand[0],game.deck[index]] = [game.deck[index],asker.hand[0]];
|
||||
const truth = asker.hand[0].id;
|
||||
assert.ok(player(game,target).spice >= 2);
|
||||
assert.ok(game.charityOrder?.remaining.includes(target));
|
||||
assert.deepEqual(inventory(game),f.original);
|
||||
game = act(game,'choam',{type:'card',card:truth});
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id)); assert.ok(actor);
|
||||
game = act(game,actor.id,{type:'truthPass'});
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage,'ask');
|
||||
game = act(game,'choam',{type:'truthAsk',question:{kind:'charity',target,turn:game.turn}});
|
||||
assert.ok(viewGame(game,target).truthCharityAnswers?.includes('yes'));
|
||||
game = act(game,target,{type:'truthAnswer',answer:'yes'});
|
||||
const promise = viewGame(game,target).truthCharityPromises.find(row => row.answer); assert.ok(promise);
|
||||
return {f,game,target,truth,event:promise.event};
|
||||
}
|
||||
|
||||
function reachBeneGesserit(sourceGame:Game):Game {
|
||||
let game = sourceGame;
|
||||
for (let count = 0; game.charityOrder?.remaining[0] !== 'beneGesserit' && count < 4; count++) {
|
||||
const actor = game.charityOrder?.remaining[0]; assert.ok(actor);
|
||||
game = act(game,actor,{type:'ready'});
|
||||
}
|
||||
assert.equal(game.charityOrder?.remaining[0],'beneGesserit');
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const cancel of [false,true]) void test(
|
||||
`Advanced wealthy Bene Gesserit promised Charity retains its native ${cancel ? 'cancellation' : 'payment'} response`, () => {
|
||||
const f = beforeAnswer(); let game = reachBeneGesserit(f.game);
|
||||
const bgSpice = player(game,f.target).spice, choamSpice = player(game,'choam').spice;
|
||||
const order = structuredClone(game.charityOrder), guide = viewGame(game,f.target).truthCharityGuide;
|
||||
assert.ok(guide); assert.equal(guide.action.type,'charity');
|
||||
game = act(game,f.target,guide.action);
|
||||
assert.equal(game.response?.kind,'bgCharity');
|
||||
assert.equal(game.response?.owner,f.target);
|
||||
assert.deepEqual(game.charityOrder,order);
|
||||
assert.equal(player(game,f.target).charityTurn,game.turn);
|
||||
assert.equal(viewGame(game,f.target).truthCharityPromises.find(row => row.event === f.event)?.fulfilled,true);
|
||||
assert.equal(player(game,f.target).spice,bgSpice);
|
||||
if (cancel) {
|
||||
assert.ok(player(game,'atreides').hand.some(card => card.id === f.f.karama));
|
||||
game = act(game,'atreides',{type:'card',card:f.f.karama,mode:'cancel'});
|
||||
} else game = pass(game);
|
||||
assert.equal(game.response ?? null,null);
|
||||
assert.deepEqual(game.charityOrder,order);
|
||||
assert.equal(player(game,f.target).spice,bgSpice + (cancel ? 0 : 2));
|
||||
assert.equal(player(game,'choam').spice,choamSpice - (cancel ? 0 : 2));
|
||||
assert.equal(viewGame(game,f.target).truthCharityPromises.find(row => row.event === f.event)?.fulfilled,true);
|
||||
assert.equal(player(game,f.target).charityTurn,game.turn);
|
||||
game = act(game,f.target,{type:'ready'});
|
||||
assert.ok(game.phase !== 2 || !game.charityOrder?.remaining.includes(f.target));
|
||||
assert.deepEqual(inventory(game),f.f.original);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Children, createElement, isValidElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { actTruthCharityPayments as act, createTruthCharityPaymentsFixture,
|
||||
declareTruthCharityPayments, truthCharityPaymentsPlayer as player } from './fixture-truth-charity-payments';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' :
|
||||
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return {format:'module',source:`export default ${JSON.stringify(classes)}`,shortCircuit:true};
|
||||
},
|
||||
});
|
||||
const {TruthCharityPromise} = await import('../components/truth-charity-promise');
|
||||
const {CharityClaim} = await import('../components/charity-claim');
|
||||
aliases.deregister();
|
||||
|
||||
let cached: ReturnType<typeof createTruthCharityPaymentsFixture> | undefined;
|
||||
const source = () => cached ??= createTruthCharityPaymentsFixture({source:'phase2',advanced:true});
|
||||
|
||||
/** Genuine Advanced setup, Amal, an actual eligibility bribe and Truthtrance
|
||||
* declaration/answer. The original Charity order is advanced only by real
|
||||
* Ready actions; no promise, phase, order or wallet is staged. */
|
||||
function projected(waiting:boolean, answer = true): GameView {
|
||||
const f = source(); let game = structuredClone(f.game);
|
||||
const queue = game.charityOrder; assert.ok(queue && queue.remaining.length >= 3);
|
||||
const target = queue.remaining.find(id => id !== f.asker && id !== queue.remaining[0]); assert.ok(target);
|
||||
const amount = player(game,target).spice - 1;
|
||||
if (amount > 0) game = act(game,target,{type:'bribe',target:f.asker,amount});
|
||||
game = declareTruthCharityPayments(f,game).game;
|
||||
game = act(game,f.asker,{type:'truthAsk',question:{kind:'charity',target,turn:game.turn}});
|
||||
game = act(game,target,{type:'truthAnswer',answer:answer ? 'yes' : 'no'});
|
||||
if (!waiting) for (let count = 0; game.charityOrder?.remaining[0] !== target && count < 6; count++) {
|
||||
const actor = game.charityOrder?.remaining[0]; assert.ok(actor);
|
||||
game = act(game,actor,{type:'ready'});
|
||||
}
|
||||
const view = viewGame(game,target);
|
||||
assert.ok(view.charityOrder && view.phase === 2 && view.advanced);
|
||||
return view;
|
||||
}
|
||||
|
||||
type ButtonProps = {'data-testid'?:string;disabled?:boolean;onClick?:()=>void};
|
||||
function guide(view:GameView,busy = false) {
|
||||
const sent:Action[] = [];
|
||||
const element = TruthCharityPromise({game:view,busy,send(action){sent.push(action);}});
|
||||
assert.ok(element);
|
||||
const button = Children.toArray(element.props.children).find(node =>
|
||||
isValidElement<ButtonProps>(node) && node.props['data-testid'] === 'charity-promise-guide');
|
||||
return {element,button:isValidElement<ButtonProps>(button) ? button : null,sent};
|
||||
}
|
||||
const markup = (view:GameView) => renderToStaticMarkup(createElement(TruthCharityPromise,
|
||||
{game:view,busy:false,send() { assert.fail('Rendering must not send.'); }}));
|
||||
|
||||
void test('ordered Charity shows a waiting promise without an active guided claim', () => {
|
||||
const view = projected(true), {button} = guide(view);
|
||||
assert.equal(view.truthCharityGuide,null); assert.equal(button,null);
|
||||
assert.match(markup(view),/[Ww]ait[^<]*[Cc]harity/);
|
||||
assert.doesNotMatch(markup(view),/data-testid="charity-promise-guide"/);
|
||||
const claim = renderToStaticMarkup(createElement(CharityClaim,{game:view,busy:false,act() {}}));
|
||||
assert.match(claim,/Your turn comes later in the public order/);
|
||||
assert.match(claim,/<button(?=[^>]*data-testid="charity-claim-submit")(?=[^>]*disabled="")[^>]*>/);
|
||||
});
|
||||
|
||||
void test('the current ordered seat receives the exact native guided claim and a busy state cannot send', () => {
|
||||
const view = projected(false), {button,sent} = guide(view);
|
||||
const claim = renderToStaticMarkup(createElement(CharityClaim,{game:view,busy:false,act() {}}));
|
||||
assert.match(claim,/<button(?=[^>]*data-testid="charity-turn-finish")(?=[^>]*disabled="")[^>]*>/);
|
||||
assert.match(claim,/Complete your Truthtrance Charity Yes before finishing/);
|
||||
assert.ok(button); assert.equal(button.props.disabled,false);
|
||||
assert.ok(button.props.onClick); button.props.onClick();
|
||||
assert.deepEqual(sent,[view.truthCharityGuide!.action]);
|
||||
const busy = guide(view,true); assert.ok(busy.button); assert.equal(busy.button.props.disabled,true);
|
||||
busy.button.props.onClick?.(); assert.deepEqual(busy.sent,[]);
|
||||
});
|
||||
|
||||
void test('a saved guide callback cannot claim after the ordered turn changes', () => {
|
||||
const view = projected(false), {button,sent} = guide(view);
|
||||
assert.ok(button?.props.onClick); assert.equal(button.props.disabled,false);
|
||||
const order = view.charityOrder!;
|
||||
const prior = order.order.find(id => id !== view.me); assert.ok(prior);
|
||||
order.remaining = [prior,view.me];
|
||||
button.props.onClick();
|
||||
assert.deepEqual(sent,[]);
|
||||
});
|
||||
|
||||
void test('an ordered No retains its restriction without presenting a guided action', () => {
|
||||
const view = projected(true,false), {button} = guide(view);
|
||||
assert.equal(button,null); assert.match(markup(view),/You answered No/);
|
||||
assert.match(markup(view),/No forbids claiming/);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { actTruthCharityPayments as act, createTruthCharityPaymentsFixture,
|
||||
declareTruthCharityPayments, truthCharityPaymentsInventory as inventory,
|
||||
truthCharityPaymentsPlayer as player } from './fixture-truth-charity-payments';
|
||||
let original: ReturnType<typeof createTruthCharityPaymentsFixture> | undefined;
|
||||
const source = () => original ??= createTruthCharityPaymentsFixture({source:'phase2',advanced:true});
|
||||
|
||||
/** Public Advanced selected-Ix setup, original starting cards and actual Amal
|
||||
* chronology. A real bribe before asking makes the chosen future actor eligible
|
||||
* for the native claim; no wallet, phase, order, readiness or promise is staged. */
|
||||
function fixture(options: {poor?:boolean} = {}) {
|
||||
const f = source(); let game = structuredClone(f.game);
|
||||
assert.equal(game.advanced,true); assert.equal(game.phase,2);
|
||||
const queue = game.charityOrder; assert.ok(queue && queue.remaining.length >= 3);
|
||||
const target = queue.remaining.find(id => id !== f.asker && id !== queue.remaining[0]); assert.ok(target);
|
||||
const amount = player(game,target).spice - 1;
|
||||
if (options.poor !== false && amount > 0) game = act(game,target,{type:'bribe',target:f.asker,amount});
|
||||
if (options.poor !== false) assert.ok(player(game,target).spice < 2);
|
||||
assert.deepEqual(game.charityOrder,queue);
|
||||
assert.equal(player(game,target).charityTurn,undefined);
|
||||
return {f,game,target,asker:f.asker,queue:structuredClone(queue)};
|
||||
}
|
||||
function reject(game:Game, actor:string, action:Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game,actor,action));
|
||||
assert.deepEqual(game,before);
|
||||
}
|
||||
function ask(f:ReturnType<typeof fixture>, source:Game = f.game) {
|
||||
const declared = declareTruthCharityPayments(f.f,source).game;
|
||||
return act(declared,f.asker,{type:'truthAsk',question:{kind:'charity',target:f.target,turn:source.turn}});
|
||||
}
|
||||
function bind(f:ReturnType<typeof fixture>, answer:'yes'|'no', source:Game = f.game) {
|
||||
const asked = ask(f,source);
|
||||
assert.ok(viewGame(asked,f.target).truthCharityAnswers?.includes(answer));
|
||||
return {asked,game:act(asked,f.target,{type:'truthAnswer',answer})};
|
||||
}
|
||||
function toTarget(game:Game, target:string):Game {
|
||||
for (let count = 0; game.phase === 2 && game.charityOrder?.remaining[0] !== target && count < 6; count++) {
|
||||
const actor = game.charityOrder?.remaining[0]; assert.ok(actor);
|
||||
game = act(game,actor,{type:'ready'});
|
||||
}
|
||||
assert.equal(game.phase,2); assert.equal(game.charityOrder?.remaining[0],target);
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('Advanced Charity Yes preserves the order and guides only the actual current turn', () => {
|
||||
const f = fixture(); const {game: bound} = bind(f,'yes');
|
||||
assert.deepEqual(bound.charityOrder,f.queue);
|
||||
assert.equal(viewGame(bound,f.target).truthCharityGuide,null);
|
||||
assert.ok(viewGame(bound,f.target).truthCharityPromises.some(row => row.answer && !row.fulfilled && !row.released));
|
||||
reject(bound,f.target,{type:'charity'}); reject(bound,f.target,{type:'ready'});
|
||||
let game = toTarget(bound,f.target);
|
||||
reject(game,f.target,{type:'ready'});
|
||||
const guide = viewGame(game,f.target).truthCharityGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type,'charity');
|
||||
const queue = structuredClone(game.charityOrder);
|
||||
game = act(game,f.target,guide.action);
|
||||
assert.deepEqual(game.charityOrder,queue);
|
||||
assert.equal(player(game,f.target).charityTurn,game.turn);
|
||||
assert.ok(viewGame(game,f.target).truthCharityPromises.some(row => row.answer && row.fulfilled));
|
||||
game = act(game,f.target,{type:'ready'});
|
||||
assert.ok(game.phase !== 2 || !game.charityOrder?.remaining.includes(f.target));
|
||||
assert.deepEqual(inventory(game),f.f.originalCards);
|
||||
});
|
||||
|
||||
void test('Advanced Charity No remains binding through the owned turn then completes', () => {
|
||||
const f = fixture(); const {game: bound} = bind(f,'no');
|
||||
assert.deepEqual(bound.charityOrder,f.queue);
|
||||
let game = toTarget(bound,f.target);
|
||||
reject(game,f.target,{type:'charity'});
|
||||
assert.equal(viewGame(game,f.target).truthCharityGuide,null);
|
||||
game = act(game,f.target,{type:'ready'});
|
||||
assert.ok(game.phase !== 2 || !game.charityOrder?.remaining.includes(f.target));
|
||||
assert.ok(viewGame(game,f.target).truthCharityPromises.some(row => !row.answer && row.fulfilled));
|
||||
assert.equal(player(game,f.target).charityTurn,undefined);
|
||||
assert.deepEqual(inventory(game),f.f.originalCards);
|
||||
});
|
||||
|
||||
void test('Advanced Charity accepts a question during the respondent’s own unfinished turn', () => {
|
||||
const f = fixture(), current = toTarget(f.game,f.target), queue = structuredClone(current.charityOrder);
|
||||
const {game} = bind(f,'yes',current);
|
||||
assert.deepEqual(game.charityOrder,queue);
|
||||
assert.equal(game.charityOrder?.remaining[0],f.target);
|
||||
reject(game,f.target,{type:'ready'});
|
||||
const guide = viewGame(game,f.target).truthCharityGuide; assert.ok(guide);
|
||||
const completed = act(game,f.target,guide.action);
|
||||
assert.equal(player(completed,f.target).charityTurn,completed.turn);
|
||||
});
|
||||
|
||||
void test('Advanced Charity Yes guides a real bribe only once the future target owns its turn', () => {
|
||||
const f = fixture({poor:false}); assert.ok(player(f.game,f.target).spice >= 2);
|
||||
let {game} = bind(f,'yes');
|
||||
assert.equal(viewGame(game,f.target).truthCharityGuide,null);
|
||||
assert.deepEqual(game.charityOrder,f.queue);
|
||||
game = toTarget(game,f.target);
|
||||
reject(game,f.target,{type:'ready'});
|
||||
const bribe = viewGame(game,f.target).truthCharityGuide; assert.ok(bribe);
|
||||
assert.equal(bribe.action.type,'bribe');
|
||||
const before = player(game,f.target).spice;
|
||||
game = act(game,f.target,bribe.action);
|
||||
assert.ok(player(game,f.target).spice < 2);
|
||||
assert.equal(player(game,f.target).spice,before - Number(bribe.action.amount));
|
||||
const claim = viewGame(game,f.target).truthCharityGuide; assert.ok(claim);
|
||||
assert.equal(claim.action.type,'charity');
|
||||
game = act(game,f.target,claim.action);
|
||||
assert.equal(player(game,f.target).charityTurn,game.turn);
|
||||
assert.ok(viewGame(game,f.target).truthCharityPromises.some(row => row.answer && row.fulfilled));
|
||||
assert.deepEqual(inventory(game),f.f.originalCards);
|
||||
});
|
||||
|
||||
void test('a No by the final Advanced Charity actor completes on the actual phase transition', () => {
|
||||
const base = source(); let game = structuredClone(base.game);
|
||||
const queue = game.charityOrder; assert.ok(queue && queue.remaining.length >= 3);
|
||||
const target = queue.remaining.at(-1)!;
|
||||
let asker = base.asker;
|
||||
// If the final actor holds Truthtrance, exchange it with another real hand
|
||||
// card so a different player can ask; this conserves the physical inventory.
|
||||
if (target === asker) {
|
||||
const other = game.players.find(seat => seat.id !== target && seat.hand.length); assert.ok(other);
|
||||
const holder = player(game,asker), index = holder.hand.findIndex(card => card.id === base.truths[0]); assert.ok(index >= 0);
|
||||
[holder.hand[index],other.hand[0]] = [other.hand[0],holder.hand[index]];
|
||||
asker = other.id;
|
||||
}
|
||||
const amount = player(game,target).spice - 1;
|
||||
if (amount > 0) game = act(game,target,{type:'bribe',target:asker,amount});
|
||||
const alternate = {...base,asker};
|
||||
const declared = declareTruthCharityPayments(alternate,game).game;
|
||||
game = act(declared,asker,{type:'truthAsk',question:{kind:'charity',target,turn:game.turn}});
|
||||
assert.ok(viewGame(game,target).truthCharityAnswers?.includes('no'));
|
||||
game = act(game,target,{type:'truthAnswer',answer:'no'});
|
||||
game = toTarget(game,target);
|
||||
assert.deepEqual(game.charityOrder?.remaining,[target]);
|
||||
reject(game,target,{type:'charity'});
|
||||
game = act(game,target,{type:'ready'});
|
||||
assert.notEqual(game.phase,2);
|
||||
assert.ok(viewGame(game,target).truthCharityPromises.some(row => !row.answer && row.fulfilled));
|
||||
assert.deepEqual(inventory(game),base.originalCards);
|
||||
});
|
||||
|
||||
void test('Advanced Charity cannot reopen an already finished ordered turn', () => {
|
||||
const f = fixture(); let game = toTarget(f.game,f.target);
|
||||
game = act(game,f.target,{type:'ready'});
|
||||
if (game.phase !== 2) {
|
||||
assert.equal(game.charityOrder ?? null,null);
|
||||
return;
|
||||
}
|
||||
assert.ok(!game.charityOrder?.remaining.includes(f.target));
|
||||
const declared = declareTruthCharityPayments(f.f,game).game;
|
||||
reject(declared,f.asker,{type:'truthAsk',question:{kind:'charity',target:f.target,turn:game.turn}});
|
||||
});
|
||||
|
||||
void test('all four minimal bots use the native Charity guide on their actual ordered turn', () => {
|
||||
const f = fixture(), asked = ask(f);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const answerView = viewGame(asked,f.target);
|
||||
answerView.players.find(row => row.id === f.target)!.bot = difficulty;
|
||||
const answer = botActions(answerView)[0]; assert.ok(answer);
|
||||
assert.equal(answer.type,'truthAnswer');
|
||||
assert.ok(answerView.truthCharityAnswers?.includes(answer.answer as 'yes'|'no'));
|
||||
// A separate Yes branch tests the promised legal path for every profile,
|
||||
// regardless of its provisional answering strategy.
|
||||
let game = act(structuredClone(asked),f.target,{type:'truthAnswer',answer:'yes'});
|
||||
const waiting = viewGame(game,f.target); waiting.players.find(row => row.id === f.target)!.bot = difficulty;
|
||||
assert.equal(waiting.truthCharityGuide,null);
|
||||
assert.ok(!botActions(waiting).some(action => action.type === 'charity' || action.type === 'ready'));
|
||||
game = toTarget(game,f.target);
|
||||
const view = viewGame(game,f.target); view.players.find(row => row.id === f.target)!.bot = difficulty;
|
||||
const guide = view.truthCharityGuide; assert.ok(guide);
|
||||
const action = botActions(view).find(candidate => candidate.type === 'charity');
|
||||
assert.deepEqual(action,guide.action);
|
||||
const completed = act(game,f.target,action!);
|
||||
assert.equal(player(completed,f.target).charityTurn,completed.turn);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user