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
@@ -22,7 +22,9 @@ export function CharityClaim(props: Props) {
|
||||
!!game.response || !!game.decision || !!game.phaseOpening || !!game.automaticContinuationPending ||
|
||||
!ownTurn || !!game.charity.orderBlocked;
|
||||
const canClaim = !blocked && !game.charity.truthBlocked && !me.charityClaimed && game.charity.amount > 0;
|
||||
const canFinish = !!order && !blocked;
|
||||
const charityYes = !!order && !!game.truthCharityGuide && game.truthCharityPromises.some(promise =>
|
||||
promise.player === actor && promise.turn === turn && promise.answer && !promise.fulfilled && !promise.released);
|
||||
const canFinish = !!order && !blocked && !charityYes;
|
||||
return (
|
||||
<>
|
||||
{order && <div className="fine" data-testid="charity-turn-status">
|
||||
@@ -65,6 +67,7 @@ export function CharityClaim(props: Props) {
|
||||
: 'Claim charity'}
|
||||
<ArrowRight size={15} />
|
||||
</Button>
|
||||
{charityYes && <p className="fine">Complete your Truthtrance Charity Yes before finishing your turn.</p>}
|
||||
{order && <Button
|
||||
data-testid="charity-turn-finish"
|
||||
className="game-action"
|
||||
|
||||
@@ -27,6 +27,7 @@ export function TruthCharityPromise({ game, busy, send }: Props) {
|
||||
game.truthCharityPromises.some(promise => promise.player === actor && promise.turn === turn &&
|
||||
promise.event === event && promise.answer && !promise.fulfilled && !promise.released) &&
|
||||
game.status === 'playing' && game.phase === 2 && game.me === actor && game.turn === turn &&
|
||||
(!game.charityOrder || game.charityOrder.turn === turn && game.charityOrder.remaining[0] === actor) &&
|
||||
!!me && !me.autopilot && (payment || claim) && !game.truthtrance && !game.response &&
|
||||
!game.decision && !game.phaseOpening && !game.automaticContinuationPending &&
|
||||
!game.roomControl?.paused && !game.roomControl?.closed;
|
||||
@@ -41,6 +42,8 @@ export function TruthCharityPromise({ game, busy, send }: Props) {
|
||||
</li>)}</ul>
|
||||
<p>Yes requires an actual eligible claim after the answer; No forbids claiming during this Charity phase. Earlier claims do not count.</p>
|
||||
{game.phase < 2 && <p className="fine">Charity has not opened yet. The current turn and its original decisions continue; any claim guide appears only when Charity is available.</p>}
|
||||
{game.phase === 2 && game.charityOrder?.remaining.includes(actor) && game.charityOrder.remaining[0] !== actor &&
|
||||
<p className="fine">Wait for your Charity turn. The guide appears when your turn begins.</p>}
|
||||
{guide && action?.type === 'bribe' && <p className="fine">The quoted payment can make you eligible for Charity. You pay immediately; the other player receives the bribe at the start of Mentat Pause.</p>}
|
||||
{guide && action && <Button className="game-action min-h-11 whitespace-normal" data-testid="charity-promise-guide"
|
||||
disabled={!canAct()} onClick={() => { if (canAct()) send(action); }}>
|
||||
|
||||
@@ -17,6 +17,19 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Advanced ordered Truthtrance Charity — 29 October. Prototyped / Partial.**
|
||||
During live Advanced Charity, an unclaimed player whose turn remains can
|
||||
answer a claim question without changing the original queue. An attainable Yes
|
||||
retains the native claim or a single-payment route; No ends when that player's
|
||||
turn closes. The private guide waits for the recipient's turn, and Finish
|
||||
cannot abandon an attainable Yes. Genuine Advanced setup exercises actual
|
||||
bribes, current/future and final turns, four legal bot profiles, and Bene
|
||||
Gesserit's normal and Karama-canceled claim with a conserved Truth-card swap.
|
||||
Thirteen related files pass 72 cases, including 13 new cases; type checking,
|
||||
lint and production build pass. The forecast and timing are provisional;
|
||||
upcoming Advanced windows, broader combinations and deployed acceptance remain
|
||||
open. [Scope](TRUTHTRANCE_CHARITY.md#live-advanced-ordered-charity--29-october-2026).
|
||||
|
||||
**Basic leaderless preparation with Harkonnen — 29 October. Prototyped /
|
||||
Partial.** Fresh classic Basic games with Harkonnen now use public native
|
||||
leader snapshots to open dead-leader preparation and qualify early notices.
|
||||
|
||||
@@ -1010,11 +1010,16 @@ phase; do not treat an intervening native response as lost eligibility. Use the
|
||||
known own purse and one legal ordinary payment as a bounded future witness,
|
||||
without inventing future spice, CHOAM outcomes or foreign actions. The FAQ
|
||||
supports current-turn action commitments but does not prescribe this forecast.
|
||||
Other preparation paths, combined promises and Advanced timing remain separate.
|
||||
Other preparation paths, combined promises and upcoming Advanced timing remain separate.
|
||||
[Source and boundaries](TRUTHTRANCE_CHARITY.md#upcoming-basic-charity--27-october-2026).
|
||||
|
||||
## Truthtrance Charity commitment — 27 October 2026
|
||||
|
||||
The same provisional claim commitment now includes the live Advanced ordered
|
||||
Charity phase for players with an unclaimed turn remaining. The current queue
|
||||
is preserved, and a private action guide appears only on the respondent's turn.
|
||||
[Scope](TRUTHTRANCE_CHARITY.md#live-advanced-ordered-charity--29-october-2026).
|
||||
|
||||
Provisionally bind a question about claiming after the answer in the current
|
||||
concurrent Basic Charity phase. Yes may use a quoted native ordinary payment
|
||||
to reach the actual claim entitlement; No prohibits claiming until phase close.
|
||||
@@ -1023,8 +1028,8 @@ feasible Yes against voluntary abandonment, including after an external funds
|
||||
change if the payment route survives, and release it when an external change
|
||||
makes every supported route impossible. The general FAQ describes action
|
||||
commitments but does not specify this Charity implementation. Other eligibility
|
||||
routes, unknown answers needing joint planning and Advanced ordering remain
|
||||
separate; the earlier-phase extension is described above.
|
||||
routes and unknown answers needing joint planning remain separate; the earlier
|
||||
Basic extension and later live Advanced prototype are described above.
|
||||
[Source and boundaries](TRUTHTRANCE_CHARITY.md#live-basic-charity--27-october-2026).
|
||||
|
||||
## Sapho Truthtrance order during Richese cache auctions — 27 October 2026
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Truthtrance: Charity claim commitment
|
||||
|
||||
## Live Advanced ordered Charity — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** In a quiet live Advanced Charity phase, a player may
|
||||
ask whether another player whose unclaimed Charity turn is still in the native
|
||||
queue will claim. The answer uses the same real claim and one-payment witness
|
||||
as the Basic prototype, quoted without changing the queue or inventing another
|
||||
player's actions. A waiting player's guide appears only when their turn begins;
|
||||
an attainable Yes must be completed before finishing that turn, and No forbids
|
||||
the claim until that turn closes. A completed turn cannot be reopened. The
|
||||
existing native claim, payment and cancellation procedures remain authoritative.
|
||||
|
||||
This is a provisional digital interpretation of the FAQ's current-turn
|
||||
commitment, not a Charity-specific publisher ruling. Upcoming Advanced phases,
|
||||
broader payment forecasts, pending responses and other combinations remain
|
||||
open. Native ordered Charity and the original Basic paths remain unchanged.
|
||||
|
||||
## Upcoming Basic Charity — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** The Charity claim question also covers supported quiet
|
||||
|
||||
+21
-8
@@ -17725,7 +17725,7 @@ function phaseBidWitnesses(g: Game, p: Player): PhaseBidOpportunity[] {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/** The first structured Charity question covers the live concurrent Basic phase.
|
||||
/** A structured Charity answer retains the recipient's native opportunity.
|
||||
* A detached native action quotes the real entitlement and payer; it never
|
||||
* commits a payment or opens a response at the table. */
|
||||
function charityPromiseRule<T>(run: () => T): T {
|
||||
@@ -17735,17 +17735,25 @@ function charityPromiseRule<T>(run: () => T): T {
|
||||
}
|
||||
}
|
||||
function charityQuestionWindow(g: Game): boolean {
|
||||
return g.status === 'playing' && !g.advanced && g.phase >= 0 && g.phase <= 2 && !g.charityOrder &&
|
||||
const timing = !g.advanced && g.phase >= 0 && g.phase <= 2 && !g.charityOrder ||
|
||||
g.advanced && g.phase === 2 && !!g.charityOrder;
|
||||
return g.status === 'playing' && timing &&
|
||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard &&
|
||||
(g.phase < 2 || !byFaction(g, 'choam') || g.choamCharity?.turn === g.turn);
|
||||
}
|
||||
function charityQuestionTarget(g: Game, player: Player): boolean {
|
||||
return player.charityTurn !== g.turn && (!g.charityOrder || g.charityOrder.remaining.includes(player.id));
|
||||
}
|
||||
function charityPromiseCompletion(g: Game, player: string): Action | null {
|
||||
if (!charityQuestionWindow(g) || getPlayer(g,player).charityTurn === g.turn) return null;
|
||||
if (!charityQuestionWindow(g) || !charityQuestionTarget(g,getPlayer(g,player))) return null;
|
||||
// In early phases the action is only a detached witness for the later
|
||||
// Charity opportunity. Never project it as a currently executable guide.
|
||||
const trial = g.phase < 2 ? charityForecastContext(g) : structuredClone(g);
|
||||
trial.truthtrance = null;
|
||||
trial.ready = trial.ready.filter(id => id !== player);
|
||||
// Quote this seat's eventual native claim without changing the live order.
|
||||
// The caller has already checked that its opportunity remains in the queue.
|
||||
if (trial.charityOrder) delete trial.charityOrder;
|
||||
// Either CHOAM opening outcome pays another faction's ordinary claim; use
|
||||
// the existing bank-fallback branch only on this detached quote. Its own
|
||||
// future opening income is uncertain and is handled separately below.
|
||||
@@ -17778,7 +17786,7 @@ function charityPromiseCanClaim(g: Game, player: string): boolean {
|
||||
return !!charityPromiseCompletion(g,player);
|
||||
}
|
||||
function charityTruthAnswers(g: Game, p: Player, turn: number): TruthAnswer[] {
|
||||
if (!charityQuestionWindow(g) || turn !== g.turn || p.charityTurn === turn) return [];
|
||||
if (!charityQuestionWindow(g) || turn !== g.turn || !charityQuestionTarget(g,p)) return [];
|
||||
if (richeseBidOtherPromises(g,p)) return ['unknown'];
|
||||
// CHOAM's own unsettled opening changes its purse and depends on another
|
||||
// player's cancellation. No remains an owned choice; do not invent Yes.
|
||||
@@ -17792,7 +17800,7 @@ function bindCharityTruth(g: Game,p: Player,turn: number,answer: boolean,asker:
|
||||
g.charityPromises = charityPromiseRule(() => bindCharityPromise(g.charityPromises ?? [],p.id,asker,turn,answer,charityPromiseCanClaim(g,p.id)));
|
||||
// Only the actual concurrent Charity phase reopens Ready; Storm/Spice
|
||||
// readiness belongs to the original phase and must remain intact.
|
||||
if (g.phase === 2) g.ready = g.ready.filter(id => id !== p.id);
|
||||
if (g.phase === 2 && !g.charityOrder) g.ready = g.ready.filter(id => id !== p.id);
|
||||
}
|
||||
function reconcileCharityTruth(g: Game,voluntary?: {actor:string;action:Action}): void {
|
||||
if (!g.charityPromises?.length || g.truthtrance || g.response || g.decision || g.phaseOpening ||
|
||||
@@ -17803,7 +17811,9 @@ function reconcileCharityTruth(g: Game,voluntary?: {actor:string;action:Action})
|
||||
for (const player of people) {
|
||||
const rows: CharityPromise[] = promises.filter(row => row.player === player && !row.fulfilled && !row.released);
|
||||
for (const turn of new Set(rows.map(row => row.turn))) {
|
||||
const open = g.status === 'playing' && !g.advanced && g.phase >= 0 && g.phase <= 2 && g.turn === turn;
|
||||
const open = g.status === 'playing' && g.turn === turn &&
|
||||
(!g.advanced && g.phase >= 0 && g.phase <= 2 ||
|
||||
g.advanced && g.phase === 2 && !!g.charityOrder?.remaining.includes(player));
|
||||
const before: CharityPromise[] = promises;
|
||||
promises = charityPromiseRule(() => reconcileCharityPromises(before,player,turn,
|
||||
{phaseOpen:open,canClaim:open && charityPromiseCanClaim(g,player),
|
||||
@@ -17815,7 +17825,8 @@ function reconcileCharityTruth(g: Game,voluntary?: {actor:string;action:Action})
|
||||
g.charityPromises = promises;
|
||||
}
|
||||
function projectedCharityTruthGuide(g: Game,player: string): {action:Action} | null {
|
||||
if (g.phase !== 2 || g.truthtrance || !charityQuestionWindow(g) || g.ready.includes(player)) return null;
|
||||
if (g.phase !== 2 || g.truthtrance || !charityQuestionWindow(g) || g.ready.includes(player) ||
|
||||
g.charityOrder && g.charityOrder.remaining[0] !== player) return null;
|
||||
const promise = liveCharityPromises(g.charityPromises ?? [],player,g.turn).find(row => row.answer);
|
||||
const action = promise && charityPromiseCompletion(g,player);
|
||||
return action && promise ? {action:{...action,charityPromiseEvent:promise.event,charityPromiseTurn:promise.turn}} : null;
|
||||
@@ -30385,11 +30396,13 @@ function guardCharityOrderFinish(g: Game, player: string, frame: CharityOrder, a
|
||||
closed.ready.push(player);
|
||||
closed.active = frame.remaining[0] ?? null;
|
||||
const voluntary = { actor: player, action };
|
||||
reconcileCharityTruth(closed, voluntary);
|
||||
reconcileTravelPromises(closed, player, action, g.phase, g);
|
||||
reconcileRevivalPromises(closed, voluntary);
|
||||
reconcileBribePromises(closed, voluntary);
|
||||
reconcileShipmentPromises(closed, voluntary);
|
||||
reconcileGroundMovementPromises(closed, player, action, g.phase);
|
||||
if (closed.charityPromises) g.charityPromises = closed.charityPromises;
|
||||
}
|
||||
function revivalOrderRule<T>(calculate: () => T): T {
|
||||
try { return calculate(); }
|
||||
@@ -48138,7 +48151,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
}).map(player => player.id) : [],
|
||||
truthBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'bid' ? bidTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
truthCharityTargets: charityQuestionWindow(g) ? g.players.filter(player => player.charityTurn !== g.turn).map(player => player.id) : [],
|
||||
truthCharityTargets: charityQuestionWindow(g) ? g.players.filter(player => charityQuestionTarget(g,player)).map(player => player.id) : [],
|
||||
truthCharityAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'charity' ? charityTruthAnswers(g,me,g.truthtrance.question.turn) : null,
|
||||
truthCharityPromises: (g.charityPromises ?? []).filter(row => row.player === id).map(row => ({...row})),
|
||||
|
||||
+5
-5
@@ -3974,20 +3974,20 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Ask whether another player will claim Charity after answering during this turn’s upcoming or current Basic Charity phase.',
|
||||
summary: 'Ask whether another player will claim Charity during a supported Basic or live Advanced Charity opportunity.',
|
||||
steps: [
|
||||
'Yes requires an actual native Charity claim while possible; No forbids it until the original Charity phase ends. An answer in Charity reopens that player’s concurrent Ready; earlier Storm or Spice readiness stays intact, and an earlier claim does not count.',
|
||||
'Yes requires an actual native Charity claim while possible; No forbids it through the original Basic Charity phase or the player’s Advanced ordered turn. An answer in concurrent Basic Charity reopens that player’s Ready; earlier Storm or Spice readiness and the Advanced order stay intact, and an earlier claim does not count.',
|
||||
'During Charity a private guide can suggest the exact claim or an ordinary bribe that makes the claim eligible. Before Charity, the forecast offers no executable future action. The bribe is paid now and received at Mentat; the claim uses the normal eligibility, payer and inflation rules.',
|
||||
'A Yes remains binding while a supported claim or payment route exists. A voluntary action cannot abandon it; an external change that removes the supported route releases it. Independent future commitments may require an unknown answer until their joint planning is supported.',
|
||||
'The supported quiet Basic Storm, Spice and Charity timing is provisional. Advanced ordered Charity, other preparations, pending windows and broader combinations remain unfinished.',
|
||||
'During live Advanced Charity, an unclaimed player whose turn remains can answer without changing the order. Their private guide appears only on their own turn; No ends when that turn closes. Quiet timing and the forecast remain provisional. Upcoming Advanced Charity, other preparations, pending windows and broader combinations remain unfinished.',
|
||||
],
|
||||
related: ['card-truthtrance', 'charity', 'truthtrance-bribe'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Native Basic upcoming/current claim, one-payment route, turn-bound Yes/No and event-bound live guide are connected; wider forecasts and combined obligations remain open.', evidence: ['game/charity-promises.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Native Basic upcoming/current and live Advanced ordered claims, one-payment route, turn-bound Yes/No and event-bound guide are connected; wider forecasts and combined obligations remain open.', evidence: ['game/charity-promises.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Question, recipient answers, persistent private guidance and claim/Ready reasons are connected; browser acceptance remains open.', evidence: ['components/truthtrance.tsx', 'components/truth-charity-promise.tsx', 'components/charity-claim.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Profiles use the private answer and native guided action; strategy and calibration are deferred.', evidence: ['game/bots.ts', 'tests/truth-charity-commitment-runtime.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'The FAQ source, provisional timing and bounded preparation route are recorded.', evidence: ['docs/TRUTHTRANCE_CHARITY.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused native and rendered controls cover the Basic prototype; wider combinations and deployed acceptance remain open.', evidence: ['tests/charity-promises.test.ts', 'tests/truth-charity-commitment-runtime.test.ts', 'tests/truth-charity-commitment-controls.test.tsx', 'tests/truth-charity-early-runtime.test.ts', 'tests/truth-charity-early-controls.test.tsx'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused native and rendered controls cover the Basic and ordered Advanced prototypes; wider combinations and deployed acceptance remain open.', evidence: ['tests/charity-promises.test.ts', 'tests/truth-charity-commitment-runtime.test.ts', 'tests/truth-charity-commitment-controls.test.tsx', 'tests/truth-charity-early-runtime.test.ts', 'tests/truth-charity-early-controls.test.tsx', 'tests/truth-charity-ordered-runtime.test.ts', 'tests/truth-charity-ordered-controls.test.tsx', 'tests/truth-charity-ordered-bg.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+4
-2
@@ -294,9 +294,11 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
throw new TruthError('Finish this player’s conditional battle answer before binding another action question.');
|
||||
if (v.kind === 'charity') {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,target,turn' && v.turn === g.turn &&
|
||||
g.status === 'playing' && !g.advanced && g.phase >= 0 && g.phase <= 2 && !g.charityOrder &&
|
||||
g.status === 'playing' && (!g.advanced && g.phase >= 0 && g.phase <= 2 && !g.charityOrder ||
|
||||
g.advanced && g.phase === 2 && !!g.charityOrder?.remaining.includes(v.target as string)) &&
|
||||
g.players.find(player => player.id === v.target)?.charityTurn !== g.turn &&
|
||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard,
|
||||
'Choose this turn’s upcoming or current Basic Charity in a quiet window.');
|
||||
'Choose an unclaimed Charity opportunity in a quiet supported window.');
|
||||
return { kind: 'charity', target: v.target as string, turn: g.turn };
|
||||
}
|
||||
if (v.kind === 'mixedRevival') {
|
||||
|
||||
@@ -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