Sequence Advanced Charity from the First Player

This commit is contained in:
Quintine committed 2026-10-27 10:50:22 +11:00
1 parent 84fdd404c9
commit 54787c7f7b
23 files changed
+1016 -62

No files matched your search

+1
View File
@@ -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 &&
+78
View File
@@ -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
View File
@@ -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
View File
@@ -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',