Sequence Advanced Charity from the First Player
This commit is contained in:
1 parent
84fdd404c9
commit
54787c7f7b
23 files changed
+1016
-62
No files matched your search
@@ -32,6 +32,12 @@ next player acts; support responses keep their own authorized actors. Basic
|
||||
and already-opened older Revival phases retain their current readiness timing.
|
||||
See [Revival source and preview scope](docs/ADVANCED_REVIVAL_ORDER.md).
|
||||
|
||||
New Advanced Charity phases also follow First Player order counterclockwise.
|
||||
Wait for your turn, claim if eligible, then choose **Finish Charity turn**.
|
||||
CHOAM opening income and any cancellation response finish before the next seat
|
||||
acts. Basic and already-opened older phases retain concurrent readiness.
|
||||
See [Charity order and remaining scope](docs/ADVANCED_CHARITY_ORDER.md).
|
||||
|
||||
Choose expansion families when creating the room. Before starting, the host can
|
||||
select **Leader Skills**, **Ecaz Treachery Cards** (the independent three-card
|
||||
variant), **Nexus Cards**, **Discoveries**, **Homeworlds**, **Tech Tokens** or
|
||||
|
||||
@@ -4,21 +4,37 @@ import { ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
|
||||
export function CharityClaim({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
type Props = {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
};
|
||||
|
||||
export function CharityClaim(props: Props) {
|
||||
const { game, act, busy } = props;
|
||||
const me = game.players.find((player) => player.id === game.me);
|
||||
if (game.status !== 'playing' || game.phase !== 2 || !me) return null;
|
||||
const disabled = busy || !!game.truthtrance || !!game.nexusTraitors?.pending ||
|
||||
!!me.charityClaimed || game.charity.incomePending || game.charity.amount === 0;
|
||||
const actor = game.me, turn = game.turn, order = game.charityOrder;
|
||||
const currentActor = order?.remaining[0];
|
||||
const currentName = game.players.find(player => player.id === currentActor)?.name ?? 'the current player';
|
||||
const ownTurn = !order || order.version === 1 && order.turn === turn && order.remaining[0] === actor;
|
||||
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 canFinish = !!order && !blocked;
|
||||
return (
|
||||
<>
|
||||
{order && <div className="fine" data-testid="charity-turn-status">
|
||||
<p>Advanced Charity proceeds from the First Player counterclockwise, one player at a time.</p>
|
||||
<p>{order.version !== 1 || order.turn !== turn
|
||||
? 'This Charity turn order is no longer current.'
|
||||
: !currentActor ? 'All Charity turns are complete.'
|
||||
: !order.remaining.includes(actor) ? `Your Charity turn is complete. Waiting for ${currentName} to finish Charity.`
|
||||
: currentActor === actor ? 'It is your Charity turn. Claim if eligible, then finish your turn to continue.'
|
||||
: `Waiting for ${currentName} to finish Charity. Your turn comes later in the public order.`}</p>
|
||||
{game.charity.orderBlocked && <p>{game.charity.orderBlocked}</p>}
|
||||
</div>}
|
||||
<p className="muted">
|
||||
{game.charity.multiplier === 0
|
||||
? 'Inflation cancels charity for every faction this turn.'
|
||||
@@ -38,9 +54,9 @@ export function CharityClaim({
|
||||
<Button
|
||||
data-testid="charity-claim-submit"
|
||||
className="game-action"
|
||||
disabled={disabled}
|
||||
disabled={!canClaim}
|
||||
onClick={() => {
|
||||
if (!disabled) act({ type: 'charity' });
|
||||
if (canClaim) act({ type: 'charity' });
|
||||
}}
|
||||
>
|
||||
{game.charity.amount > 0
|
||||
@@ -48,6 +64,14 @@ export function CharityClaim({
|
||||
: 'Claim charity'}
|
||||
<ArrowRight size={15} />
|
||||
</Button>
|
||||
{order && <Button
|
||||
data-testid="charity-turn-finish"
|
||||
className="game-action"
|
||||
disabled={!canFinish}
|
||||
onClick={() => { if (canFinish) act({ type: 'ready' }); }}
|
||||
>
|
||||
Finish Charity turn
|
||||
</Button>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5824,7 +5824,7 @@ export function GameTable({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{[1, 2, 4, 7, 8].includes(g.phase) && !(g.phase === 4 && g.revivalOrder) &&
|
||||
{[1, 2, 4, 7, 8].includes(g.phase) && !(g.phase === 4 && g.revivalOrder) && !(g.phase === 2 && g.charityOrder) &&
|
||||
actionButton(
|
||||
g.ready.includes(me.id)
|
||||
? 'Waiting for the table'
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Advanced Charity player order
|
||||
|
||||
27 October 2026. **Prototyped / Partial.** This is a core source correction;
|
||||
complete Advanced, optional combinations and final acceptance remain open.
|
||||
|
||||
## Source and cutover
|
||||
|
||||
The user-authorized `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf`,
|
||||
physical8/printed6, starts Charity with the First Player and proceeds
|
||||
counterclockwise. This is the supplied Advanced authority bound by the
|
||||
[source amendment](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026),
|
||||
not a newly located publisher ruling. The bounded source read is:
|
||||
|
||||
```sh
|
||||
pdftotext -f 8 -l 8 -layout UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf -
|
||||
```
|
||||
|
||||
New Advanced Charity phases retain the actual First Player order established
|
||||
by Storm. Basic and already-opened unstamped Advanced phases keep concurrent
|
||||
readiness; viewing or claiming does not manufacture a new order midway.
|
||||
|
||||
## Connected behavior
|
||||
|
||||
- A version1 public `charityOrder` retains the original turn, full order and
|
||||
remaining suffix. Only its current seat may make an ordinary Charity claim
|
||||
or use **Finish Charity turn**. Claiming is optional; finishing without a
|
||||
claim passes that opportunity. Wealth and entitlement remain private.
|
||||
- CHOAM opening income resolves before any claim or finish. A wealthy Advanced
|
||||
Bene Gesserit claim retains its native Karama response. Neither the claim nor
|
||||
a response advances the player order; required interactions finish first.
|
||||
- Finish consumes one opportunity and records its completed prefix. Closing
|
||||
market/Nexus decisions retain the frame until the phase actually ends.
|
||||
No second claim, replayed Finish or new Truthtrance answer reopens a completed
|
||||
ordered turn. Existing Basic/unstamped readiness reopening remains.
|
||||
- Detached Truthtrance witnesses may quote one remaining owned future Charity
|
||||
preparation. They neither claim early at the real table nor replay a closed
|
||||
opportunity. Guidance and minimal AI wait for the actual current seat.
|
||||
- Inflation, Homeworld additions, Production and the existing provisional
|
||||
[CHOAM shortfall funding](CHOAM_CHARITY_FUNDING.md) retain their calculations.
|
||||
Other cards and faction responses keep their own timing and authorized actor.
|
||||
|
||||
`game/charity-order.ts` owns the immutable order calculation. The engine owns
|
||||
phase lifecycle, claims and projection; `components/charity-claim.tsx` supplies
|
||||
persistent current/wait/completed guidance and the existing `charity`/`ready`
|
||||
packets. No database migration or saved-game conversion is added.
|
||||
|
||||
Juice of Sapho first/last composition for this newly ordered phase remains
|
||||
missing and is recorded for the later interaction pass. This version does not
|
||||
pretend an unavailable card interaction is complete.
|
||||
|
||||
## Focused evidence
|
||||
|
||||
Frozen **15 files, 147/147**, types/lint/build and four native start programmes
|
||||
pass. Three explicit Advanced previews exercise allowed income, original Karama
|
||||
cancellation of income or wealthy BG; Basic uses two concurrent low claimants.
|
||||
Actual pre-Charity bribes create poverty and defer incoming money normally.
|
||||
Runtime records 11 GameTable SSR, 26 claim-panel renders, 8 actual claim
|
||||
callbacks and 9 actual Finish callbacks, with source/further action arrays
|
||||
101/30 across programmes. Native response/cancellation/closing actions are
|
||||
applied directly; browser callbacks for those separate controls were not operated.
|
||||
|
||||
Nineteen new pure/native/control/Truth cases cover order, optional claims,
|
||||
responses, Basic/labelled unstamped compatibility, legal policies and future or
|
||||
completed Charity entitlement. A genuine final-owner case caught commitment
|
||||
evasion when Finish immediately opened a later phase; preflight now checks the
|
||||
closed opportunity before its voluntary actor can disappear behind that opening.
|
||||
Older payment, Production and Inflation sources finish preceding native turns.
|
||||
The cancelled-income smoke correctly accepts automatic wealthy BG settlement
|
||||
once the sole original Karama is spent. No game money, phase or queue is installed.
|
||||
|
||||
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-advanced-charity-order-proof-v4/`;
|
||||
HTML/journeys: adjacent `20261027-advanced-charity-order-runtime/run-v2/`.
|
||||
Tests/types/lint carry from v3 under exactly the same source snapshot; only the
|
||||
private smoke harness changed. The existing server returned HTTP200 unchanged.
|
||||
This is local native/SSR/callback evidence. Sapho, full modes, wider interactions,
|
||||
final rules/recovery/privacy and deployed acceptance remain due.
|
||||
@@ -1,5 +1,10 @@
|
||||
# CHOAM Charity funding
|
||||
|
||||
The [27 October Advanced player-order cutover](ADVANCED_CHARITY_ORDER.md)
|
||||
now sequences fresh Advanced claims and Finish. Basic/old unstamped phases
|
||||
retain concurrent timing. The funding calculations and provisional shortfall
|
||||
policy below are unchanged; earlier evidence does not certify the new order.
|
||||
|
||||
## Source and selected prototype policy — 24 October 2026
|
||||
|
||||
Publisher E2 physical p7 says CHOAM collects two spice per faction before
|
||||
|
||||
+38
-8
@@ -20,6 +20,32 @@ open mode gates.
|
||||
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
||||
Prototype labels and final acceptance gates remain.
|
||||
|
||||
**Advanced Charity player order — 27 October. Prototyped / Partial.**
|
||||
New Advanced phases proceed from the First Player counterclockwise. Only the
|
||||
current seat may claim or Finish; CHOAM income and wealthy BG responses retain
|
||||
their original timing. Basic/old unstamped phases stay concurrent. A completed
|
||||
turn cannot reopen through Truthtrance; remaining future entitlement remains
|
||||
quotable, with executable guidance waiting for the actual owner. A native case
|
||||
found and repaired last-Finish evasion before a phase-opening interruption.
|
||||
|
||||
Frozen **15 affected files, 147/147**, types/lint/build and 4 fresh native
|
||||
start programmes pass: three explicit Advanced previews (allowed income,
|
||||
canceled income, canceled wealthy BG) and a Basic concurrent low-claim control.
|
||||
Runtime records 11 GameTable SSR, 26 claim-panel renders, 8 actual claim
|
||||
callbacks and 9 Finish callbacks. Recorded source/further arrays total
|
||||
101/30 across programmes. Original unopened deal/Spice positions and scalar
|
||||
lotteries are selected; actual bribes create poverty. Responses/card cancellation
|
||||
and closing packets use native actions directly, without a clicked-control claim.
|
||||
Nineteen new helper/native/control/Truth cases pass; old payment and Inflation
|
||||
sources now finish actual preceding turns. Sapho composition remains missing.
|
||||
|
||||
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-advanced-charity-order-proof-v4/`;
|
||||
journeys/HTML: adjacent `20261027-advanced-charity-order-runtime/run-v2/`.
|
||||
Passed tests/types/lint carry from v3 under an identical source hash; v4 repairs
|
||||
only a private smoke assumption about automatic uncontested BG resolution.
|
||||
The existing server returned HTTP200 without restart. Browser/deployed,
|
||||
complete modes and final assurance remain. [Contract](ADVANCED_CHARITY_ORDER.md).
|
||||
|
||||
**Ixian Advanced starting draft — 27 October. Prototyped / Partial.**
|
||||
Fresh Advanced setup with Harkonnen now includes its extra starting card in the
|
||||
original Ixian pool; both Harkonnen cards come from the shuffled remainder.
|
||||
@@ -46,14 +72,18 @@ Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-ix-starting-pool-p
|
||||
journeys/HTML: adjacent `20261027-ix-starting-pool-runtime/run/`.
|
||||
[Source contract](IX_PROTOTYPE.md#advanced-starting-draft-source-cutover--27-october-2026).
|
||||
|
||||
**Next source-identified functions:** Advanced Charity's First Player claim
|
||||
order (supplied physical8), CHOAM's total-support rounding (physical29; final
|
||||
eligibility composition still needs its source check), and Family Atomics'
|
||||
full timing/sector/removal contract (physical39; the revision's stricter
|
||||
requirements remain Advanced-only after the bounded Basic source comparison).
|
||||
These are concrete remaining code/source divergences, not a complete rules
|
||||
audit. The bounded six-classic-faction pass found connected independent
|
||||
functions; optional modules, interactions and final acceptance remain open.
|
||||
**Next source-identified functions:** CHOAM's total-support rounding
|
||||
(supplied physical29 F) and Family Atomics' full timing/sector/removal contract
|
||||
(physical39; the revision's stricter requirements remain Advanced-only after
|
||||
bounded Basic comparison). The bounded CHOAM source comparison supports a
|
||||
rounding-only correction: retain eligible `support - allyPayment`, including
|
||||
Arrakeen's expressly sourced Bank contribution; preserve CHOAM-own/CHOAM-aid
|
||||
exclusions and free-force support. Aggregate before flooring; unrelated
|
||||
Banker/Bureaucrat per-invoice rulings stay distinct. This is next implementation,
|
||||
not a passed new effect. Charity Sapho composition waits for the interaction pass.
|
||||
These findings are concrete divergences, not a complete rules audit. The bounded
|
||||
six-classic-faction pass found connected independent functions; optional modules,
|
||||
interactions and final acceptance remain open.
|
||||
|
||||
**Native E3 trained capture — 27 October. Prototyped / Partial.**
|
||||
Fresh bare Advanced E3/all14 setups now connect Harkonnen with actual Ecaz
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 27 October 2026 — Ordered Advanced Charity
|
||||
|
||||
Prototyped / Partial. New Advanced phases proceed from the First Player counterclockwise. Only the
|
||||
current seat may claim or Finish; CHOAM income and wealthy BG responses retain
|
||||
their original timing. Basic/old unstamped phases stay concurrent. A completed
|
||||
turn cannot reopen through Truthtrance; remaining future entitlement remains
|
||||
quotable, with executable guidance waiting for the actual owner. A native case
|
||||
found and repaired last-Finish evasion before a phase-opening interruption.
|
||||
|
||||
Frozen **15 affected files, 147/147**, types/lint/build and 4 fresh native
|
||||
start programmes pass: three explicit Advanced previews (allowed income,
|
||||
canceled income, canceled wealthy BG) and a Basic concurrent low-claim control.
|
||||
Runtime records 11 GameTable SSR, 26 claim-panel renders, 8 actual claim
|
||||
callbacks and 9 Finish callbacks. Recorded source/further arrays total
|
||||
101/30 across programmes. Original unopened deal/Spice positions and scalar
|
||||
lotteries are selected; actual bribes create poverty. Responses/card cancellation
|
||||
and closing packets use native actions directly, without a clicked-control claim.
|
||||
Nineteen new helper/native/control/Truth cases pass; old payment and Inflation
|
||||
sources now finish actual preceding turns. Sapho composition remains missing.
|
||||
|
||||
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-advanced-charity-order-proof-v4/`;
|
||||
journeys/HTML: adjacent `20261027-advanced-charity-order-runtime/run-v2/`.
|
||||
Passed tests/types/lint carry from v3 under an identical source hash; v4 repairs
|
||||
only a private smoke assumption about automatic uncontested BG resolution.
|
||||
The existing server returned HTTP200 without restart. Browser/deployed,
|
||||
complete modes and final assurance remain. [Contract](ADVANCED_CHARITY_ORDER.md).
|
||||
|
||||
## 27 October 2026 — Source-aligned Advanced Ixian starting draft
|
||||
|
||||
Prototyped / Partial. Fresh Advanced setup with Harkonnen now includes its extra starting card in the
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Rule decision index
|
||||
|
||||
## Advanced Charity player order — 27 October 2026
|
||||
|
||||
Supplied physical8/printed6 expressly begins Charity with the First Player and
|
||||
proceeds counterclockwise. New Advanced phases stamp that native order; only
|
||||
the current seat claims or finishes. CHOAM opening income and wealthy Bene
|
||||
Gesserit responses precede completion. Basic/old unstamped phases retain their
|
||||
procedure. Completed ordered turns cannot reopen through Truthtrance, while
|
||||
remaining owned future entitlements can still supply detached witnesses.
|
||||
The claim-then-Finish control is explicit software composition of the optional
|
||||
claim and printed order. Sapho composition and broader interactions remain.
|
||||
[Source, lifecycle and boundaries](ADVANCED_CHARITY_ORDER.md).
|
||||
|
||||
## Ixian starting draft with Harkonnen — 27 October 2026
|
||||
|
||||
Supplied Advanced physical34/printed32 A explicitly draws N+1 when Harkonnen
|
||||
|
||||
@@ -262,8 +262,12 @@ agreement must fit before the future phase and retain recipient restrictions.
|
||||
No chosen branch, timeline or prospective income is installed in a real game.
|
||||
|
||||
Bare, grouped, mixed and combined payment questions use guarded current-phase
|
||||
payment/Charity packets. Positive acceptance reopens owned Charity readiness;
|
||||
own Ready cannot forfeit the necessary claim. After an actual claim or phase
|
||||
payment/Charity packets. Positive acceptance reopens Basic/unstamped Charity
|
||||
readiness. The [new ordered Advanced phase](ADVANCED_CHARITY_ORDER.md) instead
|
||||
retains a remaining owned entitlement while guides wait for its real turn;
|
||||
completed turns cannot reopen. Own Finish checks commitments before advancing,
|
||||
including the last player whose Finish immediately opens another phase.
|
||||
Own Ready cannot forfeit the necessary claim. After an actual claim or phase
|
||||
expiry it cannot be reserved again. Cumulative No caps and original history
|
||||
persist. Four profiles consume legal guides; strategy and calibration remain
|
||||
deferred. Native phase controls handle waiting before the actual Charity phase.
|
||||
|
||||
@@ -3213,6 +3213,7 @@ function policyActions(g: GameView): Action[] {
|
||||
.every(p => fighterCount(p, t) < countAt(me, t)));
|
||||
if (territory) return [{ type: 'prepareAdvisors', territory }];
|
||||
}
|
||||
if (g.phase === 2 && g.charity.orderBlocked) return [];
|
||||
if (
|
||||
g.phase === 2 &&
|
||||
!me.charityClaimed &&
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/** Native Charity follows the actual First Player order, one opportunity per seat. */
|
||||
export type CharityOrder = {
|
||||
version: 1;
|
||||
turn: number;
|
||||
order: string[];
|
||||
remaining: string[];
|
||||
};
|
||||
export type CharityOrderContext = {
|
||||
turn: number;
|
||||
order: readonly string[];
|
||||
players: readonly { id: string }[];
|
||||
ready: readonly string[];
|
||||
};
|
||||
export class CharityOrderError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'CharityOrderError';
|
||||
}
|
||||
}
|
||||
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value) &&
|
||||
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
||||
const id = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
|
||||
const ids = (value: unknown): value is readonly string[] => Array.isArray(value) &&
|
||||
[...value].every(id) && new Set(value).size === value.length;
|
||||
const same = (left: readonly string[], right: readonly string[]) =>
|
||||
left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
function requireOrder(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new CharityOrderError(message);
|
||||
}
|
||||
function validateContext(context: CharityOrderContext): void {
|
||||
requireOrder(record(context) && Number.isSafeInteger(context.turn) && context.turn > 0 &&
|
||||
Array.isArray(context.players) && context.players.length >= 2 && context.players.length <= 6 &&
|
||||
[...context.players].every(player => record(player) && id(player.id)) && ids(context.order),
|
||||
'Charity needs a current turn and two through six actual seats in public order.');
|
||||
const seats = context.players.map(player => player.id);
|
||||
requireOrder(new Set(seats).size === seats.length && context.order.length === seats.length &&
|
||||
context.order.every(player => seats.includes(player)),
|
||||
'Charity order must contain every actual seat exactly once.');
|
||||
requireOrder(ids(context.ready) && context.ready.length <= context.order.length &&
|
||||
same(context.ready, context.order.slice(0, context.ready.length)),
|
||||
'Completed Charity opportunities must be the exact public-order prefix.');
|
||||
}
|
||||
|
||||
export function createCharityOrder(context: CharityOrderContext): CharityOrder {
|
||||
validateContext(context);
|
||||
return { version: 1, turn: context.turn, order: [...context.order],
|
||||
remaining: context.order.slice(context.ready.length) };
|
||||
}
|
||||
|
||||
export function validateCharityOrder(context: CharityOrderContext, frame: CharityOrder): void {
|
||||
validateContext(context);
|
||||
requireOrder(record(frame) && Object.keys(frame).sort().join(',') === 'order,remaining,turn,version' &&
|
||||
frame.version === 1 && frame.turn === context.turn && ids(frame.order) && ids(frame.remaining),
|
||||
'The current Charity order is malformed or belongs to another turn.');
|
||||
requireOrder(same(frame.order, context.order) &&
|
||||
same(frame.remaining, context.order.slice(context.ready.length)),
|
||||
'Charity must retain its original public order and exact remaining suffix.');
|
||||
}
|
||||
|
||||
export function charityOrderBlock(context: CharityOrderContext, frame: CharityOrder, player: string): string | null {
|
||||
validateCharityOrder(context, frame);
|
||||
requireOrder(id(player), 'Choose a valid Charity participant.');
|
||||
if (!frame.order.includes(player)) return 'Only seated factions have a Charity opportunity.';
|
||||
if (!frame.remaining.length) return 'Charity opportunities are complete.';
|
||||
if (context.ready.includes(player)) return 'Your Charity opportunity has finished.';
|
||||
return frame.remaining[0] === player ? null : 'Wait for your Charity opportunity.';
|
||||
}
|
||||
|
||||
/** Consume the pre-finish ready prefix. The caller appends this actor to ready
|
||||
* with the native claim/pass in the same commit; no wallet or card changes occur here.
|
||||
*/
|
||||
export function finishCharityOrder(context: CharityOrderContext, frame: CharityOrder, player: string): CharityOrder {
|
||||
const blocked = charityOrderBlock(context, frame, player);
|
||||
requireOrder(blocked === null, blocked ?? 'This Charity opportunity is unavailable.');
|
||||
return { version: 1, turn: frame.turn, order: [...frame.order], remaining: frame.remaining.slice(1) };
|
||||
}
|
||||
+97
-16
@@ -44,6 +44,8 @@ import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income';
|
||||
import { createRevivalOrder, validateRevivalOrder, revivalOrderBlock, startRevivalOrder,
|
||||
saphoRevivalOrderEvent, reorderSaphoRevivalOrder,
|
||||
finishRevivalOrder, RevivalOrderError, type RevivalOrder } from './revival-order';
|
||||
import { createCharityOrder, validateCharityOrder, charityOrderBlock,
|
||||
finishCharityOrder, CharityOrderError, type CharityOrder } from './charity-order';
|
||||
import { semutaTerminalArrivalSupported } from './semuta-terminal-arrival';
|
||||
import { semutaDisasterArrivalSupported } from './semuta-disaster-arrival';
|
||||
import { semutaAdvisorArrivalSupported } from './semuta-advisor-arrival';
|
||||
@@ -1372,6 +1374,7 @@ type RicheseAllyOffer = {
|
||||
};
|
||||
export type Game = {
|
||||
revivalOrder?: RevivalOrder;
|
||||
charityOrder?: CharityOrder;
|
||||
pendingAutomaticAdvisorFlips?: AutomaticAdvisorFlipSource[];
|
||||
pendingAutomaticAdvisorTransport?: AutomaticAdvisorTransport;
|
||||
pendingAutomaticAdvisorGamont?: { receipt: AutomaticAdvisorGamont; card: string; nexusEvent?: string };
|
||||
@@ -15422,6 +15425,9 @@ function bribeQuestionWindow(g: Game): boolean {
|
||||
* completing foreign choices at the real table. */
|
||||
function charityForecastContext(source: Game): Game {
|
||||
const g = structuredClone(source); g.phase = 2; g.ready = [];
|
||||
// A detached witness quotes a remaining owned opportunity, never an early
|
||||
// claim at the real table or a replay of a completed ordered turn.
|
||||
delete g.charityOrder;
|
||||
g.truthtrance = null; g.response = null; g.decision = null; g.phaseOpening = null;
|
||||
g.pendingKarama = null; g.pendingTreacheryDiscard = null;
|
||||
g.spiceWindow = null; g.spiceResolution = null; g.spiceSequence = null; g.nexus = false;
|
||||
@@ -15445,7 +15451,8 @@ function forecastChoamOpeningIncome(source: Game, owner: string): Game | null {
|
||||
function forecastOrdinaryCharity(source: Game, owner: string): Game | null {
|
||||
const before = getPlayer(source, owner);
|
||||
if (source.phase > 2 || before.charityTurn === source.turn ||
|
||||
source.phase === 2 && source.ready.includes(owner)) return null;
|
||||
source.phase === 2 && (source.ready.includes(owner) ||
|
||||
source.charityOrder && !source.charityOrder.remaining.includes(owner))) return null;
|
||||
const g = charityForecastContext(source);
|
||||
const player = getPlayer(g, owner);
|
||||
if (charityQuote(g, player).total <= 0) return null;
|
||||
@@ -15533,7 +15540,7 @@ function bribeCompatibleLaterPromises(g: Game, p: Player,
|
||||
function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no' | 'unknown')[] {
|
||||
if (!bribeQuestionWindow(g) || claim.turn !== g.turn || claim.recipient === p.id ||
|
||||
claim.recipient === p.ally || !g.players.some(player => player.id === claim.recipient)) return [];
|
||||
const source = g.phase === 2 ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
const source = g.phase === 2 && !g.charityOrder ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
return (['yes', 'no'] as const).filter(answer => !!bribePromiseCompletion(source, p, [...(g.bribePromises ?? []),
|
||||
{ ...claim, event: 'candidate', player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host,
|
||||
answer: answer === 'yes', progress: 0 }]));
|
||||
@@ -15541,13 +15548,13 @@ function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no'
|
||||
function bribeExpressionTruthAnswers(g: Game, p: Player, claim: BribeExpressionClaim): ('yes' | 'no' | 'unknown')[] {
|
||||
if (!bribeQuestionWindow(g) || claim.turn !== g.turn || bribeExpressionRecipients(claim.claim)
|
||||
.some(recipient => recipient === p.id || recipient === p.ally || !g.players.some(player => player.id === recipient))) return [];
|
||||
const source = g.phase === 2 ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
const source = g.phase === 2 && !g.charityOrder ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
return (['yes', 'no'] as const).filter(answer => !!bribePromiseCompletion(source, p, g.bribePromises ?? [],
|
||||
[...(g.bribeExpressionPromises ?? []), { ...claim, event: 'candidate', player: p.id,
|
||||
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: [] }]));
|
||||
}
|
||||
function reopenBribePayer(g: Game, p: Player, answer: boolean) {
|
||||
if (answer && (g.phase === 2 || g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g)) && g.ready.includes(p.id)) {
|
||||
if (answer && (g.phase === 2 && !g.charityOrder || g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g)) && g.ready.includes(p.id)) {
|
||||
g.ready = g.ready.filter(player => player !== p.id);
|
||||
log(g, `${p.name} has a new Truthtrance bribe commitment and must finish ${g.phase === 2 ? 'Charity' : g.phase === 8 ? 'Mentat Pause' : 'Collection'} again after its required preparation.`);
|
||||
}
|
||||
@@ -15658,6 +15665,7 @@ function reconcileBribePromises(g: Game, voluntary?: { actor: string; action: Ac
|
||||
}
|
||||
function ordinaryPaymentActionAvailable(g: Game, player: Player, action: Action): boolean {
|
||||
if (action.type === 'charity') return g.phase === 2 && !g.ready.includes(player.id) &&
|
||||
!nativeCharityOrderBlock(g, player.id) &&
|
||||
player.charityTurn !== g.turn && charityQuote(g, player).total > 0 &&
|
||||
(!byFaction(g, 'choam') || g.choamCharity?.turn === g.turn) &&
|
||||
!g.truthtrance && !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard;
|
||||
@@ -19493,6 +19501,7 @@ function guardTurnActionPrintedCardCost(g: Game, player: Player, used: Card) {
|
||||
* normal hidden draw. It grants no spice, cards or foreign decisions. */
|
||||
function projectAfterKnownThumper(g: Game) {
|
||||
g.phase = 2; g.active = null; g.ready = [];
|
||||
delete g.charityOrder;
|
||||
g.spiceSequence = null; g.spiceResolution = null; g.spiceWindow = null;
|
||||
g.nexus = false;
|
||||
}
|
||||
@@ -20200,7 +20209,7 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
}
|
||||
function turnActionAnswerSource(g: Game, player: string, alliance = false, window = false,
|
||||
fresh?: { claim: TurnActionClaim; answer: boolean }): Game {
|
||||
const source = g.phase === 0 && window || g.phase === 1 && (alliance || window) || g.phase === 2 || g.phase === 4 && !g.revivalOrder || g.phase === 8 ||
|
||||
const source = g.phase === 0 && window || g.phase === 1 && (alliance || window) || g.phase === 2 && !g.charityOrder || g.phase === 4 && !g.revivalOrder || g.phase === 8 ||
|
||||
g.phase === 7 && bribesCloseBeforeNextMentat(g)
|
||||
? { ...g, ready: g.ready.filter(id => id !== player) } : g;
|
||||
const opened = source.phaseOpening ? { ...source, phaseOpening: { ...source.phaseOpening,
|
||||
@@ -20392,7 +20401,8 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
|
||||
...liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], player.id, g.turn),
|
||||
].some(row => row.answer);
|
||||
if (!action && completion.revival && !ownedBox &&
|
||||
!(g.phase === 4 && nativeRevivalOrderBlock(g, player.id)))
|
||||
!(g.phase === 4 && nativeRevivalOrderBlock(g, player.id)) &&
|
||||
!(g.phase === 2 && nativeCharityOrderBlock(g, player.id)))
|
||||
action = g.phase === 4 ? completion.revival.action
|
||||
: g.phase === 2 || g.phase === 3 && ownedInvoice
|
||||
? completion.revival.early.find(step => step.phase === g.phase)?.action : undefined;
|
||||
@@ -20404,7 +20414,8 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
|
||||
if (ownedInvoice) action = current?.type === 'decision' ? current : { type: 'decision', karama: false };
|
||||
else if (g.auction?.active === player.id) action = { type: 'passBid' };
|
||||
} else if ([2, 4].includes(g.phase) && !g.ready.includes(player.id) &&
|
||||
!(g.phase === 4 && nativeRevivalOrderBlock(g, player.id))) action = current ?? { type: 'ready' };
|
||||
!(g.phase === 4 && nativeRevivalOrderBlock(g, player.id)) &&
|
||||
!(g.phase === 2 && nativeCharityOrderBlock(g, player.id))) action = current ?? { type: 'ready' };
|
||||
} else if (!action && g.phase === 5 && g.active === player.id && !promise.transportClosed)
|
||||
action = completion.actions[0] ?? { type: 'endMovement' };
|
||||
}
|
||||
@@ -20526,7 +20537,7 @@ function travelTruthAnswers(g: Game, p: Player, claim: TravelClaim): TruthAnswer
|
||||
asker: g.players.find(player => player.id !== p.id)!.id, answer, compiled,
|
||||
shipment: { pending: g.phase < 5 || shipmentAvailable(g, p), event: null }, progress: [], movementClosed: false,
|
||||
fulfilled: compiled.kind === 'constant', released: false };
|
||||
const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder)
|
||||
const answerSource = (g.phase === 2 && !g.charityOrder) || (g.phase === 4 && !g.revivalOrder)
|
||||
? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
// Candidate witnesses must include the new row, which has no accepted-order
|
||||
// slot until the real answer binds. The authoritative order stays untouched.
|
||||
@@ -20877,6 +20888,7 @@ function earlyGroundMovementPolicy(g: Game, player: Player): Action[] | null {
|
||||
if (g.decision?.kind === 'auctionPayment')
|
||||
return [current?.type === 'decision' ? current : { type: 'decision', karama: false }];
|
||||
if (g.phase === 4 && nativeRevivalOrderBlock(g,player.id)) return [];
|
||||
if (g.phase === 2 && nativeCharityOrderBlock(g,player.id)) return [];
|
||||
if (g.phase === 2 || g.phase === 4) return current ? [current] : g.ready.includes(player.id) ? [] : [{ type: 'ready' }];
|
||||
return null;
|
||||
}
|
||||
@@ -20885,7 +20897,7 @@ function movementTruthAnswers(g: Game, p: Player, claim: MovementClaim): TruthAn
|
||||
const answers: TruthAnswer[] = [];
|
||||
for (const answer of [true, false]) {
|
||||
const promise = { ...claim, turn: g.turn, player: p.id, asker: g.players.find(other => other.id !== p.id)!.id, answer };
|
||||
const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
const answerSource = (g.phase === 2 && !g.charityOrder) || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
if (groundMovementCompletion(answerSource, p.id, [...live, promise]) &&
|
||||
revivalCompatibleLaterPromises(answerSource, p, { movementPromises: [...(g.movementPromises ?? []), promise] }) &&
|
||||
bribeCompatibleLaterPromises(answerSource, p, { movementPromises: [...(g.movementPromises ?? []), promise] }))
|
||||
@@ -20894,7 +20906,7 @@ function movementTruthAnswers(g: Game, p: Player, claim: MovementClaim): TruthAn
|
||||
return answers.length ? answers : ['unknown'];
|
||||
}
|
||||
function reopenMovementPreparation(g: Game, p: Player) {
|
||||
if ((g.phase === 2 || (g.phase === 4 && !g.revivalOrder)) && g.ready.includes(p.id)) {
|
||||
if (((g.phase === 2 && !g.charityOrder) || (g.phase === 4 && !g.revivalOrder)) && g.ready.includes(p.id)) {
|
||||
g.ready = g.ready.filter(id => id !== p.id);
|
||||
log(g, `${p.name} has a new Truthtrance movement commitment and must confirm readiness again after any needed preparation.`);
|
||||
}
|
||||
@@ -20908,7 +20920,7 @@ function bindMovementTruth(g: Game, p: Player, claim: MovementClaim, answer: boo
|
||||
}
|
||||
function movementExpressionTruthAnswers(g: Game, p: Player, claim: MovementExpressionClaim): TruthAnswer[] {
|
||||
if (claim.turn !== g.turn || !movementPromiseTargetAvailable(g, p.id)) return [];
|
||||
const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
const answerSource = (g.phase === 2 && !g.charityOrder) || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
const answers: TruthAnswer[] = [];
|
||||
for (const answer of [true, false]) {
|
||||
const promise: MovementExpressionPromise = { ...structuredClone(claim), event: 'candidate', player: p.id,
|
||||
@@ -26618,7 +26630,7 @@ function advancePhase(g: Game) {
|
||||
requireRule(!g.moritaniExtortion, 'Settle Extortion before leaving Mentat Pause.');
|
||||
if (g.phase === 6) boardResolution(() => quoteBattlePhaseAdvance(g));
|
||||
commitPhaseResources(g, currentPhaseResources(g));
|
||||
if (!(g.phase === 4 && g.revivalOrder)) g.ready = [];
|
||||
if (!(g.phase === 4 && g.revivalOrder) && !(g.phase === 2 && g.charityOrder)) g.ready = [];
|
||||
g.active = null;
|
||||
const choam = byFaction(g, 'choam');
|
||||
if (g.phase === 8 && choam) {
|
||||
@@ -26657,6 +26669,10 @@ function finishEcazPlacement(g: Game) {
|
||||
}
|
||||
function completePhase(g: Game) {
|
||||
if (closeNexusCards(g)) return;
|
||||
if (g.phase === 2 && g.charityOrder) {
|
||||
delete g.charityOrder;
|
||||
g.ready = [];
|
||||
}
|
||||
if (g.phase === 4 && g.revivalOrder) {
|
||||
delete g.revivalOrder;
|
||||
g.ready = [];
|
||||
@@ -26703,6 +26719,43 @@ function openPhase(g: Game, initialize = true) {
|
||||
function revivalOrderContext(g: Game) {
|
||||
return {turn:g.turn,order:g.order,ready:g.ready};
|
||||
}
|
||||
function charityOrderContext(g: Game) {
|
||||
return {turn:g.turn,order:g.order,players:g.players,ready:g.ready};
|
||||
}
|
||||
function charityOrderRule<T>(calculate: () => T): T {
|
||||
try { return calculate(); }
|
||||
catch (error) {
|
||||
if (error instanceof CharityOrderError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function charityOrderIntegrity(g: Game) {
|
||||
if (!g.charityOrder) return;
|
||||
requireRule(g.advanced && g.status === 'playing' && g.phase === 2,
|
||||
'The ordered Charity belongs to its original Advanced phase.');
|
||||
charityOrderRule(() => validateCharityOrder(charityOrderContext(g),g.charityOrder!));
|
||||
requireRule(g.active === (g.charityOrder.remaining[0] ?? null),
|
||||
'Charity must retain its current player through the complete opportunity.');
|
||||
}
|
||||
function nativeCharityOrderBlock(g: Game, player: string): string | null {
|
||||
return g.charityOrder
|
||||
? charityOrderRule(() => charityOrderBlock(charityOrderContext(g),g.charityOrder!,player))
|
||||
: null;
|
||||
}
|
||||
function guardCharityOrderFinish(g: Game, player: string, frame: CharityOrder, action: Action) {
|
||||
// Check the closed owned opportunity before the last Finish can open a
|
||||
// different phase/response and defer reconciliation past its voluntary actor.
|
||||
const closed = structuredClone(g);
|
||||
closed.charityOrder = frame;
|
||||
closed.ready.push(player);
|
||||
closed.active = frame.remaining[0] ?? null;
|
||||
const voluntary = { actor: player, action };
|
||||
reconcileTravelPromises(closed, player, action, g.phase);
|
||||
reconcileRevivalPromises(closed, voluntary);
|
||||
reconcileBribePromises(closed, voluntary);
|
||||
reconcileShipmentPromises(closed, voluntary);
|
||||
reconcileGroundMovementPromises(closed, player, action, g.phase);
|
||||
}
|
||||
function revivalOrderRule<T>(calculate: () => T): T {
|
||||
try { return calculate(); }
|
||||
catch (error) {
|
||||
@@ -26744,7 +26797,14 @@ function beginPhase(g: Game) {
|
||||
g.auditorInsight = null;
|
||||
retireNexusChoamInsight(g);
|
||||
}
|
||||
if (g.phase === 2) openAdvisorPreparation(g);
|
||||
if (g.phase === 2) {
|
||||
if (g.advanced && !g.charityOrder) {
|
||||
g.charityOrder = charityOrderRule(() => createCharityOrder(charityOrderContext(g)));
|
||||
g.active = g.charityOrder.remaining[0] ?? null;
|
||||
log(g, 'Advanced Charity proceeds from the First Player counterclockwise. Claim if eligible, then finish your Charity turn; CHOAM opening income and authorized responses resolve first.');
|
||||
}
|
||||
openAdvisorPreparation(g);
|
||||
}
|
||||
if (g.phase === 2 && g.choamCharity?.turn !== g.turn) {
|
||||
const choam = byFaction(g, 'choam');
|
||||
if (choam && charityMultiplier(g) === 0) {
|
||||
@@ -29235,6 +29295,7 @@ function earlyRevivalCompletionSeeds(initial: Game, source: Game, owner: string)
|
||||
}
|
||||
return seeds.map(seed => {
|
||||
const g = structuredClone(seed.game); g.phase = 4;
|
||||
delete g.charityOrder;
|
||||
if (source.phase < 4) {
|
||||
g.revivalRules = newRevivalRules(); g.revivalRequests = {}; g.freeRevival = []; g.emperorExtra = {};
|
||||
g.ready = []; g.active = owner; delete g.revivalOrder;
|
||||
@@ -29510,6 +29571,7 @@ function earlyShipmentGuidance(g: Game, p: Player): { preparation: { actions: Ac
|
||||
return { preparation: null, policy: g.auction?.active === p.id ? [{ type: 'passBid' }] : [] };
|
||||
}
|
||||
if (g.phase === 4 && nativeRevivalOrderBlock(g,p.id)) return {preparation:null,policy:[]};
|
||||
if (g.phase === 2 && nativeCharityOrderBlock(g,p.id)) return {preparation:null,policy:[]};
|
||||
if (g.phase === 2 || g.phase === 4)
|
||||
return { preparation: current ? { actions: [current] } : null,
|
||||
policy: current ? [current] : g.ready.includes(p.id) ? [] : [{ type: 'ready' }] };
|
||||
@@ -29528,7 +29590,7 @@ function shipmentTruthAnswers(
|
||||
{ ...claim, turn: g.turn, player: p.id, asker: '', answer },
|
||||
];
|
||||
// Concurrent legacy readiness can reopen; a completed ordered turn cannot.
|
||||
const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder)
|
||||
const answerSource = (g.phase === 2 && !g.charityOrder) || (g.phase === 4 && !g.revivalOrder)
|
||||
? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
if (findShipmentCompletion(answerSource, p, promises) &&
|
||||
revivalCompatibleLaterPromises(answerSource, p, { shipmentPromises: [...(g.shipmentPromises ?? []), promises[promises.length - 1]] }) &&
|
||||
@@ -29545,7 +29607,7 @@ function bindShipmentTruth(
|
||||
asker: string,
|
||||
mixed?: MixedShipmentExpression,
|
||||
) {
|
||||
if ((g.phase === 2 || (g.phase === 4 && !g.revivalOrder)) && g.ready.includes(p.id)) {
|
||||
if (((g.phase === 2 && !g.charityOrder) || (g.phase === 4 && !g.revivalOrder)) && g.ready.includes(p.id)) {
|
||||
g.ready = g.ready.filter(id => id !== p.id);
|
||||
log(g, `${p.name} has a new Truthtrance shipment commitment and must confirm readiness again after completing any needed preparation.`);
|
||||
}
|
||||
@@ -33352,7 +33414,8 @@ function projectedTypedRevivalPromiseGuide(g: Game, player: string, kind: 'force
|
||||
const ownPayment = g.phase === 3 && !g.response && !g.phaseOpening && g.decision?.kind === 'auctionPayment' && g.decision.player === player;
|
||||
if ((!revivalQuestionWindow(g) && !ownPayment) || g.truthtrance || !revivalOpportunity(g, player) ||
|
||||
g.pendingRevival || g.pendingKarama || g.pendingTreacheryDiscard ||
|
||||
g.phase === 4 && nativeRevivalOrderBlock(g, player)) return null;
|
||||
g.phase === 4 && nativeRevivalOrderBlock(g, player) ||
|
||||
g.phase === 2 && nativeCharityOrderBlock(g, player)) return null;
|
||||
// The separate bid guide owns a jointly binding Yes declaration. Passing
|
||||
// cannot evade it merely because Revival comes later in this turn.
|
||||
const lot = g.phase === 3 && !ownPayment && !g.richeseAuction && !g.ixAuction ? bidOpportunity(g) : null;
|
||||
@@ -38228,6 +38291,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
bribePromiseIntegrity(state);
|
||||
alliancePromiseIntegrity(state);
|
||||
revivalOrderIntegrity(state);
|
||||
charityOrderIntegrity(state);
|
||||
state = migrateAtomicsTargets(state);
|
||||
state = migrateFremenNexusRevival(state);
|
||||
if (state.spiceBankerIncomePreview) {
|
||||
@@ -38558,6 +38622,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
guildBetrayalIntegrity(g);
|
||||
harkonnenNexusBetrayalIntegrity(g);
|
||||
revivalOrderIntegrity(g);
|
||||
charityOrderIntegrity(g);
|
||||
return g;
|
||||
}
|
||||
function finishActionContinuations(g: Game) {
|
||||
@@ -41510,6 +41575,8 @@ function applyActionInner(
|
||||
return g;
|
||||
}
|
||||
if (t === 'charity') {
|
||||
const orderBlock = nativeCharityOrderBlock(g, id);
|
||||
requireRule(!orderBlock, orderBlock ?? 'Wait for your Charity turn.');
|
||||
const advancedBG = g.advanced && p.faction === 'beneGesserit';
|
||||
const quote = charityQuote(g, p);
|
||||
const amount = quote.total;
|
||||
@@ -42799,6 +42866,10 @@ function applyActionInner(
|
||||
return g;
|
||||
}
|
||||
if (t === 'ready') {
|
||||
if (g.phase === 2) {
|
||||
const orderBlock = nativeCharityOrderBlock(g, id);
|
||||
requireRule(!orderBlock, orderBlock ?? 'Wait for your Charity turn.');
|
||||
}
|
||||
if (allianceQuestionWindow(g)) guardAlliancePromiseReady(g, p);
|
||||
if (g.turnActionPromises && (g.phase === 0 || g.phase === 1 && (g.spiceWindow || thumperReadyStartsDraw(g)))) {
|
||||
const closed = ownTurnActionWindowClosure(g, p.id);
|
||||
@@ -42837,6 +42908,13 @@ function applyActionInner(
|
||||
'Complete the current phase action first.',
|
||||
);
|
||||
requireRule(!g.ready.includes(id), 'You are already ready.');
|
||||
if (g.phase === 2 && g.charityOrder) {
|
||||
const finished = charityOrderRule(() => finishCharityOrder(charityOrderContext(g),g.charityOrder!,id));
|
||||
guardCharityOrderFinish(g, id, finished, action);
|
||||
g.charityOrder = finished;
|
||||
g.active = g.charityOrder.remaining[0] ?? null;
|
||||
log(g, `${p.name} finished their Charity turn.`,{faction:p.faction,name:'Charity turn complete'});
|
||||
}
|
||||
if (g.phase === 4 && g.revivalOrder) {
|
||||
g.revivalOrder = revivalOrderRule(() => finishRevivalOrder(revivalOrderContext(g),g.revivalOrder!,id));
|
||||
g.active = g.revivalOrder.remaining[0] ?? null;
|
||||
@@ -42861,6 +42939,7 @@ function applyActionInner(
|
||||
}
|
||||
export function viewGame(state: Game, id: string, now?: number) {
|
||||
revivalOrderIntegrity(state);
|
||||
charityOrderIntegrity(state);
|
||||
state = migrateAtomicsTargets(state);
|
||||
state = migrateFremenNexusRevival(state);
|
||||
harkonnenNexusBetrayalIntegrity(state);
|
||||
@@ -43751,7 +43830,9 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
incomeCanceled:
|
||||
g.choamCharity?.turn === g.turn && g.choamCharity.canceled,
|
||||
incomeHomeworld: choamPlayer ? homeworldLowBonus(g, choamPlayer.id) : 0,
|
||||
orderBlocked: nativeCharityOrderBlock(g, id),
|
||||
},
|
||||
charityOrder: g.charityOrder ? structuredClone(g.charityOrder) : null,
|
||||
moritaniAssassinate: projectedMoritaniAssassinate(g,id),
|
||||
moritaniRetention: g.moritaniRetention ?? null,
|
||||
nexusMoritaniRetention: quoteNexusMoritaniRetention(g, id),
|
||||
|
||||
+26
-2
@@ -44,7 +44,7 @@ export type RuleTopic = {
|
||||
export const PHASE_HELP = [
|
||||
'Complete the table’s storm dials or card reveal. After the distance is revealed, play any storm cards and confirm movement.',
|
||||
'Reveal a spice blow, resolve worms and card responses, then finish any Nexus and Fremen rides.',
|
||||
'Claim charity once if below two spice. Advanced Bene Gesserit receives two spice regardless of wealth. Then mark yourself ready.',
|
||||
'Claim charity once if below two spice. Advanced Bene Gesserit receives two regardless of wealth. New Advanced phases follow the First Player counterclockwise: claim if eligible, then finish your Charity turn. Basic and older unstamped phases retain concurrent readiness.',
|
||||
'Bid or pass in turn. New ordinary lots allow ten seconds per bidder while native decisions hold the clock. A winning bidder chooses how to pay before income and bonus-card responses resolve.',
|
||||
'Return eligible forces and leaders from the tanks to your reserves. New Advanced Revival phases begin from the First Player counterclockwise; Sapho may change unfinished positions. Finish your own force and leader choices before the next player acts. Basic and already-opened older phases retain their current readiness timing.',
|
||||
'Ship before moving. Select a destination sector and, for a move, the forces leaving one territory.',
|
||||
@@ -121,7 +121,8 @@ const phaseDetails = [
|
||||
],
|
||||
[
|
||||
'If CHOAM is present, first resolve its income response. CHOAM then pays other factions’ charity; if its income was canceled, charity comes from the bank. Use Claim charity when eligible to bring ordinary available spice to two.',
|
||||
'Mark yourself ready after making your choice. Other players complete their choices independently.',
|
||||
'New Advanced Charity follows the First Player counterclockwise. Only the current player may claim or finish; a claim and any cancellation response finish before the player chooses Finish Charity turn. You may finish without claiming. Waiting and completed seats cannot take another Charity turn.',
|
||||
'Basic and already-opened older unstamped Advanced Charity keep concurrent readiness. Mark yourself ready after making your choice. New Truthtrance answers do not reopen completed ordered turns. Sapho first/last composition for the newly ordered phase remains unfinished.',
|
||||
],
|
||||
[
|
||||
'Before each auction, resolve the Atreides foresight response. The card stays hidden until that response is allowed. Cancellation affects this card only.',
|
||||
@@ -2576,6 +2577,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
? ['ecaz-ambassadors', 'tleilaxu-revival']
|
||||
: phase === 3
|
||||
? ['ordinary-auction-clock', 'ix-technology', 'nexus-ixian-betrayal', 'nexus-ixian-replacement', 'richese-cards']
|
||||
: phase === 2
|
||||
? ['advanced-charity-order', 'choam-economy', 'bg-charity-karama']
|
||||
: phase === 1
|
||||
? ['card-harvester', 'faction-fremen']
|
||||
: phase === 0
|
||||
@@ -2583,6 +2586,27 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
: undefined,
|
||||
}),
|
||||
),
|
||||
{
|
||||
id: 'advanced-charity-order',
|
||||
title: 'Advanced Charity player order',
|
||||
category: 'Turn phases', coverage: 'Partial', developmentStage: 'Prototyped',
|
||||
summary: 'New Advanced Charity phases give each faction one opportunity, beginning with the First Player counterclockwise.',
|
||||
steps: [
|
||||
'Resolve CHOAM opening income before any ordinary claim. When it is your turn, claim if eligible and then finish your Charity turn. Finishing without a claim passes your opportunity.',
|
||||
'A claim and its native responses retain the current player. Completing a Karama response does not finish or consume the responder’s own Charity turn.',
|
||||
'Waiting and completed seats cannot claim or finish. A new Truthtrance answer cannot reopen a completed ordered turn, while a remaining owned future claim may still support a binding answer.',
|
||||
'Basic and already-opened unstamped Advanced phases keep concurrent readiness. Inflation, Homeworld bonuses and CHOAM funding retain their existing calculation and limitations.',
|
||||
'Sapho first/last composition, broader interactions and full Advanced acceptance remain unfinished.',
|
||||
],
|
||||
related: ['charity', 'choam-economy', 'bg-charity-karama', 'truthtrance-turn-actions'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Fresh native order, current-only claim/finish and original response/closing lifecycle are connected; Sapho composition remains.', evidence: ['game/charity-order.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Persistent current/wait/completed status and native claim/finish controls retain funding explanations.', evidence: ['components/charity-claim.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Existing policies wait for their actual opportunity and use the native claim and finish actions; strategy and calibration remain deferred.', evidence: ['game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Source cutover, Basic/legacy behavior, remaining entitlements and interaction boundaries are explicit.', evidence: ['docs/ADVANCED_CHARITY_ORDER.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, native and control sources; frozen runtime evidence and complete-mode acceptance are separate.', evidence: ['tests/charity-order.test.ts', 'tests/advanced-charity-order-engine.test.ts', 'tests/charity-claim-controls.test.tsx'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ordinary-auction-clock',
|
||||
title: 'Ordinary auction: ten seconds to bid',
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { Difficulty } from '../game/bot-profiles';
|
||||
import {
|
||||
actAdvancedCharityOrder as act, advancedCharityOrderClean as clean,
|
||||
advancedCharityOrderInventory as inventory, advancedCharityOrderPlayer as p,
|
||||
advancedCharityOrderReload as reload, allowAdvancedCharityOpening as allow,
|
||||
createAdvancedCharityOrderFixture as fixture, finishAdvancedCharityPhase as finishPhase,
|
||||
finishAdvancedCharityTurn as finishTurn, nextAdvancedCharityOrderStep as next,
|
||||
passAdvancedCharityResponses as pass, unstampedAdvancedCharitySupplement as unstamped,
|
||||
type AdvancedCharityOrderStep,
|
||||
} from './fixture-advanced-charity-order';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = reload(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
void test('genuine Advanced Charity source uses native First Player order and a claim retains its owned turn', () => {
|
||||
const f = fixture(); let game = allow(reload(f.game));
|
||||
assert.equal(f.sourceCounts.setup, f.setupActions.length);
|
||||
assert.equal(f.sourceCounts.bribes, f.bribeActions.length);
|
||||
assert.equal(f.sourceCounts.source, f.actions.length);
|
||||
assert.ok(f.bribeActions.some(step => step.actor === 'atreides' && step.action.type === 'bribe'));
|
||||
assert.equal(p(f.beforeIncome, 'choam').spice, 2); assert.equal(p(game, 'choam').spice, 8);
|
||||
assert.equal(p(game, 'choam').bribes, p(f.beforeIncome, 'choam').bribes);
|
||||
assert.deepEqual(game.charityOrder, { version: 1, turn: game.turn, order: game.order, remaining: game.order });
|
||||
assert.equal(game.active, 'atreides');
|
||||
assert.deepEqual(viewGame(game, 'atreides').charityOrder, game.charityOrder);
|
||||
const queue = structuredClone(game.charityOrder);
|
||||
game = act(game, 'atreides', { type: 'charity' });
|
||||
assert.equal(p(game, 'atreides').spice, 2); assert.equal(p(game, 'atreides').charityTurn, game.turn);
|
||||
assert.equal(p(game, 'choam').spice, 6);
|
||||
assert.deepEqual(game.charityOrder, queue); assert.equal(game.active, 'atreides');
|
||||
game = finishTurn(game);
|
||||
assert.equal(game.active, 'beneGesserit');
|
||||
assert.deepEqual(game.charityOrder?.remaining, ['beneGesserit', 'choam']);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('later and completed Advanced Charity actors cannot claim or finish out of order', () => {
|
||||
const f = fixture(); let game = allow(reload(f.game));
|
||||
for (const actor of ['beneGesserit', 'choam']) {
|
||||
assert.ok(viewGame(game, actor).charity.orderBlocked);
|
||||
reject(game, actor, { type: 'charity' }); reject(game, actor, { type: 'ready' });
|
||||
}
|
||||
game = finishTurn(game);
|
||||
reject(game, 'atreides', { type: 'charity' }); reject(game, 'atreides', { type: 'ready' });
|
||||
assert.ok(viewGame(game, 'atreides').charity.orderBlocked);
|
||||
assert.equal(game.active, 'beneGesserit');
|
||||
assert.deepEqual(game.charityOrder?.remaining, ['beneGesserit', 'choam']);
|
||||
});
|
||||
|
||||
void test('original CHOAM opening response holds the whole Charity queue before any claim or Finish', () => {
|
||||
const f = fixture(), before = reload(f.game), queue = structuredClone(f.game.charityOrder);
|
||||
assert.equal(before.response?.kind, 'choamCharity');
|
||||
assert.ok(queue);
|
||||
reject(before, 'atreides', { type: 'charity' }); reject(before, 'atreides', { type: 'ready' });
|
||||
let game = before;
|
||||
for (let count = 0; game.response && count < 10; count++) {
|
||||
assert.deepEqual(game.charityOrder, queue);
|
||||
const step = next(game); assert.ok(step); assert.equal(step.action.type, 'passResponse');
|
||||
game = act(game, step.actor, step.action);
|
||||
}
|
||||
assert.equal(game.response ?? null, null);
|
||||
assert.deepEqual(game.charityOrder, queue);
|
||||
assert.equal(game.active, queue.remaining[0]);
|
||||
assert.equal(p(game, 'choam').spice, 8);
|
||||
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false });
|
||||
assert.notEqual(p(game, 'atreides').charityTurn, game.turn);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('wealthy Bene Gesserit response retains the current Charity actor through native payment then real finishes close phase', () => {
|
||||
const f = fixture(); let game = allow(reload(f.game));
|
||||
game = act(game, 'atreides', { type: 'charity' }); game = finishTurn(game);
|
||||
const bg = p(game, 'beneGesserit').spice, provider = p(game, 'choam').spice;
|
||||
const queue = structuredClone(game.charityOrder);
|
||||
assert.ok(bg >= 2);
|
||||
game = act(game, 'beneGesserit', { type: 'charity' });
|
||||
assert.equal(game.response?.kind, 'bgCharity');
|
||||
assert.equal(game.active, 'beneGesserit'); assert.deepEqual(game.charityOrder, queue);
|
||||
reject(game, 'beneGesserit', { type: 'ready' }); reject(game, 'choam', { type: 'ready' });
|
||||
game = pass(game);
|
||||
assert.equal(p(game, 'beneGesserit').spice, bg + 2);
|
||||
assert.equal(p(game, 'choam').spice, provider - 2);
|
||||
assert.equal(game.active, 'beneGesserit'); assert.deepEqual(game.charityOrder, queue);
|
||||
game = finishTurn(game); assert.equal(game.active, 'choam');
|
||||
game = finishTurn(game);
|
||||
if (game.phase === 2) assert.ok(game.charityOrder, 'The original closing phase controls still own the frame');
|
||||
game = finishPhase(game);
|
||||
assert.equal(game.phase, 3); assert.equal(game.charityOrder ?? null, null);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('Basic low claimants retain concurrent Charity and no ordered frame', () => {
|
||||
const f = fixture({ advanced: false, lowBG: true }); let game = allow(reload(f.game));
|
||||
assert.equal(game.charityOrder ?? null, null);
|
||||
assert.equal(viewGame(game, 'beneGesserit').charity.orderBlocked, null);
|
||||
assert.equal(p(game, 'atreides').spice, 0); assert.equal(p(game, 'beneGesserit').spice, 0);
|
||||
game = act(game, 'beneGesserit', { type: 'charity' });
|
||||
assert.equal(p(game, 'beneGesserit').spice, 2);
|
||||
game = act(game, 'beneGesserit', { type: 'ready' });
|
||||
game = act(game, 'atreides', { type: 'charity' });
|
||||
assert.equal(p(game, 'atreides').spice, 2);
|
||||
assert.equal(p(game, 'choam').spice, 4);
|
||||
assert.equal(game.charityOrder ?? null, null);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('explicit conserved unstamped Advanced unit boundary retains the old concurrent claim contract', () => {
|
||||
const f = fixture(), ordered = allow(reload(f.game));
|
||||
let game = unstamped(ordered);
|
||||
const { charityOrder: ignored, ...rest } = ordered;
|
||||
assert.ok(ignored); assert.deepEqual(game, rest, 'Only the frame is removed in this labelled unit supplement');
|
||||
game = act(game, 'beneGesserit', { type: 'charity' });
|
||||
assert.equal(game.response?.kind, 'bgCharity'); game = pass(game);
|
||||
game = act(game, 'atreides', { type: 'charity' });
|
||||
assert.equal(p(game, 'atreides').spice, 2);
|
||||
assert.equal(game.charityOrder ?? null, null);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles claim or Finish only the current actual Advanced Charity turn', () => {
|
||||
const f = fixture(), opened = allow(reload(f.game));
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) {
|
||||
let game = reload(opened);
|
||||
const actions: AdvancedCharityOrderStep[] = [];
|
||||
for (let count = 0; game.phase === 2 && count < 40; count++) {
|
||||
if (!clean(game)) {
|
||||
const step = next(game); assert.ok(step);
|
||||
game = act(game, step.actor, step.action, actions); continue;
|
||||
}
|
||||
const actor = game.charityOrder?.remaining[0]; assert.ok(actor);
|
||||
for (const other of game.players.filter(player => player.id !== actor)) {
|
||||
const view = viewGame(game, other.id); view.players.find(player => player.id === other.id)!.bot = difficulty;
|
||||
assert.equal(botActions(view).some(action => ['charity', 'ready'].includes(action.type)), false);
|
||||
}
|
||||
const view = viewGame(game, actor); view.players.find(player => player.id === actor)!.bot = difficulty;
|
||||
const action = botActions(view).find(action => ['charity', 'ready'].includes(action.type));
|
||||
assert.ok(action, `${difficulty} supplies the current actor’s native claim or Finish`);
|
||||
game = act(game, actor, action, actions);
|
||||
}
|
||||
assert.ok(actions.length > 0); assert.equal(game.phase, 3);
|
||||
assert.equal(game.charityOrder ?? null, null);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
}
|
||||
});
|
||||
@@ -30,25 +30,35 @@ function projected({
|
||||
choamCharity: { turn: 3, canceled: false },
|
||||
});
|
||||
game.players[0].spice = spice;
|
||||
return viewGame(game, 'p');
|
||||
const view = viewGame(game, 'p');
|
||||
view.charityOrder = null;
|
||||
view.charity.orderBlocked = null;
|
||||
return view;
|
||||
}
|
||||
|
||||
function ordered(remaining = ['p', 'other'], order = ['p', 'other']): GameView {
|
||||
const game = projected({ advanced: true, spice: 1 });
|
||||
game.charityOrder = { version: 1, turn: game.turn, order, remaining };
|
||||
game.charity.orderBlocked = remaining[0] === 'p' ? null : 'Wait for the current Charity turn.';
|
||||
return game;
|
||||
}
|
||||
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(CharityClaim, { game, busy, act() {} }));
|
||||
}
|
||||
|
||||
function click(node: ReactNode): boolean {
|
||||
function click(node: ReactNode, selector = 'charity-claim-submit'): boolean {
|
||||
for (const child of Children.toArray(node)) {
|
||||
if (!isValidElement<{
|
||||
children?: ReactNode;
|
||||
onClick?: () => void;
|
||||
'data-testid'?: string;
|
||||
}>(child)) continue;
|
||||
if (child.props['data-testid'] === 'charity-claim-submit' && child.props.onClick) {
|
||||
if (child.props['data-testid'] === selector && child.props.onClick) {
|
||||
child.props.onClick();
|
||||
return true;
|
||||
}
|
||||
if (click(child.props.children)) return true;
|
||||
if (click(child.props.children, selector)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -59,6 +69,12 @@ function claim(game: GameView, busy = false): Action[] {
|
||||
return actions;
|
||||
}
|
||||
|
||||
function finish(game: GameView, busy = false): Action[] {
|
||||
const actions: Action[] = [];
|
||||
click(CharityClaim({ game, busy, act(action) { actions.push(action); } }), 'charity-turn-finish');
|
||||
return actions;
|
||||
}
|
||||
|
||||
void test('the actual charity control submits one ordinary claim action from its current entitlement', () => {
|
||||
const game = projected({ spice: 1 });
|
||||
assert.match(markup(game), /If you have fewer than two spice, claim charity once to reach two/);
|
||||
@@ -66,6 +82,55 @@ void test('the actual charity control submits one ordinary claim action from its
|
||||
assert.deepEqual(claim(game), [{ type: 'charity' }]);
|
||||
});
|
||||
|
||||
void test('stamped current Charity controls submit exact claim and finish packets without changing funding guidance', () => {
|
||||
const game = ordered(), html = markup(game);
|
||||
assert.match(html, /First Player counterclockwise, one player at a time/);
|
||||
assert.match(html, /It is your Charity turn/); assert.match(html, /Finish Charity turn/);
|
||||
assert.match(html, /CHOAM pays as much of the ordinary claim as it can/);
|
||||
assert.deepEqual(claim(game), [{ type: 'charity' }]);
|
||||
assert.deepEqual(finish(game), [{ type: 'ready' }]);
|
||||
game.players[0].charityClaimed = true; game.charity.amount = 0;
|
||||
assert.deepEqual(claim(game), []); assert.deepEqual(finish(game), [{ type: 'ready' }]);
|
||||
});
|
||||
|
||||
void test('waiting and completed Charity turns show public names and cannot claim or finish', () => {
|
||||
const waiting = ordered(['other', 'p'], ['other', 'p']);
|
||||
const complete = ordered(['other']);
|
||||
for (const game of [waiting, complete]) {
|
||||
Object.defineProperty(game.players[1], 'spice', { get() { throw Error('Turn status cannot inspect a rival wallet'); } });
|
||||
assert.match(markup(game), /Waiting for Other to finish Charity/);
|
||||
assert.deepEqual(claim(game), []); assert.deepEqual(finish(game), []);
|
||||
}
|
||||
assert.match(markup(waiting), /Your turn comes later in the public order/);
|
||||
assert.match(markup(complete), /Your Charity turn is complete/);
|
||||
const ended = ordered([]); assert.match(markup(ended), /All Charity turns are complete/);
|
||||
assert.deepEqual(finish(ended), []);
|
||||
});
|
||||
|
||||
void test('stamped Charity claim and finish retain native pending-income and modal priority', () => {
|
||||
for (const change of [
|
||||
(game: GameView) => { game.charity.incomePending = true; },
|
||||
(game: GameView) => { game.response = { kind: 'bgCharity', owner: 'other', passed: [], amount: 2 }; },
|
||||
(game: GameView) => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||||
(game: GameView) => { game.phaseOpening = { passed: [] }; },
|
||||
(game: GameView) => { game.truthtrance = {} as NonNullable<GameView['truthtrance']>; },
|
||||
(game: GameView) => { game.nexusTraitors = { offer: null, pending: {} as NonNullable<NonNullable<GameView['nexusTraitors']>['pending']> }; },
|
||||
(game: GameView) => { game.automaticContinuationPending = true; },
|
||||
]) {
|
||||
const game = ordered(); change(game); assert.deepEqual(claim(game), []); assert.deepEqual(finish(game), []);
|
||||
}
|
||||
const game = ordered(); assert.deepEqual(claim(game, true), []); assert.deepEqual(finish(game, true), []);
|
||||
assert.match(markup(game, true), /data-testid="charity-turn-finish"[^>]*disabled/);
|
||||
});
|
||||
|
||||
void test('Basic and unstamped Advanced Charity keep their existing claim control without a new finish action', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const game = projected({ advanced });
|
||||
assert.deepEqual(claim(game), [{ type: 'charity' }]); assert.deepEqual(finish(game), []);
|
||||
assert.doesNotMatch(markup(game), /charity-turn-finish|charity-turn-status|First Player counterclockwise/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('busy, pending income, a completed claim and zero entitlement block the actual callback', () => {
|
||||
const game = projected();
|
||||
assert.deepEqual(claim(game, true), []);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
CharityOrderError,
|
||||
charityOrderBlock as block,
|
||||
createCharityOrder as create,
|
||||
finishCharityOrder as finish,
|
||||
validateCharityOrder as validate,
|
||||
type CharityOrder,
|
||||
type CharityOrderContext,
|
||||
} from '../game/charity-order';
|
||||
|
||||
function context(ready: readonly string[] = []): CharityOrderContext {
|
||||
return { turn: 3, order: ['c', 'a', 'b'], players: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], ready };
|
||||
}
|
||||
function rejects(current: CharityOrderContext, frame: CharityOrder, action: () => unknown): void {
|
||||
const beforeContext = structuredClone(current), beforeFrame = structuredClone(frame);
|
||||
assert.throws(action, CharityOrderError);
|
||||
assert.deepEqual(current, beforeContext);
|
||||
assert.deepEqual(frame, beforeFrame);
|
||||
}
|
||||
|
||||
void test('Charity preserves the actual rotated First Player order rather than roster order', () => {
|
||||
const current = context(), frame = create(current);
|
||||
assert.deepEqual(frame, { version: 1, turn: 3, order: ['c', 'a', 'b'], remaining: ['c', 'a', 'b'] });
|
||||
validate(current, frame);
|
||||
assert.equal(block(current, frame, 'c'), null);
|
||||
assert.match(block(current, frame, 'a')!, /Wait/);
|
||||
assert.match(block(current, frame, 'foreign')!, /seated/);
|
||||
const resumed = context(['c']);
|
||||
assert.deepEqual(create(resumed), { ...frame, remaining: ['a', 'b'] });
|
||||
assert.match(block(resumed, create(resumed), 'c')!, /finished/);
|
||||
assert.equal(block(resumed, create(resumed), 'a'), null);
|
||||
});
|
||||
|
||||
void test('only the current seat finishes, consuming the pre-finish prefix before the caller appends ready', () => {
|
||||
const current = context(), frame = create(current);
|
||||
rejects(current, frame, () => finish(current, frame, 'a'));
|
||||
rejects(current, frame, () => finish(current, frame, 'foreign'));
|
||||
const next = finish(current, frame, 'c');
|
||||
assert.deepEqual(next.remaining, ['a', 'b']);
|
||||
assert.deepEqual(current.ready, []);
|
||||
validate(context(['c']), next);
|
||||
rejects(current, frame, () => validate(current, next));
|
||||
rejects(current, frame, () => finish(context(['c']), frame, 'c'));
|
||||
const afterSecond = finish(context(['c']), next, 'a');
|
||||
validate(context(['c', 'a']), afterSecond);
|
||||
assert.deepEqual(afterSecond.remaining, ['b']);
|
||||
rejects(context(['c']), next, () => finish(context(['c']), next, 'c'));
|
||||
});
|
||||
|
||||
void test('the final completed Charity frame stays valid and never reopens a seat', () => {
|
||||
const current = context(['c', 'a']), frame = create(current);
|
||||
const done = finish(current, frame, 'b'), completed = context(['c', 'a', 'b']);
|
||||
validate(completed, done);
|
||||
assert.deepEqual(done.remaining, []);
|
||||
assert.deepEqual(create(completed), done);
|
||||
for (const player of completed.order) {
|
||||
assert.match(block(completed, done, player)!, /complete/);
|
||||
rejects(completed, done, () => finish(completed, done, player));
|
||||
}
|
||||
});
|
||||
|
||||
void test('the native context requires actual unique seats, their permutation and an exact completed prefix', () => {
|
||||
const base = context();
|
||||
for (const current of [
|
||||
{ ...base, turn: 0 }, { ...base, turn: 1.5 }, { ...base, turn: Number.NaN },
|
||||
{ ...base, players: [{ id: 'a' }] },
|
||||
{ ...base, players: ['a', 'b', 'c', 'd', 'e', 'f', 'g'].map(id => ({ id })) },
|
||||
{ ...base, players: [{ id: 'a' }, { id: 'a' }, { id: 'c' }] },
|
||||
{ ...base, players: [{ id: 'a' }, { id: 'b' }, { id: ' ' }] },
|
||||
{ ...base, order: ['c', 'a'] }, { ...base, order: ['c', 'a', 'a'] },
|
||||
{ ...base, order: ['c', 'a', 'foreign'] }, context(['a']), context(['c', 'b']),
|
||||
context(['c', 'c']), context(['c', 'a', 'b', 'foreign']),
|
||||
]) assert.throws(() => create(current), CharityOrderError);
|
||||
for (const current of [null, [], { ...base, players: undefined }, { ...base, order: 'c,a,b' },
|
||||
{ ...base, ready: undefined }, { ...base, order: Array.from({ length: 3 }) },
|
||||
{ ...base, players: Array.from({ length: 3 }) }, { ...base, ready: Array.from({ length: 1 }) }])
|
||||
assert.throws(() => create(current as unknown as CharityOrderContext), CharityOrderError);
|
||||
for (const order of [['b', 'a'], ['f', 'a', 'b', 'c', 'd', 'e']]) {
|
||||
const current = { turn: 1, order, players: [...order].reverse().map(id => ({ id })), ready: [] };
|
||||
validate(current, create(current));
|
||||
}
|
||||
});
|
||||
|
||||
void test('stale, rearranged or extended frames cannot replace the original remaining suffix', () => {
|
||||
const current = context(['c']), frame = create(current);
|
||||
const missing = { ...frame } as Partial<CharityOrder>;
|
||||
delete missing.remaining;
|
||||
for (const value of [
|
||||
null, [], missing, { ...frame, extra: true }, { ...frame, started: false },
|
||||
{ ...frame, version: 2 }, { ...frame, turn: 2 },
|
||||
{ ...frame, order: ['a', 'c', 'b'] }, { ...frame, order: ['c', 'a', 'a'] },
|
||||
{ ...frame, remaining: ['b', 'a'] }, { ...frame, remaining: ['a'] },
|
||||
{ ...frame, remaining: ['c', 'a', 'b'] }, { ...frame, remaining: ['a', 'foreign'] },
|
||||
]) rejects(current, frame, () => validate(current, value as unknown as CharityOrder));
|
||||
rejects(current, frame, () => validate({ ...current, turn: 4 }, frame));
|
||||
rejects(current, frame, () => validate({ ...current, order: ['a', 'c', 'b'], ready: [] }, frame));
|
||||
rejects(current, frame, () => finish(current, frame, ''));
|
||||
});
|
||||
|
||||
void test('creation and finish detach both public arrays without changing the original context or frame', () => {
|
||||
const current = context(['c']), before = structuredClone(current);
|
||||
Object.freeze(current.order);
|
||||
Object.freeze(current.ready);
|
||||
Object.freeze(current.players);
|
||||
Object.freeze(current);
|
||||
const frame = create(current), original = structuredClone(frame);
|
||||
Object.freeze(frame.order);
|
||||
Object.freeze(frame.remaining);
|
||||
Object.freeze(frame);
|
||||
const next = finish(current, frame, 'a');
|
||||
assert.notEqual(frame.order, current.order);
|
||||
assert.notEqual(frame.remaining, current.order);
|
||||
assert.notEqual(frame.order, frame.remaining);
|
||||
assert.notEqual(next.order, frame.order);
|
||||
assert.notEqual(next.remaining, frame.remaining);
|
||||
next.order.pop();
|
||||
next.remaining.pop();
|
||||
assert.deepEqual(current, before);
|
||||
assert.deepEqual(frame, original);
|
||||
});
|
||||
@@ -26,7 +26,10 @@ for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} native other-faction full claim uses provisional shortfall funding`, () => {
|
||||
for (const choamSpice of [0, 1] as const) {
|
||||
const f = fixture({ advanced, choamSpice }), before = structuredClone(f.game);
|
||||
const g = act(f.game, f.guild, f.otherClaim);
|
||||
let g = structuredClone(f.game);
|
||||
while (g.charityOrder && g.charityOrder.remaining[0] !== f.guild)
|
||||
g = act(g, g.charityOrder.remaining[0], { type: 'ready' });
|
||||
g = act(g, f.guild, f.otherClaim);
|
||||
assert.equal(player(g, f.choam).spice, 0);
|
||||
assert.equal(player(g, f.guild).spice, 2);
|
||||
assert.equal(player(g, f.recipient).bribes, 13 - choamSpice);
|
||||
@@ -37,17 +40,21 @@ for (const advanced of [false, true]) {
|
||||
assert.throws(() => act(g, f.guild, f.otherClaim), /once per turn|fewer than/);
|
||||
}
|
||||
});
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} each low faction keeps exactly one independent claim in either order`, () => {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} each low faction keeps exactly one claim ${advanced ? 'in native player order' : 'in either order'}`, () => {
|
||||
const f = fixture({ advanced, choamSpice: 1, guildSpice: 1 });
|
||||
for (const order of [[f.choam, f.guild], [f.guild, f.choam]]) {
|
||||
for (const order of advanced ? [[f.choam, f.guild]] : [[f.choam, f.guild], [f.guild, f.choam]]) {
|
||||
let g = structuredClone(f.game);
|
||||
for (const actor of order) g = act(g, actor, { type: 'charity' });
|
||||
for (const actor of order) {
|
||||
while (g.charityOrder && g.charityOrder.remaining[0] !== actor)
|
||||
g = act(g, g.charityOrder.remaining[0], { type: 'ready' });
|
||||
g = act(g, actor, { type: 'charity' });
|
||||
}
|
||||
assert.equal(player(g, f.guild).spice, 2);
|
||||
assert.equal(player(g, f.choam).spice, order[0] === f.choam ? 1 : 2);
|
||||
assert.equal(player(g, f.recipient).bribes, 11);
|
||||
for (const actor of order) {
|
||||
assert.equal(viewGame(g, actor).players.find(p => p.id === actor)!.charityClaimed, true);
|
||||
assert.throws(() => act(g, actor, { type: 'charity' }), /once per turn|fewer than/);
|
||||
assert.throws(() => act(g, actor, { type: 'charity' }), /once per turn|fewer than|Charity opportunity/);
|
||||
}
|
||||
assert.deepEqual(inventory(g), f.originalInventory);
|
||||
}
|
||||
|
||||
@@ -40,11 +40,21 @@ function fixture(advanced = false, ix = false) {
|
||||
}
|
||||
function ready(state: Game) {
|
||||
let g = state;
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
const players = g.phase === 2 && g.charityOrder ? [...g.charityOrder.remaining] : g.players.map(p => p.id);
|
||||
for (const player of players) g = applyAction(g, player, { type: 'ready' });
|
||||
while (g.decision?.kind === 'choamMarket')
|
||||
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
|
||||
return g;
|
||||
}
|
||||
function charityTurn(state: Game, player: string) {
|
||||
let g = state;
|
||||
while (g.phase === 2 && g.charityOrder && g.charityOrder.remaining[0] !== player) {
|
||||
const current = g.charityOrder.remaining[0];
|
||||
assert.ok(current, 'the requested Charity opportunity must still be pending');
|
||||
g = applyAction(g, current, { type: 'ready' });
|
||||
}
|
||||
return g;
|
||||
}
|
||||
function allow(state: Game) {
|
||||
let g = state;
|
||||
while (g.response) {
|
||||
@@ -162,6 +172,7 @@ void test('clean BG charity Karama cancellation offers Semuta without awarding e
|
||||
g = allow(ready(g));
|
||||
contest(g);
|
||||
g.players[2].spice = 12;
|
||||
g = charityTurn(g, 'b');
|
||||
g = applyAction(g, 'b', { type: 'charity' });
|
||||
assert.equal(g.response?.kind, 'bgCharity');
|
||||
assert.equal(viewGame(g, 'b').players[2].charityClaimed, true);
|
||||
@@ -210,9 +221,11 @@ void test('CHOAM pays ordinary charity by actual need and advanced Bene Gesserit
|
||||
let g = allow(ready(fixture(advanced)));
|
||||
g.players[1].spice = starting;
|
||||
g.players[2].spice = starting;
|
||||
g = charityTurn(g, 'e');
|
||||
g = applyAction(g, 'e', { type: 'charity' });
|
||||
assert.equal(g.players[1].spice, 2);
|
||||
assert.equal(g.players[0].spice, 6 - (2 - starting));
|
||||
g = charityTurn(g, 'b');
|
||||
g = applyAction(g, 'b', { type: 'charity' });
|
||||
assert.equal(g.players[2].spice, advanced ? starting + 2 : 2);
|
||||
assert.equal(
|
||||
@@ -230,6 +243,7 @@ void test('wealthy Bene Gesserit payment waits for its response; cancellation ne
|
||||
let g = allow(ready(fixture(true)));
|
||||
contest(g);
|
||||
g.players[2].spice = 12;
|
||||
g = charityTurn(g, 'b');
|
||||
g = applyAction(g, 'b', { type: 'charity' });
|
||||
assert.equal(g.response?.kind, 'bgCharity');
|
||||
assert.equal(g.players[0].spice, 6);
|
||||
@@ -245,6 +259,7 @@ void test('wealthy BG response preserves cancellation before provisional shortfa
|
||||
contest(g);
|
||||
g = applyAction(g, 'c', { type: 'bribe', target: 'e', amount: 6 });
|
||||
g.players[2].spice = 12;
|
||||
g = charityTurn(g, 'b');
|
||||
g = applyAction(g, 'b', { type: 'charity' });
|
||||
assert.equal(g.response?.kind, 'bgCharity');
|
||||
assert.equal(g.players[0].spice, 0);
|
||||
@@ -260,7 +275,10 @@ void test('Karama cancels CHOAM income, restores bank payments and permits CHOAM
|
||||
let g = cancel(ready(contest(fixture(true))));
|
||||
assert.equal(g.players[0].spice, 0);
|
||||
assert.deepEqual(g.choamCharity, { turn: 1, canceled: true });
|
||||
for (const id of ['c', 'e', 'b']) g = applyAction(g, id, { type: 'charity' });
|
||||
for (const id of g.order) {
|
||||
g = charityTurn(g, id);
|
||||
g = applyAction(g, id, { type: 'charity' });
|
||||
}
|
||||
assert.deepEqual(
|
||||
g.players.map((p) => p.spice),
|
||||
[2, 2, 2],
|
||||
@@ -336,6 +354,7 @@ void test('charity projections expose the payer and own entitlement without leak
|
||||
incomePending: false,
|
||||
incomeCanceled: false,
|
||||
incomeHomeworld: 0,
|
||||
orderBlocked: null,
|
||||
});
|
||||
assert.equal(v.players[0].spice, undefined);
|
||||
assert.equal(v.players[0].handLimit, 5);
|
||||
@@ -346,10 +365,13 @@ void test('ordinary funded claims still accrue and settle Spice Production once'
|
||||
g.techTokens = createTechTokens();
|
||||
g.techTokens.production.owner = 'e';
|
||||
g = allow(ready(g));
|
||||
g = applyAction(g, 'b', { type: 'charity' });
|
||||
g = charityTurn(g, 'e');
|
||||
assert.equal(g.techTokens!.production.spice, 0);
|
||||
g = applyAction(g, 'e', { type: 'charity' });
|
||||
assert.equal(g.techTokens!.production.spice, 1);
|
||||
g = charityTurn(g, 'b');
|
||||
g = applyAction(g, 'b', { type: 'charity' });
|
||||
assert.equal(g.techTokens!.production.spice, 1);
|
||||
g = ready(g);
|
||||
assert.equal(g.players[1].spice, 3);
|
||||
assert.equal(g.techTokens!.production.spice, 0);
|
||||
@@ -419,10 +441,20 @@ void test('all AI levels proceed after automatic CHOAM income and claim funded c
|
||||
assert.ok(actions.length);
|
||||
g = applyAction(g, p.id, actions[0]);
|
||||
}
|
||||
assert.equal(g.charityOrder?.remaining[0], 'c');
|
||||
const choam = botActions(viewGame(g, g.charityOrder!.remaining[0]));
|
||||
assert.equal(choam[0]?.type, 'ready');
|
||||
g = applyAction(g, 'c', choam[0]);
|
||||
for (const id of ['e', 'b']) {
|
||||
const actions = botActions(viewGame(g, id));
|
||||
assert.equal(actions[0].type, 'charity');
|
||||
assert.equal(g.charityOrder?.remaining[0], id);
|
||||
const actions = botActions(viewGame(g, g.charityOrder!.remaining[0]));
|
||||
assert.equal(actions[0]?.type, 'charity');
|
||||
g = applyAction(g, id, actions[0]);
|
||||
if (id === 'e') {
|
||||
const finish = botActions(viewGame(g, g.charityOrder!.remaining[0]));
|
||||
assert.equal(finish[0]?.type, 'ready');
|
||||
g = applyAction(g, id, finish[0]);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
g.players.map((p) => p.spice),
|
||||
|
||||
@@ -35,11 +35,21 @@ function fixture(advanced = false) {
|
||||
}
|
||||
function ready(state: Game) {
|
||||
let g = state;
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
const players = g.phase === 2 && g.charityOrder ? [...g.charityOrder.remaining] : g.players.map(p => p.id);
|
||||
for (const player of players) g = applyAction(g, player, { type: 'ready' });
|
||||
while (g.decision?.kind === 'choamMarket')
|
||||
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
|
||||
return g;
|
||||
}
|
||||
function charityTurn(state: Game, player: string) {
|
||||
let g = state;
|
||||
while (g.phase === 2 && g.charityOrder && g.charityOrder.remaining[0] !== player) {
|
||||
const current = g.charityOrder.remaining[0];
|
||||
assert.ok(current, 'the requested Charity opportunity must still be pending');
|
||||
g = applyAction(g, current, { type: 'ready' });
|
||||
}
|
||||
return g;
|
||||
}
|
||||
function allow(state: Game) {
|
||||
let g = state;
|
||||
while (g.response) {
|
||||
@@ -79,7 +89,7 @@ function nextCharity(state: Game) {
|
||||
return ready(g);
|
||||
}
|
||||
function mentat(state: Game) {
|
||||
const g = structuredClone(state);
|
||||
const g = structuredClone(state.phase === 2 && state.charityOrder ? ready(state) : state);
|
||||
g.phase = 7;
|
||||
g.ready = [];
|
||||
return ready(g);
|
||||
@@ -186,8 +196,10 @@ void test('Double changes ordinary payout, preserves ordinary eligibility and do
|
||||
assert.equal(charityAmount(g, g.players[1]), ordinary);
|
||||
assert.equal(charityAmount(g, g.players[2]), bg);
|
||||
const before = g.players[0].spice;
|
||||
g = charityTurn(g, 'e');
|
||||
if (ordinary) g = applyAction(g, 'e', { type: 'charity' });
|
||||
else assert.throws(() => applyAction(g, 'e', { type: 'charity' }), /fewer/);
|
||||
g = charityTurn(g, 'b');
|
||||
g = allow(applyAction(g, 'b', { type: 'charity' }));
|
||||
assert.equal(g.players[1].spice, starting + ordinary);
|
||||
assert.equal(g.players[2].spice, starting + bg);
|
||||
@@ -198,10 +210,12 @@ void test('Cancel suppresses all claims and CHOAM income, including wealthy adva
|
||||
let g = nextCharity(allow(place(ready(fixture(true)), 'cancel')));
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.players[0].spice, 20);
|
||||
for (const p of g.players) {
|
||||
for (const id of g.order) {
|
||||
g = charityTurn(g, id);
|
||||
const p = g.players.find(player => player.id === id)!;
|
||||
assert.equal(charityAmount(g, p), 0);
|
||||
assert.throws(() => applyAction(g, p.id, { type: 'charity' }), /fewer/);
|
||||
assert.equal(viewGame(g, p.id).charity.incomePending, false);
|
||||
assert.throws(() => applyAction(g, id, { type: 'charity' }), /fewer/);
|
||||
assert.equal(viewGame(g, id).charity.incomePending, false);
|
||||
}
|
||||
g = ready(g);
|
||||
assert.equal(g.phase, 3);
|
||||
@@ -211,7 +225,9 @@ void test('canceling doubled CHOAM income preserves doubled charity from the ban
|
||||
g.players[0].spice = 0;
|
||||
g.players[1].spice = 1;
|
||||
g = applyAction(g, 'c', { type: 'charity' });
|
||||
g = charityTurn(g, 'e');
|
||||
g = applyAction(g, 'e', { type: 'charity' });
|
||||
g = charityTurn(g, 'b');
|
||||
g = allow(applyAction(g, 'b', { type: 'charity' }));
|
||||
assert.deepEqual(
|
||||
g.players.map((p) => p.spice),
|
||||
@@ -222,6 +238,7 @@ void test('canceling doubled CHOAM income preserves doubled charity from the ban
|
||||
void test('canceling doubled wealthy Bene Gesserit charity does not charge CHOAM', () => {
|
||||
let g = allow(nextCharity(allow(place(ready(fixture(true))))));
|
||||
const before = g.players[0].spice;
|
||||
g = charityTurn(g, 'b');
|
||||
g = cancel(applyAction(contest(g), 'b', { type: 'charity' }));
|
||||
assert.equal(g.players[0].spice, before);
|
||||
assert.equal(g.players[2].spice, 20);
|
||||
@@ -327,7 +344,11 @@ void test('all AI levels place Inflation legally, finish its response and do not
|
||||
p.bot = difficulty;
|
||||
p.spice = 0;
|
||||
});
|
||||
for (const p of g.players)
|
||||
assert.equal(botActions(viewGame(g, p.id))[0].type, 'ready');
|
||||
for (const id of g.order) {
|
||||
assert.equal(g.charityOrder?.remaining[0], id);
|
||||
const actions = botActions(viewGame(g, g.charityOrder!.remaining[0]));
|
||||
assert.equal(actions[0]?.type, 'ready');
|
||||
g = applyAction(g, id, actions[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { actChoamCharityFunding, nextChoamCharityFundingStep,
|
||||
type ChoamCharityFundingStep } from './fixture-choam-charity-funding';
|
||||
|
||||
export type AdvancedCharityOrderStep = ChoamCharityFundingStep;
|
||||
export const actAdvancedCharityOrder = actChoamCharityFunding;
|
||||
export const advancedCharityOrderReload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
|
||||
export const advancedCharityOrderPlayer = (game: Game, actor: string) => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const advancedCharityOrderClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.pendingTreacheryDiscard && !game.truthtrance;
|
||||
|
||||
export function advancedCharityOrderInventory(game: Game) {
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])]
|
||||
.map(card => card.id).sort();
|
||||
assert.deepEqual(cards, treacheryDeck(game.expansions).map(card => card.id).sort());
|
||||
const traitors = [...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
|
||||
[...player.traitors, ...player.traitorChoices])].sort();
|
||||
if (traitors.length) assert.deepEqual(traitors, traitorDeck(game.players, false).sort());
|
||||
const spice = [...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort();
|
||||
for (const player of game.players)
|
||||
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
|
||||
return { cards, traitors, spice };
|
||||
}
|
||||
|
||||
/** Setup, original counters and current Charity Finish only; no automatic claim. */
|
||||
export function nextAdvancedCharityOrderStep(game: Game): AdvancedCharityOrderStep | null {
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
||||
if (game.status === 'setup' && game.setupStage === 'prediction')
|
||||
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'atreides', turn: 10 } };
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && game.advanced &&
|
||||
!advancedCharityOrderPlayer(game, 'beneGesserit').advisorSetup)
|
||||
return { actor: 'beneGesserit', action: { type: 'advisorSetup', territory: 'polar_sink', sector: 0 } };
|
||||
if (game.decision?.kind === 'advisorBattle')
|
||||
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
|
||||
if (game.phase === 2 && !game.decision && game.charityOrder?.remaining.length)
|
||||
return { actor: game.charityOrder.remaining[0], action: { type: 'ready' } };
|
||||
}
|
||||
return nextChoamCharityFundingStep(game);
|
||||
}
|
||||
export function advanceAdvancedCharityOrder(game: Game, until: (state: Game) => boolean,
|
||||
actions: AdvancedCharityOrderStep[] = []): Game {
|
||||
for (let count = 0; count < 600; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Charity chronology ended before its requested boundary');
|
||||
const next = nextAdvancedCharityOrderStep(game); assert.ok(next);
|
||||
game = actAdvancedCharityOrder(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original Charity programme did not reach its finite boundary');
|
||||
}
|
||||
export function passAdvancedCharityResponses(game: Game, actions: AdvancedCharityOrderStep[] = []): Game {
|
||||
for (let count = 0; game.response && count < 40; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actAdvancedCharityOrder(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.equal(game.response ?? null, null); return game;
|
||||
}
|
||||
export function allowAdvancedCharityOpening(game: Game, actions: AdvancedCharityOrderStep[] = []): Game {
|
||||
assert.equal(game.response?.kind, 'choamCharity');
|
||||
game = passAdvancedCharityResponses(game, actions);
|
||||
return advanceAdvancedCharityOrder(game, state => state.phase === 2 && advancedCharityOrderClean(state), actions);
|
||||
}
|
||||
export function finishAdvancedCharityTurn(game: Game, actions: AdvancedCharityOrderStep[] = []): Game {
|
||||
const actor = game.charityOrder?.remaining[0]; assert.ok(actor);
|
||||
return actAdvancedCharityOrder(game, actor, { type: 'ready' }, actions);
|
||||
}
|
||||
export function finishAdvancedCharityPhase(game: Game, actions: AdvancedCharityOrderStep[] = []): Game {
|
||||
return advanceAdvancedCharityOrder(game, state => state.phase !== 2, actions);
|
||||
}
|
||||
|
||||
/** Actual public CHOAM/BG/classic setup. Only original unopened starting-card
|
||||
* and land-Spice positions plus scalar lotteries are selected. A real phase1
|
||||
* bribe empties Atreides' purse; its recipient retains deferred incoming spice.
|
||||
* No purse, phase, order, readiness, claim, force or receipt is installed.
|
||||
*/
|
||||
export function createAdvancedCharityOrderFixture(options: { advanced?: boolean; lowBG?: boolean } = {}) {
|
||||
const advanced = options.advanced ?? true;
|
||||
let game = createGame('ADVANCEDCHARITYORDER', newPlayer('atreides', 'Atreides', 'atreides'), advanced, ['choam']);
|
||||
joinGame(game, newPlayer('beneGesserit', 'Bene Gesserit', 'beneGesserit'));
|
||||
joinGame(game, newPlayer('choam', 'CHOAM', 'choam'));
|
||||
const initial = structuredClone(game), actions: AdvancedCharityOrderStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actAdvancedCharityOrder(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceAdvancedCharityOrder(game, until, actions); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => !player.hand.length));
|
||||
const karama = game.deck.find(card => card.effect === 'karama');
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 2);
|
||||
assert.ok(karama); assert.equal(ordinary.length, 2);
|
||||
const prefix = [karama, ...ordinary];
|
||||
const dealOrder = prefix.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), setupActions = structuredClone(actions);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), prefix.map(card => card.id));
|
||||
const original = advancedCharityOrderInventory(game);
|
||||
const spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card); assert.ok(from >= position);
|
||||
const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
|
||||
return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
reach(state => state.phase === 1 && advancedCharityOrderClean(state));
|
||||
const beforeBribes = structuredClone(game), bribeFrom = actions.length;
|
||||
const low = ['atreides', ...(options.lowBG ? ['beneGesserit'] : [])];
|
||||
for (const actor of low) {
|
||||
const amount = advancedCharityOrderPlayer(game, actor).spice; assert.ok(amount > 0);
|
||||
act(actor, { type: 'bribe', target: 'choam', amount });
|
||||
assert.equal(advancedCharityOrderPlayer(game, actor).spice, 0);
|
||||
}
|
||||
const bribeActions = structuredClone(actions.slice(bribeFrom));
|
||||
reach(state => state.phase === 2 && state.response?.kind === 'choamCharity');
|
||||
assert.equal(advancedCharityOrderPlayer(game, 'choam').spice, 2);
|
||||
assert.equal(advancedCharityOrderPlayer(game, 'atreides').spice, 0);
|
||||
assert.ok(advancedCharityOrderPlayer(game, 'choam').bribes > 0);
|
||||
assert.deepEqual(advancedCharityOrderInventory(game), original);
|
||||
return { initial, lobby, setup, afterSetup, beforeBribes, beforeIncome: structuredClone(game), game,
|
||||
advanced, lowBG: options.lowBG ?? false, karama: karama.id, original,
|
||||
claimant: 'atreides', bg: 'beneGesserit', provider: 'choam', dealOrder, spiceOrder,
|
||||
actions, setupActions, bribeActions,
|
||||
sourceCounts: { setup: setupActions.length, bribes: bribeActions.length, source: actions.length },
|
||||
sourceDisclosure: 'Original public CHOAM/BeneGesserit/Atreides setup and native first Storm/Spice/closing controls. Advanced uses the explicit original preview start. Only original unopened starting-deal and land-Spice positions plus scalar lotteries selected. Real pre-Charity bribes create actual low claimants and deferred CHOAM incoming receipts; no money, order, phase, readiness or claim marker installed. Source stops at the original first CHOAM opening income response.' };
|
||||
}
|
||||
|
||||
/** Explicit unstamped UNIT supplement; not claimed historical saved-game proof. */
|
||||
export function unstampedAdvancedCharitySupplement(game: Game): Game {
|
||||
const old = advancedCharityOrderReload(game);
|
||||
delete old.charityOrder;
|
||||
return old;
|
||||
}
|
||||
@@ -144,6 +144,10 @@ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactions
|
||||
}
|
||||
if (game.phase === 3 && game.auction) return { actor: game.auction.active, action: { type: 'passBid' } };
|
||||
if (game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } };
|
||||
if (game.phase === 2 && game.charityOrder) {
|
||||
const actor = game.charityOrder.remaining[0];
|
||||
return actor ? {actor,action:{type:'ready'}} : null;
|
||||
}
|
||||
if (game.phase === 4 && game.revivalOrder) {
|
||||
const actor = game.revivalOrder.remaining[0];
|
||||
return actor ? {actor,action:{type:'ready'}} : null;
|
||||
|
||||
@@ -149,7 +149,7 @@ void test('already claimed or genuinely expired Charity cannot be reserved again
|
||||
game = answer(asked, expired.target, 'no'); physical(game, expired);
|
||||
});
|
||||
|
||||
void test('a phase2 positive answer reopens real own Ready and cannot forfeit its necessary Charity claim', () => {
|
||||
void test('a Basic phase2 positive answer reopens real own Ready and cannot forfeit its necessary Charity claim', () => {
|
||||
for (const kind of ['turnActions', 'bribe'] as const) {
|
||||
const f = fixture({ source: 'phase2' }); let source = pay(f.game, f, 5);
|
||||
source = act(source, f.target, { type: 'ready' }); assert.ok(source.ready.includes(f.target));
|
||||
@@ -162,6 +162,47 @@ void test('a phase2 positive answer reopens real own Ready and cannot forfeit it
|
||||
}
|
||||
});
|
||||
|
||||
void test('Advanced future Charity can fund an answer but its real claim guide waits for the original owned turn', () => {
|
||||
const f = fixture({ source: 'phase2', advanced: true });
|
||||
assert.notEqual(f.game.charityOrder!.remaining[0], f.target);
|
||||
let game = bind(f, 'yes').game;
|
||||
game = pay(game, f, 5);
|
||||
assert.equal(player(game, f.target).spice, 0);
|
||||
assert.notEqual(viewGame(game, f.target).turnActionGuide?.action.type, 'charity');
|
||||
rejected(game, f.target, f.charity);
|
||||
while (game.charityOrder!.remaining[0] !== f.target)
|
||||
game = act(game, game.charityOrder!.remaining[0], { type: 'ready' });
|
||||
const guide = viewGame(game, f.target).turnActionGuide;
|
||||
assert.equal(guide?.action.type, 'charity');
|
||||
rejected(game, f.target, { type: 'ready' });
|
||||
game = act(game, f.target, guide!.action);
|
||||
game = pay(game, f, 1);
|
||||
assert.equal(promise(game).fulfilled, true);
|
||||
assert.equal(player(game, f.target).spice, 1);
|
||||
physical(game, f);
|
||||
});
|
||||
|
||||
void test('new Advanced answers cannot reopen an actually completed Charity opportunity for another claim', () => {
|
||||
for (const kind of ['turnActions', 'bribe'] as const) {
|
||||
const f = fixture({ source: 'phase2', advanced: true }), respondent = 'atreides';
|
||||
let game = act(f.game, respondent, { type: 'bribe', target: f.asker, amount: player(f.game, respondent).spice });
|
||||
while (!game.ready.includes(respondent))
|
||||
game = act(game, game.charityOrder!.remaining[0], { type: 'ready' });
|
||||
assert.equal(game.phase, 2); assert.equal(game.active, f.target);
|
||||
const queue = structuredClone(game.charityOrder), ready = [...game.ready];
|
||||
const raw: TruthQuestion = { ...question(f, kind, 1), target: respondent };
|
||||
const asked = ask(declare(f, game).game, f.asker, raw);
|
||||
const view = viewGame(asked, respondent);
|
||||
assert.deepEqual(kind === 'turnActions' ? view.truthTurnActionAnswers : view.truthBribeAnswers, ['no']);
|
||||
rejected(asked, respondent, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = answer(asked, respondent, 'no');
|
||||
assert.deepEqual(game.charityOrder, queue); assert.deepEqual(game.ready, ready);
|
||||
rejected(game, respondent, f.charity);
|
||||
assert.notEqual(player(game, respondent).charityTurn, game.turn);
|
||||
physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('one native Charity is shared with shipment and cannot be claimed early to strand remaining payments', () => {
|
||||
const f = fixture({ source: 'afterAmal' });
|
||||
const impossible: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn,
|
||||
|
||||
@@ -172,9 +172,13 @@ void test('Advanced BG forced settlement after CHOAM passes last releases the fi
|
||||
const f = fixture({ advanced: true, bg: true }); assert.ok(f.bg);
|
||||
assert.ok(f.providerCard); assert.deepEqual(player(f.game, f.target).hand.map(card => card.id), [f.providerCard]);
|
||||
for (const kind of ['turnActions', 'bribe'] as const) {
|
||||
let game = bind(f, 'yes', question(f, kind, f.maximum), structuredClone(f.game)).game;
|
||||
let game = bind(f, 'yes', question(f, kind, f.minimum), structuredClone(f.game)).game;
|
||||
game = pay(game, f, f.startCash); game = allow(reachIncome(game));
|
||||
assert.equal(player(game, f.target).spice, f.incomeAmount); assert.equal(player(game, f.bg).spice, 5);
|
||||
// CHOAM's current cash meets this fixed-income answer. Finish its actual
|
||||
// turn before BG acts; the closed ordinary claim cannot repair a later debit.
|
||||
while (game.charityOrder!.remaining[0] !== f.bg)
|
||||
game = act(game, game.charityOrder!.remaining[0], { type: 'ready' });
|
||||
game = act(game, f.bg, f.charity); assert.equal(game.response?.kind, 'bgCharity');
|
||||
assert.equal(player(game, f.target).spice, f.incomeAmount); assert.ok(!promise(game, kind).released);
|
||||
while (game.response?.kind === 'bgCharity') {
|
||||
|
||||
Reference in new issue
Block a user