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
@@ -10,7 +10,8 @@ type Props = { game: GameView; busy: boolean; send: (action: Action) => void };
|
||||
export function TruthCharityPromise({ game, busy, send }: Props) {
|
||||
const own = game.truthCharityPromises.filter(promise => promise.turn === game.turn && promise.player === game.me);
|
||||
if (!own.length) return null;
|
||||
const guide = game.truthCharityGuide, actor = game.me, turn = game.turn, action = guide?.action;
|
||||
// A later-phase forecast never becomes a current action before Charity opens.
|
||||
const guide = game.phase === 2 ? game.truthCharityGuide : null, actor = game.me, turn = game.turn, action = guide?.action;
|
||||
const signature = JSON.stringify(action), event = action?.charityPromiseEvent;
|
||||
const canAct = () => {
|
||||
const me = game.players.find(player => player.id === actor);
|
||||
@@ -34,11 +35,12 @@ export function TruthCharityPromise({ game, busy, send }: Props) {
|
||||
return <section className="notice space-y-3" aria-label="Your Truthtrance Charity promises" id="charity-promise-guidance">
|
||||
<h3>Your Charity promises</h3>
|
||||
<ul>{own.map(promise => <li key={promise.event}>
|
||||
You answered {promise.answer ? 'Yes' : 'No'} to claiming Charity in this phase.
|
||||
You answered {promise.answer ? 'Yes' : 'No'} to claiming Charity in this turn’s {game.phase < 2 ? 'upcoming' : game.phase === 2 ? 'current' : 'completed'} Charity phase.
|
||||
{' '}{promise.released ? 'Released: this commitment is no longer possible.'
|
||||
: promise.fulfilled ? 'Fulfilled: this commitment is complete.' : 'This answer remains binding while possible.'}
|
||||
</li>)}</ul>
|
||||
<p>Yes requires an actual eligible claim after the answer; No forbids claiming during the rest of this Charity phase. Earlier claims do not count.</p>
|
||||
<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>}
|
||||
{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); }}>
|
||||
|
||||
@@ -434,7 +434,7 @@ export function Truthtrance({
|
||||
</>
|
||||
) : w.question!.kind === 'charity' ? (
|
||||
<>
|
||||
<p className="notice">This asks whether you will claim Charity after your answer in this Charity phase. Yes requires a legal claim while it remains possible; No means you cannot claim during this phase. Native eligibility and payment rules still apply.</p>
|
||||
<p className="notice">This asks whether you will claim Charity after your answer in the upcoming or current Charity phase. Yes requires a legal claim while it remains possible; No means you cannot claim during that phase. The provisional forecast can include one ordinary payment to reach eligibility. Native eligibility and payment rules still apply, and an unresolved outcome may make an answer unknown.</p>
|
||||
{(g.truthCharityAnswers ?? []).map(answer => <div key={answer}>
|
||||
{button(answer === 'unknown' ? 'I don’t know' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}
|
||||
</div>)}
|
||||
@@ -608,7 +608,7 @@ export function Truthtrance({
|
||||
<option value="leaderRevival">Bind a named leader revival this turn</option>
|
||||
<option value="revival">Bind ordinary force revivals this turn</option>
|
||||
<option value="bid">Bind a bid in this ordinary lot</option>
|
||||
<option value="charity">Bind a Charity claim this phase</option>
|
||||
<option value="charity">Bind a Charity claim this turn</option>
|
||||
<option value="phaseBid">Bind a bid this Bidding phase</option>
|
||||
<option value="richeseBid">Bind a bid in this Richese lot</option>
|
||||
<option value="mixedBid">Combine current facts and ordinary bids</option>
|
||||
@@ -756,7 +756,7 @@ export function Truthtrance({
|
||||
</>
|
||||
) : kind === 'charity' ? (
|
||||
<>
|
||||
<p className="fine">Ask whether the selected player will claim Charity after answering during this phase. Earlier claims do not count; native eligibility and payment still apply.</p>
|
||||
<p className="fine">Ask whether the selected player will claim Charity after answering in the upcoming or current Charity phase. Earlier claims do not count; native eligibility and payment still apply. An unresolved outcome may leave the answer unknown.</p>
|
||||
{!g.truthCharityTargets.includes(target) && <p className="notice">Choose a player with a supported remaining Charity opportunity.</p>}
|
||||
<p className="notice" aria-label="Public Charity question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||
</>
|
||||
|
||||
+13
-2
@@ -20,6 +20,17 @@ open mode gates.
|
||||
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
||||
Prototype labels and final acceptance gates remain.
|
||||
|
||||
**Upcoming Basic Charity Truthtrance — 27 October. Prototyped / Partial.**
|
||||
The Charity question also accepts supported quiet Storm and Spice Blow windows
|
||||
for the upcoming phase in the same turn. Its private quote uses the known purse
|
||||
and a possible native single bribe without inventing later income, blow outcomes
|
||||
or other players' decisions. The Yes/No receipt survives intervening phases and
|
||||
pending responses; actual claim guidance appears only when Charity opens. Its
|
||||
forecast and release policy remain provisional; other preparations, broader
|
||||
combinations, Advanced and deployed acceptance remain open. Eleven related files
|
||||
pass 155/155; type/lint and build pass.
|
||||
[Scope and source](TRUTHTRANCE_CHARITY.md#upcoming-basic-charity--27-october-2026).
|
||||
|
||||
**Truthtrance Charity commitment — 27 October. Prototyped / Partial.**
|
||||
A current concurrent Basic Charity question can bind whether the target claims
|
||||
after answering. A Yes requires an attainable native claim; No prevents a claim
|
||||
@@ -28,8 +39,8 @@ control and minimal legal AI use the same native entitlement. An event-bound
|
||||
guide can quote a real ordinary payment to reach eligibility before claiming,
|
||||
including after another player changes the target's funds. Voluntary and
|
||||
external loss of opportunity differ. Other future routes and unknown answers
|
||||
requiring joint planning remain partial; earlier-phase questions, Advanced,
|
||||
wider combinations and deployed acceptance remain open. The general
|
||||
requiring joint planning remain partial; the earlier-phase extension is
|
||||
described above. Advanced, wider combinations and deployed acceptance remain open. The general
|
||||
FAQ supports current-turn questions; this particular binding procedure remains
|
||||
provisional. Fourteen related files pass 187/187; type/lint and build pass.
|
||||
[Scope and source](TRUTHTRANCE_CHARITY.md#live-basic-charity--27-october-2026).
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 27 October 2026 — Upcoming Basic Charity Truthtrance
|
||||
|
||||
Extend the provisional Charity question to supported quiet Basic Storm and Spice
|
||||
Blow windows before the same turn's Charity. Retain a definite receipt through
|
||||
intervening phases and suspend impossibility reconciliation across pending
|
||||
responses. The private future quote uses the current known purse and one legal
|
||||
ordinary payment without inventing mandatory spice, CHOAM or foreign choices;
|
||||
the actual claim or payment guide appears only during real Charity. No continues
|
||||
to prohibit the actual claim and Yes requires it while a supported route remains.
|
||||
Further preparations, overlapping future commitments, Advanced, broader
|
||||
combinations, browser and deployed acceptance remain open. Eleven related files
|
||||
pass 155/155; type/lint and build pass. Private proof is
|
||||
`20261027-truth-charity-early-proof/`.
|
||||
[Contract](TRUTHTRANCE_CHARITY.md#upcoming-basic-charity--27-october-2026).
|
||||
|
||||
## 27 October 2026 — Truthtrance Charity commitment
|
||||
|
||||
Connect an explicit current Basic Charity claim question to target-only answer
|
||||
@@ -12,9 +27,9 @@ on the quote. An external funds change retains Yes when this route remains.
|
||||
Other preparation, unknown answers and independent action combinations needing
|
||||
a joint planner remain partial.
|
||||
The source fixture uses original cards, genuine Amal and an actual four-spice
|
||||
bribe, with no reassigned wallets. This is provisional, limited to the current
|
||||
concurrent Basic phase; Advanced, earlier-phase and wider combinations plus
|
||||
browser/deployed acceptance remain open. Fourteen related files pass 187/187;
|
||||
bribe, with no reassigned wallets. This checkpoint was limited to the concurrent
|
||||
Basic phase; the earlier-phase extension is described above. Advanced, wider
|
||||
combinations and browser/deployed acceptance remain open. Fourteen related files pass 187/187;
|
||||
type/lint and build pass. The external-income supplement uses a labeled,
|
||||
conserved controlled transfer. Private proof is
|
||||
`20261027-truth-charity-claim-promise-proof-v2/`.
|
||||
|
||||
+13
-2
@@ -1,5 +1,16 @@
|
||||
# Rule decision index
|
||||
|
||||
## Upcoming Basic Charity Truthtrance — 27 October 2026
|
||||
|
||||
Provisionally allow the same Charity question in supported quiet Basic Storm and
|
||||
Spice Blow windows. Retain the current-turn receipt until the real Charity
|
||||
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.
|
||||
[Source and boundaries](TRUTHTRANCE_CHARITY.md#upcoming-basic-charity--27-october-2026).
|
||||
|
||||
## Truthtrance Charity commitment — 27 October 2026
|
||||
|
||||
Provisionally bind a question about claiming after the answer in the current
|
||||
@@ -10,8 +21,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, earlier-phase questions and
|
||||
Advanced ordering remain separate.
|
||||
routes, unknown answers needing joint planning and Advanced ordering remain
|
||||
separate; the earlier-phase extension is 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,38 @@
|
||||
# Truthtrance: Charity claim commitment
|
||||
|
||||
## Upcoming Basic Charity — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** The Charity claim question also covers supported quiet
|
||||
Basic Storm and Spice Blow windows before the current turn's Charity phase.
|
||||
It asks about a claim after the answer, in that upcoming phase. The FAQ's
|
||||
current-turn commitment rule supplies the general source; this specific future
|
||||
quote and interruption policy is a provisional implementation interpretation.
|
||||
|
||||
The private quote uses the respondent's known current purse and, where
|
||||
available, a single legal native ordinary bribe as a route to the later claim.
|
||||
It does not manufacture future spice, mandatory blow outcomes, CHOAM income or
|
||||
cancellation, another player's actions, or other undisclosed preparations.
|
||||
A definite receipt survives the intervening native phase transitions. No remains
|
||||
binding through the subsequent Charity phase; Yes requires a real eligible claim
|
||||
while a supported route remains possible. A pending opening or response is not
|
||||
itself proof that the claim has become impossible: reconcile the real outcome
|
||||
once the interruption resolves. Voluntary loss of an attainable Yes and an
|
||||
external loss retain their distinct treatment.
|
||||
|
||||
Before Charity opens, the private status explains that the action is upcoming;
|
||||
it does not offer a simulated future claim or bribe as an immediately legal
|
||||
control. In the real Charity phase, the existing event- and turn-bound native
|
||||
bribe/claim guidance, No restriction, Ready and payment rules continue. Other
|
||||
preparation routes, action combinations needing joint planning, pending-window
|
||||
compositions and Advanced ordered Charity remain open. This first version does
|
||||
not certify complete Truthtrance, modes, browser or deployed play.
|
||||
|
||||
The earlier-window tests use original Basic sources with actual Storm and
|
||||
post-Amal Spice Blow continuations, plus a real CHOAM opening; an unopened safe
|
||||
Spice prefix is selected for the phase-zero case. No wallets or cards are
|
||||
reassigned. Eleven related files pass 155/155; type/lint and build pass.
|
||||
Server-rendered controls are covered, not browser or deployed acceptance.
|
||||
|
||||
## Live Basic Charity — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** A Truthtrance holder may ask another player whether
|
||||
@@ -50,7 +83,7 @@ transfer before real Ready, bribe and claim actions. Fourteen related files pass
|
||||
187/187; type/lint and build pass. Server-rendered controls are exercised, but
|
||||
that is not browser or deployed acceptance.
|
||||
|
||||
Questions earlier than the live phase, other preparation or later eligibility
|
||||
forecasts, Advanced ordered Charity, broader overlapping promises,
|
||||
Other early-phase windows or preparations, later eligibility forecasts,
|
||||
Advanced ordered Charity, broader overlapping promises,
|
||||
optional-module and pending-response combinations, full games, browser and
|
||||
deployed acceptance remain open. No independent joint planner is introduced.
|
||||
+21
-8
@@ -16159,15 +16159,22 @@ function charityPromiseRule<T>(run: () => T): T {
|
||||
}
|
||||
}
|
||||
function charityQuestionWindow(g: Game): boolean {
|
||||
return g.status === 'playing' && !g.advanced && g.phase === 2 && !g.charityOrder &&
|
||||
return g.status === 'playing' && !g.advanced && g.phase >= 0 && g.phase <= 2 && !g.charityOrder &&
|
||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard &&
|
||||
(!byFaction(g, 'choam') || g.choamCharity?.turn === g.turn);
|
||||
(g.phase < 2 || !byFaction(g, 'choam') || g.choamCharity?.turn === g.turn);
|
||||
}
|
||||
function charityPromiseCompletion(g: Game, player: string): Action | null {
|
||||
if (!charityQuestionWindow(g) || getPlayer(g,player).charityTurn === g.turn) return null;
|
||||
const trial = structuredClone(g);
|
||||
// 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);
|
||||
// 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.
|
||||
if (g.phase < 2 && byFaction(trial,'choam') && trial.choamCharity?.turn !== trial.turn)
|
||||
trial.choamCharity = {turn:trial.turn,canceled:true};
|
||||
// Evaluate physical availability independently of this answer's own guard.
|
||||
delete trial.charityPromises;
|
||||
const claim = (source: Game): boolean => {
|
||||
@@ -16197,24 +16204,30 @@ function charityPromiseCanClaim(g: Game, player: string): boolean {
|
||||
function charityTruthAnswers(g: Game, p: Player, turn: number): TruthAnswer[] {
|
||||
if (!charityQuestionWindow(g) || turn !== g.turn || p.charityTurn === turn) 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.
|
||||
if (g.phase < 2 && p.faction === 'choam' && g.choamCharity?.turn !== turn)
|
||||
return ['no','unknown'];
|
||||
return charityPromiseRule(() => charityPromiseAnswers(g.charityPromises ?? [],p.id,turn,charityPromiseCanClaim(g,p.id)));
|
||||
}
|
||||
function bindCharityTruth(g: Game,p: Player,turn: number,answer: boolean,asker: string): void {
|
||||
requireRule(charityTruthAnswers(g,p,turn).includes(answer ? 'yes' : 'no'),
|
||||
'Choose an answer compatible with your current Charity opportunity.');
|
||||
g.charityPromises = charityPromiseRule(() => bindCharityPromise(g.charityPromises ?? [],p.id,asker,turn,answer,charityPromiseCanClaim(g,p.id)));
|
||||
// A definite new answer reopens a concurrent Basic Ready, not a completed phase.
|
||||
g.ready = g.ready.filter(id => id !== 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);
|
||||
}
|
||||
function reconcileCharityTruth(g: Game,voluntary?: {actor:string;action:Action}): void {
|
||||
if (!g.charityPromises?.length || g.truthtrance || g.response || g.decision || g.phaseOpening ||
|
||||
g.pendingKarama || g.pendingTreacheryDiscard) return;
|
||||
g.pendingKarama || g.pendingTreacheryDiscard ||
|
||||
(g.phase === 2 && !!byFaction(g,'choam') && g.choamCharity?.turn !== g.turn)) return;
|
||||
let promises: CharityPromise[] = g.charityPromises;
|
||||
const people = new Set(promises.filter(row => !row.fulfilled && !row.released).map(row => row.player));
|
||||
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 === 2 && g.turn === turn;
|
||||
const open = g.status === 'playing' && !g.advanced && g.phase >= 0 && g.phase <= 2 && g.turn === turn;
|
||||
const before: CharityPromise[] = promises;
|
||||
promises = charityPromiseRule(() => reconcileCharityPromises(before,player,turn,
|
||||
{phaseOpen:open,canClaim:open && charityPromiseCanClaim(g,player),
|
||||
@@ -16226,7 +16239,7 @@ function reconcileCharityTruth(g: Game,voluntary?: {actor:string;action:Action})
|
||||
g.charityPromises = promises;
|
||||
}
|
||||
function projectedCharityTruthGuide(g: Game,player: string): {action:Action} | null {
|
||||
if (g.truthtrance || !charityQuestionWindow(g) || g.ready.includes(player)) return null;
|
||||
if (g.phase !== 2 || g.truthtrance || !charityQuestionWindow(g) || g.ready.includes(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;
|
||||
|
||||
+6
-6
@@ -3905,20 +3905,20 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Ask whether another player will claim Charity after answering during the current Basic Charity phase.',
|
||||
summary: 'Ask whether another player will claim Charity after answering during this turn’s upcoming or current Basic Charity phase.',
|
||||
steps: [
|
||||
'Yes requires an actual native Charity claim while possible; No forbids it until the original Charity phase ends. An answer reopens that player’s concurrent Ready, but does not reopen a completed phase or count an earlier claim.',
|
||||
'A private guide can suggest the exact claim or an ordinary bribe that makes the claim eligible. The bribe is paid now and received at Mentat; the claim uses the normal eligibility, payer and inflation rules.',
|
||||
'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.',
|
||||
'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 live Basic timing and interaction with other obligations are provisional. Earlier phases, Advanced ordered Charity, other preparations and broader combinations remain unfinished.',
|
||||
'The supported quiet Basic Storm, Spice and Charity timing is provisional. Advanced ordered Charity, other preparations, pending windows and broader combinations remain unfinished.',
|
||||
],
|
||||
related: ['card-truthtrance', 'charity', 'truthtrance-bribe'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Native Basic claim, one-payment eligibility route, same-phase 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: '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: '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'] },
|
||||
{ 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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+3
-3
@@ -268,9 +268,9 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
throw new TruthError('Finish this player’s current Charity 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 === 2 && !g.charityOrder &&
|
||||
g.status === 'playing' && !g.advanced && g.phase >= 0 && g.phase <= 2 && !g.charityOrder &&
|
||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard,
|
||||
'Choose the current quiet Basic Charity phase and its original turn.');
|
||||
'Choose this turn’s upcoming or current Basic Charity in a quiet window.');
|
||||
return { kind: 'charity', target: v.target as string, turn: g.turn };
|
||||
}
|
||||
if (v.kind === 'mixedRevival') {
|
||||
@@ -752,7 +752,7 @@ export function truthQuestionText(
|
||||
playerName: (id: string) => string = id => id,
|
||||
): string {
|
||||
if (q.kind === 'freeform') return q.text;
|
||||
if (q.kind === 'charity') return 'Will you claim Charity after this answer during this Charity phase?';
|
||||
if (q.kind === 'charity') return 'Will you claim Charity after this answer during this turn’s Charity phase?';
|
||||
if (q.kind === 'revivalExpression') return `Will you ${revivalExpressionText(q.claim, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))} from now until your Revival opportunity ends this turn?`;
|
||||
if (q.kind === 'leaderRevival') return `Will you ${leaderRevivalClaimText(q, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))}?`;
|
||||
if (q.kind === 'allianceExpression') return `Is it true that you ${allianceExpressionText(q.claim, playerName)}?`;
|
||||
|
||||
@@ -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