Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion client/src/game/__tests__/dispatchResolveAll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useGameStore } from "../../stores/gameStore";
import { useAppNotificationStore } from "../../stores/appToastStore";
import { usePreferencesStore } from "../../stores/preferencesStore";
import { buildGameState, buildPriorityWaitingFor, buildStackEntry } from "../../test/factories/gameStateFactory";
import { dispatchResolveAll } from "../dispatch";
import { dispatchAction, dispatchResolveAll } from "../dispatch";

// A Priority-on-the-storming-player WaitingFor (active player holds priority).
const priorityWf: BatchResolveResult["waitingFor"] = buildPriorityWaitingFor();
Expand Down Expand Up @@ -66,6 +66,7 @@ describe("dispatchResolveAll progress", () => {

afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});

it("reports the engine-proved prefix once and clears progress at the end", async () => {
Expand Down Expand Up @@ -224,6 +225,73 @@ describe("dispatchResolveAll progress", () => {

expect(resolveAll).toHaveBeenCalledWith(0, [], 5);
});

it("submits a Resolve All click queued behind a fresh Priority snapshot", async () => {
vi.useFakeTimers();
usePreferencesStore.setState({ animationSpeedMultiplier: 1 });

const initialPriority = buildPriorityWaitingFor();
const freshPriority = buildPriorityWaitingFor();
const priorityState = buildGameState({
waiting_for: initialPriority,
stack: [buildStackEntry({ id: 1 })],
});
const postPassState = buildGameState({
waiting_for: freshPriority,
stack: [buildStackEntry({ id: 1 })],
});
const consentState = buildGameState({
waiting_for: { type: "ResolveAllConsent", data: { epoch: 1, representative: 1 } },
stack: [buildStackEntry({ id: 1 })],
});
const submitAction = vi
.fn()
.mockResolvedValueOnce({
events: [{ type: "LifeChanged", data: { player_id: 0, amount: -1 } }],
log_entries: [],
})
.mockResolvedValue({ events: [], log_entries: [] });
const getSnapshot = vi
.fn<() => Promise<EngineSnapshot>>()
.mockResolvedValueOnce({
state: postPassState,
legalResult: { actions: [{ type: "PassPriority" }], autoPassRecommended: false },
seq: nextSnapshotSeq(),
})
.mockResolvedValueOnce({
state: consentState,
legalResult: { actions: [], autoPassRecommended: false },
seq: nextSnapshotSeq(),
})
.mockResolvedValue({
state: consentState,
legalResult: { actions: [], autoPassRecommended: false },
seq: nextSnapshotSeq(),
});

useGameStore.setState({
gameState: priorityState,
waitingFor: initialPriority,
legalActions: [{ type: "PassPriority" }],
adapter: { submitAction, getSnapshot, resolveAll: vi.fn() } as never,
});

const pass = dispatchAction({ type: "PassPriority" }, 0);
await vi.advanceTimersByTimeAsync(0);
const resolveAll = dispatchResolveAll(0, [{ playerId: 1, difficulty: "Medium" }]);
const cancelAutoPass = dispatchAction({ type: "CancelAutoPass" }, 0);

await vi.runAllTimersAsync();
await pass;
await resolveAll;
await cancelAutoPass;

expect(submitAction).toHaveBeenNthCalledWith(2, {
type: "BeginResolveAll",
data: { max_resolutions: 5 },
}, 0);
expect(submitAction).toHaveBeenNthCalledWith(3, { type: "CancelAutoPass" }, 0);
});
it("consumes Ready consent with an empty AI-seat list when the server owns native AI", async () => {
const resolveAll = vi.fn<EngineResolveAll>().mockResolvedValue(chunk(0, 2));
const getState = vi.fn().mockResolvedValue(stateWithStack(0));
Expand Down
5 changes: 4 additions & 1 deletion client/src/game/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,15 +217,18 @@ function queuedLocalActionStillApplies(next: PendingLocalAction): boolean {
if (
next.action.type === "SetPhaseStops"
|| next.action.type === "SetPriorityPassingMode"
|| next.action.type === "CancelAutoPass"
) {
return true;
}
const { gameState, legalActions, waitingFor } = useGameStore.getState();
if (Object.is(next.waitingFor, waitingFor)) return true;
if (!waitingForActorMatches(waitingFor, gameState, next.actor)) return false;
if (legalActions.some((action) => actionsEqual(action, next.action))) return true;
// Resolve All begins from Priority but is deliberately absent from normal
// legal actions: it opens its own engine-authored consent protocol.
return (
next.action.type === "PassPriority" &&
(next.action.type === "PassPriority" || next.action.type === "BeginResolveAll") &&
waitingFor?.type === "Priority" &&
gameState != null
);
Expand Down
157 changes: 127 additions & 30 deletions crates/engine/src/game/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7590,11 +7590,7 @@ fn begin_resolve_all_consent(
priority_player: PlayerId,
max_resolutions: u32,
) -> Result<WaitingFor, EngineError> {
if state.priority_player
!= turn_control::authorized_submitter_for_player(state, priority_player)
{
return Err(EngineError::NotYourPriority);
}
super::priority::pass_priority_legality(state, priority_player)?;
let current_representative =
super::topology::priority_pass_representative(state, priority_player);
let mut representatives = super::topology::priority_pass_participants(state);
Expand Down Expand Up @@ -7657,8 +7653,9 @@ fn resolve_all_consent_waiting_for(state: &GameState) -> Option<WaitingFor> {
)
}

// CR 117.3d + CR 117.4: A declined shortcut resumes the exact ordinary
// priority-pass sequence it interrupted; no spell or ability has resolved.
// CR 117.3d + CR 117.4: A declined optimized batch restores the exact
// priority-pass sequence it interrupted before ordinary priority handling
// resumes; this restoration itself resolves no stack object.
fn restore_resolve_all_priority_snapshot(state: &mut GameState) -> Result<WaitingFor, EngineError> {
let run = state.resolve_all_consent_run.take().ok_or_else(|| {
EngineError::InvalidAction("Resolve All consent is not active".to_string())
Expand All @@ -7672,6 +7669,109 @@ fn restore_resolve_all_priority_snapshot(state: &mut GameState) -> Result<Waitin
})
}

/// Installs one player's requested auto-pass mode and, when that player holds
/// the current Priority window, consumes it through the ordinary pipeline.
///
/// `SetAutoPass` and a declined Resolve All consent share this exact reducer
/// path: both preserve the current stack baseline and must obey the same
/// shortened-precast-pass restriction.
fn install_auto_pass_and_pass_priority(
state: &mut GameState,
auto_pass_owner: PlayerId,
mode: AutoPassRequest,
events: &mut Vec<GameEvent>,
) -> Result<ActionResult, EngineError> {
let WaitingFor::Priority { player } = &state.waiting_for else {
unreachable!("auto-pass may only be installed from a Priority window");
};
let pass_immediately = *player == auto_pass_owner;
if pass_immediately && super::precast_copy_shortcut::blocks_pass(state, *player) {
return Err(EngineError::ActionNotAllowed(
"A shortened pre-cast shortcut requires a different meaningful action before passing"
.to_string(),
));
}
store_auto_pass_request(state, auto_pass_owner, mode);
if !pass_immediately {
return Ok(ActionResult {
events: std::mem::take(events),
waiting_for: state.waiting_for.clone(),
log_entries: vec![],
});
}
let waiting_for = pass_priority_once_with_pipeline(state, events, None)?;
Ok(ActionResult {
events: std::mem::take(events),
waiting_for,
log_entries: vec![],
})
}

fn store_auto_pass_request(
state: &mut GameState,
auto_pass_owner: PlayerId,
mode: AutoPassRequest,
) {
let stored_mode = match mode {
AutoPassRequest::UntilStackEmpty => AutoPassMode::UntilStackEmpty {
initial_stack_len: state.stack.len(),
},
AutoPassRequest::UntilTurnBoundary { until } => AutoPassMode::UntilTurnBoundary { until },
};
state.auto_pass.insert(auto_pass_owner, stored_mode);
}

/// Stores Resolve All's durable "do not make me pass each frame" intent in
/// the same engine-owned `UntilStackEmpty` flow as a direct priority request.
pub(crate) fn install_until_stack_empty_auto_pass_and_pass_priority(
state: &mut GameState,
auto_pass_owner: PlayerId,
events: &mut Vec<GameEvent>,
) -> Result<ActionResult, EngineError> {
install_auto_pass_and_pass_priority(
state,
auto_pass_owner,
AutoPassRequest::UntilStackEmpty,
events,
)
}

/// Retains Resolve All's durable no-manual-priority preference when a rules
/// guard prevents its initial immediate pass. The normal auto-pass loop resumes
/// after that required action completes.
pub(crate) fn install_until_stack_empty_auto_pass(
state: &mut GameState,
auto_pass_owner: PlayerId,
) {
store_auto_pass_request(state, auto_pass_owner, AutoPassRequest::UntilStackEmpty);
}

/// CR 117.3d + CR 117.4: Declining the optimized Resolve All batch preserves
/// the requester's intent by switching to the ordinary engine auto-pass flow.
fn decline_resolve_all_consent_with_auto_pass(
state: &mut GameState,
epoch: u64,
representative: PlayerId,
response_epoch: u64,
events: &mut Vec<GameEvent>,
) -> Result<ActionResult, EngineError> {
let waiting_for = respond_resolve_all_consent(
state,
epoch,
representative,
response_epoch,
ResolveAllConsentDecision::Decline,
)?;
let WaitingFor::Priority { player } = waiting_for else {
unreachable!("declined Resolve All consent must restore Priority");
};
// `pass_priority_once_with_pipeline` derives the semantic priority seat
// from this state. Install the captured Priority window before reusing the
// normal SetAutoPass path, rather than passing from the consent prompt.
state.waiting_for = WaitingFor::Priority { player };
install_until_stack_empty_auto_pass_and_pass_priority(state, player, events)
}

fn respond_resolve_all_consent(
state: &mut GameState,
epoch: u64,
Expand Down Expand Up @@ -8174,6 +8274,24 @@ fn apply_action(
(WaitingFor::Priority { player }, GameAction::BeginResolveAll { max_resolutions }) => {
begin_resolve_all_consent(state, *player, max_resolutions)?
}
(
WaitingFor::ResolveAllConsent {
epoch,
representative,
},
GameAction::RespondResolveAllConsent {
epoch: response_epoch,
decision: ResolveAllConsentDecision::Decline,
},
) => {
return decline_resolve_all_consent_with_auto_pass(
state,
*epoch,
*representative,
response_epoch,
&mut events,
);
}
(
WaitingFor::ResolveAllConsent {
epoch,
Expand Down Expand Up @@ -11863,28 +11981,7 @@ fn apply_action(
GameAction::PassParadigmOffer,
) => WaitingFor::Priority { player: *player },
(WaitingFor::Priority { player }, GameAction::SetAutoPass { mode }) => {
if super::precast_copy_shortcut::blocks_pass(state, *player) {
return Err(EngineError::ActionNotAllowed(
"A shortened pre-cast shortcut requires a different meaningful action before passing"
.to_string(),
));
}
// Convert request to stored mode, capturing engine state as needed.
let stored_mode = match mode {
AutoPassRequest::UntilStackEmpty => AutoPassMode::UntilStackEmpty {
initial_stack_len: state.stack.len(),
},
AutoPassRequest::UntilTurnBoundary { until } => {
AutoPassMode::UntilTurnBoundary { until }
}
};
state.auto_pass.insert(*player, stored_mode);
let wf = pass_priority_once_with_pipeline(state, &mut events, None)?;
return Ok(ActionResult {
events,
waiting_for: wf,
log_entries: vec![],
});
return install_auto_pass_and_pass_priority(state, *player, mode, &mut events);
}
// CR 701.34a: Proliferate — player selected targets to proliferate.
(
Expand Down Expand Up @@ -20056,7 +20153,7 @@ mod stage2_injector_tests {
// Resolve All consent adds its frozen-authority protocol above this producer:
// `:12912 ⇒ :13113`. It does not create a CR 603.5 prompt, and the pinned
// line remains the same `OptionalEffectChoice` construction.
"game/engine.rs:13113".to_string(),
"game/engine.rs:13210".to_string(),
],
"the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \
plus the two repeated-optional-payment drivers, the per-player acceptance cursor \
Expand Down
34 changes: 31 additions & 3 deletions crates/engine/src/game/engine_resolve_batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ pub fn resolve_all_ready_prefix(
let mut log_entries = Vec::new();
let mut recorded_actions = Vec::new();
let mut items_resolved = 0;
let mut proof_stopped = false;

let Some(run) = ready_consent_run(state, requester).cloned() else {
if matches!(&state.waiting_for, WaitingFor::ResolveAllReady { .. }) {
Expand Down Expand Up @@ -104,6 +105,7 @@ pub fn resolve_all_ready_prefix(
let stack_before = proof.stack.len();
let Some((boundary, mut actions)) = materialize_one_consented_resolution(&mut proof, &run)
else {
proof_stopped = true;
break;
};

Expand All @@ -117,6 +119,7 @@ pub fn resolve_all_ready_prefix(
|| !stack::priority_checkpoint_is_settled(&proof)
|| !consent_authorization_matches(&proof, &run)
{
proof_stopped = true;
break;
}

Expand All @@ -127,10 +130,35 @@ pub fn resolve_all_ready_prefix(
*state = proof;
}

// Authorization is one run only. Once the proved prefix ends (including
// a zero-length or cap boundary), return the remaining stack to ordinary
// priority; no later stack entry inherits this consent.
// Authorization is one run only. Once the proved prefix ends, no later
// stack entry inherits this consent. A proof failure is different from a
// requested cap: it only rejects collapsing this sequence, not the
// requester's durable intent to avoid manual priority passes. Continue
// through the ordinary `UntilStackEmpty` engine path in that case.
turn_control::invalidate_resolve_all_consent(state);
if proof_stopped {
let mut fallback_events = Vec::new();
match super::engine::install_until_stack_empty_auto_pass_and_pass_priority(
state,
run.priority_snapshot.waiting_player,
&mut fallback_events,
) {
Ok(fallback) => {
items_resolved += stack_resolved_count(&fallback.events);
events.extend(fallback.events);
log_entries.extend(fallback.log_entries);
}
Err(_) => {
// A pre-cast shortcut may require a meaningful action before the
// current Priority window can pass. Keep the requester's durable
// preference so the ordinary loop resumes after that action.
super::engine::install_until_stack_empty_auto_pass(
state,
run.priority_snapshot.waiting_player,
);
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
finalize_display_state(state);
interaction::ensure_interaction_authority(state);

Expand Down
8 changes: 8 additions & 0 deletions crates/engine/src/types/game_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10650,6 +10650,14 @@ impl PersistedGameState {
// CR 732.2a (FIX-3): drop stale transient loop-detection bookkeeping on load unless the save
// sits in an object-growth shortcut window whose pending resolution still consumes it.
state.migrate_transient_loop_sequence();
// `pending_trigger_event_batch` is a construction carrier for the
// corresponding `pending_trigger`. A historical save can retain the
// carrier after its trigger was dropped; it cannot represent live
// mid-resolution work and would make every conservative stack batch
// reject an otherwise valid Priority checkpoint.
if state.pending_trigger.is_none() {
state.pending_trigger_event_batch.clear();
}
// CR 109.5 + CR 611.2a: discard any restriction still carrying the raw
// `SourceController` placeholder — a legitimately-captured state never has
// one (it is lowered to the activator at creation), so this only sanitizes
Expand Down
Loading
Loading