diff --git a/importer/src/main.rs b/importer/src/main.rs index 27fa64efa..7e70eaa67 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -466,14 +466,10 @@ mod qt { pub const LIMIT_TIME: usize = 19; pub const SPECIAL_FLAGS: usize = 21; // bit0 = QUEST_SPECIAL_FLAGS_REPEATABLE pub const PREV_QUEST_ID: usize = 22; // prerequisite quest (must be turned in first); 0 = none - // [V] NextQuestId/NextQuestInChain (the successor auto-offered on turn-in) — unverified column - // index (no cmangos dump in this sandbox to confirm against). Bracketed between the two - // already-trusted constants either side: 3 slots after PREV_QUEST_ID=22 (PrevQuestId, NextQuestId, - // ExclusiveGroup sit between), 2 slots before SRC_ITEM_ID=27. Coverage-printed at import time - // (chained-quest count) + a presence floor (`FLOOR_QUESTS_CHAINED`, importer/scripts/import-manifest.sh) — - // McBride's 783->7->15->21->54 chain (already verified via the working `prev_quest_id` import) - // should populate this symmetrically, so >=1 is a safe floor, unlike LIMIT_TIME above. - pub const NEXT_QUEST_IN_CHAIN: usize = 25; + // ClassicDB names columns 23 through 26 NextQuestId, ExclusiveGroup, BreadcrumbForQuestId, + // and NextQuestInChain. The forward chain is column 26. Quest 783 carries 0 at column 25 and 7 + // at column 26, so using the breadcrumb column silently loses its link to quest 7. + pub const NEXT_QUEST_IN_CHAIN: usize = 26; pub const SRC_ITEM_ID: usize = 27; // SrcItemId — item HANDED to the player on accept (provided item) pub const SRC_ITEM_COUNT: usize = 28; // SrcItemCount (0 in the dump means 1) pub const TITLE: usize = 30; @@ -8285,6 +8281,28 @@ mod tests { format!("({})", cols.join(",")) } + /// The leading fields of ClassicDB's real quest 783 row. Keep the two adjacent zero and seven + /// values literal so this catches a column constant that drifts back to BreadcrumbForQuestId. + fn classicdb_quest_783_row() -> String { + let mut cols = vec!["0".to_string(); 123]; + for (index, value) in [ + (0, "783"), + (1, "2"), + (2, "9"), + (3, "1"), + (4, "255"), + (5, "1"), + (8, "77"), + (20, "8"), + (25, "0"), + (26, "7"), + (30, "'A Threat Within'"), + ] { + cols[index] = value.to_string(); + } + format!("({})", cols.join(",")) + } + /// A tiny but REAL synthetic dump: one in-box creature (a quest giver) + its template + one /// quest + the giver relation linking them — just enough for `build_dump_plan` to run /// end-to-end (every family's prerequisite parsing touches this data) without a real cmangos @@ -8945,6 +8963,33 @@ mod tests { assert!(insert.contains(",501,3600,777"), "next_quest_id=501, limit_time=3600, reward_money_max_level=777 must thread through, in order, as END-appended columns: {insert}"); } + #[test] + fn classicdb_quest_783_uses_next_quest_in_chain_after_breadcrumb() { + let dump = format!( + "INSERT INTO `creature` VALUES (1,823,0,1,-8933.54,-136.523,83.4466,0,300,300,0,0); \ + INSERT INTO `creature_template` VALUES {}; \ + INSERT INTO `quest_template` VALUES {}; \ + INSERT INTO `creature_questrelation` VALUES (823,783);", + creature_template_row(823, 0), + classicdb_quest_783_row(), + ); + let mut args = test_args(); + args.family = Some("quests".to_string()); + let plan = build_dump_plan(&dump, &args, &None, &None).expect("quest import plan"); + let insert = plan + .stmts + .iter() + .find(|statement| { + statement.starts_with("INSERT INTO game_quest_template") + && statement.contains("(783,") + }) + .expect("quest 783 insert"); + assert!( + insert.contains(",false,7,0,"), + "quest 783 must retain its forward link to quest 7: {insert}" + ); + } + #[test] fn insert_row_count_counts_values_tuples_not_statements() { // The provenance-stamp fix (216 review finding): a 3-row batched INSERT is 3 rows, a DELETE diff --git a/module/src/actor.rs b/module/src/actor.rs index b644df236..6846282e6 100644 --- a/module/src/actor.rs +++ b/module/src/actor.rs @@ -19,8 +19,10 @@ //! | `accept_quest` | `quest::apply_accept_quest` | alive + giver in range offering the quest + level/race/class/prereq/duplicate gates | //! | `stage_quest` | `quest::grant_quest_unchecked` | HARNESS/BOT staging: same row shape, all accept gates SKIPPED (giver-less) | //! | `turn_in_quest` | `quest::apply_turn_in_quest` | alive + giver in range ending the quest + objectives complete; rewards atomic | -//! | `open_creature_loot` | `loot::open_creature_corpse` | alive + dead creature corpse on the same map within 10yd + Loot Tag eligibility; authorizes the following read | -//! | `take_loot` | `items::apply_take_loot` | alive + corpse/GO on same map within 10yd + Loot Tag eligibility for creatures + slot occupied; inventory-full rolls back | +//! | `open_creature_loot` | `loot::open_creature_corpse` | legacy result adapter used by the Gateway | +//! | `request_open_creature_loot` | `loot::request_open_creature_corpse` | typed open acceptance or Refusal; applies the same corpse and Loot Tag Gates | +//! | `take_loot` | `items::apply_take_loot` | legacy result adapter used by the existing rest-and-loot goal | +//! | `request_take_loot` | `items::request_take_loot` | typed take completion or Refusal; inventory-full leaves the item and Loot Source unchanged | //! | `loot_money` | `loot::apply_loot_money` | alive + dead creature corpse, same map, 10yd, money > 0 + Loot Tag eligibility | //! | `buy_item` | `items::apply_buy_item` | vendor in range + stocked + money; stacks/slots validated | //! | `sell_item` | `items::apply_item_sell` | vendor in range + sellable item in slot; feeds the buyback ring | @@ -35,6 +37,8 @@ //! | `reconcile_profile_item` | `items::request_profile_item` | bounded top-up + capacity/uniqueness gates | //! | `equip_profile_upgrade` | `items::apply_equip_profile_upgrade` | normal equip gates + preserves equal or stronger gear | //! | `use_gameobject` | `gameobject::apply_use_gameobject` | GO resolved by guid + range/use gates | +//! | `request_use_gameobject` | `gameobject::request_use_gameobject` | typed target, partition, range, and use Refusal | +//! | `import_revision` | `game_import_meta` read | current importer source and file identities for one family | //! | `repop` | `world::do_repop` | dead actor releases to the graveyard ghost | //! | `respond_resurrect` | `spell::do_resurrect_response` | consume the actor's pending rez offer; accept revives IN PLACE at the offer's % | //! | `spirit_res` | `world::do_spirit_healer_res` | ghost actor res at the spirit healer (sickness applies) | @@ -52,8 +56,27 @@ //! `packages/` drop-ins, discovered by `module/build.rs`). Both silence unused-import ONLY in the //! build where the consumer isn't compiled, and neither is a licence to keep a verb no tree calls. +use crate::import_meta::game_import_meta; use spacetimedb::ReducerContext; +#[cfg_attr(not(has_packages), allow(dead_code))] +pub(crate) struct ImportRevision { + pub source_sha: String, + pub file_hash: String, +} + +#[cfg_attr(not(has_packages), allow(dead_code))] +pub(crate) fn import_revision(ctx: &ReducerContext, family: &str) -> Option { + ctx.db + .game_import_meta() + .family() + .find(family.to_string()) + .map(|meta| ImportRevision { + source_sha: meta.source_sha, + file_hash: meta.file_hash, + }) +} + // ---- combat ---- // A `debug_only!` verb's sole consumer today is the feature-gated harness; a default build @@ -126,12 +149,14 @@ debug_only! { pub(crate) use crate::quest::grant_quest_unchecked as stage_quest; // ---- loot / inventory / vendor ---- +pub(crate) use crate::loot::open_creature_corpse as open_creature_loot; package_only! { pub(crate) use crate::items::apply_item_use as use_item; - pub(crate) use crate::loot::open_creature_corpse as open_creature_loot; + pub(crate) use crate::loot::request_open_creature_corpse as request_open_creature_loot; pub(crate) use crate::items::apply_take_loot as take_loot; pub(crate) use crate::items::request_profile_item as reconcile_profile_item; pub(crate) use crate::items::apply_equip_profile_upgrade as equip_profile_upgrade; + pub(crate) use crate::items::request_take_loot as request_take_loot; // `loot_money` also feeds playerbots' drink-at-rest behavior (work-item 154). pub(crate) use crate::loot::apply_loot_money as loot_money; } @@ -150,10 +175,11 @@ package_only! { // ---- NPC services / world ---- -debug_only! { +package_only! { pub(crate) use crate::gameobject::apply_use_gameobject as use_gameobject; - pub(crate) use crate::trainer::apply_trainer_buy as trainer_buy; + pub(crate) use crate::gameobject::request_use_gameobject as request_use_gameobject; } +debug_only! { pub(crate) use crate::trainer::apply_trainer_buy as trainer_buy; } package_only! { pub(crate) use crate::spell::do_resurrect_response as respond_resurrect; pub(crate) use crate::world::do_repop as repop; diff --git a/module/src/creatures/spawn.rs b/module/src/creatures/spawn.rs index 90c3d051b..55632c8da 100644 --- a/module/src/creatures/spawn.rs +++ b/module/src/creatures/spawn.rs @@ -521,7 +521,8 @@ pub(crate) fn timer_never(ctx: &spacetimedb::ReducerContext) -> spacetimedb::Tim // `game_aura`'s `by_expiry`, and `gateway/tests/schema_parity.rs` checks columns/bindings, not // indexes, so the gateway is unaffected). index(accessor = by_respawn_at, btree(columns = [respawn_at])), - index(accessor = by_despawn_at, btree(columns = [despawn_at])) + index(accessor = by_despawn_at, btree(columns = [despawn_at])), + index(accessor = by_entry_map, btree(columns = [entry, map_id])) )] pub struct CreatureSpawn { #[primary_key] @@ -562,6 +563,24 @@ pub struct CreatureSpawn { pub life_seq: u64, } +/// Read stored spawn evidence for one creature entry without walking the world spawn table. +/// Callers still inspect `game_world_entity` when they need a live target they can act on now. +#[cfg_attr(not(has_packages), allow(dead_code))] +pub(crate) fn creature_spawn_evidence( + ctx: &ReducerContext, + entry: u32, + map_id: u32, + limit: usize, +) -> Vec { + const MAX_RESULTS: usize = 128; + ctx.db + .game_creature_spawn() + .by_entry_map() + .filter((entry, map_id)) + .take(limit.min(MAX_RESULTS)) + .collect() +} + /// Which life of `creature_guid` is standing right now. /// /// A creature keeps its guid across death and respawn, so a durable row keyed on the guid needs this diff --git a/module/src/gameobject.rs b/module/src/gameobject.rs index 3cb9e7264..c1b455ec8 100644 --- a/module/src/gameobject.rs +++ b/module/src/gameobject.rs @@ -201,6 +201,10 @@ pub struct GameObjectUnlocked { // sentinel on every live row, so a `1..=now` range visits only DUE nodes — the same trick // `game_aura.by_next_tick` uses. index(accessor = by_respawn_at, btree(columns = [respawn_at_micros])), + index( + accessor = by_template_scope, + btree(columns = [template_entry, map_id, instance_id]) + ), // The AOI cell index — exactly 3 columns, all matched by equality terms, which is the // only shape SpacetimeDB 2.7.1's subscription planner can serve (see the `cell` column). index(accessor = by_cell, btree(columns = [map_id, instance_id, cell])) @@ -287,6 +291,26 @@ pub struct GameObject { pub rotation_3: f32, } +/// Read stored GameObject destination evidence through an exact template and partition index. +/// State and distance decide whether a returned object can be used now. They do not erase the +/// stored destination when every matching object is depleted or far away. +#[cfg_attr(not(has_packages), allow(dead_code))] +pub(crate) fn gameobject_destination_evidence( + ctx: &ReducerContext, + template_entry: u32, + map_id: u32, + instance_id: u64, + limit: usize, +) -> Vec { + const MAX_RESULTS: usize = 128; + ctx.db + .game_gameobject() + .by_template_scope() + .filter((template_entry, map_id, instance_id)) + .take(limit.min(MAX_RESULTS)) + .collect() +} + // =========================================================================================== // Gathering POOL model (cmangos pool_template + pool_gameobject) — MODEL A (delete/insert) // =========================================================================================== @@ -643,39 +667,55 @@ fn locked_shut(ctx: &ReducerContext, go_guid: u64, lock_id: u32) -> bool { .is_none() } -/// The GO target-acquisition gate shared by [`apply_pick_lock`] and [`apply_use_gameobject`]: -/// resolve the GameObject + its template, resolve the caster's live entity, and confirm they -/// share a map+instance and are within [`USE_RANGE_SQ`]. Every error string below is preserved verbatim -/// from both call sites (the two ~30-line blocks were byte-identical before this extraction). +/// The typed GameObject target Gate shared by lock picking and both use adapters. It resolves the +/// GameObject, template and live Character, then applies partition and range Gates once. fn usable_go( ctx: &ReducerContext, caster_guid: u64, go_guid: u64, -) -> Result<(GameObject, GameObjectTemplate), String> { +) -> Result<(GameObject, GameObjectTemplate), crate::actor::ActionRefusal> { + use crate::actor::{ActionRefusal, ActionRefusalKind}; + let go = ctx .db .game_gameobject() .guid() .find(go_guid) - .ok_or_else(|| "no such gameobject".to_string())?; + .ok_or_else(|| { + ActionRefusal::new(ActionRefusalKind::MissingTarget, "no such gameobject") + })?; let player = crate::helpers::live_entity(ctx, caster_guid) - .map_err(|_| "user not in world".to_string())?; + .map_err(|_| ActionRefusal::new(ActionRefusalKind::MissingActor, "user not in world"))?; if player.map_id != go.map_id { - return Err("gameobject on another map".to_string()); + return Err(ActionRefusal::new( + ActionRefusalKind::OtherPartition, + "gameobject on another map", + )); } if player.instance_id != go.instance_id { - return Err("gameobject in another instance".to_string()); + return Err(ActionRefusal::new( + ActionRefusalKind::OtherPartition, + "gameobject in another instance", + )); } let (dx, dy, dz) = (go.x - player.x, go.y - player.y, go.z - player.z); if dx * dx + dy * dy + dz * dz > USE_RANGE_SQ { - return Err("gameobject out of range".to_string()); + return Err(ActionRefusal::new( + ActionRefusalKind::OutOfRange, + "gameobject out of range", + )); } let tmpl = ctx .db .game_gameobject_template() .entry() .find(go.template_entry) - .ok_or_else(|| format!("no gameobject template {}", go.template_entry))?; + .ok_or_else(|| { + ActionRefusal::new( + ActionRefusalKind::MissingTarget, + format!("no gameobject template {}", go.template_entry), + ) + })?; Ok((go, tmpl)) } @@ -692,7 +732,7 @@ pub(crate) fn apply_pick_lock( caster_guid: u64, go_guid: u64, ) -> Result<(), String> { - let (_go, tmpl) = usable_go(ctx, caster_guid, go_guid)?; + let (_go, tmpl) = usable_go(ctx, caster_guid, go_guid).map_err(String::from)?; if tmpl.lock_id == 0 { return Err("it is not locked".to_string()); } @@ -753,6 +793,16 @@ pub(crate) fn apply_pick_lock( /// Shared use-a-gameobject core for Character and debug paths. [`usable_go`] requires a live /// Character, a loaded GameObject template, the same map and instance, and use range before /// type-specific effects run. Supported lock-bearing types also apply [`locked_shut`] first. [entity] +#[cfg_attr(not(has_packages), allow(dead_code))] +pub(crate) fn request_use_gameobject( + ctx: &ReducerContext, + caster_guid: u64, + go_guid: u64, +) -> Result<(), crate::actor::ActionRefusal> { + let (go, tmpl) = usable_go(ctx, caster_guid, go_guid)?; + use_resolved_gameobject(ctx, caster_guid, go, tmpl).map_err(Into::into) +} + pub(crate) fn apply_use_gameobject( ctx: &ReducerContext, caster_guid: u64, @@ -761,7 +811,16 @@ pub(crate) fn apply_use_gameobject( // Map + instance gated (190 slice 2 — GO rows carry `instance_id` now): a player may only use // a GO in THEIR OWN instance (dungeon doors/chests are per-instance copies; the static // instance-0 originals on a dungeon map are the copy SOURCES, unreachable from inside a run). - let (mut go, tmpl) = usable_go(ctx, caster_guid, go_guid)?; + let (go, tmpl) = usable_go(ctx, caster_guid, go_guid).map_err(String::from)?; + use_resolved_gameobject(ctx, caster_guid, go, tmpl) +} + +fn use_resolved_gameobject( + ctx: &ReducerContext, + caster_guid: u64, + mut go: GameObject, + tmpl: GameObjectTemplate, +) -> Result<(), String> { // Snapshot for the on_go_used notify hook fired at the success exit below — `go` is moved into // an update (or deleted by a pool reroll) inside the dispatch arms. The instance is the GO // ROW'S OWN (190 slice 2, per 228's documented splice note; equal to the user's after the gate diff --git a/module/src/items/ops.rs b/module/src/items/ops.rs index 3823545e7..d3cd48563 100644 --- a/module/src/items/ops.rs +++ b/module/src/items/ops.rs @@ -4,7 +4,7 @@ //! reducer and its debug twin (see `reducers.rs`). All effects are additive: they touch only the item //! rows + the actor's health/money. -use crate::actor::ActionRefusal; +use crate::actor::{ActionRefusal, ActionRefusalKind}; use spacetimedb::{Identity, ReducerContext, Table}; use lyracore_shared::constants::starter_item; @@ -752,7 +752,8 @@ pub(crate) fn apply_death_durability_loss(ctx: &ReducerContext, player_guid: u64 } } -/// Shared take-an-item-from-a-corpse logic for the player + debug paths (`CMSG_AUTOSTORE_LOOT_ITEM`): +/// Typed take-an-item-from-a-corpse request for Package callers and the legacy player adapter +/// (`CMSG_AUTOSTORE_LOOT_ITEM`): /// move one `game_corpse_loot` item into the looter's backpack. Validates the looter is in world and /// alive, the corpse exists / is dead / is on the same map; resolves the loot row at `loot_slot`, mints /// a fresh owned `ItemInstance` in the first free backpack slot, then DELETES the consumed loot row. @@ -761,20 +762,22 @@ pub(crate) fn apply_death_durability_loss(ctx: &ReducerContext, player_guid: u64 /// missing/alive/on another map, the Loot Tag excludes the looter, the slot has no loot, the item /// template is missing, or the backpack is full. Creature corpses clone quest rows to their resolved /// eligibility set. GameObjects retain their current-group clone behavior. [entity] -pub(crate) fn apply_take_loot( +pub(crate) fn request_take_loot( ctx: &ReducerContext, player_guid: u64, corpse_guid: u64, loot_slot: u8, -) -> Result<(), String> { +) -> Result<(), ActionRefusal> { let entities = ctx.db.game_world_entity(); - let player = entities - .guid() - .find(player_guid) - .ok_or_else(|| "looter not in world".to_string())?; + let player = entities.guid().find(player_guid).ok_or_else(|| { + ActionRefusal::new(ActionRefusalKind::MissingActor, "looter not in world") + })?; // Death is server-authoritative everywhere — a dead/ghost looter can't take loot (mirrors loot_money). if player.dead { - return Err("dead players cannot loot".to_string()); + return Err(ActionRefusal::new( + ActionRefusalKind::DeadActor, + "dead players cannot loot", + )); } // The loot source is EITHER a dead creature corpse (game_world_entity) OR a used gameobject // (game_gameobject — a chest, whose `use_gameobject` rolled rows into game_corpse_loot keyed on its @@ -783,7 +786,10 @@ pub(crate) fn apply_take_loot( let (src_map, src_instance, sx, sy, sz, source_is_corpse) = if let Some(corpse) = entities.guid().find(corpse_guid) { if !corpse.dead { - return Err("target is not a corpse".to_string()); + return Err(ActionRefusal::new( + ActionRefusalKind::MissingTarget, + "target is not a corpse", + )); } ( corpse.map_id, @@ -796,13 +802,27 @@ pub(crate) fn apply_take_loot( } else if let Some(go) = ctx.db.game_gameobject().guid().find(corpse_guid) { (go.map_id, go.instance_id, go.x, go.y, go.z, false) } else { - return Err("no such loot source".to_string()); + return Err(ActionRefusal::new( + ActionRefusalKind::MissingTarget, + "no such loot source", + )); }; if source_is_corpse { - crate::loot::corpse_access_gate(ctx, player_guid, corpse_guid)?; + crate::loot::corpse_access(ctx, player_guid, corpse_guid).map_err(|refusal| { + ActionRefusal::new( + ActionRefusalKind::CannotAct, + crate::loot::refused( + refusal, + &format!("actor_guid={player_guid} corpse_guid={corpse_guid}"), + ), + ) + })?; } if src_map != player.map_id { - return Err("loot on another map".to_string()); + return Err(ActionRefusal::new( + ActionRefusalKind::OtherPartition, + "loot on another map", + )); } // Instance gate (190 slice 2 review HIGH): instances overlay IDENTICAL coordinates, so the // range gate below is routinely satisfiable across the instance wall — party B's looter in @@ -810,14 +830,20 @@ pub(crate) fn apply_take_loot( // loot path with no slice-1 DEFERRED marker, so the gate sweep missed it: the module is the // authority regardless of what any client shows. if src_instance != player.instance_id { - return Err("loot in another instance".to_string()); + return Err(ActionRefusal::new( + ActionRefusalKind::OtherPartition, + "loot in another instance", + )); } // Range gate (anti-exploit), mirroring the money path `loot_money` — a client can't autostore a // corpse's item from across the map. Shares the same `LOOT_RANGE_SQ` (10 yd)² so the loot paths // never drift. let (dx, dy, dz) = (sx - player.x, sy - player.y, sz - player.z); if dx * dx + dy * dy + dz * dz > crate::loot::LOOT_RANGE_SQ { - return Err("loot out of range".to_string()); + return Err(ActionRefusal::new( + ActionRefusalKind::OutOfRange, + "loot out of range", + )); } // The specific loot-window row the client asked for. `by_corpse` then match the slot index. let loot = ctx.db.game_corpse_loot(); @@ -825,7 +851,9 @@ pub(crate) fn apply_take_loot( .by_corpse() .filter(&corpse_guid) .find(|l| l.slot == loot_slot) - .ok_or_else(|| "no loot in that slot".to_string())?; + .ok_or_else(|| { + ActionRefusal::new(ActionRefusalKind::MissingTarget, "no loot in that slot") + })?; // Quest-only rows (work-item 187 slice 0): the TAKER's OWN need is re-validated server-side (the // gateway's window is a display hint, not authoritative) — an unreserved row (`reserved_for == 0`, // the shared row nobody has split yet) is claimable by anyone who currently needs it; an already @@ -834,13 +862,14 @@ pub(crate) fn apply_take_loot( if row.quest_only { let needs = crate::loot::killer_needs_item(ctx, Some(player_guid), row.item_entry); if !crate::loot::quest_take_allowed(row.reserved_for, player_guid, needs) { - return Err( + return Err(ActionRefusal::new( + ActionRefusalKind::CannotAct, if row.reserved_for != 0 && row.reserved_for != player_guid { - "this item is reserved for another player".to_string() + "this item is reserved for another player" } else { - "you do not need this quest item".to_string() + "you do not need this quest item" }, - ); + )); } } else if !crate::loot::group_loot_take_allowed( row.withheld, @@ -853,22 +882,30 @@ pub(crate) fn apply_take_loot( // (`reserved_for`), a MASTER-only row, or a round-robin/below-threshold row designated to // someone else all reject the plain autostore path here — server-authoritative, the gateway's // per-viewer loot-window filter (`reads.rs`) is a display hint only. - return Err(if row.master_only { - "this item requires the master looter to distribute it".to_string() - } else { - "this item belongs to another looter right now".to_string() - }); + return Err(ActionRefusal::new( + ActionRefusalKind::CannotAct, + if row.master_only { + "this item requires the master looter to distribute it" + } else { + "this item belongs to another looter right now" + }, + )); } let tmpl = ctx .db .game_item_template() .entry() .find(row.item_entry) - .ok_or_else(|| format!("no template for item entry {}", row.item_entry))?; + .ok_or_else(|| { + ActionRefusal::new( + ActionRefusalKind::MissingTarget, + format!("no template for item entry {}", row.item_entry), + ) + })?; // Auto-store with stacking (parity): a looted stackable tops up a matching partial stack first, // then spills into free slots (visible only to its owner via owner_identity). `?` so an inventory-full // loot rolls back and the loot row stays for a retry. - store_item( + store_item_typed( ctx, player_guid, player.owner_identity, @@ -923,6 +960,15 @@ pub(crate) fn apply_take_loot( Ok(()) } +pub(crate) fn apply_take_loot( + ctx: &ReducerContext, + player_guid: u64, + corpse_guid: u64, + loot_slot: u8, +) -> Result<(), String> { + request_take_loot(ctx, player_guid, corpse_guid, loot_slot).map_err(Into::into) +} + #[cfg(test)] mod tests { use super::{ diff --git a/module/src/loot/mod.rs b/module/src/loot/mod.rs index 8201b53a8..4db205c50 100644 --- a/module/src/loot/mod.rs +++ b/module/src/loot/mod.rs @@ -45,6 +45,7 @@ use spacetimedb::{table, ReducerContext, Table}; +use crate::actor::{ActionRefusal, ActionRefusalKind}; use crate::character::game_character; // credit_purse's offline-recipient fallback (work-item 221) use crate::game_group_member; // clone_quest_loot_for_group's GameObject roster read use crate::game_world_entity; @@ -784,21 +785,29 @@ pub(crate) fn refused(refusal: LootRefusal, detail: &str) -> String { /// Open a creature corpse for the read that follows. This reducer core only authorizes the read; /// it does not create durable loot-window state. -pub(crate) fn open_creature_corpse( +pub(crate) fn request_open_creature_corpse( ctx: &ReducerContext, actor_guid: u64, corpse_guid: u64, -) -> Result<(), String> { +) -> Result<(), ActionRefusal> { let actor = ctx .db .game_world_entity() .guid() .find(actor_guid) - .ok_or_else(|| refused(LootRefusal::LooterUnavailable, "looter not in world"))?; + .ok_or_else(|| { + ActionRefusal::new( + ActionRefusalKind::MissingActor, + refused(LootRefusal::LooterUnavailable, "looter not in world"), + ) + })?; if actor.dead { - return Err(refused( - LootRefusal::LooterUnavailable, - "dead Characters cannot loot", + return Err(ActionRefusal::new( + ActionRefusalKind::DeadActor, + refused( + LootRefusal::LooterUnavailable, + "dead Characters cannot loot", + ), )); } let corpse = ctx @@ -806,24 +815,54 @@ pub(crate) fn open_creature_corpse( .game_world_entity() .guid() .find(corpse_guid) - .ok_or_else(|| refused(LootRefusal::NoLootSource, "no such corpse"))?; + .ok_or_else(|| { + ActionRefusal::new( + ActionRefusalKind::MissingTarget, + refused(LootRefusal::NoLootSource, "no such corpse"), + ) + })?; if corpse.is_player() || !corpse.dead { - return Err(refused( - LootRefusal::NoLootSource, - "Loot Source is not a creature corpse", + return Err(ActionRefusal::new( + ActionRefusalKind::MissingTarget, + refused( + LootRefusal::NoLootSource, + "Loot Source is not a creature corpse", + ), )); } if corpse.map_id != actor.map_id || corpse.instance_id != actor.instance_id { - return Err(refused( - LootRefusal::OutOfRange, - "Loot Source is on another map or instance", + return Err(ActionRefusal::new( + ActionRefusalKind::OtherPartition, + refused( + LootRefusal::OutOfRange, + "Loot Source is on another map or instance", + ), )); } let (dx, dy, dz) = (corpse.x - actor.x, corpse.y - actor.y, corpse.z - actor.z); if dx * dx + dy * dy + dz * dz > LOOT_RANGE_SQ { - return Err(refused(LootRefusal::OutOfRange, "corpse is out of reach")); + return Err(ActionRefusal::new( + ActionRefusalKind::OutOfRange, + refused(LootRefusal::OutOfRange, "corpse is out of reach"), + )); } - corpse_access_gate(ctx, actor_guid, corpse_guid) + corpse_access(ctx, actor_guid, corpse_guid).map_err(|refusal| { + ActionRefusal::new( + ActionRefusalKind::CannotAct, + refused( + refusal, + &format!("actor_guid={actor_guid} corpse_guid={corpse_guid}"), + ), + ) + }) +} + +pub(crate) fn open_creature_corpse( + ctx: &ReducerContext, + actor_guid: u64, + corpse_guid: u64, +) -> Result<(), String> { + request_open_creature_corpse(ctx, actor_guid, corpse_guid).map_err(Into::into) } /// Shared core: take corpse money by explicit looter guid, behind the `loot_money` reducer and diff --git a/module/src/loot/tag.rs b/module/src/loot/tag.rs index 3bd86d5d1..f56e6fa3d 100644 --- a/module/src/loot/tag.rs +++ b/module/src/loot/tag.rs @@ -278,14 +278,26 @@ pub(crate) fn corpse_access_gate( actor_guid: u64, corpse_guid: u64, ) -> Result<(), String> { + corpse_access(ctx, actor_guid, corpse_guid).map_err(|refusal| { + super::refused( + refusal, + &format!("actor_guid={actor_guid} corpse_guid={corpse_guid}"), + ) + }) +} + +/// Typed Loot Tag Gate for in-module Actor requests. The reducer adapter above keeps the stable +/// wire tag for legacy callers; Package callers retain the refusal without parsing that tag. +pub(crate) fn corpse_access( + ctx: &ReducerContext, + actor_guid: u64, + corpse_guid: u64, +) -> Result<(), LootRefusal> { let eligible = corpse_eligible_recipients(ctx, corpse_guid); if corpse_eligible_for_access(&eligible, actor_guid) { return Ok(()); } - Err(super::refused( - LootRefusal::LootTagIneligible, - &format!("actor_guid={actor_guid} corpse_guid={corpse_guid}"), - )) + Err(LootRefusal::LootTagIneligible) } /// Clear a creature's live Loot Tag at combat end or despawn. Corpse and other dynamic flags stay diff --git a/module/tests/playerbots_quest_catalog.rs b/module/tests/playerbots_quest_catalog.rs new file mode 100644 index 000000000..fd80b659a --- /dev/null +++ b/module/tests/playerbots_quest_catalog.rs @@ -0,0 +1,1018 @@ +//! Supported quest admission and executor evidence on private Standalone databases. + +mod support; + +use std::collections::BTreeMap; +use support::Standalone; + +const CREATURE_197: &str = "17379390965327855617"; +const CREATURE_6: &str = "17379390962123407361"; +const CREATURE_952: &str = "17379390977994653697"; +const GAMEOBJECT_55: &str = "17370383762768003127"; +const GAMEOBJECT_56: &str = "17370383762768003128"; +const GAMEOBJECT_161557: &str = "17370383762768164629"; + +fn remove_builtin_weather_import_stamp(node: &Standalone) { + let imports = + node.query_rows("SELECT family, source_sha, file_hash, row_count FROM game_import_meta"); + assert_eq!(imports.len(), 1, "unexpected init Import Catalogue"); + assert_eq!(imports[0]["family"], "weather_seed"); + assert_eq!(imports[0]["source_sha"], ""); + assert_eq!(imports[0]["file_hash"], ""); + assert_eq!(imports[0]["row_count"], "2"); + + // This disposable Standalone removes only its verified synthetic init stamp. Any later import + // remains visible to the Package Gate and must make the quest fixture refuse. + node.assert_sql( + "DELETE FROM game_import_meta WHERE family = 'weather_seed' AND source_sha = '' AND file_hash = '' AND row_count = 2", + ); + assert!(node.query_rows("SELECT * FROM game_import_meta").is_empty()); +} + +fn unstaged_fixture(name: &str) -> (Standalone, Vec>) { + let mut node = Standalone::start(name); + node.publish_module(); + remove_builtin_weather_import_stamp(&node); + node.assert_call("claim_operator", &[]); + node.assert_call("install_guid_range", &["1000000"]); + for (class, role) in [("1", "0"), ("5", "1"), ("8", "2")] { + node.assert_call( + "playerbots_spawn_class_role", + &["1", "1200", "1200", "50", class, role], + ); + } + let mut bots = node.query_rows("SELECT character_guid, class FROM pkg_playerbots_bot"); + bots.sort_by_key(|bot| bot["class"].parse::().unwrap()); + for bot in &bots { + node.assert_call( + "playerbots_select_controller", + &[&bot["character_guid"], "{\"frozen\":[]}"], + ); + } + (node, bots) +} + +fn fixture(name: &str) -> (Standalone, Vec>) { + let (node, bots) = unstaged_fixture(name); + node.assert_call( + "playerbots_quest_fixture_stage", + &[&bots[0]["character_guid"]], + ); + record(&node, "inputs"); + (node, bots) +} + +fn record(node: &Standalone, suffix: &str) { + let core = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap(); + let package = core.join("packages/playerbots"); + let git = |path: &std::path::Path, args: &[&str]| { + let output = std::process::Command::new("git") + .current_dir(path) + .args(args) + .output() + .unwrap(); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap().trim().to_string() + }; + fn digest_files(path: &std::path::Path, digest: &mut blake3::Hasher) { + let mut children: Vec<_> = std::fs::read_dir(path) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + children.sort(); + digest.update(&(children.len() as u64).to_le_bytes()); + for child in children { + let name = child.file_name().unwrap().as_encoded_bytes(); + digest.update(&(name.len() as u64).to_le_bytes()); + digest.update(name); + digest.update(&[u8::from(child.is_dir())]); + if child.is_dir() { + digest_files(&child, digest); + } else { + let contents = std::fs::read(&child).unwrap(); + digest.update(&(contents.len() as u64).to_le_bytes()); + digest.update(&contents); + } + } + } + let mut package_digest = blake3::Hasher::new(); + digest_files(&package, &mut package_digest); + let fixture: serde_json::Value = serde_json::from_slice( + &std::fs::read(package.join("fixtures/quest-catalog.json")).unwrap(), + ) + .unwrap(); + let result = serde_json::json!({ + "fixture": fixture, + "tested_core": git(core, &["rev-parse", "HEAD"]), + "tested_collection": git(&package, &["rev-parse", "HEAD"]), + "core_dirty": !git(core, &["status", "--porcelain"]).is_empty(), + "collection_dirty": !git(&package, &["status", "--porcelain"]).is_empty(), + "package_content_identity": package_digest.finalize().to_hex().to_string(), + "module_wasm_blake3": blake3::hash(support::module_bytes()).to_hex().to_string(), + "catalog": node.query_rows("SELECT * FROM pkg_playerbots_quest_catalog"), + "starter_seeds": node.query_rows("SELECT * FROM pkg_playerbots_catalog_seed"), + "quests": node.query_rows("SELECT * FROM pkg_playerbots_catalog_quest"), + "objectives": node.query_rows("SELECT * FROM pkg_playerbots_catalog_objective"), + "admission": node.query_rows("SELECT * FROM pkg_playerbots_quest_admission"), + "retained": node.query_rows("SELECT * FROM pkg_playerbots_quest_objective"), + "provisioning": node.query_rows("SELECT * FROM pkg_playerbots_provisioning"), + "actions": node.query_rows("SELECT * FROM pkg_playerbots_action"), + "character_quests": node.query_rows("SELECT * FROM game_character_quest"), + "items": node.query_rows("SELECT * FROM game_item_instance"), + "loot": node.query_rows("SELECT * FROM game_corpse_loot"), + "import_catalogue": node.query_rows("SELECT * FROM game_import_meta"), + "fixture_ownership": node.query_rows("SELECT * FROM pkg_playerbots_quest_fixture_ownership"), + }); + let path = support::log_dir().join(format!("{}-{suffix}.json", node.shard_name())); + std::fs::write(path, serde_json::to_vec_pretty(&result).unwrap()).unwrap(); +} + +fn bot_for_class<'a>(bots: &'a [BTreeMap], class: &str) -> &'a str { + &bots.iter().find(|bot| bot["class"] == class).unwrap()["character_guid"] +} + +fn quest(node: &Standalone, bot: &str, entry: u32) -> BTreeMap { + node.query_rows(&format!( + "SELECT * FROM game_character_quest WHERE character_guid = {bot} AND quest_entry = {entry}" + )) + .into_iter() + .next() + .expect("quest row missing") +} + +fn item_count(node: &Standalone, bot: &str, entry: u32) -> u32 { + node.query_rows(&format!( + "SELECT stack_count FROM game_item_instance WHERE owner_guid = {bot} AND entry = {entry}" + )) + .iter() + .map(|row| row["stack_count"].parse::().unwrap()) + .sum() +} + +fn sorted_rows(node: &Standalone, query: &str, key: &str) -> Vec> { + let mut rows = node.query_rows(query); + rows.sort_by_key(|row| row[key].parse::().unwrap()); + rows +} + +fn catalog_definition_snapshot(node: &Standalone) -> serde_json::Value { + serde_json::json!({ + "item": node.query_rows("SELECT * FROM game_item_template WHERE entry = 750"), + "creature": node.query_rows("SELECT * FROM game_creature_template WHERE entry = 823"), + "creature_spawn": node.query_rows("SELECT * FROM game_creature_spawn WHERE entry = 823"), + "gameobject": node.query_rows("SELECT * FROM game_gameobject_template WHERE entry = 55"), + "gameobject_spawn": node.query_rows("SELECT * FROM game_gameobject WHERE template_entry = 161557"), + "quest": node.query_rows("SELECT * FROM game_quest_template WHERE entry = 783"), + "creature_relations": sorted_rows(node, "SELECT * FROM game_creature_quest WHERE creature_entry = 823", "id"), + "gameobject_relations": sorted_rows(node, "SELECT * FROM game_gameobject_quest WHERE go_entry = 55", "id"), + "objectives": sorted_rows(node, "SELECT * FROM game_quest_objective WHERE quest_entry = 7", "id"), + "creature_loot": sorted_rows(node, "SELECT * FROM game_creature_loot WHERE creature_entry = 299", "id"), + "gameobject_loot": sorted_rows(node, "SELECT * FROM game_gameobject_loot WHERE loot_id = 10119", "id"), + }) +} + +fn select_cohort(node: &Standalone, bot: &str) { + node.assert_call("playerbots_select_controller", &[bot, "{\"cohort\":[]}"]); +} + +fn run_once(node: &Standalone) { + node.assert_call("playerbots_fixture_runner_due", &[]); + node.assert_call("playerbots_fixture_runner_pass", &[]); +} + +fn runner(node: &Standalone, bot: &str) -> BTreeMap { + node.query_rows(&format!( + "SELECT * FROM pkg_playerbots_runner WHERE character_guid = {bot}" + )) + .into_iter() + .next() + .expect("runner row missing") +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_catalog_is_named_versioned_and_shared_by_starter_classes() { + let (node, bots) = unstaged_fixture("playerbots-quest-catalog"); + node.assert_sql( + "INSERT INTO game_creature_quest (id,creature_entry,quest_entry,role) VALUES (1,5099997,5099997,0)", + ); + node.assert_call( + "playerbots_quest_fixture_stage", + &[&bots[0]["character_guid"]], + ); + record(&node, "inputs"); + let explicit_relation = node.query_rows("SELECT * FROM game_creature_quest WHERE id = 1"); + assert_eq!(explicit_relation.len(), 1); + assert_eq!( + bots.iter() + .map(|bot| bot["class"].as_str()) + .collect::>(), + ["1", "5", "8"] + ); + let header = node.query_rows("SELECT * FROM pkg_playerbots_quest_catalog"); + assert_eq!(header.len(), 1); + assert_eq!(header[0]["name"], "northshire-elwynn-supported-v1"); + assert_eq!(header[0]["revision"], "1"); + assert_eq!(header[0]["quest_count"], "12"); + assert_eq!(header[0]["reference_source_revision"], "unknown"); + assert!(header[0]["blueprint_revision"].contains("d2083bcd")); + let mut seeds = node.query_rows( + "SELECT class, fixture_seed, catalog_revision, quest_order FROM pkg_playerbots_catalog_seed", + ); + seeds.sort_by_key(|row| row["class"].parse::().unwrap()); + assert_eq!( + seeds + .iter() + .map(|row| (row["class"].as_str(), row["fixture_seed"].as_str())) + .collect::>(), + [("1", "783001"), ("5", "783005"), ("8", "783008")] + ); + assert!(seeds + .iter() + .all(|row| row["catalog_revision"] == "1" && row["quest_order"].contains("3904"))); + assert_eq!( + node.query_rows("SELECT quest_entry FROM pkg_playerbots_catalog_quest") + .len(), + 12 + ); + let harvest = node.query_rows( + "SELECT kind, target_entry, executor, source_entries, source_destinations, work_area, destination_evidence_revision FROM pkg_playerbots_catalog_objective WHERE quest_entry = 3904", + ); + assert_eq!(harvest.len(), 1); + assert!(harvest[0]["kind"].contains("collectItem")); + assert!(harvest[0]["executor"].contains("gameObjectLoot")); + assert_eq!(harvest[0]["target_entry"], "11119"); + assert!(harvest[0]["source_entries"].contains("161557")); + assert!(harvest[0]["source_destinations"].contains(GAMEOBJECT_161557)); + assert!(!harvest[0]["work_area"].contains("none")); + assert!(harvest[0]["destination_evidence_revision"].starts_with("observed-catalog-v1:")); + let provided = node.query_rows( + "SELECT kind, target_entry, executor, source_entries FROM pkg_playerbots_catalog_objective WHERE quest_entry = 3905", + ); + assert!(provided[0]["kind"].contains("collectItem")); + assert_eq!(provided[0]["target_entry"], "11125"); + assert!(provided[0]["executor"].contains("providedItem")); + assert!(provided[0]["source_entries"].is_empty()); + let before = harvest[0]["destination_evidence_revision"].clone(); + node.assert_call("playerbots_quest_fixture_recheck_unchanged", &[]); + let unchanged = node.query_rows( + "SELECT content_revision, refresh_after_micros FROM pkg_playerbots_quest_catalog", + ); + assert_eq!(unchanged[0]["content_revision"], before); + assert!(unchanged[0]["refresh_after_micros"].parse::().unwrap() > 0); + node.assert_call( + "playerbots_quest_fixture_move_gameobject", + &["161557", "1210"], + ); + node.assert_call("playerbots_quest_fixture_refresh", &[]); + let after = node.query_rows( + "SELECT destination_evidence_revision FROM pkg_playerbots_catalog_objective WHERE quest_entry = 3904", + ); + assert_ne!(after[0]["destination_evidence_revision"], before); + for query in [ + "SELECT id FROM game_creature_quest WHERE creature_entry = 823", + "SELECT id FROM game_gameobject_quest WHERE go_entry = 55", + "SELECT id FROM game_quest_objective WHERE quest_entry = 7", + "SELECT id FROM game_creature_loot WHERE creature_entry = 299", + "SELECT id FROM game_gameobject_loot WHERE loot_id = 10119", + ] { + assert!(node.query_rows(query).iter().all(|row| { + let id = row["id"].parse::().unwrap(); + (5_099_000..=5_099_999).contains(&id) + })); + } + let staged = catalog_definition_snapshot(&node); + node.assert_call( + "playerbots_quest_fixture_stage", + &[&bots[0]["character_guid"]], + ); + assert_eq!(catalog_definition_snapshot(&node), staged); + assert_eq!( + node.query_rows("SELECT * FROM game_creature_quest WHERE id = 1"), + explicit_relation + ); + node.assert_call( + "stamp_import_meta", + &[ + "unrelated-fixture-import", + "fixture-source", + "fixture-hash", + "1", + ], + ); + let refused = node.call( + "playerbots_quest_fixture_stage", + &[&bots[0]["character_guid"]], + ); + assert!( + !refused.status.success(), + "imported stage unexpectedly succeeded" + ); + assert_eq!(catalog_definition_snapshot(&node), staged); + assert_eq!( + node.query_rows("SELECT * FROM game_creature_quest WHERE id = 1"), + explicit_relation + ); + let refused = node.call( + "playerbots_quest_fixture_move_gameobject", + &["161557", "1220"], + ); + assert!( + !refused.status.success(), + "fixture operation accepted imported content" + ); + assert_eq!(catalog_definition_snapshot(&node), staged); + record(&node, "catalog"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_catalog_fixture_refuses_preexisting_semantic_rows_without_mutation() { + let (conflict, bots) = unstaged_fixture("playerbots-quest-catalog-conflict"); + conflict.assert_sql( + "INSERT INTO game_creature_quest (id,creature_entry,quest_entry,role) VALUES (1,823,783,0)", + ); + let before = conflict.query_rows("SELECT * FROM game_creature_quest WHERE id = 1"); + let character = conflict.query_rows(&format!( + "SELECT * FROM game_world_entity WHERE guid = {}", + bots[0]["character_guid"] + )); + let roster = conflict.query_rows("SELECT * FROM pkg_playerbots_bot"); + let refused = conflict.call( + "playerbots_quest_fixture_stage", + &[&bots[0]["character_guid"]], + ); + assert!( + !refused.status.success(), + "pre-existing semantic relation was replaced" + ); + assert_eq!( + conflict.query_rows("SELECT * FROM game_creature_quest WHERE id = 1"), + before + ); + assert_eq!( + conflict.query_rows(&format!( + "SELECT * FROM game_world_entity WHERE guid = {}", + bots[0]["character_guid"] + )), + character + ); + assert_eq!( + conflict.query_rows("SELECT * FROM pkg_playerbots_bot"), + roster + ); + assert!(conflict + .query_rows("SELECT * FROM pkg_playerbots_quest_fixture_ownership") + .is_empty()); + assert!(conflict + .query_rows("SELECT * FROM game_item_template WHERE entry = 750") + .is_empty()); + assert!(conflict + .query_rows("SELECT * FROM game_creature_template WHERE entry = 823") + .is_empty()); + assert!(conflict + .query_rows("SELECT * FROM game_gameobject_template WHERE entry = 55") + .is_empty()); + assert!(conflict + .query_rows("SELECT * FROM game_quest_template WHERE entry = 783") + .is_empty()); + assert!(conflict + .query_rows("SELECT * FROM game_spell WHERE spell_id = 5090100") + .is_empty()); + record(&conflict, "preexisting-refusal"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_talk_kill_creature_drop_and_provided_item_paths_change_core_state() { + let (node, bots) = fixture("playerbots-quest-creature-paths"); + let warrior = bot_for_class(&bots, "1"); + node.assert_call("playerbots_quest_fixture_admit_accept", &[warrior, "783"]); + let admitted = node.query_rows(&format!( + "SELECT * FROM pkg_playerbots_quest_admission WHERE character_guid = {warrior}" + )); + assert!(admitted[0]["state"].contains("admitted")); + node.assert_call("playerbots_quest_fixture_turn_in", &[warrior, "783"]); + assert_eq!(quest(&node, warrior, 783)["rewarded"], "true"); + let turn_in = node.query_rows(&format!( + "SELECT target_guid, outcome FROM pkg_playerbots_action WHERE character_guid = {warrior} AND quest_entry = 783" + )); + assert!(turn_in.iter().any(|row| row["target_guid"] == CREATURE_197)); + + node.assert_call("playerbots_quest_fixture_admit_accept", &[warrior, "37"]); + node.assert_call("playerbots_quest_fixture_turn_in", &[warrior, "37"]); + assert!(node + .query_rows(&format!( + "SELECT target_guid FROM pkg_playerbots_action WHERE character_guid = {warrior} AND quest_entry = 37" + )) + .iter() + .any(|row| row["target_guid"] == GAMEOBJECT_55)); + node.assert_call("playerbots_quest_fixture_admit_accept", &[warrior, "45"]); + assert!(node + .query_rows(&format!( + "SELECT target_guid FROM pkg_playerbots_action WHERE character_guid = {warrior} AND quest_entry = 45" + )) + .iter() + .any(|row| row["target_guid"] == GAMEOBJECT_55)); + node.assert_call("playerbots_quest_fixture_turn_in", &[warrior, "45"]); + assert!(node + .query_rows(&format!( + "SELECT target_guid FROM pkg_playerbots_action WHERE character_guid = {warrior} AND quest_entry = 45" + )) + .iter() + .any(|row| row["target_guid"] == GAMEOBJECT_56)); + + node.assert_call("playerbots_quest_fixture_admit_accept", &[warrior, "7"]); + node.assert_call("playerbots_quest_fixture_kill", &[warrior, "6"]); + assert!(quest(&node, warrior, 7)["counts"].contains('1')); + assert!(node + .query_rows(&format!( + "SELECT outcome FROM pkg_playerbots_action WHERE character_guid = {warrior}" + )) + .iter() + .any(|row| row["outcome"].contains("attackAccepted"))); + + let priest = bot_for_class(&bots, "5"); + node.assert_call("playerbots_quest_fixture_admit_accept", &[priest, "33"]); + node.assert_call("playerbots_quest_fixture_kill", &[priest, "299"]); + node.assert_call( + "playerbots_quest_fixture_take_creature_loot", + &[priest, "299"], + ); + assert_eq!(item_count(&node, priest, 750), 8); + let loot_actions = node.query_rows(&format!( + "SELECT kind, outcome FROM pkg_playerbots_action WHERE character_guid = {priest}" + )); + assert!(loot_actions + .iter() + .any(|row| row["kind"].contains("openLoot") && row["outcome"].contains("completed"))); + assert!(loot_actions + .iter() + .any(|row| row["kind"].contains("takeLoot") && row["outcome"].contains("completed"))); + node.assert_call("playerbots_quest_fixture_turn_in", &[priest, "33"]); + assert_eq!(quest(&node, priest, 33)["rewarded"], "true"); + assert_eq!(item_count(&node, priest, 750), 0); + + node.assert_call("playerbots_quest_fixture_admit_accept", &[warrior, "3905"]); + assert_eq!(item_count(&node, warrior, 11125), 1); + node.assert_call("playerbots_quest_fixture_turn_in", &[warrior, "3905"]); + assert_eq!(quest(&node, warrior, 3905)["rewarded"], "true"); + assert_eq!(item_count(&node, warrior, 11125), 0); + assert!(node + .query_rows(&format!( + "SELECT target_guid FROM pkg_playerbots_action WHERE character_guid = {warrior} AND quest_entry = 3905" + )) + .iter() + .any(|row| row["target_guid"] == CREATURE_952)); + record(&node, "creature-paths"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_gameobject_loot_and_simple_use_paths_change_core_state() { + let (node, bots) = fixture("playerbots-quest-gameobject-loot"); + let mage = bot_for_class(&bots, "8"); + node.assert_call("playerbots_quest_fixture_admit_accept", &[mage, "3904"]); + node.assert_call( + "playerbots_quest_fixture_move_gameobject", + &["161557", "1300"], + ); + node.assert_call( + "playerbots_quest_fixture_try_use_gameobject", + &[mage, "161557"], + ); + let refusal = node.query_rows(&format!( + "SELECT outcome FROM pkg_playerbots_action WHERE character_guid = {mage}" + )); + assert!(refusal + .iter() + .any(|row| row["outcome"].contains("outOfRange"))); + assert_eq!( + node.query_rows(&format!( + "SELECT state FROM game_gameobject WHERE guid = {GAMEOBJECT_161557}" + ))[0]["state"], + "0" + ); + record(&node, "gameobject-refusal"); + node.assert_call( + "playerbots_quest_fixture_move_gameobject", + &["161557", "1202"], + ); + node.assert_call( + "playerbots_quest_fixture_use_gameobject", + &[mage, "161557", "false"], + ); + node.assert_call("playerbots_quest_fixture_fill_inventory", &[mage]); + let inventory_query = format!("SELECT * FROM game_item_instance WHERE owner_guid = {mage}"); + let loot_query = + format!("SELECT * FROM game_corpse_loot WHERE corpse_guid = {GAMEOBJECT_161557}"); + let inventory_before = sorted_rows(&node, &inventory_query, "guid"); + let loot_before = sorted_rows(&node, &loot_query, "id"); + assert!(!loot_before.is_empty()); + node.assert_call("playerbots_quest_fixture_try_take_gameobject_loot", &[mage]); + assert_eq!( + sorted_rows(&node, &inventory_query, "guid"), + inventory_before + ); + assert_eq!(sorted_rows(&node, &loot_query, "id"), loot_before); + let take_refusal = node.query_rows(&format!( + "SELECT outcome FROM pkg_playerbots_action WHERE character_guid = {mage}" + )); + assert!(take_refusal + .iter() + .any(|row| row["outcome"].contains("inventoryFull"))); + record(&node, "gameobject-inventory-refusal"); + node.assert_call( + "playerbots_quest_fixture_clear_filler_and_take_gameobject_loot", + &[mage], + ); + assert_eq!(item_count(&node, mage, 11119), 8); + assert_eq!( + node.query_rows(&format!( + "SELECT state FROM game_gameobject WHERE guid = {GAMEOBJECT_161557}" + ))[0]["state"], + "1" + ); + node.assert_call("playerbots_quest_fixture_turn_in", &[mage, "3904"]); + assert_eq!(quest(&node, mage, 3904)["rewarded"], "true"); + record(&node, "gameobject-loot"); + + let (node, bots) = fixture("playerbots-quest-gameobject-use"); + let mage = bot_for_class(&bots, "8"); + node.assert_call("playerbots_quest_fixture_direct_gameobject", &[mage]); + node.assert_call( + "playerbots_quest_fixture_use_gameobject", + &[mage, "161557", "false"], + ); + assert!(quest(&node, mage, 3904)["counts"].contains('1')); + assert!(node + .query_rows(&format!( + "SELECT outcome FROM pkg_playerbots_action WHERE character_guid = {mage}" + )) + .iter() + .any(|row| row["outcome"].contains("completed"))); + record(&node, "gameobject-use"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_admission_refuses_each_unsupported_requirement_and_mixed_work() { + let expected = [ + "exploration", + "escort", + "scriptedEvent", + "transport", + "complexGameObject", + ]; + let (node, bots) = fixture("playerbots-quest-unsupported"); + let bot = bot_for_class(&bots, "1"); + node.assert_call( + "playerbots_quest_fixture_level_admission", + &[bot, "1", "18"], + ); + let ineligible = node.query_rows(&format!( + "SELECT state, detail FROM pkg_playerbots_quest_admission WHERE character_guid = {bot}" + )); + assert!(ineligible[0]["state"].contains("ineligible")); + assert!(ineligible[0]["detail"].contains("requires level 2")); + node.assert_call( + "playerbots_quest_fixture_level_admission", + &[bot, "2", "783"], + ); + for (kind, expected) in expected.into_iter().enumerate() { + node.assert_call( + "playerbots_quest_fixture_unsupported", + &[bot, &kind.to_string()], + ); + let admission = node.query_rows(&format!( + "SELECT state, missing_capability, observed_micros, wait_until_micros FROM pkg_playerbots_quest_admission WHERE character_guid = {bot}" + )); + assert!(admission[0]["state"].contains("waiting")); + assert!(admission[0]["missing_capability"].contains(expected)); + assert!( + admission[0]["wait_until_micros"].parse::().unwrap() + > admission[0]["observed_micros"].parse::().unwrap() + ); + assert!(node + .query_rows(&format!( + "SELECT id FROM game_character_quest WHERE character_guid = {bot} AND quest_entry = 7" + )) + .is_empty()); + node.assert_call("playerbots_quest_fixture_refresh", &[]); + } + node.assert_call("playerbots_quest_fixture_mixed_unsupported", &[bot]); + let admission = node.query_rows(&format!( + "SELECT missing_capability FROM pkg_playerbots_quest_admission WHERE character_guid = {bot}" + )); + assert!(admission[0]["missing_capability"].contains("escort")); + + node.assert_call("playerbots_quest_fixture_mixed_progress", &[bot]); + node.assert_call("playerbots_fixture_runner_stage", &[bot, "false"]); + select_cohort(&node, bot); + run_once(&node); + let retained = node.query_rows(&format!( + "SELECT target FROM pkg_playerbots_quest_objective WHERE character_guid = {bot}" + )); + assert!(retained[0]["target"].contains("752")); + record(&node, "unsupported"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_imported_destination_and_live_target_are_separate_facts() { + let (node, bots) = fixture("playerbots-quest-destination-facts"); + let bot = bot_for_class(&bots, "1"); + node.assert_call("playerbots_quest_fixture_hide_live_target", &["6"]); + node.assert_call( + "playerbots_quest_fixture_assert_no_live_target", + &[bot, "6"], + ); + node.assert_call("playerbots_quest_fixture_admit_accept", &[bot, "7"]); + assert_eq!(quest(&node, bot, 7)["rewarded"], "false"); + let objective = node.query_rows( + "SELECT source_destinations FROM pkg_playerbots_catalog_objective WHERE quest_entry = 7", + ); + assert!(!objective[0]["source_destinations"].is_empty()); + record(&node, "destination-facts"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_quest_objective_survives_combat_and_refreshes_changed_evidence() { + let (node, bots) = fixture("playerbots-quest-retention"); + let bot = bot_for_class(&bots, "1"); + node.assert_call("playerbots_quest_fixture_admit_accept", &[bot, "7"]); + node.assert_call("playerbots_fixture_runner_stage", &[bot, "false"]); + select_cohort(&node, bot); + run_once(&node); + let before_runner = runner(&node, bot); + assert!(before_runner["objective"].contains("quest")); + let before_detail = node.query_rows(&format!( + "SELECT * FROM pkg_playerbots_quest_objective WHERE character_guid = {bot}" + ))[0] + .clone(); + assert_eq!(before_detail["quest_entry"], "7"); + assert_eq!(before_detail["actual_ender_entry"], "197"); + assert_eq!(before_detail["catalog_revision"], "1"); + assert!(before_detail["destination_evidence_revision"].starts_with("observed-catalog-v1:")); + + node.assert_call("playerbots_fixture_runner_damage", &[bot, CREATURE_6, "1"]); + run_once(&node); + let interrupted = runner(&node, bot); + assert!(interrupted["chosen"].contains("defense")); + assert!(interrupted["chosen"].contains(CREATURE_6)); + assert_eq!(interrupted["objective"], before_runner["objective"]); + assert_eq!( + node.query_rows(&format!( + "SELECT * FROM pkg_playerbots_quest_objective WHERE character_guid = {bot}" + ))[0], + before_detail + ); + + node.assert_call( + "playerbots_quest_fixture_move_creature_spawn", + &["6", "1230"], + ); + node.assert_call("playerbots_quest_fixture_refresh", &[]); + run_once(&node); + let refreshed_runner = runner(&node, bot); + let refreshed_detail = node.query_rows(&format!( + "SELECT * FROM pkg_playerbots_quest_objective WHERE character_guid = {bot}" + ))[0] + .clone(); + assert_ne!( + refreshed_runner["objective_sequence"], + before_runner["objective_sequence"] + ); + assert_ne!( + refreshed_detail["destination_evidence_revision"], + before_detail["destination_evidence_revision"] + ); + assert!(refreshed_detail["destination"].contains("1230")); + record(&node, "retention"); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_companion_control_precedes_held_quest_work() { + let (node, bots) = fixture("playerbots-quest-companion-control"); + let bot = bot_for_class(&bots, "1"); + let leader = bot_for_class(&bots, "5"); + let ally = bot_for_class(&bots, "8"); + node.assert_call("playerbots_quest_fixture_admit_accept", &[bot, "7"]); + node.assert_call("playerbots_fixture_runner_select_cohort", &[bot]); + node.assert_call("playerbots_fixture_runner_pass_once", &[bot]); + let solo = runner(&node, bot); + assert!(solo["objective"].contains("quest"), "{solo:?}"); + let held_quest = quest(&node, bot, 7); + + node.assert_call("playerbots_fixture_companion_stage", &[bot, leader, ally]); + let mut following = None; + let mut companion_sequence = None; + for pass in 0..32 { + node.assert_call("playerbots_fixture_runner_pass_once", &[bot]); + let state = runner(&node, bot); + record(&node, &format!("companion-pass-{pass}")); + assert!(state["objective"].contains("companion"), "{state:?}"); + let sequence = + companion_sequence.get_or_insert_with(|| state["objective_sequence"].clone()); + assert_eq!(state["objective_sequence"].as_str(), sequence.as_str()); + assert_eq!(quest(&node, bot, 7), held_quest); + if state["chosen"].contains("follow") { + following = Some(state); + break; + } + assert!(state["chosen"].contains("provisioning"), "{state:?}"); + assert!(state["last_outcome"].contains("provisioning"), "{state:?}"); + assert!(state["foreground"].contains("none"), "{state:?}"); + } + let following = following.expect("companion did not follow after bounded upkeep"); + record(&node, "companion-follow"); + assert!( + following["objective"].contains("companion"), + "{following:?}" + ); + assert!(following["chosen"].contains("follow"), "{following:?}"); + assert!( + following["foreground"].contains("movement"), + "{following:?}" + ); + assert!(support::poll_until(support::POLL_TIMEOUT, || { + let position = node.query_rows(&format!( + "SELECT x, y FROM game_world_entity WHERE guid = {bot}" + )); + (position[0]["x"].parse::().unwrap() - 1200.0).abs() > 0.01 + || (position[0]["y"].parse::().unwrap() - 1200.0).abs() > 0.01 + })); + assert_ne!(following["objective_sequence"], solo["objective_sequence"]); + assert_eq!(quest(&node, bot, 7), held_quest); + assert!(node + .query_rows(&format!( + "SELECT * FROM game_melee_attack WHERE attacker_guid = {bot}" + )) + .is_empty()); + + node.assert_call("playerbots_fixture_companion_remove_group", &[]); + node.assert_call("playerbots_fixture_runner_pass_once", &[bot]); + let unavailable = runner(&node, bot); + record(&node, "companion-unavailable"); + let path = support::log_dir().join(format!("{}-runner.json", node.shard_name())); + std::fs::write( + path, + serde_json::to_vec_pretty(&serde_json::json!({ + "solo": solo, "following": following, "unavailable": unavailable + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(unavailable["objective"], following["objective"]); + assert_eq!( + unavailable["objective_sequence"], + following["objective_sequence"] + ); + assert!( + unavailable["chosen"].contains("partyUnavailable"), + "{unavailable:?}" + ); + assert!(unavailable["foreground"].contains("none")); + assert_eq!(quest(&node, bot, 7), held_quest); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_quest_retries_after_deferral_without_replacing_its_purpose() { + let (node, bots) = fixture("playerbots-quest-deferred-retry"); + let bot = bot_for_class(&bots, "1"); + node.assert_call("playerbots_quest_fixture_admit_accept", &[bot, "7"]); + node.assert_call( + "playerbots_quest_fixture_move_creature_spawn", + &["6", "1230"], + ); + node.assert_call("playerbots_quest_fixture_refresh", &[]); + node.assert_call("playerbots_fixture_runner_stage", &[bot, "false"]); + select_cohort(&node, bot); + run_once(&node); + let initial = runner(&node, bot); + let retained = node.query_rows(&format!( + "SELECT * FROM pkg_playerbots_quest_objective WHERE character_guid = {bot}" + )); + assert!(initial["objective"].contains("quest")); + assert!(initial["objective"].contains("travelling"), "{initial:?}"); + + node.assert_call("playerbots_fixture_runner_expire_objective", &[bot]); + run_once(&node); + let deferred = runner(&node, bot); + record(&node, "deferral-start"); + assert!(deferred["objective"].contains("deferred"), "{deferred:?}"); + assert!(!deferred["deferred_destinations"].is_empty()); + run_once(&node); + let waiting = runner(&node, bot); + assert_eq!(waiting["objective"], deferred["objective"]); + assert_eq!( + waiting["deferred_destinations"], + deferred["deferred_destinations"] + ); + + let retried = support::poll_until(std::time::Duration::from_secs(45), || { + runner(&node, bot)["objective"].contains("travelling") + }); + let resumed = runner(&node, bot); + record(&node, "deferred-retry"); + let path = support::log_dir().join(format!("{}-runner.json", node.shard_name())); + std::fs::write( + path, + serde_json::to_vec_pretty(&serde_json::json!({ + "initial": initial, "deferred": deferred, "waiting": waiting, "resumed": resumed + })) + .unwrap(), + ) + .unwrap(); + assert!( + retried, + "quest did not retry after its deferral expired: {resumed:?}" + ); + assert_eq!(resumed["objective_sequence"], initial["objective_sequence"]); + assert!(resumed["deferred_destinations"].is_empty()); + assert_eq!(resumed["retry_count"], "0"); + assert_eq!( + node.query_rows(&format!( + "SELECT * FROM pkg_playerbots_quest_objective WHERE character_guid = {bot}" + )), + retained + ); +} + +#[test] +#[ignore = "requires SpacetimeDB, Wasm, and the playerbots Package"] +fn playerbots_held_unsupported_quest_selects_supported_work_without_reaccepting() { + let (node, bots) = fixture("playerbots-quest-reconcile"); + let bot = bot_for_class(&bots, "1"); + node.assert_call("playerbots_quest_fixture_admit_accept", &[bot, "7"]); + node.assert_call("playerbots_fixture_runner_stage", &[bot, "false"]); + select_cohort(&node, bot); + run_once(&node); + let objective_before = runner(&node, bot)["objective_sequence"].clone(); + let before = quest(&node, bot, 7); + node.assert_call("playerbots_quest_fixture_held_becomes_unsupported", &[bot]); + run_once(&node); + let after = quest(&node, bot, 7); + assert_eq!(after, before); + assert_eq!(quest(&node, bot, 5261)["rewarded"], "false"); + let admission = node.query_rows(&format!( + "SELECT selected_quest, state, missing_capability FROM pkg_playerbots_quest_admission WHERE character_guid = {bot}" + )); + assert_eq!(admission[0]["selected_quest"], "(some = 5261)"); + assert!(admission[0]["state"].contains("unsupported")); + assert!(admission[0]["missing_capability"].contains("escort")); + let selected = node.query_rows(&format!( + "SELECT quest_entry, actual_ender_entry FROM pkg_playerbots_quest_objective WHERE character_guid = {bot}" + )); + assert_eq!(selected[0]["quest_entry"], "5261"); + assert_eq!(selected[0]["actual_ender_entry"], "196"); + let reconciled = runner(&node, bot); + assert_ne!(reconciled["objective_sequence"], objective_before); + assert!(reconciled["chosen"].contains("quest")); + assert_eq!( + node.query_rows(&format!( + "SELECT id FROM game_character_quest WHERE character_guid = {bot} AND rewarded = false" + )) + .len(), + 2 + ); + let stable_seven = quest(&node, bot, 7); + let stable_alternative = quest(&node, bot, 5261); + run_once(&node); + assert_eq!(quest(&node, bot, 7), stable_seven); + assert_eq!(quest(&node, bot, 5261), stable_alternative); + assert_eq!( + runner(&node, bot)["objective_sequence"], + reconciled["objective_sequence"] + ); + let persistent = node.query_rows(&format!( + "SELECT selected_quest, missing_capability FROM pkg_playerbots_quest_admission WHERE character_guid = {bot}" + )); + assert_eq!(persistent[0]["selected_quest"], "(some = 5261)"); + assert!(persistent[0]["missing_capability"].contains("escort")); + record(&node, "reconcile"); + + let (node, bots) = fixture("playerbots-quest-provided-item-loss"); + for (class, banked) in [("1", "false"), ("5", "true")] { + let bot = bot_for_class(&bots, class); + node.assert_call("playerbots_quest_fixture_admit_accept", &[bot, "3905"]); + node.assert_call("playerbots_fixture_runner_stage", &[bot, "false"]); + select_cohort(&node, bot); + run_once(&node); + assert_eq!( + node.query_rows(&format!( + "SELECT quest_entry FROM pkg_playerbots_quest_objective WHERE character_guid = {bot}" + ))[0]["quest_entry"], + "3905" + ); + let held_before = quest(&node, bot, 3905); + node.assert_call( + "playerbots_quest_fixture_lose_provided_item", + &[bot, banked], + ); + run_once(&node); + record(&node, &format!("provided-item-loss-class-{class}")); + assert_eq!(quest(&node, bot, 3905), held_before); + let admission = node.query_rows(&format!( + "SELECT selected_quest, state, missing_capability, detail, observed_micros, wait_until_micros FROM pkg_playerbots_quest_admission WHERE character_guid = {bot}" + )); + assert_eq!(admission[0]["selected_quest"], "(none = ())"); + assert!(admission[0]["state"].contains("waiting")); + assert!(admission[0]["missing_capability"].contains("missingProvidedItem")); + assert!(admission[0]["detail"].contains("no longer carried")); + assert!( + admission[0]["wait_until_micros"].parse::().unwrap() + > admission[0]["observed_micros"].parse::().unwrap() + ); + assert!(node + .query_rows(&format!( + "SELECT * FROM pkg_playerbots_quest_objective WHERE character_guid = {bot}" + )) + .is_empty()); + let held = runner(&node, bot); + assert!(held["chosen"].contains("hold")); + assert!(held["chosen"].contains("quest")); + let provided = node.query_rows(&format!( + "SELECT slot FROM game_item_instance WHERE owner_guid = {bot} AND entry = 11125" + )); + if banked == "true" { + assert_eq!(provided.len(), 1); + assert_eq!(provided[0]["slot"], "39"); + } else { + assert!(provided.is_empty()); + } + } + record(&node, "provided-item-loss"); +} + +#[test] +#[ignore = "requires pinned PB-002 Wasm, SpacetimeDB, and the playerbots Package"] +fn playerbots_quest_catalog_upgrades_populated_pb002_runner_state() { + let old_path = std::env::var_os("PLAYERBOTS_CATALOG_PRECEDING_WASM") + .expect("PLAYERBOTS_CATALOG_PRECEDING_WASM must name the PB-002 Wasm"); + let old_wasm = std::fs::read(old_path).unwrap(); + let mut node = Standalone::start("playerbots-quest-pb002-migration"); + node.publish_module_bytes(&old_wasm); + node.assert_call("claim_operator", &[]); + node.assert_call("install_guid_range", &["1000000"]); + node.assert_call("playerbots_spawn_role", &["1", "1200", "1200", "50", "1"]); + node.assert_call("playerbots_fixture_prepare", &[]); + let bot = node.query_rows("SELECT character_guid FROM pkg_playerbots_bot")[0]["character_guid"] + .clone(); + node.assert_sql("UPDATE game_spell SET cast_time_ms = 60000 WHERE spell_id = 5090100"); + node.assert_call("playerbots_fixture_runner_stage", &[&bot, "true"]); + node.assert_call("playerbots_select_controller", &[&bot, "{\"cohort\":[]}"]); + node.assert_call("playerbots_fixture_runner_due", &[]); + node.assert_call("playerbots_fixture_runner_pass", &[]); + node.assert_call("playerbots_fixture_freeze", &[&bot]); + let preceding_rows = node.query_rows(&format!( + "SELECT * FROM pkg_playerbots_runner WHERE character_guid = {bot}" + )); + assert_eq!(preceding_rows.len(), 1); + let preceding = preceding_rows[0].clone(); + assert!(preceding["objective"].contains("returnHome")); + assert!(preceding["foreground"].contains("cast")); + let preceding_cast = node.query_rows("SELECT * FROM game_pending_cast"); + assert_eq!(preceding_cast.len(), 1); + node.publish_module(); + let upgraded = node.query_rows(&format!( + "SELECT * FROM pkg_playerbots_runner WHERE character_guid = {bot}" + )); + let upgraded_cast = node.query_rows("SELECT * FROM game_pending_cast"); + record(&node, "pb002-migration-current"); + let path = support::log_dir().join(format!("{}-pb002-migration.json", node.shard_name())); + std::fs::write( + path, + serde_json::to_vec_pretty(&serde_json::json!({ + "preceding_core": "e6a755db0a150bbf73ad97b972fe829f20f6816c", + "preceding_collection": "155c9e401afb06d5731acedf8fc35a81dbe4aaa6", + "local_shared_reference_sha256": "9a041750c7e67f61b0d015504c7f40187c167254a377559f22a474e477297c32", + "preceding_wasm_bytes": old_wasm.len(), + "preceding_wasm_blake3": blake3::hash(&old_wasm).to_hex().to_string(), + "current_wasm_blake3": blake3::hash(support::module_bytes()).to_hex().to_string(), + "preceding_runner": preceding, + "upgraded_runner": upgraded, + "fixture_cast_time_ms": 60000, + "preceding_pending_cast": preceding_cast, + "upgraded_pending_cast": upgraded_cast, + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(upgraded.len(), 1); + assert_eq!(upgraded[0]["objective"], preceding["objective"]); + assert_eq!(upgraded[0]["foreground"], preceding["foreground"]); + assert_eq!( + upgraded[0]["objective_sequence"], + preceding["objective_sequence"] + ); + assert_eq!(upgraded_cast, preceding_cast); + assert!(node + .query_rows("SELECT * FROM pkg_playerbots_quest_objective") + .is_empty()); +} diff --git a/module/tests/playerbots_rewrite/runner.rs b/module/tests/playerbots_rewrite/runner.rs index a3576a160..f3a8160fa 100644 --- a/module/tests/playerbots_rewrite/runner.rs +++ b/module/tests/playerbots_rewrite/runner.rs @@ -344,9 +344,14 @@ fn playerbots_runner_defense_preserves_home_and_accepted_attack_is_not_progress( node.assert_call("playerbots_fixture_runner_stage", &[bot, "false"]); node.assert_sql("DELETE FROM game_melee_schedule"); select(&node, bot, "cohort"); - assert!(poll_until(POLL_TIMEOUT, || runner(&node, bot)["chosen"] - .contains("returnHome"))); - let objective_id = runner(&node, bot)["objective_sequence"].clone(); + let waiting = poll_until(POLL_TIMEOUT, || { + runner(&node, bot)["chosen"].contains("quest") + }); + outcomes(&node); + assert!(waiting); + let initial = runner(&node, bot); + assert!(initial["objective"].contains("returnHome")); + let objective_id = initial["objective_sequence"].clone(); let target = ((0xF130u64 << 48) | (5_090_101u64 << 24) | 1).to_string(); node.assert_call("playerbots_fixture_runner_damage", &[bot, &target, "1"]); assert!(poll_until(POLL_TIMEOUT, || runner(&node, bot)["chosen"] @@ -359,12 +364,20 @@ fn playerbots_runner_defense_preserves_home_and_accepted_attack_is_not_progress( assert!(defended["objective"].contains("last_verified_progress_micros = (none")); assert!(defended["quest_progress"].contains("credit = 0")); node.assert_call("playerbots_fixture_credit_kill", &[bot]); + let credited = poll_until(POLL_TIMEOUT, || { + let observed = runner(&node, bot); + observed["quest_progress"].contains("credit = 1") + && !observed["combat_progress"].contains("none") + }); + outcomes(&node); + assert!(credited); + assert_eq!(runner(&node, bot)["objective_sequence"], objective_id); + node.assert_call("gw_abandon_quest", &[&support::actor(bot), "50909"]); node.assert_call("playerbots_fixture_runner_clear_navigation", &[bot]); assert!(poll_until(POLL_TIMEOUT, || runner(&node, bot)["objective"] .contains("completed"))); let resumed = runner(&node, bot); assert_eq!(resumed["objective_sequence"], objective_id); - assert!(resumed["quest_progress"].contains("credit = 1")); assert!(!resumed["combat_progress"].contains("none")); outcomes(&node); } @@ -376,10 +389,13 @@ fn playerbots_runner_defers_a_blocked_destination_with_bounded_failure_memory() let bot = &bots[0]; node.assert_call("playerbots_fixture_blocked_quest", &[bot]); node.assert_call("playerbots_fixture_runner_stage", &[bot, "false"]); + node.assert_call("gw_abandon_quest", &[&support::actor(bot), "50909"]); select(&node, bot, "cohort"); - assert!(poll_until(Duration::from_secs(38), || runner(&node, bot) - ["objective"] - .contains("deferred"))); + let deferred = poll_until(Duration::from_secs(38), || { + runner(&node, bot)["objective"].contains("deferred") + }); + outcomes(&node); + assert!(deferred); let deferred = runner(&node, bot); assert_eq!(deferred["retry_count"], "3"); assert!(deferred["failures"].contains("noMovement"));