Support early Charity Truthtrance commitments
This commit is contained in:
1 parent
72ff9db5e8
commit
2cd2a60a36
12 files changed
+407
-32
No files matched your search
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { TruthQuestion } from '../game/truthtrance';
|
||||
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
|
||||
import {
|
||||
actTruthCharityPayments as act,
|
||||
advanceTruthCharityPayments,
|
||||
createTruthCharityPaymentsFixture,
|
||||
reachTruthCharityPaymentsClaim,
|
||||
truthCharityPaymentsClean as clean,
|
||||
truthCharityPaymentsInventory as inventory,
|
||||
truthCharityPaymentsPlayer as player,
|
||||
} from './fixture-truth-charity-payments';
|
||||
import { createTruthTurnAmalFixture } from './fixture-truth-turn-amal';
|
||||
|
||||
export { act, clean, inventory, player };
|
||||
|
||||
/** Two original selected-Ix classic Basic starts: phase 0 from the real setup,
|
||||
* or phase 1 after actually resolving Amal. Only original unopened deal/Spice
|
||||
* positions and native scalar lotteries are selected, as in parent fixtures.
|
||||
* No played wallet, hand, pass, phase, promise or income is installed. */
|
||||
export function createTruthCharityEarlyFixture(phase: 0 | 1 = 1) {
|
||||
const base = phase === 0
|
||||
? createTruthTurnAmalFixture({ phase: 0, advanced: false })
|
||||
: createTruthCharityPaymentsFixture({ source: 'afterAmal', advanced: false });
|
||||
const game = phase === 0
|
||||
? advanceTruthCharityPayments(structuredClone(base.game), g => g.phase === 0 && clean(g), [], true)
|
||||
: structuredClone(base.game);
|
||||
assert.equal(game.phase, phase);
|
||||
assert.ok(clean(game));
|
||||
assert.equal(game.advanced, false);
|
||||
assert.equal(player(game, base.target).charityTurn, undefined);
|
||||
assert.deepEqual(inventory(game), base.originalCards);
|
||||
const charityQuestion = { kind:'charity',target:base.target,turn:game.turn } satisfies TruthQuestion;
|
||||
return { ...base, game, charityQuestion, initialPhase:phase };
|
||||
}
|
||||
export type TruthCharityEarlyFixture = ReturnType<typeof createTruthCharityEarlyFixture>;
|
||||
|
||||
export function declareTruthCharityEarly(f: TruthCharityEarlyFixture, source = f.game, copy: 0 | 1 = 0) {
|
||||
let game = act(source,f.asker,{type:'card',card:f.truths[copy]});
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(actor);
|
||||
game = act(game,actor.id,{type:'truthPass'});
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage,'ask');
|
||||
return game;
|
||||
}
|
||||
export function askTruthCharityEarly(f: TruthCharityEarlyFixture,source = f.game,copy:0|1 = 0): Game {
|
||||
const declared = declareTruthCharityEarly(f,source,copy);
|
||||
return act(declared,f.asker,{type:'truthAsk',question:f.charityQuestion});
|
||||
}
|
||||
export function bindTruthCharityEarly(f: TruthCharityEarlyFixture,answer:'yes'|'no',source=f.game,copy:0|1=0) {
|
||||
const asked = askTruthCharityEarly(f,source,copy);
|
||||
assert.ok(viewGame(asked,f.target).truthCharityAnswers?.includes(answer));
|
||||
return {asked,game:act(asked,f.target,{type:'truthAnswer',answer})};
|
||||
}
|
||||
export function reachEarlyCharity(game: Game): Game {
|
||||
let current = game;
|
||||
if (current.phase === 0) {
|
||||
current = advanceTruthCharityPayments(current,beforeIxSpiceDraw,[],true);
|
||||
// Select only a safe unopened physical Spice prefix, matching the parent
|
||||
// native source; this does not alter any previously played card or money.
|
||||
orderIxSpiceCards(current,[{territory:'hagga_basin'}]);
|
||||
}
|
||||
return reachTruthCharityPaymentsClaim(current);
|
||||
}
|
||||
export function rejectTruthCharityEarly(game: Game,actor: string,action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game,actor,action));
|
||||
assert.deepEqual(game,before,'Rejected action must not mutate its input');
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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 { act, askTruthCharityEarly as ask, createTruthCharityEarlyFixture as fixture,
|
||||
declareTruthCharityEarly as declare, inventory, player, reachEarlyCharity as reach,
|
||||
rejectTruthCharityEarly as reject } from './fixture-truth-charity-early';
|
||||
|
||||
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 {GameTable} = await import('../components/game-table');
|
||||
const {Truthtrance} = await import('../components/truthtrance');
|
||||
const {TruthCharityPromise} = await import('../components/truth-charity-promise');
|
||||
aliases.deregister();
|
||||
const table = (game: GameView) => renderToStaticMarkup(createElement(GameTable,{game,busy:false,send:async()=>{},onExit(){}}));
|
||||
const truth = (game: GameView) => renderToStaticMarkup(createElement(Truthtrance,{game,busy:false,act(){}}));
|
||||
|
||||
for (const phase of [0,1] as const) {
|
||||
void test(`Basic phase ${phase} Truthtrance presents Charity question and private answer controls`, () => {
|
||||
const f=fixture(phase), declared=declare(f), asker=viewGame(declared,f.asker);
|
||||
assert.ok(asker.truthCharityTargets.includes(f.target));
|
||||
assert.match(truth(asker),/<option value="charity">[^<]*[Cc]harity/);
|
||||
const asked=ask(f), target=viewGame(asked,f.target);
|
||||
assert.equal(target.truthtrance?.question?.kind,'charity');
|
||||
assert.ok(target.truthCharityAnswers?.includes('yes'));
|
||||
assert.ok(target.truthCharityAnswers?.includes('no'));
|
||||
assert.equal(viewGame(asked,f.asker).truthCharityAnswers,null);
|
||||
assert.equal(viewGame(asked,f.observer).truthCharityAnswers,null);
|
||||
assert.match(truth(target),/Answer Yes/);
|
||||
assert.match(truth(target),/Answer No/);
|
||||
assert.match(table(target),/Answer Yes/);
|
||||
const bound=act(asked,f.target,{type:'truthAnswer',answer:'yes'}), early=viewGame(bound,f.target);
|
||||
assert.equal(early.truthCharityGuide,null);
|
||||
assert.match(table(early),/[Cc]harity/);
|
||||
assert.doesNotMatch(table(early),/data-testid="charity-promise-guide"/);
|
||||
assert.deepEqual(inventory(bound),f.originalCards);
|
||||
});
|
||||
}
|
||||
|
||||
void test('An early Yes presents the original native guide only when Charity actually opens', () => {
|
||||
const f=fixture(1), asked=ask(f);
|
||||
const early=act(asked,f.target,{type:'truthAnswer',answer:'yes'});
|
||||
assert.equal(viewGame(early,f.target).truthCharityGuide,null);
|
||||
const game=reach(early), own=viewGame(game,f.target), sent:Action[]=[];
|
||||
assert.ok(own.truthCharityGuide);
|
||||
const panel=TruthCharityPromise({game:own,busy:false,send(action){sent.push(action);}});
|
||||
assert.ok(panel);
|
||||
type GuideProps={'data-testid'?:string;disabled?:boolean;onClick?:()=>void};
|
||||
const button=Children.toArray(panel.props.children).find(node =>
|
||||
isValidElement<GuideProps>(node) && node.props['data-testid'] === 'charity-promise-guide');
|
||||
assert.ok(isValidElement<GuideProps>(button));
|
||||
assert.equal(button.props.disabled,false);
|
||||
assert.ok(button.props.onClick); button.props.onClick();
|
||||
assert.deepEqual(sent,[own.truthCharityGuide.action]);
|
||||
let progressed=act(game,f.target,sent[0]);
|
||||
if (sent[0].type === 'bribe') {
|
||||
const claim=viewGame(progressed,f.target).truthCharityGuide; assert.ok(claim);
|
||||
assert.equal(claim.action.type,'charity'); progressed=act(progressed,f.target,claim.action);
|
||||
}
|
||||
assert.equal(player(progressed,f.target).charityTurn,progressed.turn);
|
||||
const no=reach(act(asked,f.target,{type:'truthAnswer',answer:'no'}));
|
||||
reject(no,f.target,sent[0]);
|
||||
assert.deepEqual(inventory(progressed),f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { advanceTruthCharityPayments } from './fixture-truth-charity-payments';
|
||||
import { actTruthtranceChoamPromises as actChoam, advanceTruthtranceChoamPromises as advanceChoam,
|
||||
createTruthtranceChoamPromisesFixture as choamFixture, truthtranceChoamPromisesInventory as choamInventory,
|
||||
truthtranceChoamPromisesPlayer as choamPlayer } from './fixture-truthtrance-choam-promises';
|
||||
import { act, askTruthCharityEarly as ask, bindTruthCharityEarly as bind, clean,
|
||||
createTruthCharityEarlyFixture as fixture, declareTruthCharityEarly as declare,
|
||||
inventory, player, reachEarlyCharity as reach, rejectTruthCharityEarly as reject } from './fixture-truth-charity-early';
|
||||
|
||||
for (const phase of [0,1] as const) {
|
||||
void test(`Basic phase ${phase} Charity Yes survives native openings and uses its real later claim`, () => {
|
||||
const f = fixture(phase), {asked,game:bound} = bind(f,'yes');
|
||||
assert.equal(viewGame(asked,f.asker).truthCharityAnswers,null);
|
||||
assert.deepEqual(bound.ready,asked.ready,'Answering does not reopen readiness in an earlier phase');
|
||||
assert.equal(viewGame(bound,f.target).truthCharityGuide,null);
|
||||
assert.ok(viewGame(bound,f.target).truthCharityPromises.some(row => row.answer && !row.fulfilled && !row.released));
|
||||
assert.equal(bound.discard.filter(card => card.id === f.truth).length,1);
|
||||
const charity = reach(bound);
|
||||
assert.equal(charity.phase,2);
|
||||
assert.ok(viewGame(charity,f.target).truthCharityPromises.some(row => row.answer && !row.fulfilled && !row.released));
|
||||
reject(charity,f.target,{type:'ready'});
|
||||
const guide = viewGame(charity,f.target).truthCharityGuide; assert.ok(guide);
|
||||
let game = act(charity,f.target,guide.action);
|
||||
if (guide.action.type === 'bribe') {
|
||||
assert.ok(player(game,f.target).spice < 2);
|
||||
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.originalCards);
|
||||
});
|
||||
|
||||
void test(`Basic phase ${phase} Charity No remains binding until the actual later phase closes`, () => {
|
||||
const f = fixture(phase), {asked,game:bound} = bind(f,'no');
|
||||
assert.deepEqual(bound.ready,asked.ready);
|
||||
assert.equal(viewGame(bound,f.target).truthCharityGuide,null);
|
||||
assert.ok(viewGame(bound,f.target).truthCharityPromises.some(row => !row.answer && !row.fulfilled && !row.released));
|
||||
let game = reach(bound);
|
||||
assert.equal(game.phase,2);
|
||||
assert.ok(viewGame(game,f.target).truthCharityPromises.some(row => !row.answer && !row.fulfilled));
|
||||
// Earn eligibility by the real ordinary payment, so rejecting a physical
|
||||
// claim proves the answered No rather than the ordinary wealth threshold.
|
||||
const amount = player(game,f.target).spice - 1;
|
||||
assert.ok(amount > 0);
|
||||
game = act(game,f.target,{type:'bribe',target:f.asker,amount});
|
||||
assert.equal(player(game,f.target).spice,1);
|
||||
reject(game,f.target,{type:'charity'});
|
||||
game = act(game,f.target,{type:'ready'});
|
||||
for (const seat of game.players) if (game.phase === 2 && !game.ready.includes(seat.id))
|
||||
game = act(game,seat.id,{type:'ready'});
|
||||
assert.notEqual(game.phase,2);
|
||||
assert.equal(player(game,f.target).charityTurn,undefined);
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
}
|
||||
|
||||
void test('An earlier-phase Charity answer preserves an actual prior Spice Blow Ready', () => {
|
||||
const f = fixture(1);
|
||||
const blow = advanceTruthCharityPayments(f.game,g => !!g.spiceWindow && clean(g),[],true);
|
||||
const ready = act(blow,f.target,{type:'ready'});
|
||||
assert.ok(ready.ready.includes(f.target));
|
||||
const {asked,game:bound} = bind(f,'yes',ready);
|
||||
assert.ok(asked.ready.includes(f.target));
|
||||
assert.deepEqual(bound.ready,asked.ready,'Only the future Charity Ready may be reopened');
|
||||
const charity = reach(bound);
|
||||
assert.equal(charity.phase,2);
|
||||
assert.ok(viewGame(charity,f.target).truthCharityGuide);
|
||||
});
|
||||
|
||||
void test('Earlier-phase stale turns and foreign answers reject without changes', () => {
|
||||
const f = fixture(1), declared = declare(f);
|
||||
reject(declared,f.asker,{type:'truthAsk',question:{...f.charityQuestion,turn:f.game.turn+1}});
|
||||
const asked = ask(f);
|
||||
reject(asked,f.asker,{type:'truthAnswer',answer:'yes'});
|
||||
assert.deepEqual(inventory(asked),f.originalCards);
|
||||
});
|
||||
|
||||
void test('All four bots answer early and follow the later native Charity guide', () => {
|
||||
const f = fixture(1), asked = ask(f);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const projected = viewGame(asked,f.target);
|
||||
projected.players.find(row => row.id === f.target)!.bot = difficulty;
|
||||
const answer = botActions(projected)[0]; assert.ok(answer,difficulty);
|
||||
assert.equal(answer.type,'truthAnswer');
|
||||
assert.ok(projected.truthCharityAnswers?.includes(answer.answer as 'yes'|'no'));
|
||||
let game = act(structuredClone(asked),f.target,answer);
|
||||
assert.equal(viewGame(game,f.target).truthCharityGuide,null);
|
||||
game = reach(game);
|
||||
const own = viewGame(game,f.target); own.players.find(row => row.id === f.target)!.bot = difficulty;
|
||||
if (answer.answer === 'yes') {
|
||||
assert.ok(own.truthCharityGuide);
|
||||
assert.deepEqual(botActions(own)[0],own.truthCharityGuide.action);
|
||||
game = act(game,f.target,own.truthCharityGuide.action);
|
||||
const claim = viewGame(game,f.target).truthCharityGuide;
|
||||
if (claim) game = act(game,f.target,claim.action);
|
||||
assert.equal(player(game,f.target).charityTurn,game.turn);
|
||||
} else {
|
||||
const action = botActions(own)[0]; assert.ok(action,difficulty);
|
||||
game = act(game,f.target,action);
|
||||
}
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('An early CHOAM Charity question does not invent an unanswered opening income', () => {
|
||||
// Original native Basic four-seat CHOAM setup and unopened physical deals;
|
||||
// CHOAM's earned opening is deliberately not allowed, canceled or credited.
|
||||
const f=choamFixture(), source=structuredClone(f.game);
|
||||
assert.equal(source.phase,0); assert.equal(source.choamCharity,undefined);
|
||||
assert.equal(choamPlayer(source,f.target).spice,f.rawCash);
|
||||
let game=actChoam(source,f.asker,{type:'card',card:f.truth});
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor=game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(actor);
|
||||
game=actChoam(game,actor.id,{type:'truthPass'});
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage,'ask');
|
||||
game=actChoam(game,f.asker,{type:'truthAsk',question:{kind:'charity',target:f.target,turn:game.turn}});
|
||||
const own=viewGame(game,f.target);
|
||||
assert.ok(own.truthCharityAnswers?.includes('no'));
|
||||
assert.ok(!own.truthCharityAnswers?.includes('yes'),'A not-yet-resolved CHOAM opening is not guaranteed income');
|
||||
assert.equal(viewGame(game,f.asker).truthCharityAnswers,null);
|
||||
assert.equal(choamPlayer(game,f.target).spice,f.rawCash);
|
||||
assert.equal(game.choamCharity,undefined);
|
||||
const before=structuredClone(game);
|
||||
assert.throws(() => actChoam(game,f.target,{type:'truthAnswer',answer:'yes'}));
|
||||
assert.deepEqual(game,before);
|
||||
game=actChoam(game,f.target,{type:'truthAnswer',answer:'no'});
|
||||
assert.equal(choamPlayer(game,f.target).spice,f.rawCash);
|
||||
assert.equal(game.choamCharity,undefined);
|
||||
assert.ok(viewGame(game,f.target).truthCharityPromises.some(row => !row.answer && !row.fulfilled && !row.released));
|
||||
const opening=advanceChoam(game,state => state.response?.kind === 'choamCharity');
|
||||
assert.equal(opening.response?.kind,'choamCharity');
|
||||
assert.equal(opening.response.owner,f.target);
|
||||
assert.equal(opening.choamCharity,undefined);
|
||||
assert.equal(choamPlayer(opening,f.target).spice,f.rawCash);
|
||||
assert.deepEqual(choamInventory(opening),f.originalInventory);
|
||||
});
|
||||
Reference in new issue
Block a user