From 11916306d0261cfb29ebd8de0e58e349446e34f8 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 15 Aug 2026 19:33:32 -0700 Subject: [PATCH 1/2] fix(wasm): seed AI passes in resolve all --- crates/engine-wasm/src/lib.rs | 66 ++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index d52e67921d..d003c9e92b 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -3062,19 +3062,30 @@ fn resolve_all_inner( let ai_difficulty = AiDifficulty::from_label(&seat.difficulty); let config = create_config_for_players(ai_difficulty, Platform::Wasm, state.players.len() as u8); - let Some(semantic_owner) = state - .waiting_for - .acting_player() - .or_else(|| state.waiting_for.acting_players().first().copied()) - else { - return ResolveAllCallbackDecision::Stop; - }; - let contract = AiDecisionContract::issue(state, semantic_owner); - match choose_action_with_session(state, semantic_owner, &config, rng, &session) { - Some(action) if contract.permits(state, actor, &action) => { - ResolveAllCallbackDecision::Proposal { contract, action } + match choose_action_with_session(state, actor, &config, rng, &session) { + // `seed_remaining_priority_cycle_passes` asks about future + // priority seats before `WaitingFor` advances to them. A + // priority pass is the sole raw action the batch accepts, so + // it remains valid without fabricating a future contract. + Some(GameAction::PassPriority) => { + ResolveAllCallbackDecision::Action(GameAction::PassPriority) } - Some(_) | None => ResolveAllCallbackDecision::Stop, + Some(action) => { + let Some(semantic_owner) = state + .waiting_for + .acting_player() + .or_else(|| state.waiting_for.acting_players().first().copied()) + else { + return ResolveAllCallbackDecision::Stop; + }; + let contract = AiDecisionContract::issue(state, semantic_owner); + if contract.permits(state, actor, &action) { + ResolveAllCallbackDecision::Proposal { contract, action } + } else { + ResolveAllCallbackDecision::Stop + } + } + None => ResolveAllCallbackDecision::Stop, } } else { ResolveAllCallbackDecision::Stop @@ -3311,6 +3322,37 @@ mod resolve_all_tests { with_state(|state| assert!(state.stack.is_empty())).unwrap(); clear_game_state(); } + + #[test] + fn resolve_all_tls_production_path_seeds_ai_priority_passes() { + let mut state = GameState::new(FormatConfig::free_for_all(), 3, 7); + state.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; + state.priority_player = PlayerId(0); + state.stack.push_back(no_op_entry(1, PlayerId(2))); + GAME_STATE.with(|cell| cell.set(Some(state))); + + let ai_seats = vec![ + AiSeatConfig { + player_id: 1, + difficulty: "Medium".to_string(), + }, + AiSeatConfig { + player_id: 2, + difficulty: "Medium".to_string(), + }, + ]; + let result = with_state_mut(|state| { + let mut rng = ChaCha20Rng::seed_from_u64(13); + resolve_all_inner(state, PlayerId(0), &ai_seats, 0, &mut rng) + }) + .unwrap(); + + assert_eq!(result.items_resolved, 1); + with_state(|state| assert!(state.stack.is_empty())).unwrap(); + clear_game_state(); + } } #[cfg(all(test, target_arch = "wasm32"))] From 5d88c4399dfad574ba07089d007ea7643351f442 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 15 Aug 2026 20:29:24 -0700 Subject: [PATCH 2/2] fix(wasm): evaluate seeded AI priority prompts --- crates/engine-wasm/src/lib.rs | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index d003c9e92b..29c7d0b149 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -3032,7 +3032,7 @@ pub fn submit_ai_action_proposal(token: &str, actor: u8, action: JsValue) -> JsV /// - Stack grows beyond the chunk-origin depth /// - An interactive `WaitingFor` appears (target selection, scry, etc.) /// - An unknown/non-requester human actor receives priority -/// - AI has no action for its priority decision +/// - AI declines to pass priority /// - Game ends /// - Safety cap reached (prevents infinite loops from cascading triggers) #[derive(serde::Deserialize)] @@ -3062,7 +3062,14 @@ fn resolve_all_inner( let ai_difficulty = AiDifficulty::from_label(&seat.difficulty); let config = create_config_for_players(ai_difficulty, Platform::Wasm, state.players.len() as u8); - match choose_action_with_session(state, actor, &config, rng, &session) { + // Seeding asks whether a later seat will pass before the live + // `WaitingFor` advances to that seat. Give the AI the exact + // future priority prompt on a clone; its contract otherwise has + // an empty candidate domain and it cannot select PassPriority. + let mut decision_state = state.clone(); + decision_state.waiting_for = WaitingFor::Priority { player: actor }; + decision_state.priority_player = actor; + match choose_action_with_session(&decision_state, actor, &config, rng, &session) { // `seed_remaining_priority_cycle_passes` asks about future // priority seats before `WaitingFor` advances to them. A // priority pass is the sole raw action the batch accepts, so @@ -3070,22 +3077,7 @@ fn resolve_all_inner( Some(GameAction::PassPriority) => { ResolveAllCallbackDecision::Action(GameAction::PassPriority) } - Some(action) => { - let Some(semantic_owner) = state - .waiting_for - .acting_player() - .or_else(|| state.waiting_for.acting_players().first().copied()) - else { - return ResolveAllCallbackDecision::Stop; - }; - let contract = AiDecisionContract::issue(state, semantic_owner); - if contract.permits(state, actor, &action) { - ResolveAllCallbackDecision::Proposal { contract, action } - } else { - ResolveAllCallbackDecision::Stop - } - } - None => ResolveAllCallbackDecision::Stop, + Some(_) | None => ResolveAllCallbackDecision::Stop, } } else { ResolveAllCallbackDecision::Stop