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
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user