From e7108a3f02e7c8271b0ce2125c7c7ca7bc9401b9 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 15:21:06 +0000 Subject: [PATCH 1/6] perf(spells): look up learned spells by character and spell --- module/src/spell/spellbook.rs | 50 ++++++++++++++--------------------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/module/src/spell/spellbook.rs b/module/src/spell/spellbook.rs index 4c2390837..451c02793 100644 --- a/module/src/spell/spellbook.rs +++ b/module/src/spell/spellbook.rs @@ -10,10 +10,13 @@ use spacetimedb::{table, Identity, ReducerContext, Table}; use crate::{game_aura, game_spell}; // racial-passive attribute lookup (game_spell); aura strip (game_aura) -/// A spell a character has LEARNED (beyond the class baseline). Per-player, owner-scoped (RLS like -/// `game_character`/`game_player_skill`). Logical key `(character_guid, spell_id)` via an `#[auto_inc]` PK -/// + `by_character` btree. Durable (a learned ability persists across logout — no re-grant at login). [entity] -#[table(accessor = game_player_spell, public, index(accessor = by_character, btree(columns = [character_guid])))] +/// A Character's learned spell. The pair index answers membership without scanning the spellbook. +#[table( + accessor = game_player_spell, + public, + index(accessor = by_character, btree(columns = [character_guid])), + index(accessor = by_character_spell, btree(columns = [character_guid, spell_id])) +)] pub struct PlayerSpell { #[primary_key] #[auto_inc] @@ -91,15 +94,14 @@ pub(crate) fn createinfo_row_matches(row_race: u8, row_class: u8, race: u8, clas (row_race == 0 || row_race == race) && (row_class == 0 || row_class == class) } -/// Whether `guid` may cast `spell_id`: exactly the `game_player_spell` rows. The single spellbook gate -/// `cast_spell` enforces — creation grants the createinfo kit as rows, so there is no separate -/// "baseline" arm to drift from the stored book. [entity] +/// Whether the Character has learned this spell, including its creation kit. pub(crate) fn knows_spell(ctx: &ReducerContext, guid: u64, spell_id: u32) -> bool { ctx.db .game_player_spell() - .by_character() - .filter(&guid) - .any(|s| s.spell_id == spell_id) + .by_character_spell() + .filter((guid, spell_id)) + .next() + .is_some() } /// Apply the PASSIVE racial spells' auras for `race` at login (parallel to `apply_learned_talents`). Only @@ -127,19 +129,12 @@ pub(crate) fn apply_racial_passives(ctx: &ReducerContext, guid: u64, race: u8, l } } -/// Teach `guid` the spell `spell_id` — insert the row if absent (idempotent; never duplicates). -/// The single grant path: character creation (createinfo kit), ability talents, trainers, quest -/// rewards all come through here. [entity] +/// Learn a missing spell once. Creation, talents, trainers, and quest rewards use this operation. pub(crate) fn learn_spell(ctx: &ReducerContext, guid: u64, owner: Identity, spell_id: u32) { - let spells = ctx.db.game_player_spell(); - if spells - .by_character() - .filter(&guid) - .any(|s| s.spell_id == spell_id) - { - return; // already learned + if knows_spell(ctx, guid, spell_id) { + return; } - spells.insert(PlayerSpell { + ctx.db.game_player_spell().insert(PlayerSpell { id: 0, character_guid: guid, owner_identity: owner, @@ -150,18 +145,13 @@ pub(crate) fn learn_spell(ctx: &ReducerContext, guid: u64, owner: Identity, spel crate::skill::grant_lockpicking_on_learn(ctx, guid, owner, spell_id); } -/// Forget `guid`'s `spell_id` — delete every `game_player_spell` row for it (collect-then-delete; never -/// duplicates, so at most one row exists, but the pattern matches every other book sweep in this crate). -/// The unlearn twin of [`learn_spell`]: talent supersede/respec are the current callers, and any future -/// unlearn source (GM strip, a gossip respec option) should route through here rather than re-deriving -/// the collect-then-delete dance. Does not touch auras — pair with [`strip_spell_auras`] when the spell -/// also applied a passive. [entity] +/// Forget every matching row without changing auras. Talent supersession and respec remove those +/// separately through `strip_spell_auras`. pub(crate) fn forget_spell(ctx: &ReducerContext, guid: u64, spell_id: u32) { let spells = ctx.db.game_player_spell(); for row in spells - .by_character() - .filter(&guid) - .filter(|s| s.spell_id == spell_id) + .by_character_spell() + .filter((guid, spell_id)) .collect::>() { spells.id().delete(row.id); From d67e11e187a1b071a7a9b8b445e05211e0ed0560 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 15:23:16 +0000 Subject: [PATCH 2/6] test(playerbots): cover bounded recovery lookup and fixture limits --- module/tests/playerbots_rewrite/runner.rs | 104 ++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/module/tests/playerbots_rewrite/runner.rs b/module/tests/playerbots_rewrite/runner.rs index 7d93af329..0dfef6c02 100644 --- a/module/tests/playerbots_rewrite/runner.rs +++ b/module/tests/playerbots_rewrite/runner.rs @@ -33,6 +33,7 @@ fn outcomes(node: &Standalone) { "bots": node.query_rows("SELECT * FROM pkg_playerbots_bot"), "runner": node.query_rows("SELECT * FROM pkg_playerbots_runner"), "scheduler": node.query_rows("SELECT * FROM pkg_playerbots_scheduler"), + "recovery_scan": node.query_rows("SELECT * FROM pkg_playerbots_recovery_scan"), }); std::fs::write(path, serde_json::to_vec_pretty(&record).unwrap()).unwrap(); } @@ -656,3 +657,106 @@ fn playerbots_runner_observes_tactical_movement_without_advancing_the_home_clock assert!(observed["objective"].contains("last_verified_progress_micros = (none")); outcomes(&node); } + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_runner_finishes_bounded_recovery_lookup_and_revalidates_changes() { + let (node, bots) = fixture("playerbots-runner-recovery-scan", "1"); + let bot = &bots[0]; + node.assert_sql("DELETE FROM game_creature_move_schedule"); + node.assert_call("playerbots_fixture_runner_wide_recovery", &[bot]); + select(&node, bot, "recordOnly"); + let pass = || { + node.assert_call("playerbots_fixture_runner_due", &[]); + node.assert_call("playerbots_fixture_runner_pass", &[]); + }; + let scan = || { + node.query_rows(&format!( + "SELECT * FROM pkg_playerbots_recovery_scan WHERE character_guid = {bot}" + ))[0] + .clone() + }; + pass(); + assert!(scan()["stage"].contains("pending")); + assert_eq!(scan()["rows_examined"], "24"); + assert!(runner(&node, bot)["chosen"].contains("recovery")); + assert!(runner(&node, bot)["chosen"].contains("hold")); + assert!(node + .query_rows("SELECT * FROM game_pending_cast") + .is_empty()); + pass(); + assert!(scan()["stage"].contains("complete")); + assert_eq!(scan()["rows_examined"], "2"); + assert!(runner(&node, bot)["chosen"].contains("5090100")); + select(&node, bot, "cohort"); + pass(); + assert_eq!( + node.query_rows("SELECT spell_id FROM game_pending_cast")[0]["spell_id"], + "5090100" + ); + select(&node, bot, "recordOnly"); + let rotation = node + .query_rows("SELECT * FROM pkg_playerbots_rotation WHERE spell_id = 5090100")[0] + .clone(); + node.assert_sql(&format!( + "UPDATE pkg_playerbots_rotation SET condition = 0 WHERE id = {}", + rotation["id"] + )); + pass(); + assert!(!runner(&node, bot)["chosen"].contains("5090100")); + node.assert_sql(&format!( + "UPDATE pkg_playerbots_rotation SET condition = {} WHERE id = {}", + rotation["condition"], rotation["id"] + )); + pass(); + pass(); + assert!(runner(&node, bot)["chosen"].contains("5090100")); + node.assert_sql(&format!( + "DELETE FROM game_player_spell WHERE character_guid = {bot} AND spell_id = 5090100" + )); + pass(); + assert!(!runner(&node, bot)["chosen"].contains("5090100")); + node.assert_call("playerbots_fixture_runner_stage", &[bot, "true"]); + pass(); + pass(); + assert!(runner(&node, bot)["chosen"].contains("5090100")); + node.assert_sql("DELETE FROM pkg_playerbots_rotation WHERE spell_id = 5090100"); + pass(); + assert!(!runner(&node, bot)["chosen"].contains("5090100")); + assert!(scan()["stage"].contains("complete")); + assert!(scan()["selected_id"].contains("none")); + outcomes(&node); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_runner_fixture_targeting_preserves_other_bots_and_handles_grid_edges() { + let (node, bots) = fixture("playerbots-runner-fixture-scope", "2"); + node.assert_sql("DELETE FROM game_creature_move_schedule"); + for bot in &bots { + node.assert_call("playerbots_fixture_runner_stage", &[bot, "false"]); + select(&node, bot, "recordOnly"); + } + node.assert_call("playerbots_fixture_runner_due", &[]); + node.assert_call("playerbots_fixture_runner_pass", &[]); + node.assert_sql("UPDATE pkg_playerbots_bot SET next_think_micros = 9223372036854775807"); + node.assert_call("playerbots_fixture_runner_survival", &[&bots[0]]); + let other_due = || { + node.query_rows(&format!( + "SELECT next_think_micros FROM pkg_playerbots_bot WHERE character_guid = {}", + bots[1] + ))[0]["next_think_micros"] + .clone() + }; + assert_eq!(other_due(), "9223372036854775807"); + node.assert_call("playerbots_fixture_runner_expire_objective", &[&bots[0]]); + assert_eq!(other_due(), "9223372036854775807"); + for coordinate in ["17066", "-17066"] { + node.assert_sql(&format!( + "UPDATE game_world_entity SET x = {coordinate}, y = {coordinate} WHERE guid = {}", + bots[0] + )); + node.assert_call("playerbots_fixture_runner_clear_navigation", &[&bots[0]]); + } + outcomes(&node); +} From 511a70bac72dc87c6517ac2fdc13262825b6d6d7 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 15:29:00 +0000 Subject: [PATCH 3/6] test(playerbots): keep completed missing recovery facts during rescans --- module/tests/playerbots_rewrite/runner.rs | 54 +++++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/module/tests/playerbots_rewrite/runner.rs b/module/tests/playerbots_rewrite/runner.rs index 0dfef6c02..89578db6e 100644 --- a/module/tests/playerbots_rewrite/runner.rs +++ b/module/tests/playerbots_rewrite/runner.rs @@ -678,7 +678,7 @@ fn playerbots_runner_finishes_bounded_recovery_lookup_and_revalidates_changes() }; pass(); assert!(scan()["stage"].contains("pending")); - assert_eq!(scan()["rows_examined"], "24"); + assert_eq!(scan()["rows_scanned"], "24"); assert!(runner(&node, bot)["chosen"].contains("recovery")); assert!(runner(&node, bot)["chosen"].contains("hold")); assert!(node @@ -686,7 +686,7 @@ fn playerbots_runner_finishes_bounded_recovery_lookup_and_revalidates_changes() .is_empty()); pass(); assert!(scan()["stage"].contains("complete")); - assert_eq!(scan()["rows_examined"], "2"); + assert_eq!(scan()["rows_scanned"], "2"); assert!(runner(&node, bot)["chosen"].contains("5090100")); select(&node, bot, "cohort"); pass(); @@ -724,7 +724,7 @@ fn playerbots_runner_finishes_bounded_recovery_lookup_and_revalidates_changes() pass(); assert!(!runner(&node, bot)["chosen"].contains("5090100")); assert!(scan()["stage"].contains("complete")); - assert!(scan()["selected_id"].contains("none")); + assert!(scan()["result"].contains("missing")); outcomes(&node); } @@ -760,3 +760,51 @@ fn playerbots_runner_fixture_targeting_preserves_other_bots_and_handles_grid_edg } outcomes(&node); } + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_runner_finished_negative_recovery_scan_keeps_other_actions_eligible() { + let (node, bots) = fixture("playerbots-runner-recovery-missing", "1"); + let bot = &bots[0]; + node.assert_sql("DELETE FROM game_creature_move_schedule"); + node.assert_call("playerbots_fixture_runner_wide_recovery", &[bot]); + node.assert_sql(&format!("UPDATE game_player_spell SET spell_id = 5090999 WHERE character_guid = {bot} AND spell_id = 5090100")); + select(&node, bot, "recordOnly"); + let pass = || { + node.assert_call("playerbots_fixture_runner_due", &[]); + node.assert_call("playerbots_fixture_runner_pass", &[]); + }; + let scan = || { + node.query_rows(&format!( + "SELECT * FROM pkg_playerbots_recovery_scan WHERE character_guid = {bot}" + ))[0] + .clone() + }; + pass(); + assert!(scan()["result"].contains("pending")); + assert!(runner(&node, bot)["chosen"].contains("recovery")); + pass(); + assert!(scan()["stage"].contains("complete")); + assert!(scan()["result"].contains("missing")); + select(&node, bot, "cohort"); + pass(); + assert!(scan()["stage"].contains("pending")); + assert!(runner(&node, bot)["chosen"].contains("returnHome")); + assert!(!node + .query_rows(&format!( + "SELECT * FROM game_creature_spline WHERE guid = {bot}" + )) + .is_empty()); + select(&node, bot, "recordOnly"); + for _ in 0..4 { + pass(); + assert!(scan()["result"].contains("missing")); + assert!(runner(&node, bot)["chosen"].contains("returnHome")); + } + node.assert_sql(&format!("UPDATE game_player_spell SET spell_id = 5090100 WHERE character_guid = {bot} AND spell_id = 5090999")); + for _ in 0..2 { + pass(); + } + assert!(runner(&node, bot)["chosen"].contains("5090100")); + outcomes(&node); +} From 8109d980b2a74c06000d382bdebc936d53db3f34 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 15:32:14 +0000 Subject: [PATCH 4/6] docs(playerbots): define the bounded Recovery Scan --- CONTEXT.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CONTEXT.md b/CONTEXT.md index 1dfa81d3a..b0936f0d4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -217,6 +217,11 @@ A proposed action identified by its action, target, spell, reason, and Bot Objec **Strategy**: A composition of typed triggers, defaults, and integer priorities used to select Candidates. +**Recovery Scan**: +A bounded scan of healing rotations that retains its last completed result while reading the next +batch. The scan can be pending or complete; its result distinguishes unread, missing, and a selected +rotation. It rechecks selections before use and restarts after Transfer using destination state. + **Sessionless Action Consent**: A Character-owned core projection of a Package's controller selection. An absent row permits legacy behavior. Each selection updates consent and clears unclaimed Group Intents in one transaction, From a4b551893897c647b2cc1dfc6d7f1f2e2b375081 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 15:34:18 +0000 Subject: [PATCH 5/6] test(playerbots): separate recovery and fixture behavior contracts --- module/tests/playerbots_rewrite/runner.rs | 185 +++++++++++++--------- 1 file changed, 107 insertions(+), 78 deletions(-) diff --git a/module/tests/playerbots_rewrite/runner.rs b/module/tests/playerbots_rewrite/runner.rs index 89578db6e..6beb114da 100644 --- a/module/tests/playerbots_rewrite/runner.rs +++ b/module/tests/playerbots_rewrite/runner.rs @@ -658,43 +658,59 @@ fn playerbots_runner_observes_tactical_movement_without_advancing_the_home_clock outcomes(&node); } +fn recovery_fixture(label: &str) -> (Standalone, String) { + let (node, bots) = fixture(label, "1"); + let bot = bots[0].clone(); + node.assert_sql("DELETE FROM game_creature_move_schedule"); + node.assert_call("playerbots_fixture_runner_wide_recovery", &[&bot]); + select(&node, &bot, "recordOnly"); + (node, bot) +} + +fn runner_pass(node: &Standalone) { + node.assert_call("playerbots_fixture_runner_due", &[]); + node.assert_call("playerbots_fixture_runner_pass", &[]); +} + +fn recovery_scan(node: &Standalone, bot: &str) -> BTreeMap { + node.query_rows(&format!( + "SELECT * FROM pkg_playerbots_recovery_scan WHERE character_guid = {bot}" + ))[0] + .clone() +} + #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn playerbots_runner_finishes_bounded_recovery_lookup_and_revalidates_changes() { - let (node, bots) = fixture("playerbots-runner-recovery-scan", "1"); - let bot = &bots[0]; - node.assert_sql("DELETE FROM game_creature_move_schedule"); - node.assert_call("playerbots_fixture_runner_wide_recovery", &[bot]); - select(&node, bot, "recordOnly"); - let pass = || { - node.assert_call("playerbots_fixture_runner_due", &[]); - node.assert_call("playerbots_fixture_runner_pass", &[]); - }; - let scan = || { - node.query_rows(&format!( - "SELECT * FROM pkg_playerbots_recovery_scan WHERE character_guid = {bot}" - ))[0] - .clone() - }; - pass(); - assert!(scan()["stage"].contains("pending")); - assert_eq!(scan()["rows_scanned"], "24"); - assert!(runner(&node, bot)["chosen"].contains("recovery")); - assert!(runner(&node, bot)["chosen"].contains("hold")); +fn playerbots_runner_recovery_pages_reach_a_late_known_spell() { + let (node, bot) = recovery_fixture("playerbots-runner-recovery-scan"); + runner_pass(&node); + assert!(recovery_scan(&node, &bot)["stage"].contains("pending")); + assert_eq!(recovery_scan(&node, &bot)["rows_scanned"], "24"); + assert!(runner(&node, &bot)["chosen"].contains("recovery")); + assert!(runner(&node, &bot)["chosen"].contains("hold")); assert!(node .query_rows("SELECT * FROM game_pending_cast") .is_empty()); - pass(); - assert!(scan()["stage"].contains("complete")); - assert_eq!(scan()["rows_scanned"], "2"); - assert!(runner(&node, bot)["chosen"].contains("5090100")); - select(&node, bot, "cohort"); - pass(); + runner_pass(&node); + assert!(recovery_scan(&node, &bot)["stage"].contains("complete")); + assert_eq!(recovery_scan(&node, &bot)["rows_scanned"], "2"); + assert!(runner(&node, &bot)["chosen"].contains("5090100")); + select(&node, &bot, "cohort"); + runner_pass(&node); assert_eq!( node.query_rows("SELECT spell_id FROM game_pending_cast")[0]["spell_id"], "5090100" ); - select(&node, bot, "recordOnly"); + outcomes(&node); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_runner_recovery_revalidates_retained_rotation_and_spellbook_rows() { + let (node, bot) = recovery_fixture("playerbots-runner-recovery-validity"); + runner_pass(&node); + runner_pass(&node); + assert!(runner(&node, &bot)["chosen"].contains("5090100")); let rotation = node .query_rows("SELECT * FROM pkg_playerbots_rotation WHERE spell_id = 5090100")[0] .clone(); @@ -702,43 +718,61 @@ fn playerbots_runner_finishes_bounded_recovery_lookup_and_revalidates_changes() "UPDATE pkg_playerbots_rotation SET condition = 0 WHERE id = {}", rotation["id"] )); - pass(); - assert!(!runner(&node, bot)["chosen"].contains("5090100")); + runner_pass(&node); + assert!(!runner(&node, &bot)["chosen"].contains("5090100")); node.assert_sql(&format!( "UPDATE pkg_playerbots_rotation SET condition = {} WHERE id = {}", rotation["condition"], rotation["id"] )); - pass(); - pass(); - assert!(runner(&node, bot)["chosen"].contains("5090100")); + runner_pass(&node); + runner_pass(&node); + assert!(runner(&node, &bot)["chosen"].contains("5090100")); node.assert_sql(&format!( "DELETE FROM game_player_spell WHERE character_guid = {bot} AND spell_id = 5090100" )); - pass(); - assert!(!runner(&node, bot)["chosen"].contains("5090100")); - node.assert_call("playerbots_fixture_runner_stage", &[bot, "true"]); - pass(); - pass(); - assert!(runner(&node, bot)["chosen"].contains("5090100")); + runner_pass(&node); + assert!(!runner(&node, &bot)["chosen"].contains("5090100")); + node.assert_call("playerbots_fixture_runner_stage", &[&bot, "true"]); + runner_pass(&node); + runner_pass(&node); + assert!(runner(&node, &bot)["chosen"].contains("5090100")); node.assert_sql("DELETE FROM pkg_playerbots_rotation WHERE spell_id = 5090100"); - pass(); - assert!(!runner(&node, bot)["chosen"].contains("5090100")); - assert!(scan()["stage"].contains("complete")); - assert!(scan()["result"].contains("missing")); + runner_pass(&node); + assert!(!runner(&node, &bot)["chosen"].contains("5090100")); + assert!(recovery_scan(&node, &bot)["stage"].contains("complete")); + assert!(recovery_scan(&node, &bot)["result"].contains("missing")); + outcomes(&node); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_runner_wounded_comparison_preserves_large_health_ratios() { + let (node, bots) = fixture("playerbots-runner-large-health", "1"); + let bot = &bots[0]; + node.assert_sql("DELETE FROM game_creature_move_schedule"); + node.assert_call("playerbots_fixture_runner_stage", &[bot, "true"]); + select(&node, bot, "recordOnly"); + node.assert_sql(&format!("UPDATE game_world_entity SET health = 1000000000, max_health = 4000000000 WHERE guid = {bot}")); + runner_pass(&node); + assert!(runner(&node, bot)["chosen"].contains("recovery")); + node.assert_sql(&format!( + "UPDATE game_world_entity SET health = 2000000000 WHERE guid = {bot}" + )); + runner_pass(&node); + assert!(!runner(&node, bot)["chosen"].contains("recovery")); outcomes(&node); } #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn playerbots_runner_fixture_targeting_preserves_other_bots_and_handles_grid_edges() { +fn playerbots_runner_fixture_due_helpers_preserve_other_bots() { let (node, bots) = fixture("playerbots-runner-fixture-scope", "2"); node.assert_sql("DELETE FROM game_creature_move_schedule"); for bot in &bots { node.assert_call("playerbots_fixture_runner_stage", &[bot, "false"]); select(&node, bot, "recordOnly"); } - node.assert_call("playerbots_fixture_runner_due", &[]); - node.assert_call("playerbots_fixture_runner_pass", &[]); + runner_pass(&node); node.assert_sql("UPDATE pkg_playerbots_bot SET next_think_micros = 9223372036854775807"); node.assert_call("playerbots_fixture_runner_survival", &[&bots[0]]); let other_due = || { @@ -751,6 +785,14 @@ fn playerbots_runner_fixture_targeting_preserves_other_bots_and_handles_grid_edg assert_eq!(other_due(), "9223372036854775807"); node.assert_call("playerbots_fixture_runner_expire_objective", &[&bots[0]]); assert_eq!(other_due(), "9223372036854775807"); + outcomes(&node); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_runner_fixture_navigation_handles_both_grid_edges() { + let (node, bots) = fixture("playerbots-runner-fixture-grid", "1"); + node.assert_sql("DELETE FROM game_creature_move_schedule"); for coordinate in ["17066", "-17066"] { node.assert_sql(&format!( "UPDATE game_world_entity SET x = {coordinate}, y = {coordinate} WHERE guid = {}", @@ -763,48 +805,35 @@ fn playerbots_runner_fixture_targeting_preserves_other_bots_and_handles_grid_edg #[test] #[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] -fn playerbots_runner_finished_negative_recovery_scan_keeps_other_actions_eligible() { - let (node, bots) = fixture("playerbots-runner-recovery-missing", "1"); - let bot = &bots[0]; - node.assert_sql("DELETE FROM game_creature_move_schedule"); - node.assert_call("playerbots_fixture_runner_wide_recovery", &[bot]); +fn playerbots_runner_recovery_missing_result_keeps_other_actions_eligible() { + let (node, bot) = recovery_fixture("playerbots-runner-recovery-missing"); node.assert_sql(&format!("UPDATE game_player_spell SET spell_id = 5090999 WHERE character_guid = {bot} AND spell_id = 5090100")); - select(&node, bot, "recordOnly"); - let pass = || { - node.assert_call("playerbots_fixture_runner_due", &[]); - node.assert_call("playerbots_fixture_runner_pass", &[]); - }; - let scan = || { - node.query_rows(&format!( - "SELECT * FROM pkg_playerbots_recovery_scan WHERE character_guid = {bot}" - ))[0] - .clone() - }; - pass(); - assert!(scan()["result"].contains("pending")); - assert!(runner(&node, bot)["chosen"].contains("recovery")); - pass(); - assert!(scan()["stage"].contains("complete")); - assert!(scan()["result"].contains("missing")); - select(&node, bot, "cohort"); - pass(); - assert!(scan()["stage"].contains("pending")); - assert!(runner(&node, bot)["chosen"].contains("returnHome")); + runner_pass(&node); + assert!(recovery_scan(&node, &bot)["result"].contains("pending")); + assert!(runner(&node, &bot)["chosen"].contains("recovery")); + runner_pass(&node); + assert!(recovery_scan(&node, &bot)["stage"].contains("complete")); + assert!(recovery_scan(&node, &bot)["result"].contains("missing")); + select(&node, &bot, "cohort"); + std::thread::sleep(Duration::from_millis(1100)); + runner_pass(&node); + assert!(recovery_scan(&node, &bot)["stage"].contains("pending")); + assert!(runner(&node, &bot)["chosen"].contains("returnHome")); assert!(!node .query_rows(&format!( "SELECT * FROM game_creature_spline WHERE guid = {bot}" )) .is_empty()); - select(&node, bot, "recordOnly"); + select(&node, &bot, "recordOnly"); for _ in 0..4 { - pass(); - assert!(scan()["result"].contains("missing")); - assert!(runner(&node, bot)["chosen"].contains("returnHome")); + runner_pass(&node); + assert!(recovery_scan(&node, &bot)["result"].contains("missing")); + assert!(runner(&node, &bot)["chosen"].contains("returnHome")); } node.assert_sql(&format!("UPDATE game_player_spell SET spell_id = 5090100 WHERE character_guid = {bot} AND spell_id = 5090999")); for _ in 0..2 { - pass(); + runner_pass(&node); } - assert!(runner(&node, bot)["chosen"].contains("5090100")); + assert!(runner(&node, &bot)["chosen"].contains("5090100")); outcomes(&node); } From 4f450074d76f8982d1a7d61898a00cf632132737 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 16:01:19 +0000 Subject: [PATCH 6/6] test(playerbots): verify navigation cleanup at grid edges --- module/tests/playerbots_rewrite/runner.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/module/tests/playerbots_rewrite/runner.rs b/module/tests/playerbots_rewrite/runner.rs index 6beb114da..0d638f664 100644 --- a/module/tests/playerbots_rewrite/runner.rs +++ b/module/tests/playerbots_rewrite/runner.rs @@ -793,12 +793,27 @@ fn playerbots_runner_fixture_due_helpers_preserve_other_bots() { fn playerbots_runner_fixture_navigation_handles_both_grid_edges() { let (node, bots) = fixture("playerbots-runner-fixture-grid", "1"); node.assert_sql("DELETE FROM game_creature_move_schedule"); - for coordinate in ["17066", "-17066"] { + for (coordinate, edge_chunks) in [ + ("17066", "0,0,0,0,,;0,0,1,0,,;0,1,0,0,,;0,1,1,0,,"), + ( + "-17066", + "0,1022,1022,0,,;0,1022,1023,0,,;0,1023,1022,0,,;0,1023,1023,0,,", + ), + ] { + node.assert_call( + "import_nav_chunks", + &[&serde_json::to_string(&format!("{edge_chunks};0,512,512,0,,")).unwrap()], + ); + assert_eq!(node.query_rows("SELECT key FROM game_nav_chunk").len(), 5); node.assert_sql(&format!( "UPDATE game_world_entity SET x = {coordinate}, y = {coordinate} WHERE guid = {}", bots[0] )); node.assert_call("playerbots_fixture_runner_clear_navigation", &[&bots[0]]); + let remaining = node.query_rows("SELECT cell_x, cell_y FROM game_nav_chunk"); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0]["cell_x"], "512"); + assert_eq!(remaining[0]["cell_y"], "512"); } outcomes(&node); }