Support Truthtrance charity commitments in Basic play
This commit is contained in:
1 parent
6a31315c95
commit
72ff9db5e8
18 files changed
+815
-5
No files matched your search
@@ -21,7 +21,7 @@ export function CharityClaim(props: Props) {
|
||||
const blocked = busy || !!game.truthtrance || !!game.nexusTraitors?.pending || game.charity.incomePending ||
|
||||
!!game.response || !!game.decision || !!game.phaseOpening || !!game.automaticContinuationPending ||
|
||||
!ownTurn || !!game.charity.orderBlocked;
|
||||
const canClaim = !blocked && !me.charityClaimed && game.charity.amount > 0;
|
||||
const canClaim = !blocked && !game.charity.truthBlocked && !me.charityClaimed && game.charity.amount > 0;
|
||||
const canFinish = !!order && !blocked;
|
||||
return (
|
||||
<>
|
||||
@@ -51,6 +51,7 @@ export function CharityClaim(props: Props) {
|
||||
{game.charity.homeworld > 0 &&
|
||||
` Low Homeworld population adds ${game.charity.homeworld} spice directly from the bank${game.charity.multiplier === 2 ? ' after Inflation doubles the bonus' : ''}.`}
|
||||
</p>
|
||||
{game.charity.truthBlocked && <p className="notice" data-testid="charity-truth-blocked">{game.charity.truthBlocked}</p>}
|
||||
<Button
|
||||
data-testid="charity-claim-submit"
|
||||
className="game-action"
|
||||
|
||||
@@ -181,6 +181,7 @@ import { ChoamCashIn } from './choam-cash-in';
|
||||
import { ChoamMarket } from './choam-market';
|
||||
import { BattlePromises } from './battle-promises';
|
||||
import { Truthtrance, TruthHistory } from './truthtrance';
|
||||
import { TruthCharityPromise } from './truth-charity-promise';
|
||||
import {
|
||||
forceRevivalPrice,
|
||||
eliteRevivalRemaining,
|
||||
@@ -2077,6 +2078,7 @@ export function GameTable({
|
||||
{g.status === 'playing' && g.phase < 5 && (
|
||||
<ShipmentPromises game={g} act={act} busy={busy} />
|
||||
)}
|
||||
{g.status === 'playing' && <TruthCharityPromise game={g} send={act} busy={busy} />}
|
||||
{g.status === 'playing' && <BribePromises game={g} send={act} busy={busy} />}
|
||||
{g.status === 'playing' && <BribeExpressionPromises game={g} send={act} busy={busy} />}
|
||||
{g.status === 'playing' && <MixedBribePromises game={g} send={act} busy={busy} />}
|
||||
@@ -5839,6 +5841,7 @@ export function GameTable({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{g.phase === 2 && g.truthCharityGuide && <p className="notice">Claim Charity before finishing your Truthtrance Yes.</p>}
|
||||
{[1, 2, 4, 7, 8].includes(g.phase) && !(g.phase === 4 && g.revivalOrder) && !(g.phase === 2 && g.charityOrder) &&
|
||||
actionButton(
|
||||
g.ready.includes(me.id)
|
||||
@@ -5847,7 +5850,7 @@ export function GameTable({
|
||||
? 'Accept spice blow'
|
||||
: 'Ready for next phase',
|
||||
{ type: 'ready' },
|
||||
g.ready.includes(me.id),
|
||||
g.ready.includes(me.id) || (g.phase === 2 && !!g.truthCharityGuide),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { bribeAction } from '@/game/bribe-options';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type Props = { game: GameView; busy: boolean; send: (action: Action) => void };
|
||||
|
||||
/** The server supplies an exact native claim or payment, never a simulated transfer. */
|
||||
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;
|
||||
const signature = JSON.stringify(action), event = action?.charityPromiseEvent;
|
||||
const canAct = () => {
|
||||
const me = game.players.find(player => player.id === actor);
|
||||
const recipient = typeof action?.target === 'string' ? game.players.find(player => player.id === action.target) : undefined;
|
||||
const amount = action?.amount;
|
||||
const payment = action?.type === 'bribe' && !!me && !!recipient && recipient.id !== actor && recipient.id !== me.ally &&
|
||||
typeof amount === 'number' && !!bribeAction(game.bribeOptions, recipient.id, amount);
|
||||
const claim = action?.type === 'charity' && !!me && !me.charityClaimed &&
|
||||
!game.ready.includes(actor) && !game.charity.truthBlocked && !game.charity.incomePending &&
|
||||
!game.charity.orderBlocked && game.charity.amount > 0;
|
||||
return !busy && !!guide && !!action && game.truthCharityGuide === guide && guide.action === action &&
|
||||
JSON.stringify(action) === signature && typeof event === 'string' && action.charityPromiseTurn === turn &&
|
||||
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 &&
|
||||
!!me && !me.autopilot && (payment || claim) && !game.truthtrance && !game.response &&
|
||||
!game.decision && !game.phaseOpening && !game.automaticContinuationPending &&
|
||||
!game.roomControl?.paused && !game.roomControl?.closed;
|
||||
};
|
||||
const recipient = game.players.find(player => player.id === action?.target)?.name ?? 'the quoted recipient';
|
||||
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.
|
||||
{' '}{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>
|
||||
{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); }}>
|
||||
{action.type === 'bribe' ? `Pay ${typeof action.amount === 'number' ? action.amount : 'the quoted'} spice to ${recipient} to prepare for Charity`
|
||||
: `Claim ${game.charity.amount} Charity spice to honor your answer`}
|
||||
</Button>}
|
||||
</section>;
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export function Truthtrance({
|
||||
g.players.find((p) => p.id !== me.id)!.id,
|
||||
);
|
||||
const [kind, setKind] = useState<
|
||||
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'phaseBid' | 'richeseBid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
|
||||
'fact' | 'freeform' | 'charity' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'phaseBid' | 'richeseBid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
|
||||
>('fact');
|
||||
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
|
||||
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
|
||||
@@ -245,6 +245,7 @@ export function Truthtrance({
|
||||
: kind === 'bid' ? { kind, target, ...bidClaim }
|
||||
: kind === 'phaseBid' ? { kind, target, ...phaseBidClaim }
|
||||
: kind === 'richeseBid' ? { kind, target, turn:g.turn, event:g.richeseAuction?.event ?? '', minimum:bidMinimum }
|
||||
: kind === 'charity' ? { kind, target, turn:g.turn }
|
||||
: kind === 'shipment'
|
||||
? {
|
||||
kind,
|
||||
@@ -431,6 +432,13 @@ export function Truthtrance({
|
||||
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
|
||||
</div>)}
|
||||
</>
|
||||
) : 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>
|
||||
{(g.truthCharityAnswers ?? []).map(answer => <div key={answer}>
|
||||
{button(answer === 'unknown' ? 'I don’t know' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}
|
||||
</div>)}
|
||||
</>
|
||||
) : w.question!.kind === 'richeseBid' ? (
|
||||
<>
|
||||
<p className="notice">Yes requires a qualifying bid in your one remaining opportunity while possible. No forbids a qualifying bid. Silent amounts remain sealed until all submit; winning still requires payment.</p>
|
||||
@@ -600,6 +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="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>
|
||||
@@ -745,6 +754,12 @@ export function Truthtrance({
|
||||
{!g.truthRevivalTargets.includes(target) && <p className="notice">Choose a respondent whose native Revival opportunity remains this turn and finish pending decisions. Registered native Basic and Advanced tables without optional modules are supported first.</p>}
|
||||
<p className="notice" aria-label="Public revival question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||
</>
|
||||
) : 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>
|
||||
{!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>
|
||||
</>
|
||||
) : kind === 'richeseBid' ? (
|
||||
<>
|
||||
<RicheseBidPromiseFields minimum={bidMinimum} onMinimum={setBidMinimum} disabled={busy || !g.richeseAuction} method={g.richeseAuction?.method === 'silent' ? 'silent' : 'onceAround'} />
|
||||
@@ -897,6 +912,7 @@ export function Truthtrance({
|
||||
(kind === 'mixedBid' && (!!mixedBidError || !g.truthMixedBidTargets.includes(target))) ||
|
||||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
|
||||
(kind === 'richeseBid' && (!!richeseBidMinimumError(bidMinimum) || !g.richeseAuction || !g.truthRicheseBidTargets.includes(target))) ||
|
||||
(kind === 'charity' && !g.truthCharityTargets.includes(target)) ||
|
||||
(kind === 'phaseBid' && (!!phaseBidClaimInputError(phaseBidClaim) || !g.truthPhaseBidTargets.includes(target))) ||
|
||||
(kind === 'turnActions' && (!!turnActionsError || !g.truthTurnActionTargets.includes(target))) ||
|
||||
(kind === 'travel' && (!!travelError || !g.truthTravelTargets.includes(target))) ||
|
||||
@@ -921,7 +937,7 @@ export function TruthHistory({ game: g }: { game: GameView }) {
|
||||
<details className="notice truthtrance-history">
|
||||
<summary>Truthtrance answers · Turn {g.turn}</summary>
|
||||
<p className="fine">
|
||||
Supported battle-plan, Nexus-alliance, bid, force/leader and grouped revival, bribe, movement and reserve-shipment
|
||||
Supported Charity, battle-plan, Nexus-alliance, bid, force/leader and grouped revival, bribe, movement and reserve-shipment
|
||||
answers are enforced. Current-turn freeform promises remain binding when possible,
|
||||
but their automatic enforcement is unfinished.
|
||||
</p>
|
||||
|
||||
@@ -20,6 +20,20 @@ open mode gates.
|
||||
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
||||
Prototype labels and final acceptance gates remain.
|
||||
|
||||
**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
|
||||
until the phase closes. Private legal choices and guidance, the ordinary claim
|
||||
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
|
||||
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).
|
||||
|
||||
**Sapho Truthtrance order in Richese cache auctions — 27 October. Prototyped / Partial.**
|
||||
The public question-order opportunity also opens during a clean Once Around or
|
||||
Silent cache lot. The same lot, bidder order, funding and aid continue after the
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 27 October 2026 — Truthtrance Charity commitment
|
||||
|
||||
Connect an explicit current Basic Charity claim question to target-only answer
|
||||
quotes, a saved binding, real native claim/Ready enforcement, player guidance
|
||||
and minimal legal AI. Yes uses the actual claim; No persists until phase close.
|
||||
Voluntary loss of an attainable Yes rejects and external impossibility releases
|
||||
it. A detached quote can use a native ordinary payment to reach eligibility;
|
||||
the event- and turn-bound guide sends the exact bribe or claim without paying
|
||||
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;
|
||||
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/`.
|
||||
[Contract](TRUTHTRANCE_CHARITY.md#live-basic-charity--27-october-2026).
|
||||
|
||||
## 27 October 2026 — Sapho Truthtrance order in Richese cache auctions
|
||||
|
||||
Extend the public simultaneous-question ordering opportunity to clean live
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Rule decision index
|
||||
|
||||
## Truthtrance Charity commitment — 27 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.
|
||||
The event- and turn-bound guide retains the exact bribe or claim. Preserve a
|
||||
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.
|
||||
[Source and boundaries](TRUTHTRANCE_CHARITY.md#live-basic-charity--27-october-2026).
|
||||
|
||||
## Sapho Truthtrance order during Richese cache auctions — 27 October 2026
|
||||
|
||||
Provisionally apply the simultaneous-question ordering response to a clean
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Truthtrance: Charity claim commitment
|
||||
|
||||
## Live Basic Charity — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** A Truthtrance holder may ask another player whether
|
||||
that player will claim Charity **after the answer** in the currently open,
|
||||
concurrent Basic Charity phase. The [GF9 November 2020 FAQ, page 8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8)
|
||||
supports current-turn questions about a player's actions while they remain
|
||||
possible; it does not provide a Charity-specific worked example. This precise
|
||||
online binding and release procedure is a provisional interpretation.
|
||||
|
||||
The target's answer quote uses the native current claim entitlement and payer,
|
||||
including the original faction, turn, claim status, funds and any resolved CHOAM
|
||||
opening. Yes is offered when the native claim is possible now or the player
|
||||
can make a quoted native ordinary payment to reach eligibility, provided no
|
||||
earlier live No conflicts. No is available when no earlier live Yes conflicts.
|
||||
The quote does not invent future funding or unverified preparations beyond the
|
||||
supported payment route. A player cannot be asked again about a claim already
|
||||
completed that turn. Unknown answers and conflicting independent action
|
||||
families requiring a joint future-action planner remain partial.
|
||||
|
||||
A definite answer reopens that player's concurrent Basic Ready, if already
|
||||
marked, without reopening a completed phase. The actual native Charity action
|
||||
fulfills Yes and pays through the existing rules; Ready cannot voluntarily close
|
||||
an attainable Yes. No forbids the native claim for the rest of the original
|
||||
Charity phase, including if a later response reopens Ready. A voluntary action
|
||||
cannot abandon an attainable Yes; after another player changes the target's
|
||||
funds, an existing Yes remains binding if the supported ordinary-payment route
|
||||
still makes the claim possible. A genuinely external change that removes every
|
||||
supported opportunity releases it, while a No completes at phase close. Pending
|
||||
responses are not treated as proof of impossibility. Ordinary eligibility,
|
||||
inflation, payment and the original once-per-turn claim remain authoritative.
|
||||
|
||||
The respondent sees their available answers and persistent guidance; a live No
|
||||
explains why the ordinary claim control is disabled, while an attainable Yes
|
||||
provides an event- and turn-bound guide to the exact native claim or ordinary
|
||||
bribe. The payer spends the bribe immediately and the recipient receives it at
|
||||
Mentat Pause. AI uses the same target-only quote and legal action. Questions and
|
||||
answers are public as in the existing
|
||||
Truthtrance history; private receipts and target-only guide are not projected
|
||||
to other seats. This is a usable first version, not full enforcement of all
|
||||
Charity questions or a complete implementation of Truthtrance.
|
||||
|
||||
The native fixture `tests/fixture-truth-charity-commitment.ts` starts original
|
||||
Basic classic factions with the selected Ix deck and original Truth cards,
|
||||
uses genuine Amal and Charity chronology, and makes a real four-spice bribe to
|
||||
leave the target at one spice. It does not install new wallets, cards, phase or
|
||||
readiness. A separate external-income test uses a labeled, conserved controlled
|
||||
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,
|
||||
optional-module and pending-response combinations, full games, browser and
|
||||
deployed acceptance remain open. No independent joint planner is introduced.
|
||||
@@ -1314,6 +1314,7 @@ function policyActions(g: GameView): Action[] {
|
||||
? [{ type: 'decision', event: search.event, card: ordered[0].id }]
|
||||
: [];
|
||||
}
|
||||
if (g.truthCharityGuide) return [g.truthCharityGuide.action];
|
||||
if (g.bribePromiseGuide) return [g.bribePromiseGuide.action];
|
||||
if (g.alliancePromiseGuide) return [g.alliancePromiseGuide.action];
|
||||
if (g.revivalExpressionPromiseGuide) return [g.revivalExpressionPromiseGuide.action];
|
||||
@@ -1380,6 +1381,10 @@ function policyActions(g: GameView): Action[] {
|
||||
const answer = g.truthBidAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
}
|
||||
if (w.question.kind === 'charity') {
|
||||
const answer = g.truthCharityAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
}
|
||||
if (w.question.kind === 'richeseBid') {
|
||||
const answer = g.truthRicheseBidAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
@@ -3232,6 +3237,7 @@ function policyActions(g: GameView): Action[] {
|
||||
g.phase === 2 &&
|
||||
!me.charityClaimed &&
|
||||
!g.charity.incomePending &&
|
||||
!g.charity.truthBlocked &&
|
||||
g.charity.amount > 0
|
||||
)
|
||||
return [{ type: 'charity' }];
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/** A definite answer about claiming charity after the answer in the current
|
||||
* Charity phase. Native eligibility and the cause of an opportunity changing
|
||||
* remain authoritative in the engine. */
|
||||
export type CharityPromise = {
|
||||
event: string;
|
||||
turn: number;
|
||||
player: string;
|
||||
asker: string;
|
||||
answer: boolean;
|
||||
fulfilled: boolean;
|
||||
released: boolean;
|
||||
};
|
||||
|
||||
export class CharityPromiseError extends Error {}
|
||||
export type CharityPromiseAnswer = 'yes' | 'no';
|
||||
export type CharityPromiseChange = 'voluntary' | 'external';
|
||||
|
||||
function checkContext(player: string, turn: number): void {
|
||||
if (typeof player !== 'string' || !player.trim() || player !== player.trim() ||
|
||||
!Number.isSafeInteger(turn) || turn < 1)
|
||||
throw new CharityPromiseError('Retain the original player and turn for the Charity answer.');
|
||||
}
|
||||
|
||||
/** Each physical answer owns a distinct stable receipt across continuations. */
|
||||
export function validateCharityPromises(promises: readonly CharityPromise[]): void {
|
||||
if (!Array.isArray(promises)) throw new CharityPromiseError('The Charity answer receipts are invalid.');
|
||||
const events = new Set<string>();
|
||||
for (const row of promises) {
|
||||
if (!row || typeof row.event !== 'string' || !row.event.trim() || row.event !== row.event.trim() ||
|
||||
events.has(row.event) || typeof row.player !== 'string' || !row.player.trim() ||
|
||||
row.player !== row.player.trim() || typeof row.asker !== 'string' || !row.asker.trim() ||
|
||||
row.asker !== row.asker.trim() || row.asker === row.player ||
|
||||
!Number.isSafeInteger(row.turn) || row.turn < 1 || typeof row.answer !== 'boolean' ||
|
||||
typeof row.fulfilled !== 'boolean' || typeof row.released !== 'boolean' || row.fulfilled && row.released)
|
||||
throw new CharityPromiseError('A Charity answer lost its unique event, participants or outcome.');
|
||||
events.add(row.event);
|
||||
}
|
||||
}
|
||||
|
||||
/** A No remains live through phase close, even if concurrent Basic Ready is
|
||||
* later reopened. A Yes completes only when the actual claim is accepted. */
|
||||
export function liveCharityPromises<T extends CharityPromise>(
|
||||
promises: readonly T[], player: string, turn: number,
|
||||
): T[] {
|
||||
checkContext(player, turn);
|
||||
validateCharityPromises(promises);
|
||||
return promises.filter(row => row.player === player && row.turn === turn && !row.fulfilled && !row.released);
|
||||
}
|
||||
|
||||
/** canClaim is the native, current opportunity, including funding and prior
|
||||
* claim rules. It must not be a temporary inability while a response is open. */
|
||||
export function charityPromiseAnswers(
|
||||
promises: readonly CharityPromise[], player: string, turn: number, canClaim: boolean,
|
||||
): CharityPromiseAnswer[] {
|
||||
const live = liveCharityPromises(promises, player, turn);
|
||||
return [
|
||||
...(canClaim && !live.some(row => !row.answer) ? ['yes' as const] : []),
|
||||
...(!live.some(row => row.answer) ? ['no' as const] : []),
|
||||
];
|
||||
}
|
||||
|
||||
/** Called only for a definite accepted answer, before the Truth card's actual
|
||||
* discard. The engine reopens Basic readiness separately. */
|
||||
export function bindCharityPromise(
|
||||
promises: readonly CharityPromise[], player: string, asker: string, turn: number,
|
||||
answer: boolean, canClaim: boolean, event: string = crypto.randomUUID(),
|
||||
): CharityPromise[] {
|
||||
checkContext(asker, turn);
|
||||
if (asker === player || typeof answer !== 'boolean' ||
|
||||
!charityPromiseAnswers(promises, player, turn, canClaim).includes(answer ? 'yes' : 'no'))
|
||||
throw new CharityPromiseError('Choose a Charity answer consistent with the current opportunity and previous answers.');
|
||||
const next = [...promises, { event, turn, player, asker, answer, fulfilled: false, released: false }];
|
||||
validateCharityPromises(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Invoke after native claim validation, before the actual claim settles. */
|
||||
export function guardCharityClaim<T extends CharityPromise>(
|
||||
promises: readonly T[], player: string, turn: number,
|
||||
): T[] {
|
||||
const live = liveCharityPromises(promises, player, turn);
|
||||
if (live.some(row => !row.answer))
|
||||
throw new CharityPromiseError('Your Truthtrance No prevents claiming Charity this phase.');
|
||||
return promises.map(row => live.includes(row) && row.answer ? { ...row, fulfilled: true } : row);
|
||||
}
|
||||
|
||||
/** A reachable affirmative claim must happen before this player confirms Ready. */
|
||||
export function guardCharityReady(
|
||||
promises: readonly CharityPromise[], player: string, turn: number, canClaim: boolean,
|
||||
): void {
|
||||
if (canClaim && liveCharityPromises(promises, player, turn).some(row => row.answer))
|
||||
throw new CharityPromiseError('Claim Charity before finishing your Truthtrance Yes.');
|
||||
}
|
||||
|
||||
/** Reconcile only at a stable native boundary. A voluntary change may not
|
||||
* sacrifice an attainable Yes; an externally impossible Yes is released. No
|
||||
* resolves only when the original phase closes, never merely on Ready. */
|
||||
export function reconcileCharityPromises<T extends CharityPromise>(
|
||||
promises: readonly T[], player: string, turn: number,
|
||||
opportunity: { phaseOpen: boolean; canClaim: boolean; change: CharityPromiseChange },
|
||||
): T[] {
|
||||
const live = liveCharityPromises(promises, player, turn);
|
||||
if (!opportunity || typeof opportunity.phaseOpen !== 'boolean' ||
|
||||
typeof opportunity.canClaim !== 'boolean' ||
|
||||
(opportunity.change !== 'voluntary' && opportunity.change !== 'external'))
|
||||
throw new CharityPromiseError('Retain the original Charity opportunity and the cause of its change.');
|
||||
if ((!opportunity.phaseOpen || !opportunity.canClaim) && live.some(row => row.answer) &&
|
||||
opportunity.change === 'voluntary')
|
||||
throw new CharityPromiseError('This action would abandon your Truthtrance Charity Yes.');
|
||||
return promises.map(row => {
|
||||
if (!live.includes(row)) return row;
|
||||
if (row.answer && (!opportunity.phaseOpen || !opportunity.canClaim)) return { ...row, released: true };
|
||||
if (!row.answer && !opportunity.phaseOpen) return { ...row, fulfilled: true };
|
||||
return row;
|
||||
});
|
||||
}
|
||||
+106
@@ -1,3 +1,4 @@
|
||||
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
|
||||
import { SaphoTruthOrderError, quoteSaphoTruthOrder, reorderSaphoTruthOrder, validateSaphoTruthOrder } from './sapho-truth-order';
|
||||
import { RicheseBidPromiseError, richeseBidCompletionRange, richeseBidRequired, recordRicheseBid,
|
||||
liveRicheseBidPromises, reconcileRicheseBidPromises, finishRicheseBidLot, validateRicheseBidPromises,
|
||||
@@ -1395,6 +1396,7 @@ type RicheseAllyOffer = {
|
||||
payer: string;
|
||||
};
|
||||
export type Game = {
|
||||
charityPromises?: CharityPromise[];
|
||||
revivalOrder?: RevivalOrder;
|
||||
charityOrder?: CharityOrder;
|
||||
pendingAutomaticAdvisorFlips?: AutomaticAdvisorFlipSource[];
|
||||
@@ -7096,6 +7098,7 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
|
||||
`truth:${next.historyIndex}`,
|
||||
),
|
||||
history: g.truthHistory,
|
||||
...(g.charityPromises ? { charityPromises: g.charityPromises } : {}),
|
||||
battle: g.battle,
|
||||
shipmentPromises: g.shipmentPromises,
|
||||
movementPromises: g.movementPromises,
|
||||
@@ -16146,6 +16149,88 @@ function phaseBidWitnesses(g: Game, p: Player): PhaseBidOpportunity[] {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/** The first structured Charity question covers the live concurrent Basic phase.
|
||||
* 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 {
|
||||
try { return run(); } catch (error) {
|
||||
if (error instanceof CharityPromiseError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function charityQuestionWindow(g: Game): boolean {
|
||||
return g.status === 'playing' && !g.advanced && g.phase === 2 && !g.charityOrder &&
|
||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard &&
|
||||
(!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);
|
||||
trial.truthtrance = null;
|
||||
trial.ready = trial.ready.filter(id => id !== player);
|
||||
// Evaluate physical availability independently of this answer's own guard.
|
||||
delete trial.charityPromises;
|
||||
const claim = (source: Game): boolean => {
|
||||
try {
|
||||
const result = applyActionInner(source, player, {type:'charity'}, 'shipmentPreparation');
|
||||
return getPlayer(result,player).charityTurn === result.turn;
|
||||
} catch (error) { if (error instanceof RuleError) return false; throw error; }
|
||||
};
|
||||
if (claim(trial)) return {type:'charity'};
|
||||
// A real ordinary payment may make an otherwise wealthy player eligible.
|
||||
// Quote only the minimum known one-payment route and never invent consent.
|
||||
const actor = getPlayer(trial,player), amount = actor.spice - 1;
|
||||
if (!Number.isSafeInteger(amount) || amount <= 0 || amount > uncommittedSpice(trial,actor)) return null;
|
||||
for (const recipient of trial.players) {
|
||||
if (recipient.id === player || recipient.id === actor.ally) continue;
|
||||
const action: Action = {type:'bribe',target:recipient.id,amount};
|
||||
try {
|
||||
const paid = applyActionInner(trial,player,action,'shipmentPreparation');
|
||||
if (claim(paid)) return action;
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
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 (richeseBidOtherPromises(g,p)) return ['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);
|
||||
}
|
||||
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;
|
||||
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 before: CharityPromise[] = promises;
|
||||
promises = charityPromiseRule(() => reconcileCharityPromises(before,player,turn,
|
||||
{phaseOpen:open,canClaim:open && charityPromiseCanClaim(g,player),
|
||||
change:voluntary?.actor === player ? 'voluntary' : 'external'}));
|
||||
if (promises.some((row,index) => row.player === player && row.released && !before[index]?.released))
|
||||
log(g, `${getPlayer(g,player).name} can no longer fulfill the Truthtrance Charity answer; it is no longer binding.`);
|
||||
}
|
||||
}
|
||||
g.charityPromises = promises;
|
||||
}
|
||||
function projectedCharityTruthGuide(g: Game,player: string): {action:Action} | null {
|
||||
if (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;
|
||||
}
|
||||
function richeseBidRule<T>(run: () => T): T {
|
||||
try { return run(); } catch (error) { if (error instanceof RicheseBidPromiseError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
@@ -39366,6 +39451,12 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
Object.keys(guide.action).every(key => action[key] === guide.action[key]),
|
||||
'This phase bid guide is stale or belongs to another auction lot.');
|
||||
}
|
||||
if (action.charityPromiseEvent !== undefined || action.charityPromiseTurn !== undefined) {
|
||||
const guide = projectedCharityTruthGuide(state,id);
|
||||
requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') &&
|
||||
Object.keys(guide.action).every(key => action[key] === guide.action[key]),
|
||||
'This Charity promise guide is stale or belongs to another opportunity.');
|
||||
}
|
||||
if (action.richeseBidPromiseId !== undefined) {
|
||||
const guide = projectedRicheseBidGuide(state,id);
|
||||
requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') &&
|
||||
@@ -39387,6 +39478,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
reconcileBidPromises(g, voluntary);
|
||||
reconcilePhaseBids(g, voluntary);
|
||||
reconcileRicheseBids(g, voluntary);
|
||||
reconcileCharityTruth(g, voluntary);
|
||||
reconcileRevivalPromises(g, voluntary);
|
||||
reconcileBribePromises(g, voluntary);
|
||||
reconcileGroundMovementPromises(g, promiseActor, action, state.phase);
|
||||
@@ -39513,6 +39605,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
reconcileBidPromises(g, { actor: id, action });
|
||||
reconcilePhaseBids(g, { actor: id, action });
|
||||
reconcileRicheseBids(g, { actor: id, action });
|
||||
reconcileCharityTruth(g, { actor: id, action });
|
||||
reconcileRevivalPromises(g, { actor: id, action });
|
||||
reconcileBribePromises(g, { actor: id, action });
|
||||
reconcileAlliancePromises(g, { actor: id, action });
|
||||
@@ -39732,6 +39825,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
reconcileBidPromises(g);
|
||||
reconcilePhaseBids(g);
|
||||
reconcileRicheseBids(g);
|
||||
reconcileCharityTruth(g);
|
||||
reconcileRevivalPromises(g);
|
||||
reconcileBribePromises(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
@@ -39841,6 +39935,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
reconcileBidPromises(g);
|
||||
reconcilePhaseBids(g);
|
||||
reconcileRicheseBids(g);
|
||||
reconcileCharityTruth(g);
|
||||
reconcileRevivalPromises(g);
|
||||
reconcileBribePromises(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
@@ -40123,6 +40218,8 @@ function applyActionInner(
|
||||
bindMixedBid: bindMixedBidTruth,
|
||||
bidAnswers: bidTruthAnswers,
|
||||
bindBid: bindBidTruth,
|
||||
charityAnswers: charityTruthAnswers,
|
||||
bindCharity: bindCharityTruth,
|
||||
richeseBidAnswers: richeseBidTruthAnswers,
|
||||
bindRicheseBid: bindRicheseBidTruth,
|
||||
phaseBidAnswers: phaseBidTruthAnswers,
|
||||
@@ -42544,6 +42641,7 @@ function applyActionInner(
|
||||
p.charityTurn !== g.turn,
|
||||
'Charity may be claimed only once per turn.',
|
||||
);
|
||||
if (g.charityPromises) g.charityPromises = charityPromiseRule(() => guardCharityClaim(g.charityPromises!,id,g.turn));
|
||||
if (g.charityOrder)
|
||||
g.charityOrder = charityOrderRule(() => startCharityOrder(charityOrderContext(g),g.charityOrder!,id));
|
||||
p.charityTurn = g.turn;
|
||||
@@ -43852,6 +43950,7 @@ function applyActionInner(
|
||||
}
|
||||
if (t === 'ready') {
|
||||
if (g.phase === 2) {
|
||||
if (g.charityPromises) charityPromiseRule(() => guardCharityReady(g.charityPromises!,id,g.turn,charityPromiseCanClaim(g,id)));
|
||||
const orderBlock = nativeCharityOrderBlock(g, id);
|
||||
requireRule(!orderBlock, orderBlock ?? 'Wait for your Charity turn.');
|
||||
}
|
||||
@@ -44129,6 +44228,11 @@ 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) : [],
|
||||
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})),
|
||||
truthCharityGuide: projectedCharityTruthGuide(g,id),
|
||||
truthRicheseBidTargets: richeseBidWindow(g) ? g.players.filter(player => {
|
||||
const lot = g.richeseAuction!;
|
||||
return lot.eligible.includes(player.id) && !lot.acted.includes(player.id);
|
||||
@@ -44832,6 +44936,8 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
g.choamCharity?.turn === g.turn && g.choamCharity.canceled,
|
||||
incomeHomeworld: choamPlayer ? homeworldLowBonus(g, choamPlayer.id) : 0,
|
||||
orderBlocked: nativeCharityOrderBlock(g, id),
|
||||
truthBlocked: liveCharityPromises(g.charityPromises ?? [],id,g.turn).some(row => !row.answer)
|
||||
? 'Your Truthtrance No prevents claiming Charity this phase.' : null,
|
||||
},
|
||||
charityOrder: g.charityOrder ? structuredClone(g.charityOrder) : null,
|
||||
moritaniAssassinate: projectedMoritaniAssassinate(g,id),
|
||||
|
||||
+24
-1
@@ -3899,13 +3899,35 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Pure parser/budget, original Basic battle/payment/Prescience and controlled native assistance cases; wider Advanced/faction/card preparation and final deployed acceptance remain open.', evidence: ['tests/mixed-battle-question.test.ts', 'tests/mixed-action-question.test.ts', 'tests/fixture-truth-mixed-battle.ts', 'tests/truth-mixed-battle-runtime.test.ts', 'tests/truth-mixed-battle-funding.test.ts', 'tests/truth-mixed-battle-controls.test.tsx'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-charity',
|
||||
title: 'Truthtrance: Charity commitment',
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Ask whether another player will claim Charity after answering during the 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.',
|
||||
'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.',
|
||||
],
|
||||
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: '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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'card-truthtrance',
|
||||
title: 'Truthtrance',
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
summary:
|
||||
'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, ordinary force-revival, named/grouped native revival, ordinary bribe-payment, current-Nexus alliance, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
|
||||
'Ask another player a public yes/no question. Verified facts, supported Charity, ordinary-bid, ordinary force-revival, named/grouped native revival, ordinary bribe-payment, current-Nexus alliance, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
|
||||
steps: [
|
||||
'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.',
|
||||
'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.',
|
||||
@@ -3947,6 +3969,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'truthtrance-movement-expression',
|
||||
'truthtrance-mixed-movement',
|
||||
'truthtrance-bid',
|
||||
'truthtrance-charity',
|
||||
'truthtrance-revival',
|
||||
'truthtrance-leader-revival',
|
||||
'truthtrance-revival-expression',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { liveCharityPromises } from './charity-promises';
|
||||
import { createSaphoTruthOrder, passSaphoTruthOrder, SaphoTruthOrderError, type SaphoTruthOrderFrame } from './sapho-truth-order';
|
||||
import { parseTravelActionClaim, type TravelClaim } from './travel-expression-promises';
|
||||
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES,
|
||||
@@ -87,6 +88,7 @@ export type TruthFact =
|
||||
| { kind: 'and' | 'or'; terms: TruthFact[] };
|
||||
export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression<MovementClaim> };
|
||||
export type TruthQuestion =
|
||||
| { kind: 'charity'; target: string; turn: number }
|
||||
| ({ kind: 'turnActions'; target: string } & TurnActionClaim)
|
||||
| ({ kind: 'travel'; target: string } & TravelClaim)
|
||||
| ({ kind: 'mixedTraitorCall'; target: string } & MixedTraitorClaim)
|
||||
@@ -259,6 +261,18 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
g.players.some((p) => p.id === v.target),
|
||||
'Ask one other player.',
|
||||
);
|
||||
// A joint planner for Charity and other future-action questions is not yet
|
||||
// available. Do not bind incompatible action obligations in the interim.
|
||||
if (v.kind !== 'charity' && v.kind !== 'fact' && v.kind !== 'freeform' &&
|
||||
g.charityPromises?.length && liveCharityPromises(g.charityPromises,v.target as string,g.turn).length)
|
||||
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.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard,
|
||||
'Choose the current quiet Basic Charity phase and its original turn.');
|
||||
return { kind: 'charity', target: v.target as string, turn: g.turn };
|
||||
}
|
||||
if (v.kind === 'mixedRevival') {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && v.turn === g.turn &&
|
||||
revivalExpressionPromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase <= 4 &&
|
||||
@@ -738,6 +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 === '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)}?`;
|
||||
@@ -878,6 +893,8 @@ export function resolveTruthAction(
|
||||
bindMixedBid?: (g: Game, p: Player, claim: MixedBidClaim, answer: boolean, asker: string) => void;
|
||||
bidAnswers?: (g: Game, p: Player, claim: BidClaim) => TruthAnswer[];
|
||||
bindBid?: (g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) => void;
|
||||
charityAnswers?: (g: Game, p: Player, turn: number) => TruthAnswer[];
|
||||
bindCharity?: (g: Game, p: Player, turn: number, answer: boolean, asker: string) => void;
|
||||
richeseBidAnswers?: (g: Game, p: Player, claim: RicheseBidClaim) => TruthAnswer[];
|
||||
bindRicheseBid?: (g: Game, p: Player, claim: RicheseBidClaim, answer: boolean, asker: string) => void;
|
||||
phaseBidAnswers?: (g: Game, p: Player, claim: PhaseBidClaim) => TruthAnswer[];
|
||||
@@ -1066,6 +1083,9 @@ export function resolveTruthAction(
|
||||
if (w.question.kind === 'bid')
|
||||
check(effects.bidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a bidder with a remaining ordinary opportunity in this lot.');
|
||||
if (w.question.kind === 'charity')
|
||||
check(effects.charityAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question.turn).length,
|
||||
'Choose a Charity question compatible with the current native opportunity.');
|
||||
if (w.question.kind === 'richeseBid')
|
||||
check(effects.richeseBidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a bidder with a remaining Richese auction opportunity.');
|
||||
@@ -1202,6 +1222,11 @@ export function resolveTruthAction(
|
||||
'Choose an answer consistent with this lot, your legal bids and earlier answers.');
|
||||
if (a.answer !== 'unknown') effects.bindBid(g, p, q, a.answer === 'yes', current.player);
|
||||
}
|
||||
if (q.kind === 'charity') {
|
||||
check(effects.charityAnswers && effects.bindCharity && effects.charityAnswers(g, p, q.turn).includes(a.answer),
|
||||
'Choose an answer consistent with this Charity opportunity and earlier answers.');
|
||||
if (a.answer !== 'unknown') effects.bindCharity(g, p, q.turn, a.answer === 'yes', current.player);
|
||||
}
|
||||
if (q.kind === 'richeseBid') {
|
||||
check(effects.richeseBidAnswers && effects.bindRicheseBid && effects.richeseBidAnswers(g, p, q).includes(a.answer),
|
||||
'Choose an answer consistent with this Richese lot and your legal bid.');
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
bindCharityPromise, charityPromiseAnswers, CharityPromiseError,
|
||||
guardCharityClaim, guardCharityReady, liveCharityPromises, reconcileCharityPromises, validateCharityPromises,
|
||||
type CharityPromise,
|
||||
} from '../game/charity-promises';
|
||||
|
||||
const ctx = { player: 'emperor', asker: 'atreides', turn: 3 };
|
||||
const empty: CharityPromise[] = [];
|
||||
const bind = (rows: readonly CharityPromise[], answer: boolean, canClaim = true) =>
|
||||
bindCharityPromise(rows, ctx.player, ctx.asker, ctx.turn, answer, canClaim);
|
||||
|
||||
void test('Charity answer availability follows actual eligibility and incompatible earlier answers', () => {
|
||||
assert.deepEqual(charityPromiseAnswers(empty, ctx.player, ctx.turn, true), ['yes', 'no']);
|
||||
assert.deepEqual(charityPromiseAnswers(empty, ctx.player, ctx.turn, false), ['no']);
|
||||
assert.deepEqual(charityPromiseAnswers(bind(empty, true), ctx.player, ctx.turn, true), ['yes']);
|
||||
assert.deepEqual(charityPromiseAnswers(bind(empty, false), ctx.player, ctx.turn, true), ['no']);
|
||||
assert.throws(() => bind(bind(empty, true), false), CharityPromiseError);
|
||||
assert.throws(() => bind(bind(empty, false), true), CharityPromiseError);
|
||||
assert.throws(() => bind(empty, true, false), CharityPromiseError);
|
||||
assert.throws(() => bindCharityPromise(empty, ctx.player, ctx.player, ctx.turn, false, true), CharityPromiseError);
|
||||
assert.deepEqual(empty, []);
|
||||
});
|
||||
|
||||
void test('a Yes blocks ready until the actual claim and a No blocks the actual claim', () => {
|
||||
const yes = bind(bind(empty, true), true);
|
||||
assert.throws(() => guardCharityReady(yes, ctx.player, ctx.turn, true), CharityPromiseError);
|
||||
assert.doesNotThrow(() => guardCharityReady(yes, ctx.player, ctx.turn, false));
|
||||
const claimed = guardCharityClaim(yes, ctx.player, ctx.turn);
|
||||
assert.equal(claimed.every(row => row.fulfilled && !row.released), true);
|
||||
assert.equal(yes.every(row => !row.fulfilled), true, 'the original input is unchanged');
|
||||
assert.doesNotThrow(() => guardCharityReady(claimed, ctx.player, ctx.turn, true));
|
||||
assert.throws(() => guardCharityClaim(bind(empty, false), ctx.player, ctx.turn), CharityPromiseError);
|
||||
});
|
||||
|
||||
void test('an external loss releases Yes, while a voluntary loss or phase exit rejects unchanged', () => {
|
||||
const yes = bind(empty, true);
|
||||
for (const phaseOpen of [true, false]) {
|
||||
assert.throws(() => reconcileCharityPromises(yes, ctx.player, ctx.turn,
|
||||
{ phaseOpen, canClaim: false, change: 'voluntary' }), CharityPromiseError);
|
||||
assert.equal(yes[0].released, false);
|
||||
const released = reconcileCharityPromises(yes, ctx.player, ctx.turn,
|
||||
{ phaseOpen, canClaim: false, change: 'external' });
|
||||
assert.equal(released[0].released, true);
|
||||
assert.equal(released[0].fulfilled, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('No survives Basic ready while the phase is open and finishes at phase close', () => {
|
||||
const no = bind(empty, false);
|
||||
const ready = reconcileCharityPromises(no, ctx.player, ctx.turn,
|
||||
{ phaseOpen: true, canClaim: true, change: 'voluntary' });
|
||||
assert.deepEqual(liveCharityPromises(ready, ctx.player, ctx.turn), no);
|
||||
assert.throws(() => guardCharityClaim(ready, ctx.player, ctx.turn), CharityPromiseError);
|
||||
const closed = reconcileCharityPromises(ready, ctx.player, ctx.turn,
|
||||
{ phaseOpen: false, canClaim: false, change: 'voluntary' });
|
||||
assert.equal(closed[0].fulfilled, true);
|
||||
assert.equal(closed[0].released, false);
|
||||
assert.deepEqual(liveCharityPromises(closed, ctx.player, ctx.turn), []);
|
||||
});
|
||||
|
||||
void test('promises for other players, turns and completed answers are unaffected', () => {
|
||||
const yes = bind(empty, true);
|
||||
const other = bindCharityPromise(yes, 'guild', 'atreides', 3, true, true);
|
||||
const later = bindCharityPromise(other, 'emperor', 'atreides', 4, false, true);
|
||||
const settled = guardCharityClaim(later, ctx.player, ctx.turn);
|
||||
assert.equal(settled[0].fulfilled, true);
|
||||
assert.deepEqual(settled.slice(1), later.slice(1));
|
||||
assert.deepEqual(charityPromiseAnswers(settled, ctx.player, ctx.turn, true), ['yes', 'no']);
|
||||
assert.throws(() => liveCharityPromises(settled, ctx.player, 0), CharityPromiseError);
|
||||
});
|
||||
|
||||
void test('every answer has a stable unique receipt event through claim, release and closure', () => {
|
||||
const yes = bindCharityPromise(empty, ctx.player, ctx.asker, ctx.turn, true, true, 'event-yes');
|
||||
assert.equal(yes[0].event, 'event-yes');
|
||||
assert.equal(guardCharityClaim(yes, ctx.player, ctx.turn)[0].event, 'event-yes');
|
||||
assert.equal(reconcileCharityPromises(yes, ctx.player, ctx.turn,
|
||||
{ phaseOpen: true, canClaim: false, change: 'external' })[0].event, 'event-yes');
|
||||
const no = bindCharityPromise(yes, 'guild', ctx.asker, ctx.turn, false, true, 'event-no');
|
||||
assert.equal(reconcileCharityPromises(no, 'guild', ctx.turn,
|
||||
{ phaseOpen: false, canClaim: false, change: 'voluntary' })[1].event, 'event-no');
|
||||
assert.throws(() => bindCharityPromise(yes, 'guild', ctx.asker, ctx.turn, true, true, 'event-yes'), CharityPromiseError);
|
||||
assert.throws(() => validateCharityPromises([{ ...yes[0], event: '' }]), CharityPromiseError);
|
||||
assert.throws(() => validateCharityPromises([{ ...yes[0], fulfilled: true, released: true }]), CharityPromiseError);
|
||||
assert.ok(bind(empty, false)[0].event, 'normal binding assigns an event');
|
||||
});
|
||||
@@ -355,6 +355,7 @@ void test('charity projections expose the payer and own entitlement without leak
|
||||
incomeCanceled: false,
|
||||
incomeHomeworld: 0,
|
||||
orderBlocked: null,
|
||||
truthBlocked: null,
|
||||
});
|
||||
assert.equal(v.players[0].spice, undefined);
|
||||
assert.equal(v.players[0].handLimit, 5);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { charityQuote } from '../game/charity';
|
||||
import { type Action, type Game, viewGame } from '../game/engine';
|
||||
import { type TruthQuestion } from '../game/truthtrance';
|
||||
import {
|
||||
actTruthCharityPayments as act,
|
||||
createTruthCharityPaymentsFixture,
|
||||
declareTruthCharityPayments,
|
||||
truthCharityPaymentsInventory as inventory,
|
||||
truthCharityPaymentsPlayer as player,
|
||||
} from './fixture-truth-charity-payments';
|
||||
|
||||
export { act, inventory, player };
|
||||
|
||||
/** Original Basic classic factions with the selected Ix deck and original
|
||||
* Truth cards, genuine Amal and Charity chronology. The only additional
|
||||
* preparation is a real four-spice bribe, leaving the target at one spice;
|
||||
* no wallet, cards, phase or readiness are installed by the fixture. */
|
||||
export function createTruthCharityCommitmentFixture() {
|
||||
const base = createTruthCharityPaymentsFixture({ source: 'phase2', advanced: false });
|
||||
assert.equal(base.game.phase, 2);
|
||||
assert.equal(player(base.game, base.target).spice, 5);
|
||||
const game = act(base.game, base.target, { type: 'bribe', target: base.asker, amount: 4 });
|
||||
assert.equal(player(game, base.target).spice, 1);
|
||||
assert.equal(charityQuote(game, player(game, base.target)).total, 1);
|
||||
assert.deepEqual(inventory(game), base.originalCards);
|
||||
return { ...base, game, unprepared: structuredClone(base.game), source: structuredClone(game),
|
||||
charityQuestion: { kind: 'charity', target: base.target, turn: game.turn } satisfies TruthQuestion };
|
||||
}
|
||||
export type TruthCharityCommitmentFixture = ReturnType<typeof createTruthCharityCommitmentFixture>;
|
||||
|
||||
export function askTruthCharityCommitment(f: TruthCharityCommitmentFixture, source = f.game, copy: 0 | 1 = 0) {
|
||||
const declared = declareTruthCharityPayments(f, source, copy).game;
|
||||
return act(declared, f.asker, { type: 'truthAsk', question: f.charityQuestion });
|
||||
}
|
||||
export function bindTruthCharityCommitment(f: TruthCharityCommitmentFixture, answer: 'yes' | 'no', source = f.game, copy: 0 | 1 = 0) {
|
||||
const asked = askTruthCharityCommitment(f, source, copy);
|
||||
assert.ok(viewGame(asked, f.target).truthCharityAnswers?.includes(answer));
|
||||
return { asked, game: act(asked, f.target, { type: 'truthAnswer', answer }) };
|
||||
}
|
||||
export function rejectTruthCharityCommitment(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejected actions preserve the input');
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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, askTruthCharityCommitment as ask, bindTruthCharityCommitment as bind,
|
||||
createTruthCharityCommitmentFixture as fixture, inventory, player, rejectTruthCharityCommitment as reject } from './fixture-truth-charity-commitment';
|
||||
import { declareTruthCharityPayments } 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 { 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() {} }));
|
||||
|
||||
void test('Basic native Charity question, private answer controls and guided claim appear in the real table', () => {
|
||||
const f = fixture(); const declared = declareTruthCharityPayments(f).game;
|
||||
const asker = viewGame(declared, f.asker);
|
||||
assert.ok(asker.truthCharityTargets.includes(f.target));
|
||||
assert.match(truth(asker), /<option value="charity">[^<]*[Cc]harity/);
|
||||
assert.match(table(asker), /[Cc]harity/);
|
||||
// SSR confirms the selection is present; engine actions supply the question.
|
||||
const asked = ask(f), answerView = viewGame(asked, f.target);
|
||||
assert.equal(answerView.truthtrance?.question?.kind, 'charity');
|
||||
assert.ok(answerView.truthCharityAnswers?.includes('yes'));
|
||||
assert.ok(answerView.truthCharityAnswers?.includes('no'));
|
||||
assert.equal(viewGame(asked, f.asker).truthCharityAnswers, null);
|
||||
assert.match(truth(answerView), /Answer Yes/);
|
||||
assert.match(truth(answerView), /Answer No/);
|
||||
assert.match(table(answerView), /Answer Yes/);
|
||||
const bound = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const own = viewGame(bound, f.target), guide = own.truthCharityGuide;
|
||||
assert.ok(guide); assert.equal(guide.action.type, 'charity');
|
||||
assert.match(table(own), /[Cc]harity/);
|
||||
const claimed = act(bound, f.target, guide.action);
|
||||
assert.equal(player(claimed, f.target).charityTurn, claimed.turn);
|
||||
assert.deepEqual(inventory(claimed), f.originalCards);
|
||||
});
|
||||
|
||||
void test('Basic No answer keeps its own readable Charity restriction and blocks the physical claim', () => {
|
||||
const f = fixture(), bound = bind(f, 'no').game;
|
||||
const own = viewGame(bound, f.target);
|
||||
assert.match(table(own), /[Cc]harity/);
|
||||
assert.throws(() => act(bound, f.target, { type: 'charity' }));
|
||||
assert.deepEqual(inventory(bound), f.originalCards);
|
||||
});
|
||||
|
||||
void test('A live Charity bribe guide sends the native packet and cannot replay in a No continuation', () => {
|
||||
const f = fixture(), asked = ask(f, f.unprepared);
|
||||
const yes = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const own = viewGame(yes, f.target), sent: Action[] = [];
|
||||
assert.equal(own.truthCharityGuide?.action.type, 'bribe');
|
||||
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]);
|
||||
const paid = act(yes, f.target, sent[0]);
|
||||
assert.ok(player(paid, f.target).spice < 2);
|
||||
const no = act(asked, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
reject(no, f.target, sent[0]);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { charityQuote } from '../game/charity';
|
||||
import { declareTruthCharityPayments } from './fixture-truth-charity-payments';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { act, askTruthCharityCommitment as ask, bindTruthCharityCommitment as bind,
|
||||
createTruthCharityCommitmentFixture as fixture, inventory, player, rejectTruthCharityCommitment as reject } from './fixture-truth-charity-commitment';
|
||||
|
||||
void test('Basic eligible Charity Yes reopens ready and requires one actual native claim', () => {
|
||||
const f = fixture();
|
||||
const source = act(f.game, f.target, { type: 'ready' });
|
||||
assert.ok(source.ready.includes(f.target));
|
||||
const { asked, game: bound } = bind(f, 'yes', source);
|
||||
assert.equal(viewGame(asked, f.asker).truthCharityAnswers, null);
|
||||
assert.ok(!bound.ready.includes(f.target));
|
||||
assert.equal(bound.discard.filter(card => card.id === f.truth).length, 1);
|
||||
assert.equal(player(bound, f.target).charityTurn, undefined);
|
||||
reject(bound, f.target, { type: 'ready' });
|
||||
const guide = viewGame(bound, f.target).truthCharityGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'charity');
|
||||
const claimed = act(bound, f.target, guide.action);
|
||||
assert.equal(player(claimed, f.target).charityTurn, claimed.turn);
|
||||
assert.equal(player(claimed, f.target).spice, 2);
|
||||
assert.equal(charityQuote(claimed, player(claimed, f.target)).total, 0);
|
||||
reject(claimed, f.target, { type: 'charity' });
|
||||
const ready = act(claimed, f.target, { type: 'ready' });
|
||||
assert.ok(ready.ready.includes(f.target));
|
||||
assert.deepEqual(inventory(ready), f.originalCards);
|
||||
});
|
||||
|
||||
void test('Basic Charity No prohibits the claim through ready and normal phase close', () => {
|
||||
const f = fixture(); const source = act(f.game, f.target, { type: 'ready' });
|
||||
const { game: bound } = bind(f, 'no', source);
|
||||
assert.ok(!bound.ready.includes(f.target));
|
||||
reject(bound, f.target, { type: 'charity' });
|
||||
let game = act(bound, f.target, { type: 'ready' });
|
||||
assert.ok(game.ready.includes(f.target));
|
||||
reject(game, f.target, { type: 'charity' });
|
||||
// A second actual Truthtrance answer reopens concurrent Basic readiness.
|
||||
// The first No must still forbid claiming through the same Charity phase.
|
||||
game = bind(f, 'no', game, 1).game;
|
||||
assert.ok(!game.ready.includes(f.target));
|
||||
reject(game, f.target, { type: 'charity' });
|
||||
game = act(game, f.target, { type: 'ready' });
|
||||
for (const p of game.players) if (!game.ready.includes(p.id) && game.phase === 2)
|
||||
game = act(game, p.id, { type: 'ready' });
|
||||
assert.notEqual(game.phase, 2);
|
||||
assert.equal(player(game, f.target).charityTurn, undefined);
|
||||
assert.equal(player(game, f.target).spice, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('Basic stale Charity questions and wrong answerers cannot change the native phase', () => {
|
||||
const f = fixture();
|
||||
const declared = declareTruthCharityPayments(f).game;
|
||||
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('Basic all four minimal bot profiles answer and use an allowed Charity continuation', () => {
|
||||
const f = fixture(); const 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, difficulty);
|
||||
assert.equal(answer.type, 'truthAnswer');
|
||||
assert.ok(answerView.truthCharityAnswers?.includes(answer.answer as 'yes' | 'no'));
|
||||
const bound = act(structuredClone(asked), f.target, answer);
|
||||
if (answer.answer === 'yes') {
|
||||
const own = viewGame(bound, f.target); own.players.find(row => row.id === f.target)!.bot = difficulty;
|
||||
assert.ok(own.truthCharityGuide);
|
||||
const choice = botActions(own)[0]; assert.deepEqual(choice, own.truthCharityGuide.action);
|
||||
const after = act(bound, f.target, choice);
|
||||
assert.equal(player(after, f.target).charityTurn, after.turn);
|
||||
assert.deepEqual(inventory(after), f.originalCards);
|
||||
} else {
|
||||
reject(bound, f.target, { type: 'charity' });
|
||||
const own = viewGame(bound, f.target); own.players.find(row => row.id === f.target)!.bot = difficulty;
|
||||
const choice = botActions(own)[0]; assert.ok(choice, difficulty);
|
||||
const after = act(bound, f.target, choice);
|
||||
assert.deepEqual(inventory(after), f.originalCards);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic Charity Yes at five spice guides an actual bribe and claim; its guide cannot replay against No', () => {
|
||||
const f = fixture();
|
||||
// This is the actual pre-bribe native source, with five spendable spice.
|
||||
assert.equal(player(f.unprepared, f.target).spice, 5);
|
||||
const asked = ask(f, f.unprepared);
|
||||
assert.ok(viewGame(asked, f.target).truthCharityAnswers?.includes('yes'));
|
||||
const yes = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
reject(yes, f.target, { type: 'ready' });
|
||||
const bribe = viewGame(yes, f.target).truthCharityGuide; assert.ok(bribe);
|
||||
assert.equal(bribe.action.type, 'bribe');
|
||||
assert.ok(typeof bribe.action.amount === 'number' && bribe.action.amount >= 4);
|
||||
const paid = act(yes, f.target, bribe.action);
|
||||
assert.ok(player(paid, f.target).spice < 2);
|
||||
assert.ok(viewGame(paid, f.target).truthCharityPromises.some(row => row.answer && !row.fulfilled && !row.released));
|
||||
reject(paid, f.target, bribe.action);
|
||||
const claim = viewGame(paid, f.target).truthCharityGuide; assert.ok(claim);
|
||||
assert.equal(claim.action.type, 'charity');
|
||||
const claimed = act(paid, f.target, claim.action);
|
||||
assert.equal(player(claimed, f.target).charityTurn, claimed.turn);
|
||||
assert.ok(viewGame(claimed, f.target).truthCharityPromises.some(row => row.answer && row.fulfilled));
|
||||
assert.deepEqual(inventory(claimed), f.originalCards);
|
||||
|
||||
// The same actual question has a separate valid No continuation; a normal
|
||||
// bribe remains legal here, so rejecting the old Yes packet requires its
|
||||
// original guide identity, not simply a no-bribes rule.
|
||||
const no = act(asked, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
reject(no, f.target, bribe.action);
|
||||
const ordinaryBribe = act(no, f.target, { type: 'bribe', target: f.asker, amount: 1 });
|
||||
assert.equal(player(ordinaryBribe, f.target).spice, 4);
|
||||
assert.deepEqual(inventory(ordinaryBribe), f.originalCards);
|
||||
});
|
||||
|
||||
void test('A controlled external spice gain retains Charity Yes while an actual bribe route remains', () => {
|
||||
const f = fixture(); const bound = bind(f, 'yes').game;
|
||||
assert.equal(player(bound, f.target).spice, 1);
|
||||
// Controlled supplement: transfer three actual spice between two seated
|
||||
// wallets, representing an external gain. The native fixture makes Emperor
|
||||
// the respondent, so it cannot receive its own allied Emperor gift. No card,
|
||||
// phase, readiness or promise is manufactured; the sum stays unchanged.
|
||||
const changed = structuredClone(bound);
|
||||
const donor = changed.players.find(p => p.id !== f.target && p.spice >= 3); assert.ok(donor);
|
||||
const total = changed.players.reduce((sum, p) => sum + p.spice, 0);
|
||||
donor.spice -= 3; player(changed, f.target).spice += 3;
|
||||
assert.equal(changed.players.reduce((sum, p) => sum + p.spice, 0), total);
|
||||
const observer = changed.players.find(p => p.id !== f.target && !changed.ready.includes(p.id)); assert.ok(observer);
|
||||
const reconciled = act(changed, observer.id, { type: 'ready' });
|
||||
assert.equal(player(reconciled, f.target).spice, 4);
|
||||
assert.ok(viewGame(reconciled, f.target).truthCharityPromises.some(row => row.answer && !row.fulfilled && !row.released));
|
||||
reject(reconciled, f.target, { type: 'ready' });
|
||||
const bribe = viewGame(reconciled, f.target).truthCharityGuide; assert.ok(bribe);
|
||||
assert.equal(bribe.action.type, 'bribe');
|
||||
const paid = act(reconciled, f.target, bribe.action);
|
||||
assert.ok(player(paid, f.target).spice < 2);
|
||||
const claim = viewGame(paid, f.target).truthCharityGuide; assert.ok(claim);
|
||||
assert.equal(claim.action.type, 'charity');
|
||||
const completed = act(paid, f.target, claim.action);
|
||||
assert.equal(player(completed, f.target).charityTurn, completed.turn);
|
||||
assert.deepEqual(inventory(completed), f.originalCards);
|
||||
});
|
||||
Reference in new issue
Block a user