Apply player order to fresh Advanced Revival phases
This commit is contained in:
1 parent
fe6c41ba40
commit
011b6aa096
20 files changed
+1212
-46
No files matched your search
+11
-5
@@ -3189,8 +3189,6 @@ function policyActions(g: GameView): Action[] {
|
||||
prevention.targets.some(choice => choice.player === player.id && !choice.blocked));
|
||||
if (target) return [{ type: 'card', mode: 'special', card: prevention.cards[0], target: target.id }];
|
||||
}
|
||||
if (g.revival.prevented)
|
||||
return g.ready.includes(me.id) ? [] : [{ type: 'ready' }];
|
||||
const actions: Action[] = [];
|
||||
const tleilaxu = g.players.find((p) => p.faction === 'tleilaxu');
|
||||
const ordinaryLeaderAvailable = g.revival.leaders.some((l) => !l.early);
|
||||
@@ -3365,13 +3363,21 @@ function policyActions(g: GameView): Action[] {
|
||||
actions.push({ type: 'reviveLeader', leader: leader.id });
|
||||
if (
|
||||
!g.ready.includes(me.id) &&
|
||||
!(
|
||||
(g.revival.prevented || !(
|
||||
g.revivalRequests[me.id]?.price === null &&
|
||||
!g.revivalRequests[me.id]?.declined
|
||||
)
|
||||
))
|
||||
)
|
||||
actions.push({ type: 'ready' });
|
||||
return actions;
|
||||
return actions.filter(action => {
|
||||
if (['revive','reviveLeader','reviveKwisatz','reviveForeignGhola','requestLeaderRevival',
|
||||
'acceptLeaderRevival','cancelLeaderRequest','ready'].includes(action.type))
|
||||
return !g.revival.orderBlocked &&
|
||||
(action.type === 'ready' || action.type === 'cancelLeaderRequest' || !g.revival.prevented);
|
||||
if (action.type === 'emperorRevival' && g.revivalOrder)
|
||||
return g.revivalOrder.remaining[0] === me.ally;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
if (g.phase === 5) {
|
||||
const kulon = choamPowerBotPlay(g, 'kulon', (card) => technologyCardValue(g, card));
|
||||
|
||||
+65
-6
@@ -1,4 +1,6 @@
|
||||
import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income';
|
||||
import { createRevivalOrder, validateRevivalOrder, revivalOrderBlock, startRevivalOrder,
|
||||
finishRevivalOrder, RevivalOrderError, type RevivalOrder } from './revival-order';
|
||||
import { semutaTerminalArrivalSupported } from './semuta-terminal-arrival';
|
||||
import { semutaDisasterArrivalSupported } from './semuta-disaster-arrival';
|
||||
import { semutaAdvisorArrivalSupported } from './semuta-advisor-arrival';
|
||||
@@ -1307,6 +1309,7 @@ type RicheseAllyOffer = {
|
||||
payer: string;
|
||||
};
|
||||
export type Game = {
|
||||
revivalOrder?: RevivalOrder;
|
||||
pendingAutomaticAdvisorFlips?: AutomaticAdvisorFlipSource[];
|
||||
pendingAutomaticAdvisorTransport?: AutomaticAdvisorTransport;
|
||||
pendingAutomaticAdvisorGamont?: { receipt: AutomaticAdvisorGamont; card: string; nexusEvent?: string };
|
||||
@@ -17990,6 +17993,7 @@ function earlyGroundMovementPolicy(g: Game, player: Player): Action[] | null {
|
||||
const current = completion.early?.find(step => step.phase === g.phase)?.action;
|
||||
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 || g.phase === 4) return current ? [current] : g.ready.includes(player.id) ? [] : [{ type: 'ready' }];
|
||||
return null;
|
||||
}
|
||||
@@ -17998,13 +18002,13 @@ 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, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
if (groundMovementCompletion(answerSource, p.id, [...live, promise])) answers.push(answer ? 'yes' : 'no');
|
||||
}
|
||||
return answers.length ? answers : ['unknown'];
|
||||
}
|
||||
function bindMovementTruth(g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) {
|
||||
if ((g.phase === 2 || g.phase === 4) && g.ready.includes(p.id)) {
|
||||
if ((g.phase === 2 || (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.`);
|
||||
}
|
||||
@@ -23556,7 +23560,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));
|
||||
g.ready = [];
|
||||
if (!(g.phase === 4 && g.revivalOrder)) g.ready = [];
|
||||
g.active = null;
|
||||
const choam = byFaction(g, 'choam');
|
||||
if (g.phase === 8 && choam) {
|
||||
@@ -23595,6 +23599,10 @@ function finishEcazPlacement(g: Game) {
|
||||
}
|
||||
function completePhase(g: Game) {
|
||||
if (closeNexusCards(g)) return;
|
||||
if (g.phase === 4 && g.revivalOrder) {
|
||||
delete g.revivalOrder;
|
||||
g.ready = [];
|
||||
}
|
||||
if (g.phase === 6) {
|
||||
delete g.battleOrder;
|
||||
delete g.battleOrderUseEvents;
|
||||
@@ -23632,6 +23640,33 @@ function openPhase(g: Game, initialize = true) {
|
||||
if (g.expansions.includes('ix')) g.phaseOpening = { passed: [], initialize };
|
||||
else if (initialize) beginPhase(g);
|
||||
}
|
||||
function revivalOrderContext(g: Game) {
|
||||
return {turn:g.turn,order:g.order,ready:g.ready};
|
||||
}
|
||||
function revivalOrderRule<T>(calculate: () => T): T {
|
||||
try { return calculate(); }
|
||||
catch (error) {
|
||||
if (error instanceof RevivalOrderError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function revivalOrderIntegrity(g: Game) {
|
||||
if (!g.revivalOrder) return;
|
||||
requireRule(g.advanced && g.status === 'playing' && g.phase === 4,
|
||||
'The ordered Revival belongs to its original Advanced phase.');
|
||||
revivalOrderRule(() => validateRevivalOrder(revivalOrderContext(g),g.revivalOrder!));
|
||||
requireRule(g.active === (g.revivalOrder.remaining[0] ?? null),
|
||||
'Revival must retain its current player through the complete opportunity.');
|
||||
}
|
||||
function nativeRevivalOrderBlock(g: Game, player: string): string | null {
|
||||
return g.revivalOrder
|
||||
? revivalOrderRule(() => revivalOrderBlock(revivalOrderContext(g),g.revivalOrder!,player))
|
||||
: null;
|
||||
}
|
||||
function startNativeRevivalOrder(g: Game, player: string) {
|
||||
if (g.revivalOrder)
|
||||
g.revivalOrder = revivalOrderRule(() => startRevivalOrder(revivalOrderContext(g),g.revivalOrder!,player));
|
||||
}
|
||||
function beginPhase(g: Game) {
|
||||
if (g.phase === 0) {
|
||||
g.auditorInsight = null;
|
||||
@@ -23661,6 +23696,11 @@ function beginPhase(g: Game) {
|
||||
p.freeForcesRevived = 0;
|
||||
p.leaderRevived = false;
|
||||
}
|
||||
if (g.advanced) {
|
||||
g.revivalOrder = revivalOrderRule(() => createRevivalOrder(revivalOrderContext(g)));
|
||||
g.active = g.revivalOrder.remaining[0] ?? null;
|
||||
log(g, 'Advanced Force Revival proceeds from the First Player counterclockwise. Finish your Revival turn after your native force and leader choices; authorized responses and card timing remain available.');
|
||||
}
|
||||
}
|
||||
if (g.phase === 3) setAuction(g);
|
||||
if (g.phase === 5) {
|
||||
@@ -26152,6 +26192,7 @@ function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string
|
||||
}
|
||||
return revivalSeeds.map(seed => {
|
||||
seed.game.phase = 5; seed.game.ready = []; seed.game.karamaShipping = null;
|
||||
delete seed.game.revivalOrder;
|
||||
const p = getPlayer(seed.game, owner); p.shipped = false; p.moved = 0;
|
||||
return seed;
|
||||
});
|
||||
@@ -26356,6 +26397,7 @@ function earlyShipmentGuidance(g: Game, p: Player): { preparation: { actions: Ac
|
||||
? [{type:'richeseBid',event:lot.event,amount:lot.method === 'silent' ? 0 : null,allyPayment:0}] : []};
|
||||
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 || g.phase === 4)
|
||||
return { preparation: current ? { actions: [current] } : null,
|
||||
policy: current ? [current] : g.ready.includes(p.id) ? [] : [{ type: 'ready' }] };
|
||||
@@ -26373,8 +26415,8 @@ function shipmentTruthAnswers(
|
||||
...prior,
|
||||
{ ...claim, turn: g.turn, player: p.id, asker: '', answer },
|
||||
];
|
||||
// A new commitment reopens its respondent's owned Charity/Revival Ready.
|
||||
const answerSource = g.phase === 2 || g.phase === 4
|
||||
// Concurrent legacy readiness can reopen; a completed ordered turn cannot.
|
||||
const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder)
|
||||
? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
if (findShipmentCompletion(answerSource, p, promises))
|
||||
answers.push(answer ? 'yes' : 'no');
|
||||
@@ -26389,7 +26431,7 @@ function bindShipmentTruth(
|
||||
asker: string,
|
||||
mixed?: MixedShipmentExpression,
|
||||
) {
|
||||
if ((g.phase === 2 || g.phase === 4) && g.ready.includes(p.id)) {
|
||||
if ((g.phase === 2 || (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.`);
|
||||
}
|
||||
@@ -34289,6 +34331,7 @@ function normalizeCardNames(g: Game) {
|
||||
]);
|
||||
}
|
||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
revivalOrderIntegrity(state);
|
||||
state = migrateAtomicsTargets(state);
|
||||
state = migrateFremenNexusRevival(state);
|
||||
if (state.spiceBankerIncomePreview) {
|
||||
@@ -34541,6 +34584,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
arrivalOverlapIntegrity(g);
|
||||
guildBetrayalIntegrity(g);
|
||||
harkonnenNexusBetrayalIntegrity(g);
|
||||
revivalOrderIntegrity(g);
|
||||
return g;
|
||||
}
|
||||
function finishActionContinuations(g: Game) {
|
||||
@@ -37592,6 +37636,7 @@ function applyActionInner(
|
||||
g.phase === 4,
|
||||
'Extra revivals are available during Revival.',
|
||||
);
|
||||
if (execution === 'live') startNativeRevivalOrder(g,recipient.id);
|
||||
}
|
||||
const maximum =
|
||||
t === 'emperorGift'
|
||||
@@ -37700,6 +37745,8 @@ function applyActionInner(
|
||||
'Tleilaxu leader negotiations require the Revival phase.',
|
||||
);
|
||||
g.revivalRequests ??= {};
|
||||
if (execution === 'live' && t !== 'quoteLeaderRevival' && t !== 'declineLeaderRevival')
|
||||
startNativeRevivalOrder(g,id);
|
||||
if (t === 'cancelLeaderRequest') {
|
||||
delete g.revivalRequests[id];
|
||||
return g;
|
||||
@@ -37783,6 +37830,7 @@ function applyActionInner(
|
||||
g.advanced && g.phase === 4 && p.faction === 'tleilaxu',
|
||||
'Foreign gholas require advanced Tleilaxu during Revival.',
|
||||
);
|
||||
if (execution === 'live') startNativeRevivalOrder(g,id);
|
||||
requireRule(action.leader !== DUKE_VIDAL_ID,
|
||||
'Only Ecaz may revive Duke Vidal, including with Ghola.');
|
||||
const leader = g.players
|
||||
@@ -37826,6 +37874,7 @@ function applyActionInner(
|
||||
}
|
||||
if (t === 'revive') {
|
||||
requireRule(g.phase === 4, 'Revival is not the current phase.');
|
||||
if (execution === 'live') startNativeRevivalOrder(g,id);
|
||||
const n = integer(action.amount, 1, forceRevivalRemaining(g, p), 'Forces');
|
||||
const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite);
|
||||
requireRule(
|
||||
@@ -37876,6 +37925,7 @@ function applyActionInner(
|
||||
g.phase === 4 && (!p.leaderRevived || p.faction === 'tleilaxu'),
|
||||
'Only one leader revival is permitted this phase.',
|
||||
);
|
||||
if (execution === 'live') startNativeRevivalOrder(g,id);
|
||||
requireRule(
|
||||
!revivalPrevented(g, p.id),
|
||||
'Tleilaxu prevented this faction’s normal revivals for this turn.',
|
||||
@@ -38836,6 +38886,12 @@ function applyActionInner(
|
||||
'Complete the current phase action first.',
|
||||
);
|
||||
requireRule(!g.ready.includes(id), 'You are already ready.');
|
||||
if (g.phase === 4 && g.revivalOrder) {
|
||||
g.revivalOrder = revivalOrderRule(() => finishRevivalOrder(revivalOrderContext(g),g.revivalOrder!,id));
|
||||
g.active = g.revivalOrder.remaining[0] ?? null;
|
||||
if (g.revivalRequests) delete g.revivalRequests[id];
|
||||
log(g, `${p.name} finished their Revival turn.`,{faction:p.faction,name:'Revival turn complete'});
|
||||
}
|
||||
g.ready.push(id);
|
||||
if (g.ready.length === g.players.length) {
|
||||
if (g.phase === 0) startStormMovement(g, g.stormPending!);
|
||||
@@ -38850,6 +38906,7 @@ function applyActionInner(
|
||||
throw new RuleError('That action is not available.');
|
||||
}
|
||||
export function viewGame(state: Game, id: string) {
|
||||
revivalOrderIntegrity(state);
|
||||
state = migrateAtomicsTargets(state);
|
||||
state = migrateFremenNexusRevival(state);
|
||||
harkonnenNexusBetrayalIntegrity(state);
|
||||
@@ -39711,7 +39768,9 @@ export function viewGame(state: Game, id: string) {
|
||||
([player]) => player === id || me.faction === 'tleilaxu',
|
||||
),
|
||||
),
|
||||
revivalOrder: g.revivalOrder ? structuredClone(g.revivalOrder) : null,
|
||||
revival: {
|
||||
orderBlocked: nativeRevivalOrderBlock(g,id),
|
||||
pending: !!g.pendingRevival,
|
||||
specialKaramaBlock: null,
|
||||
leaders: leaderRevivals.leaders,
|
||||
|
||||
+25
-1
@@ -46,7 +46,7 @@ export const PHASE_HELP = [
|
||||
'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.',
|
||||
'Bid or pass in turn. 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. Finish when your revival choices are complete.',
|
||||
'Return eligible forces and leaders from the tanks to your reserves. New Advanced Revival phases proceed from the First Player counterclockwise: 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.',
|
||||
'Choose a battle, resolve faction powers, seal plans, decide traitors, then choose which winning cards to keep.',
|
||||
'Forces collect spice in their own sector. Review the results, then continue to Mentat Pause.',
|
||||
@@ -3776,6 +3776,30 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'card-harvester',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'advanced-revival-order',
|
||||
title: 'Advanced Revival: player order',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Take native Revival choices from the First Player counterclockwise, then explicitly finish your turn.',
|
||||
steps: [
|
||||
'A newly opened Advanced Revival follows the adopted revised rules: the First Player acts first, then the remaining players counterclockwise. The panel names the current player, remaining turns and completed turns.',
|
||||
'During your turn, choose ordinary forces, your eligible leader or Kwisatz Haderach, and any native leader negotiation. This preview groups native force and leader choices into one opportunity; the source explicitly orders Force Revival and describes leader returns in addition.',
|
||||
'Tleilaxu can answer another player’s request, grant a larger limit or offer an ally discount outside its own turn. Fremen can grant allied free revival. Emperor can fund its ally’s extra forces during that ally’s opportunity. Finish every accepted return and its required responses before ending the turn.',
|
||||
'Choose Finish Revival turn when done. An unanswered leader request is withdrawn by this explicit finish. Completed native turns stay finished. Card effects, Nexus cards, special Karama and their responses keep their separate timing; taking part in a response does not consume your own turn.',
|
||||
'Basic retains concurrent readiness. An already-opened older Advanced Revival finishes its saved readiness timing; the next new Advanced Revival uses player order. This does not redeal or restart a game.',
|
||||
'Sapho’s additional Revival-order adapter remains unfinished. Full Advanced games, optional combinations and final multiplayer/deployed acceptance remain open.',
|
||||
],
|
||||
checklist: [
|
||||
{area:'Implementation',status:'Partial',detail:'Fresh Advanced Revival has one current native opportunity, explicit completion and retained response ownership. Leader grouping is an explicit composition; Basic and unstamped current phases retain prior timing.',evidence:['game/revival-order.ts','game/engine.ts']},
|
||||
{area:'Player controls',status:'Partial',detail:'Persistent current/remaining/completed order, guarded Finish and native-return/commerce availability use the projected opportunity.',evidence:['components/revival-turn.tsx','components/revival-commerce.tsx','components/game-table.tsx']},
|
||||
{area:'AI',status:'Partial',detail:'Minimal policies wait for native self returns and Finish while retaining authorized responder and card actions. Strategy and calibration remain deferred.',evidence:['game/bots.ts']},
|
||||
{area:'Documentation',status:'Partial',detail:'Supplied Advanced physical9/printed7 authority, explicit leader composition and fresh-phase cutover are recorded.',evidence:['docs/ADVANCED_REVIVAL_ORDER.md','docs/RULE_DECISIONS.md']},
|
||||
{area:'Verification',status:'Partial',detail:'Focused helper, played native order/return and guarded control cases cover the changed path. Full-mode, browser/mobile, recovery and deployed acceptance remain due.',evidence:['tests/revival-order.test.ts','tests/advanced-revival-order-engine.test.ts','tests/advanced-revival-order-controls.test.tsx','tests/fixture-advanced-revival-order.ts']},
|
||||
],
|
||||
related: ['advanced-storm-spice','tleilaxu-revival','choam-revival','juice-of-sapho'],
|
||||
},
|
||||
{
|
||||
id: 'advanced-allied-separation',
|
||||
title: 'Advanced alliances: ending shared occupation',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/** One native Revival opportunity per faction in public First Player order.
|
||||
* Leader decisions remain within that faction's opportunity; this grouping is
|
||||
* an implementation composition of the supplied Advanced physical9/printed7. */
|
||||
export type RevivalOrder = {
|
||||
version: 1;
|
||||
turn: number;
|
||||
original: string[];
|
||||
remaining: string[];
|
||||
completed: string[];
|
||||
started: boolean;
|
||||
};
|
||||
export type RevivalOrderContext = {
|
||||
turn: number;
|
||||
order: readonly string[];
|
||||
ready: readonly string[];
|
||||
};
|
||||
export class RevivalOrderError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'RevivalOrderError';
|
||||
}
|
||||
}
|
||||
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 string[] => Array.isArray(value) && value.every(id);
|
||||
const same = (a: readonly string[], b: readonly string[]) => a.length === b.length && a.every((value, index) => value === b[index]);
|
||||
function requireOrder(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new RevivalOrderError(message);
|
||||
}
|
||||
function validateContext(context: RevivalOrderContext): void {
|
||||
requireOrder(record(context) && Number.isSafeInteger(context.turn) && context.turn > 0 &&
|
||||
ids(context.order) && context.order.length >= 2 && context.order.length <= 6 &&
|
||||
new Set(context.order).size === context.order.length,
|
||||
'Revival needs a current turn and two through six distinct factions in public order.');
|
||||
requireOrder(ids(context.ready) && same(context.ready, context.order.slice(0, context.ready.length)),
|
||||
'Completed Revival factions must be the public-order prefix.');
|
||||
}
|
||||
function copy(frame: RevivalOrder): RevivalOrder {
|
||||
return { version: 1, turn: frame.turn, original: [...frame.original],
|
||||
remaining: [...frame.remaining], completed: [...frame.completed], started: frame.started };
|
||||
}
|
||||
|
||||
export function createRevivalOrder(context: RevivalOrderContext): RevivalOrder {
|
||||
validateContext(context);
|
||||
return { version: 1, turn: context.turn, original: [...context.order],
|
||||
remaining: context.order.slice(context.ready.length), completed: [...context.ready], started: false };
|
||||
}
|
||||
|
||||
export function validateRevivalOrder(context: RevivalOrderContext, frame: RevivalOrder): void {
|
||||
validateContext(context);
|
||||
requireOrder(record(frame) && Object.keys(frame).sort().join(',') ===
|
||||
'completed,original,remaining,started,turn,version' && frame.version === 1 &&
|
||||
Number.isSafeInteger(frame.turn) && frame.turn === context.turn &&
|
||||
ids(frame.original) && ids(frame.remaining) && ids(frame.completed) && typeof frame.started === 'boolean',
|
||||
'The current Revival order is malformed or belongs to another turn.');
|
||||
requireOrder(same(frame.original, context.order) && same(frame.completed, context.ready) &&
|
||||
same(frame.remaining, frame.original.slice(frame.completed.length)),
|
||||
'The Revival order must retain the original public order and its completed prefix.');
|
||||
requireOrder(frame.remaining.length > 0 || !frame.started,
|
||||
'Completed Revival cannot retain a started opportunity.');
|
||||
}
|
||||
|
||||
export function revivalOrderBlock(context: RevivalOrderContext, frame: RevivalOrder, player: string): string | null {
|
||||
validateRevivalOrder(context, frame);
|
||||
requireOrder(id(player), 'Choose a valid Revival faction.');
|
||||
if (!frame.original.includes(player)) return 'Only seated factions have a Revival opportunity.';
|
||||
if (!frame.remaining.length) return 'Revival opportunities are complete.';
|
||||
if (frame.completed.includes(player)) return 'Your Revival opportunity has finished.';
|
||||
return frame.remaining[0] === player ? null : 'Wait for your Revival opportunity.';
|
||||
}
|
||||
|
||||
/** Mark an accepted self request without consuming or reopening the opportunity.
|
||||
* Further requests by the same current faction retain this idempotent stamp. */
|
||||
export function startRevivalOrder(context: RevivalOrderContext, frame: RevivalOrder, player: string): RevivalOrder {
|
||||
const blocked = revivalOrderBlock(context, frame, player);
|
||||
requireOrder(!blocked, blocked ?? 'This Revival opportunity is unavailable.');
|
||||
return { ...copy(frame), started: true };
|
||||
}
|
||||
|
||||
/** Receives the pre-completion ready prefix. The caller installs this frame and
|
||||
* appends the same current faction to ready in its one accepted transaction. */
|
||||
export function finishRevivalOrder(context: RevivalOrderContext, frame: RevivalOrder, player: string): RevivalOrder {
|
||||
const blocked = revivalOrderBlock(context, frame, player);
|
||||
requireOrder(!blocked, blocked ?? 'This Revival opportunity is unavailable.');
|
||||
const next = { ...copy(frame), remaining: frame.remaining.slice(1),
|
||||
completed: [...frame.completed, player], started: false };
|
||||
validateRevivalOrder({ ...context, ready: [...context.ready, player] }, next);
|
||||
return next;
|
||||
}
|
||||
Reference in new issue
Block a user