diff --git a/.github/workflows/core-tip.yml b/.github/workflows/core-tip.yml index 061ce9d..e2a4e13 100644 --- a/.github/workflows/core-tip.yml +++ b/.github/workflows/core-tip.yml @@ -266,6 +266,7 @@ jobs: } manifest_path.write_text(json.dumps(manifest, indent=2) + "\n") PY + echo "PLAYERBOTS_CATALOG_PRECEDING_WASM=$RUNNER_TEMP/playerbots-pb002-preceding.wasm" >> "$GITHUB_ENV" echo "PLAYERBOTS_COMPANION_PRECEDING_WASM=$RUNNER_TEMP/playerbots-pb002-preceding.wasm" >> "$GITHUB_ENV" echo "PLAYERBOTS_PB002_WASM=$RUNNER_TEMP/playerbots-pb002-preceding.wasm" >> "$GITHUB_ENV" echo "PLAYERBOTS_COMPANION_PRECEDING_MANIFEST=$RUNNER_TEMP/playerbots-pb002-preceding-inputs.json" >> "$GITHUB_ENV" @@ -284,7 +285,7 @@ jobs: -p lyracore-module --features=debug_reducers cargo test --manifest-path "$GITHUB_WORKSPACE/lyracore/Cargo.toml" \ -p lyracore-module --test playerbots_rewrite --test playerbots_companion \ - --test playerbots_provisioning \ + --test playerbots_provisioning --test playerbots_quest_catalog \ --no-fail-fast -- --ignored --test-threads=1 ' - name: Upload fixture inputs and Standalone logs diff --git a/playerbots/README.md b/playerbots/README.md index 98bbc87..412c649 100644 --- a/playerbots/README.md +++ b/playerbots/README.md @@ -421,6 +421,13 @@ unchanged. The fixture covers an invalid reward choice, full inventory, successf slot is freed, and a source-item acceptance refusal. Imported item uniqueness limits now apply to storage and reward exchanges, including bank holdings and other rewards in the same exchange. +The quest catalog fixture runs only on a private Standalone with an empty Import Catalogue. Its first +stage refuses if a required Northshire row or Fixture-Reserved Identifier already exists, then records +private fixture ownership. Repeating that stage is a no-op. Any later import makes every quest fixture +operation refuse before mutation. The low quest and world entries retain their Northshire meaning, but +the fixture definitions are synthetic stand-ins and do not prove an imported-world run. Static +relation, objective, and loot rows use the project-wide Fixture-Reserved Identifier band. + `pkg_playerbots_goal.quest_credit` is an end-appended nullable count with a null default. The first observation establishes its baseline without clearing an existing stall. Later increases count as progress. The fixture exercises an existing stalled goal with nonzero objective credit and this diff --git a/playerbots/fixtures/quest-catalog.json b/playerbots/fixtures/quest-catalog.json new file mode 100644 index 0000000..5bf8b64 --- /dev/null +++ b/playerbots/fixtures/quest-catalog.json @@ -0,0 +1,44 @@ +{ + "fixture": "playerbots-quest-catalog-v1", + "catalog": "northshire-elwynn-supported-v1", + "catalog_revision": 1, + "blueprint_revision": "classicdb:d2083bcd2670451279cbf93af138eadae04c6d183a4cd0ff0357047e4a565de6", + "reference_source_revision": "unknown", + "observed_content_revision": "computed from staged quest, relation, objective, loot, item, and spawn rows", + "starter_classes": [ + { "class": 1, "name": "warrior", "seed": 783001 }, + { "class": 5, "name": "priest", "seed": 783005 }, + { "class": 8, "name": "mage", "seed": 783008 } + ], + "quest_order": [783, 7, 5261, 33, 18, 3903, 3904, 3905, 40, 35, 37, 45], + "caught_refusals": [ + "inventory-full take preserves carried inventory and GameObject loot" + ], + "held_admission": [ + "provided delivery item destroyed after accept waits with missing capability", + "provided delivery item banked after accept waits with missing capability" + ], + "synthetic_destinations": { + "creature_6": "0xf130000006000001", + "creature_299": "0xf13000012b000001", + "gameobject_55": "0xf110000000000037", + "gameobject_56": "0xf110000000000038", + "gameobject_161557": "0xf110000000027715" + }, + "source_inspection": { + "path": ".import/classic-db-full.sql", + "size_bytes": 73294017, + "sha256": "d2083bcd2670451279cbf93af138eadae04c6d183a4cd0ff0357047e4a565de6", + "imported_world_run": false, + "client_geometry": null + }, + "fixture_boundary": { + "import_catalogue_required_empty": true, + "semantic_entries_required_absent": true, + "semantic_entries_replaced": false, + "static_surrogate_id_band": "5090000-5099999", + "repeat_stage": "no-op for the same owned fixture revision", + "later_import": "refused before mutation" + }, + "provenance": "Private Standalone rows shaped from the inspected ClassicDB schema. Coordinates, item drops, and action outcomes in this fixture are synthetic." +} diff --git a/playerbots/src/actions.rs b/playerbots/src/actions.rs index 3e92ca1..8a51dc8 100644 --- a/playerbots/src/actions.rs +++ b/playerbots/src/actions.rs @@ -10,6 +10,9 @@ pub enum ActionKind { Move, AcceptQuest, TurnInQuest, + UseGameObject, + OpenLoot, + TakeLoot, } #[derive(spacetimedb::SpacetimeType, Clone, Debug)] @@ -223,6 +226,76 @@ pub(super) fn turn_in_quest( ) } +#[cfg_attr( + not(feature = "debug_reducers"), + allow( + dead_code, + reason = "the catalog executor is driven by its durable debug_reducers fixture" + ) +)] +pub(super) fn use_gameobject( + ctx: &ReducerContext, + guid: u64, + go_guid: u64, + quest: u32, +) -> Result<(), crate::actor::ActionRefusal> { + interaction( + ctx, + guid, + go_guid, + quest, + ActionKind::UseGameObject, + crate::actor::request_use_gameobject(ctx, guid, go_guid), + ) +} + +#[cfg_attr( + not(feature = "debug_reducers"), + allow( + dead_code, + reason = "the catalog executor is driven by its durable debug_reducers fixture" + ) +)] +pub(super) fn open_creature_loot( + ctx: &ReducerContext, + guid: u64, + corpse_guid: u64, + quest: u32, +) -> Result<(), crate::actor::ActionRefusal> { + interaction( + ctx, + guid, + corpse_guid, + quest, + ActionKind::OpenLoot, + crate::actor::request_open_creature_loot(ctx, guid, corpse_guid), + ) +} + +#[cfg_attr( + not(feature = "debug_reducers"), + allow( + dead_code, + reason = "the catalog executor is driven by its durable debug_reducers fixture" + ) +)] +pub(super) fn take_loot( + ctx: &ReducerContext, + guid: u64, + source_guid: u64, + loot_slot: u8, + quest: u32, +) -> Result<(), crate::actor::ActionRefusal> { + interaction( + ctx, + guid, + source_guid, + quest, + ActionKind::TakeLoot, + crate::actor::request_take_loot(ctx, guid, source_guid, loot_slot), + ) +} + /// Planning evidence plus progress measured at the next request, never at a proposed endpoint. #[derive(spacetimedb::SpacetimeType, Clone, Debug)] pub struct MovementObservation { diff --git a/playerbots/src/decision.rs b/playerbots/src/decision.rs index f7f0dd9..e083ac0 100644 --- a/playerbots/src/decision.rs +++ b/playerbots/src/decision.rs @@ -38,6 +38,7 @@ pub enum Reason { Resurrection, PartyUnavailable, Provisioning, + Quest, } #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] diff --git a/playerbots/src/mod.rs b/playerbots/src/mod.rs index 31e4af7..7fb080f 100644 --- a/playerbots/src/mod.rs +++ b/playerbots/src/mod.rs @@ -23,12 +23,16 @@ mod companion; mod decision; mod goals; mod provisioning; +mod quest_catalog; mod runner; pub(crate) use actions::*; pub(crate) use provisioning::*; +pub(crate) use quest_catalog::*; pub(crate) use runner::*; #[cfg(feature = "debug_reducers")] mod fixture; +#[cfg(feature = "debug_reducers")] +mod quest_catalog_fixture; pub(crate) use goals::*; use crate::package_config::game_package_config; @@ -376,6 +380,7 @@ pub(crate) fn ensure_defaults(ctx: &ReducerContext) { for (key, value) in CONFIG_DEFAULTS { crate::package_config::ensure_package_config_default(ctx, PACKAGE, key, value); } + quest_catalog::ensure_catalog(ctx); if ctx.db.pkg_playerbots_kit().count() > 0 { return; } diff --git a/playerbots/src/quest_catalog.rs b/playerbots/src/quest_catalog.rs new file mode 100644 index 0000000..1dc02a4 --- /dev/null +++ b/playerbots/src/quest_catalog.rs @@ -0,0 +1,1762 @@ +//! A versioned catalog of quest work this Package can execute with current core operations. + +use crate::{ + game_character_quest, game_creature_loot, game_creature_quest, game_gameobject_loot, + game_gameobject_quest, game_gameobject_template, game_item_template, game_quest_cast_objective, + game_quest_event_requirement, game_quest_objective, game_quest_template, game_world_entity, +}; +use spacetimedb::{table, ReducerContext, Table}; + +pub const CATALOG_NAME: &str = "northshire-elwynn-supported-v1"; +pub const CATALOG_REVISION: u64 = 1; +pub const CATALOG_BLUEPRINT_REVISION: &str = + "classicdb:d2083bcd2670451279cbf93af138eadae04c6d183a4cd0ff0357047e4a565de6"; +const DESTINATION_LIMIT: usize = 128; +const WAIT_MICROS: i64 = 30_000_000; +const REFRESH_INTERVAL_MICROS: i64 = 30_000_000; + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum CatalogEntityKind { + Creature, + GameObject, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub enum CatalogObjectiveKind { + TalkOnly, + KillCreature, + CollectItem, + UseGameObject, + ExploreAreaTrigger, + Escort, + ScriptedEvent, + Transport, + ComplexGameObject, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ObjectiveExecutor { + Talk, + Attack, + CreatureLoot, + GameObjectLoot, + ProvidedItem, + SimpleGameObject, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub enum MissingCapability { + MissingCatalog, + MissingQuestTemplate, + ChangedEligibility, + MissingStartRelation, + MissingStartDestination, + MissingActualEndRelation, + MissingActualEndDestination, + ObjectiveMismatch, + MissingSourceDestination, + MissingItemTemplate, + MissingLootSource, + CastObjective, + Exploration, + Escort, + ScriptedEvent, + Transport, + ComplexGameObject, + UnknownObjective, + MissingProvidedItem, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Debug, PartialEq)] +pub struct CatalogDestination { + pub kind: CatalogEntityKind, + pub entry: u32, + pub guid: u64, + pub map_id: u32, + pub instance_id: u64, + pub x: f32, + pub y: f32, + pub z: f32, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Debug, PartialEq)] +pub struct CatalogWorkArea { + pub map_id: u32, + pub instance_id: u64, + pub min_x: f32, + pub max_x: f32, + pub min_y: f32, + pub max_y: f32, +} + +#[table(accessor = pkg_playerbots_quest_catalog, public)] +pub struct PlayerbotsQuestCatalog { + #[primary_key] + pub revision: u64, + pub name: String, + pub blueprint_revision: String, + pub reference_source_revision: String, + pub content_revision: String, + pub quest_count: u32, + pub refresh_after_micros: i64, +} + +#[table(accessor = pkg_playerbots_catalog_seed, public)] +pub struct PlayerbotsCatalogSeed { + #[primary_key] + pub class: u8, + pub fixture_seed: u64, + pub catalog_revision: u64, + pub quest_order: Vec, +} + +#[table( + accessor = pkg_playerbots_catalog_quest, + public, + index(accessor = by_order, btree(columns = [catalog_revision, catalog_order])) +)] +pub struct PlayerbotsCatalogQuest { + #[primary_key] + pub quest_entry: u32, + pub catalog_revision: u64, + pub catalog_order: u16, + pub min_level: u32, + pub required_races: u32, + pub required_classes: u32, + pub prerequisite_quest: u32, + pub start_kind: CatalogEntityKind, + pub start_entry: u32, + pub start_destinations: Vec, + pub actual_ender_kind: CatalogEntityKind, + pub actual_ender_entry: u32, + pub actual_ender_destinations: Vec, + pub content_revision: String, +} + +#[table( + accessor = pkg_playerbots_catalog_objective, + public, + index(accessor = by_quest, btree(columns = [quest_entry])) +)] +pub struct PlayerbotsCatalogObjective { + #[primary_key] + pub id: u64, + pub quest_entry: u32, + pub objective_index: u8, + pub kind: CatalogObjectiveKind, + pub target_entry: u32, + pub required_count: u32, + pub executor: ObjectiveExecutor, + pub source_kind: Option, + pub source_entries: Vec, + pub source_destinations: Vec, + pub work_area: Option, + pub destination_evidence_revision: String, + pub catalog_revision: u64, +} + +#[derive(spacetimedb::SpacetimeType, Clone, Debug, PartialEq)] +pub struct RetainedQuestTarget { + pub objective_index: u8, + pub kind: CatalogObjectiveKind, + pub target_entry: u32, + pub required_count: u32, + pub executor: ObjectiveExecutor, + pub source: Option, +} + +#[table(accessor = pkg_playerbots_quest_objective, public)] +pub struct PlayerbotsQuestObjective { + #[primary_key] + pub character_guid: u64, + pub runner_objective_identity: u64, + pub quest_entry: u32, + pub target: RetainedQuestTarget, + pub actual_ender_kind: CatalogEntityKind, + pub actual_ender_entry: u32, + pub actual_ender: CatalogDestination, + pub destination: CatalogDestination, + pub destination_evidence_revision: String, + pub catalog_revision: u64, + pub reference_source_revision: String, + pub content_revision: String, +} + +crate::character_owned!(delete, fn sweep_delete_pkg_playerbots_quest_objective(ctx, character_guid) { + ctx.db.pkg_playerbots_quest_objective().character_guid().delete(character_guid); +}); +crate::character_owned!(not_transported, fn sweep_transfer_pkg_playerbots_quest_objective()); + +#[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub enum QuestAdmissionState { + Admitted, + Ineligible, + Unsupported, + Waiting, +} + +#[table(accessor = pkg_playerbots_quest_admission, public)] +pub struct PlayerbotsQuestAdmission { + #[primary_key] + pub character_guid: u64, + pub considered_quest: u32, + pub selected_quest: Option, + pub state: QuestAdmissionState, + pub missing_capability: Option, + pub detail: String, + pub catalog_revision: u64, + pub observed_micros: i64, + pub wait_until_micros: i64, +} + +crate::character_owned!(delete, fn sweep_delete_pkg_playerbots_quest_admission(ctx, character_guid) { + ctx.db.pkg_playerbots_quest_admission().character_guid().delete(character_guid); +}); +crate::character_owned!(not_transported, fn sweep_transfer_pkg_playerbots_quest_admission()); + +#[derive(Clone, Copy)] +struct EntityDefinition { + kind: CatalogEntityKind, + entry: u32, +} + +#[derive(Clone, Copy)] +struct ObjectiveDefinition { + index: u8, + kind: CatalogObjectiveKind, + target_entry: u32, + required_count: u32, + executor: ObjectiveExecutor, + source_kind: Option, + source_entries: &'static [u32], +} + +#[derive(Clone, Copy)] +struct QuestDefinition { + entry: u32, + min_level: u32, + required_races: u32, + required_classes: u32, + prerequisite_quest: u32, + start: EntityDefinition, + actual_ender: EntityDefinition, + objectives: &'static [ObjectiveDefinition], +} + +const fn creature(entry: u32) -> EntityDefinition { + EntityDefinition { + kind: CatalogEntityKind::Creature, + entry, + } +} + +const fn gameobject(entry: u32) -> EntityDefinition { + EntityDefinition { + kind: CatalogEntityKind::GameObject, + entry, + } +} + +const TALK: &[ObjectiveDefinition] = &[ObjectiveDefinition { + index: 0, + kind: CatalogObjectiveKind::TalkOnly, + target_entry: 0, + required_count: 0, + executor: ObjectiveExecutor::Talk, + source_kind: None, + source_entries: &[], +}]; +const Q7: &[ObjectiveDefinition] = &[ObjectiveDefinition { + index: 0, + kind: CatalogObjectiveKind::KillCreature, + target_entry: 6, + required_count: 10, + executor: ObjectiveExecutor::Attack, + source_kind: Some(CatalogEntityKind::Creature), + source_entries: &[6], +}]; +const Q33: &[ObjectiveDefinition] = &[ObjectiveDefinition { + index: 0, + kind: CatalogObjectiveKind::CollectItem, + target_entry: 750, + required_count: 8, + executor: ObjectiveExecutor::CreatureLoot, + source_kind: Some(CatalogEntityKind::Creature), + source_entries: &[299, 69], +}]; +const Q18: &[ObjectiveDefinition] = &[ObjectiveDefinition { + index: 0, + kind: CatalogObjectiveKind::CollectItem, + target_entry: 752, + required_count: 12, + executor: ObjectiveExecutor::CreatureLoot, + source_kind: Some(CatalogEntityKind::Creature), + source_entries: &[38], +}]; +const Q3904: &[ObjectiveDefinition] = &[ObjectiveDefinition { + index: 0, + kind: CatalogObjectiveKind::CollectItem, + target_entry: 11119, + required_count: 8, + executor: ObjectiveExecutor::GameObjectLoot, + source_kind: Some(CatalogEntityKind::GameObject), + source_entries: &[161557], +}]; +const Q3905: &[ObjectiveDefinition] = &[ObjectiveDefinition { + index: 0, + kind: CatalogObjectiveKind::CollectItem, + target_entry: 11125, + required_count: 1, + executor: ObjectiveExecutor::ProvidedItem, + source_kind: None, + source_entries: &[], +}]; + +const QUESTS: &[QuestDefinition] = &[ + QuestDefinition { + entry: 783, + min_level: 1, + required_races: 77, + required_classes: 0, + prerequisite_quest: 0, + start: creature(823), + actual_ender: creature(197), + objectives: TALK, + }, + QuestDefinition { + entry: 7, + min_level: 1, + required_races: 77, + required_classes: 0, + prerequisite_quest: 783, + start: creature(197), + actual_ender: creature(197), + objectives: Q7, + }, + QuestDefinition { + entry: 5261, + min_level: 1, + required_races: 77, + required_classes: 0, + prerequisite_quest: 783, + start: creature(823), + actual_ender: creature(196), + objectives: TALK, + }, + QuestDefinition { + entry: 33, + min_level: 1, + required_races: 77, + required_classes: 0, + prerequisite_quest: 5261, + start: creature(196), + actual_ender: creature(196), + objectives: Q33, + }, + QuestDefinition { + entry: 18, + min_level: 2, + required_races: 77, + required_classes: 0, + prerequisite_quest: 783, + start: creature(823), + actual_ender: creature(823), + objectives: Q18, + }, + QuestDefinition { + entry: 3903, + min_level: 2, + required_races: 77, + required_classes: 0, + prerequisite_quest: 33, + start: creature(823), + actual_ender: creature(9296), + objectives: TALK, + }, + QuestDefinition { + entry: 3904, + min_level: 2, + required_races: 77, + required_classes: 0, + prerequisite_quest: 3903, + start: creature(9296), + actual_ender: creature(9296), + objectives: Q3904, + }, + QuestDefinition { + entry: 3905, + min_level: 2, + required_races: 77, + required_classes: 0, + prerequisite_quest: 3904, + start: creature(9296), + actual_ender: creature(952), + objectives: Q3905, + }, + QuestDefinition { + entry: 40, + min_level: 1, + required_races: 77, + required_classes: 0, + prerequisite_quest: 0, + start: creature(241), + actual_ender: creature(240), + objectives: TALK, + }, + QuestDefinition { + entry: 35, + min_level: 1, + required_races: 77, + required_classes: 0, + prerequisite_quest: 40, + start: creature(240), + actual_ender: creature(261), + objectives: TALK, + }, + QuestDefinition { + entry: 37, + min_level: 1, + required_races: 77, + required_classes: 0, + prerequisite_quest: 35, + start: creature(261), + actual_ender: gameobject(55), + objectives: TALK, + }, + QuestDefinition { + entry: 45, + min_level: 1, + required_races: 77, + required_classes: 0, + prerequisite_quest: 37, + start: gameobject(55), + actual_ender: gameobject(56), + objectives: TALK, + }, +]; + +fn objective_id(quest_entry: u32, objective_index: u8) -> u64 { + (u64::from(quest_entry) << 8) | u64::from(objective_index) +} + +fn destinations( + ctx: &ReducerContext, + entity: EntityDefinition, + map_id: u32, +) -> Vec { + let mut found: Vec<_> = match entity.kind { + CatalogEntityKind::Creature => { + crate::creatures::creature_spawn_evidence(ctx, entity.entry, map_id, DESTINATION_LIMIT) + .into_iter() + .map(|spawn| CatalogDestination { + kind: entity.kind, + entry: entity.entry, + guid: spawn.guid, + map_id: spawn.map_id, + instance_id: 0, + x: spawn.x, + y: spawn.y, + z: spawn.z, + }) + .collect() + } + CatalogEntityKind::GameObject => crate::gameobject::gameobject_destination_evidence( + ctx, + entity.entry, + map_id, + 0, + DESTINATION_LIMIT, + ) + .into_iter() + .map(|spawn| CatalogDestination { + kind: entity.kind, + entry: entity.entry, + guid: spawn.guid, + map_id: spawn.map_id, + instance_id: spawn.instance_id, + x: spawn.x, + y: spawn.y, + z: spawn.z, + }) + .collect(), + }; + found.sort_by_key(|destination| destination.guid); + found +} + +fn source_destinations( + ctx: &ReducerContext, + source_kind: Option, + entries: &[u32], +) -> Vec { + let Some(kind) = source_kind else { + return Vec::new(); + }; + let mut found = Vec::new(); + for entry in entries { + found.extend(destinations( + ctx, + EntityDefinition { + kind, + entry: *entry, + }, + 0, + )); + } + found.sort_by_key(|destination| destination.guid); + found +} + +fn work_area(destinations: &[CatalogDestination]) -> Option { + let first = destinations.first()?; + let mut area = CatalogWorkArea { + map_id: first.map_id, + instance_id: first.instance_id, + min_x: first.x, + max_x: first.x, + min_y: first.y, + max_y: first.y, + }; + for destination in destinations.iter().skip(1).filter(|destination| { + (destination.map_id, destination.instance_id) == (area.map_id, area.instance_id) + }) { + area.min_x = area.min_x.min(destination.x); + area.max_x = area.max_x.max(destination.x); + area.min_y = area.min_y.min(destination.y); + area.max_y = area.max_y.max(destination.y); + } + Some(area) +} + +fn hash_u64(hasher: &mut blake3::Hasher, value: u64) { + hasher.update(&value.to_le_bytes()); +} + +fn hash_destination(hasher: &mut blake3::Hasher, destination: &CatalogDestination) { + hasher.update(&[destination.kind as u8]); + hash_u64(hasher, u64::from(destination.entry)); + hash_u64(hasher, destination.guid); + hash_u64(hasher, u64::from(destination.map_id)); + hash_u64(hasher, destination.instance_id); + hash_u64(hasher, u64::from(destination.x.to_bits())); + hash_u64(hasher, u64::from(destination.y.to_bits())); + hash_u64(hasher, u64::from(destination.z.to_bits())); +} + +fn observed_content_revision(ctx: &ReducerContext) -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"playerbots-observed-catalog-v1"); + for definition in QUESTS.iter().copied() { + for value in [ + definition.entry, + definition.min_level, + definition.required_races, + definition.required_classes, + definition.prerequisite_quest, + definition.start.entry, + definition.actual_ender.entry, + ] { + hash_u64(&mut hasher, u64::from(value)); + } + hasher.update(&[ + definition.start.kind as u8, + definition.actual_ender.kind as u8, + ]); + if let Some(template) = ctx.db.game_quest_template().entry().find(definition.entry) { + hasher.update(&[1]); + for value in [ + template.entry, + template.min_level, + template.required_races, + template.required_classes, + template.prev_quest_id, + template.src_item, + template.src_item_count, + ] { + hash_u64(&mut hasher, u64::from(value)); + } + } else { + hasher.update(&[0]); + } + hasher.update(&[ + relation_exists( + ctx, + definition.start.kind, + definition.start.entry, + definition.entry, + crate::quest::quest_role::START, + ) as u8, + relation_exists( + ctx, + definition.actual_ender.kind, + definition.actual_ender.entry, + definition.entry, + crate::quest::quest_role::END, + ) as u8, + ]); + let mut entity_destinations = destinations(ctx, definition.start, 0); + entity_destinations.extend(destinations(ctx, definition.actual_ender, 0)); + for destination in &entity_destinations { + hash_destination(&mut hasher, destination); + } + let mut core: Vec<_> = ctx + .db + .game_quest_objective() + .by_quest() + .filter(definition.entry) + .collect(); + core.sort_by_key(|objective| objective.obj_index); + for objective in core { + hasher.update(&[objective.obj_index, objective.kind]); + hash_u64(&mut hasher, u64::from(objective.target_entry)); + hash_u64(&mut hasher, u64::from(objective.required_count)); + } + for objective in definition.objectives.iter().copied() { + hasher.update(&[ + objective.index, + objective.kind as u8, + objective.executor as u8, + objective.source_kind.map_or(u8::MAX, |kind| kind as u8), + ]); + hash_u64(&mut hasher, u64::from(objective.target_entry)); + hash_u64(&mut hasher, u64::from(objective.required_count)); + if objective.kind == CatalogObjectiveKind::CollectItem { + if let Some(item) = ctx + .db + .game_item_template() + .entry() + .find(objective.target_entry) + { + hasher.update(&[1]); + hash_u64(&mut hasher, u64::from(item.entry)); + hash_u64(&mut hasher, u64::from(item.max_stack)); + hash_u64(&mut hasher, u64::from(item.max_count)); + } else { + hasher.update(&[0]); + } + } + for entry in objective.source_entries { + hash_u64(&mut hasher, u64::from(*entry)); + for destination in destinations( + ctx, + EntityDefinition { + kind: objective.source_kind.unwrap_or(CatalogEntityKind::Creature), + entry: *entry, + }, + 0, + ) { + hash_destination(&mut hasher, &destination); + } + if objective.source_kind == Some(CatalogEntityKind::Creature) { + let mut creature_loot: Vec<_> = ctx + .db + .game_creature_loot() + .by_creature() + .filter(entry) + .collect(); + creature_loot.sort_by_key(|loot| loot.id); + for loot in creature_loot { + for value in [loot.item_entry, loot.chance_bp, loot.count] { + hash_u64(&mut hasher, u64::from(value)); + } + hasher.update(&[loot.quest_only as u8]); + } + } + if objective.source_kind == Some(CatalogEntityKind::GameObject) { + if let Some(template) = ctx.db.game_gameobject_template().entry().find(entry) { + hasher.update(&[template.type_id]); + for value in [template.data0, template.data1, template.lock_id] { + hash_u64(&mut hasher, u64::from(value)); + } + let mut gameobject_loot: Vec<_> = ctx + .db + .game_gameobject_loot() + .by_loot() + .filter(template.data1) + .collect(); + gameobject_loot.sort_by_key(|loot| loot.id); + for loot in gameobject_loot { + for value in [loot.item_entry, loot.chance_bp, loot.count] { + hash_u64(&mut hasher, u64::from(value)); + } + hasher.update(&[loot.quest_only as u8]); + } + } + } + } + } + hasher.update(&[ + ctx.db + .game_quest_event_requirement() + .by_quest() + .filter(definition.entry) + .next() + .is_some() as u8, + ctx.db + .game_quest_cast_objective() + .by_quest() + .filter(definition.entry) + .next() + .is_some() as u8, + ]); + } + format!("observed-catalog-v1:{}", hasher.finalize().to_hex()) +} + +fn runtime_reference_revision(ctx: &ReducerContext) -> String { + let mut reference = String::from("import-meta-v1"); + let mut found = false; + for family in ["quests", "creatures", "gameobjects", "loot"] { + reference.push(';'); + reference.push_str(family); + reference.push('='); + if let Some(meta) = crate::actor::import_revision(ctx, family) { + found = true; + if meta.source_sha.is_empty() && meta.file_hash.is_empty() { + reference.push_str("unknown"); + } else { + reference.push_str(if meta.source_sha.is_empty() { + "unknown" + } else { + &meta.source_sha + }); + reference.push('@'); + reference.push_str(if meta.file_hash.is_empty() { + "unknown" + } else { + &meta.file_hash + }); + } + } else { + reference.push_str("unknown"); + } + } + if found { + reference + } else { + "unknown".to_string() + } +} + +pub(super) fn refresh_catalog(ctx: &ReducerContext, reference_source_revision: &str) { + let content_revision = observed_content_revision(ctx); + let headers = ctx.db.pkg_playerbots_quest_catalog(); + headers.revision().delete(CATALOG_REVISION); + headers.insert(PlayerbotsQuestCatalog { + revision: CATALOG_REVISION, + name: CATALOG_NAME.to_string(), + blueprint_revision: CATALOG_BLUEPRINT_REVISION.to_string(), + reference_source_revision: reference_source_revision.to_string(), + content_revision: content_revision.clone(), + quest_count: QUESTS.len() as u32, + refresh_after_micros: ctx + .timestamp + .to_micros_since_unix_epoch() + .saturating_add(REFRESH_INTERVAL_MICROS), + }); + let seeds = ctx.db.pkg_playerbots_catalog_seed(); + for (class, fixture_seed) in [(1, 783_001), (5, 783_005), (8, 783_008)] { + seeds.class().delete(class); + seeds.insert(PlayerbotsCatalogSeed { + class, + fixture_seed, + catalog_revision: CATALOG_REVISION, + quest_order: QUESTS.iter().map(|quest| quest.entry).collect(), + }); + } + let quests = ctx.db.pkg_playerbots_catalog_quest(); + let objectives = ctx.db.pkg_playerbots_catalog_objective(); + for definition in QUESTS.iter().copied() { + let template = ctx.db.game_quest_template().entry().find(definition.entry); + let start_destinations = destinations(ctx, definition.start, 0); + let actual_ender_destinations = destinations(ctx, definition.actual_ender, 0); + let eligibility = template + .map(|template| { + ( + template.min_level, + template.required_races, + template.required_classes, + template.prev_quest_id, + ) + }) + .unwrap_or(( + definition.min_level, + definition.required_races, + definition.required_classes, + definition.prerequisite_quest, + )); + quests.quest_entry().delete(definition.entry); + quests.insert(PlayerbotsCatalogQuest { + quest_entry: definition.entry, + catalog_revision: CATALOG_REVISION, + catalog_order: QUESTS + .iter() + .position(|entry| entry.entry == definition.entry) + .unwrap_or_default() as u16, + min_level: eligibility.0, + required_races: eligibility.1, + required_classes: eligibility.2, + prerequisite_quest: eligibility.3, + start_kind: definition.start.kind, + start_entry: definition.start.entry, + start_destinations, + actual_ender_kind: definition.actual_ender.kind, + actual_ender_entry: definition.actual_ender.entry, + actual_ender_destinations: actual_ender_destinations.clone(), + content_revision: content_revision.clone(), + }); + for row in objectives + .by_quest() + .filter(definition.entry) + .collect::>() + { + objectives.id().delete(row.id); + } + for objective in definition.objectives.iter().copied() { + let mut evidence = + source_destinations(ctx, objective.source_kind, objective.source_entries); + if objective.kind == CatalogObjectiveKind::TalkOnly + || objective.executor == ObjectiveExecutor::ProvidedItem + { + evidence = actual_ender_destinations.clone(); + } + let id = objective_id(definition.entry, objective.index); + objectives.insert(PlayerbotsCatalogObjective { + id, + quest_entry: definition.entry, + objective_index: objective.index, + kind: objective.kind, + target_entry: objective.target_entry, + required_count: objective.required_count, + executor: objective.executor, + source_kind: objective.source_kind, + source_entries: objective.source_entries.to_vec(), + work_area: work_area(&evidence), + source_destinations: evidence, + destination_evidence_revision: content_revision.clone(), + catalog_revision: CATALOG_REVISION, + }); + } + } +} + +/// Recheck observed catalog inputs at most once per interval for the whole Shard. Every admission +/// still reads the current quest, relation, objective, loot, and destination rows it depends on. +pub(super) fn ensure_catalog(ctx: &ReducerContext) { + let now = ctx.timestamp.to_micros_since_unix_epoch(); + if ctx + .db + .pkg_playerbots_quest_catalog() + .revision() + .find(CATALOG_REVISION) + .is_some_and(|header| header.refresh_after_micros > now) + { + return; + } + let observed_revision = observed_content_revision(ctx); + let reference_revision = runtime_reference_revision(ctx); + let current = ctx + .db + .pkg_playerbots_quest_catalog() + .revision() + .find(CATALOG_REVISION); + match current { + Some(mut header) + if header.content_revision == observed_revision + && header.reference_source_revision == reference_revision => + { + header.refresh_after_micros = now.saturating_add(REFRESH_INTERVAL_MICROS); + ctx.db + .pkg_playerbots_quest_catalog() + .revision() + .update(header); + } + _ => refresh_catalog(ctx, &reference_revision), + } +} + +fn relation_exists( + ctx: &ReducerContext, + kind: CatalogEntityKind, + entry: u32, + quest_entry: u32, + role: u8, +) -> bool { + match kind { + CatalogEntityKind::Creature => ctx + .db + .game_creature_quest() + .by_creature() + .filter(entry) + .any(|row| row.quest_entry == quest_entry && row.role == role), + CatalogEntityKind::GameObject => ctx + .db + .game_gameobject_quest() + .by_gameobject() + .filter(entry) + .any(|row| row.quest_entry == quest_entry && row.role == role), + } +} + +fn missing(kind: MissingCapability, detail: impl Into) -> AdmissionRefusal { + AdmissionRefusal::Unsupported { + capability: kind, + detail: detail.into(), + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum AdmissionRefusal { + Ineligible(String), + Unsupported { + capability: MissingCapability, + detail: String, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct QuestAdmission { + pub quest_entry: u32, + pub start: CatalogDestination, + pub target: RetainedQuestTarget, + pub actual_ender: CatalogDestination, + pub destination: CatalogDestination, + pub destination_evidence_revision: String, + pub content_revision: String, +} + +fn objective_rows(ctx: &ReducerContext, quest_entry: u32) -> Vec { + let mut rows: Vec<_> = ctx + .db + .pkg_playerbots_catalog_objective() + .by_quest() + .filter(quest_entry) + .collect(); + rows.sort_by_key(|row| row.objective_index); + rows +} + +fn source_gate( + ctx: &ReducerContext, + catalog: &PlayerbotsCatalogObjective, +) -> Result, AdmissionRefusal> { + let mut entries = Vec::new(); + match catalog.executor { + ObjectiveExecutor::Talk | ObjectiveExecutor::ProvidedItem => return Ok(None), + ObjectiveExecutor::Attack => entries.extend(catalog.source_entries.iter().copied()), + ObjectiveExecutor::CreatureLoot => { + entries.extend(catalog.source_entries.iter().copied().filter(|entry| { + ctx.db + .game_creature_loot() + .by_creature() + .filter(entry) + .any(|loot| { + loot.item_entry == catalog.target_entry + && loot.chance_bp > 0 + && loot.count > 0 + }) + })); + if entries.is_empty() { + return Err(missing( + MissingCapability::MissingLootSource, + "no catalog creature drops the required item", + )); + } + } + ObjectiveExecutor::GameObjectLoot => { + let mut has_template = false; + let mut has_simple_chest = false; + for entry in catalog.source_entries.iter().copied() { + let Some(template) = ctx.db.game_gameobject_template().entry().find(entry) else { + continue; + }; + has_template = true; + let simple = + template.type_id == crate::gameobject::go_type::CHEST && template.lock_id == 0; + has_simple_chest |= simple; + if simple + && ctx + .db + .game_gameobject_loot() + .by_loot() + .filter(template.data1) + .any(|loot| { + loot.item_entry == catalog.target_entry + && loot.chance_bp > 0 + && loot.count > 0 + }) + { + entries.push(entry); + } + } + if entries.is_empty() { + return Err(if has_template && !has_simple_chest { + missing( + MissingCapability::ComplexGameObject, + "GameObject loot source requires unsupported behavior", + ) + } else { + missing( + MissingCapability::MissingLootSource, + "no simple chest source drops the required item", + ) + }); + } + } + ObjectiveExecutor::SimpleGameObject => { + entries.extend(catalog.source_entries.iter().copied().filter(|entry| { + ctx.db + .game_gameobject_template() + .entry() + .find(entry) + .is_some_and(|template| { + template.type_id == crate::gameobject::go_type::GOOBER + && template.lock_id == 0 + }) + })); + if entries.is_empty() { + return Err(missing( + MissingCapability::ComplexGameObject, + "GameObject requires unsupported behavior", + )); + } + } + } + source_destinations(ctx, catalog.source_kind, &entries) + .into_iter() + .next() + .map(Some) + .ok_or_else(|| { + missing( + MissingCapability::MissingSourceDestination, + match catalog.executor { + ObjectiveExecutor::Attack => "kill target has no stored destination", + ObjectiveExecutor::CreatureLoot => { + "creature drop source has no stored destination" + } + ObjectiveExecutor::GameObjectLoot => { + "GameObject drop source has no imported spawn" + } + ObjectiveExecutor::SimpleGameObject => { + "GameObject objective has no imported spawn" + } + ObjectiveExecutor::Talk | ObjectiveExecutor::ProvidedItem => unreachable!(), + }, + ) + }) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum AdmissionKind { + Available, + Held, +} + +fn executor_gate( + ctx: &ReducerContext, + character_guid: u64, + admission_kind: AdmissionKind, + quest: &PlayerbotsCatalogQuest, + catalog: &PlayerbotsCatalogObjective, + core: Option<&crate::QuestObjective>, +) -> Result, AdmissionRefusal> { + match catalog.kind { + CatalogObjectiveKind::ExploreAreaTrigger => { + return Err(missing( + MissingCapability::Exploration, + "area-trigger exploration has no sessionless executor", + )); + } + CatalogObjectiveKind::Escort => { + return Err(missing( + MissingCapability::Escort, + "escort state has no quest executor", + )); + } + CatalogObjectiveKind::ScriptedEvent => { + return Err(missing( + MissingCapability::ScriptedEvent, + "scripted event credit has no quest executor", + )); + } + CatalogObjectiveKind::Transport => { + return Err(missing( + MissingCapability::Transport, + "transport steps have no quest executor", + )); + } + CatalogObjectiveKind::ComplexGameObject => { + return Err(missing( + MissingCapability::ComplexGameObject, + "complex GameObject behavior is outside the supported executor", + )); + } + CatalogObjectiveKind::TalkOnly => { + if core.is_some() { + return Err(missing( + MissingCapability::ObjectiveMismatch, + "talk-only catalog entry has a core objective", + )); + } + if catalog.executor != ObjectiveExecutor::Talk { + return Err(missing( + MissingCapability::ObjectiveMismatch, + "talk-only entry does not name the talk executor", + )); + } + Ok(None) + } + CatalogObjectiveKind::KillCreature => { + let Some(core) = core else { + return Err(missing( + MissingCapability::ObjectiveMismatch, + "kill objective is absent from core content", + )); + }; + if core.kind != crate::quest::objective_kind::KILL_CREATURE + || core.target_entry != catalog.target_entry + || core.required_count != catalog.required_count + || catalog.executor != ObjectiveExecutor::Attack + { + return Err(missing( + MissingCapability::ObjectiveMismatch, + "kill objective differs from the catalog", + )); + } + source_gate(ctx, catalog) + } + CatalogObjectiveKind::CollectItem => { + let Some(core) = core else { + return Err(missing( + MissingCapability::ObjectiveMismatch, + "collect objective is absent from core content", + )); + }; + if core.kind != crate::quest::objective_kind::COLLECT_ITEM + || core.target_entry != catalog.target_entry + || core.required_count != catalog.required_count + { + return Err(missing( + MissingCapability::ObjectiveMismatch, + "collect objective differs from the catalog", + )); + } + if ctx + .db + .game_item_template() + .entry() + .find(catalog.target_entry) + .is_none() + { + return Err(missing( + MissingCapability::MissingItemTemplate, + "collect objective item is absent", + )); + } + match catalog.executor { + ObjectiveExecutor::ProvidedItem => { + let template = ctx + .db + .game_quest_template() + .entry() + .find(quest.quest_entry) + .ok_or_else(|| { + missing( + MissingCapability::MissingQuestTemplate, + "quest template disappeared", + ) + })?; + if template.src_item != catalog.target_entry + || template.src_item_count.max(1) < catalog.required_count + { + return Err(missing( + MissingCapability::MissingLootSource, + "acceptance no longer supplies the required item", + )); + } + if admission_kind == AdmissionKind::Held + && crate::items::item_count(ctx, character_guid, catalog.target_entry) + < catalog.required_count + { + return Err(missing( + MissingCapability::MissingProvidedItem, + "provided delivery item is no longer carried", + )); + } + Ok(None) + } + ObjectiveExecutor::CreatureLoot | ObjectiveExecutor::GameObjectLoot => { + source_gate(ctx, catalog) + } + _ => { + return Err(missing( + MissingCapability::ObjectiveMismatch, + "collect objective names the wrong executor", + )); + } + } + } + CatalogObjectiveKind::UseGameObject => { + let Some(core) = core else { + return Err(missing( + MissingCapability::ObjectiveMismatch, + "GameObject objective is absent from core content", + )); + }; + if core.kind != crate::quest::objective_kind::USE_GAMEOBJECT + || core.target_entry != catalog.target_entry + || core.required_count != catalog.required_count + || catalog.executor != ObjectiveExecutor::SimpleGameObject + { + return Err(missing( + MissingCapability::ObjectiveMismatch, + "GameObject objective differs from the catalog", + )); + } + source_gate(ctx, catalog) + } + } +} + +fn inspect( + ctx: &ReducerContext, + character_guid: u64, + quest_entry: u32, + admission_kind: AdmissionKind, +) -> Result { + ensure_catalog(ctx); + let quest = ctx + .db + .pkg_playerbots_catalog_quest() + .quest_entry() + .find(quest_entry) + .ok_or_else(|| { + missing( + MissingCapability::MissingCatalog, + "quest is not in the supported catalog", + ) + })?; + let template = ctx + .db + .game_quest_template() + .entry() + .find(quest_entry) + .ok_or_else(|| { + missing( + MissingCapability::MissingQuestTemplate, + "quest template is absent", + ) + })?; + if ( + template.min_level, + template.required_races, + template.required_classes, + template.prev_quest_id, + ) != ( + quest.min_level, + quest.required_races, + quest.required_classes, + quest.prerequisite_quest, + ) { + return Err(missing( + MissingCapability::ChangedEligibility, + "live quest eligibility differs from the catalog", + )); + } + let character = ctx + .db + .game_world_entity() + .guid() + .find(character_guid) + .ok_or_else(|| AdmissionRefusal::Ineligible("Character is not in the world".to_string()))?; + if admission_kind == AdmissionKind::Available { + crate::quest::accept_gates(ctx, &character, &template) + .map_err(AdmissionRefusal::Ineligible)?; + } + if !relation_exists( + ctx, + quest.start_kind, + quest.start_entry, + quest_entry, + crate::quest::quest_role::START, + ) { + return Err(missing( + MissingCapability::MissingStartRelation, + "catalog start giver does not offer the quest", + )); + } + if !relation_exists( + ctx, + quest.actual_ender_kind, + quest.actual_ender_entry, + quest_entry, + crate::quest::quest_role::END, + ) { + return Err(missing( + MissingCapability::MissingActualEndRelation, + "catalog actual ender does not end the quest", + )); + } + let start = destinations( + ctx, + EntityDefinition { + kind: quest.start_kind, + entry: quest.start_entry, + }, + 0, + ) + .into_iter() + .next() + .ok_or_else(|| { + missing( + MissingCapability::MissingStartDestination, + "start giver has no stored destination", + ) + })?; + let actual_ender = destinations( + ctx, + EntityDefinition { + kind: quest.actual_ender_kind, + entry: quest.actual_ender_entry, + }, + 0, + ) + .into_iter() + .next() + .ok_or_else(|| { + missing( + MissingCapability::MissingActualEndDestination, + "actual ender has no stored destination", + ) + })?; + if ctx + .db + .game_quest_event_requirement() + .by_quest() + .filter(quest_entry) + .next() + .is_some() + { + return Err(missing( + MissingCapability::ScriptedEvent, + "quest requires scripted event credit", + )); + } + if ctx + .db + .game_quest_cast_objective() + .by_quest() + .filter(quest_entry) + .next() + .is_some() + { + return Err(missing( + MissingCapability::CastObjective, + "quest requires a cast objective", + )); + } + let mut core: Vec<_> = ctx + .db + .game_quest_objective() + .by_quest() + .filter(quest_entry) + .collect(); + core.sort_by_key(|objective| objective.obj_index); + let catalog = objective_rows(ctx, quest_entry); + let talk_only = catalog.len() == 1 && catalog[0].kind == CatalogObjectiveKind::TalkOnly; + if (!talk_only && catalog.len() != core.len()) || (talk_only && !core.is_empty()) { + return Err(missing( + MissingCapability::ObjectiveMismatch, + "core objective count differs from the catalog", + )); + } + let mut gated = Vec::with_capacity(catalog.len()); + for catalog_objective in &catalog { + let core_objective = core + .iter() + .find(|objective| objective.obj_index == catalog_objective.objective_index); + let source = executor_gate( + ctx, + character_guid, + admission_kind, + &quest, + catalog_objective, + core_objective, + )?; + gated.push((catalog_objective, source)); + } + let (selected, source) = gated + .iter() + .find(|(objective, _)| { + core.iter() + .find(|core| core.obj_index == objective.objective_index) + .is_none_or(|core| { + let count = if core.kind == crate::quest::objective_kind::COLLECT_ITEM { + crate::items::item_count(ctx, character_guid, core.target_entry) + } else { + ctx.db + .game_character_quest() + .by_character() + .filter(character_guid) + .find(|row| row.quest_entry == quest_entry) + .and_then(|row| row.counts.get(core.obj_index as usize).copied()) + .unwrap_or(0) + }; + count < core.required_count + }) + }) + .or_else(|| gated.first()) + .ok_or_else(|| { + missing( + MissingCapability::ObjectiveMismatch, + "catalog has no objective classification", + ) + })?; + let destination = if talk_only || selected.executor == ObjectiveExecutor::ProvidedItem { + actual_ender.clone() + } else { + source.clone().ok_or_else(|| { + missing( + MissingCapability::MissingSourceDestination, + "selected objective has no destination", + ) + })? + }; + Ok(QuestAdmission { + quest_entry, + start, + target: RetainedQuestTarget { + objective_index: selected.objective_index, + kind: selected.kind, + target_entry: selected.target_entry, + required_count: selected.required_count, + executor: selected.executor, + source: source.clone(), + }, + actual_ender, + destination, + destination_evidence_revision: selected.destination_evidence_revision.clone(), + content_revision: quest.content_revision, + }) +} + +#[cfg_attr( + not(feature = "debug_reducers"), + allow( + dead_code, + reason = "available-quest admission is driven by the durable catalog fixture" + ) +)] +pub(super) fn admit_available( + ctx: &ReducerContext, + character_guid: u64, + quest_entry: u32, +) -> Result { + inspect(ctx, character_guid, quest_entry, AdmissionKind::Available) +} + +pub(super) fn admit_held( + ctx: &ReducerContext, + character_guid: u64, + quest_entry: u32, +) -> Result { + let held = ctx + .db + .game_character_quest() + .by_character() + .filter(character_guid) + .any(|row| row.quest_entry == quest_entry && !row.rewarded && !row.failed); + if !held { + return Err(AdmissionRefusal::Ineligible( + "quest is not active".to_string(), + )); + } + inspect(ctx, character_guid, quest_entry, AdmissionKind::Held) +} + +pub(super) fn reconcile_active( + ctx: &ReducerContext, + character_guid: u64, +) -> Option { + ensure_catalog(ctx); + let reconsider = ctx + .db + .pkg_playerbots_quest_admission() + .character_guid() + .find(character_guid) + .filter(|row| row.missing_capability.is_some()) + .map(|row| row.considered_quest); + let retained = ctx + .db + .pkg_playerbots_quest_objective() + .character_guid() + .find(character_guid) + .map(|row| row.quest_entry); + let mut entries: Vec<_> = ctx + .db + .game_character_quest() + .by_character() + .filter(character_guid) + .filter(|quest| !quest.rewarded && !quest.failed) + .map(|quest| quest.quest_entry) + .collect(); + entries.sort_by_key(|entry| { + ( + u8::from(Some(*entry) != reconsider), + u8::from(Some(*entry) != retained), + QUESTS + .iter() + .position(|quest| quest.entry == *entry) + .unwrap_or(usize::MAX), + ) + }); + let mut first_refusal = None; + for entry in entries { + match admit_held(ctx, character_guid, entry) { + Ok(admission) => { + if let Some((considered, refusal)) = first_refusal.as_ref() { + record_admission( + ctx, + character_guid, + *considered, + Some(admission.quest_entry), + Some(refusal), + ); + } else { + record_admission( + ctx, + character_guid, + admission.quest_entry, + Some(admission.quest_entry), + None, + ); + } + return Some(admission); + } + Err(refusal @ AdmissionRefusal::Unsupported { .. }) => { + first_refusal.get_or_insert((entry, refusal)); + } + Err(AdmissionRefusal::Ineligible(_)) => {} + } + } + if let Some((entry, refusal)) = first_refusal.as_ref() { + record_admission(ctx, character_guid, *entry, None, Some(refusal)); + } + None +} + +pub(super) fn retain( + ctx: &ReducerContext, + character_guid: u64, + runner_objective_identity: u64, + admission: QuestAdmission, +) { + let reference_source_revision = ctx + .db + .pkg_playerbots_quest_catalog() + .revision() + .find(CATALOG_REVISION) + .map_or_else( + || "unknown".to_string(), + |header| header.reference_source_revision, + ); + let row = PlayerbotsQuestObjective { + character_guid, + runner_objective_identity, + quest_entry: admission.quest_entry, + target: admission.target, + actual_ender_kind: admission.actual_ender.kind, + actual_ender_entry: admission.actual_ender.entry, + actual_ender: admission.actual_ender, + destination: admission.destination, + destination_evidence_revision: admission.destination_evidence_revision, + catalog_revision: CATALOG_REVISION, + reference_source_revision, + content_revision: admission.content_revision, + }; + let rows = ctx.db.pkg_playerbots_quest_objective(); + if rows.character_guid().find(character_guid).is_some() { + rows.character_guid().update(row); + } else { + rows.insert(row); + } +} + +pub(super) fn retained_matches( + ctx: &ReducerContext, + character_guid: u64, + admission: &QuestAdmission, +) -> bool { + let reference_source_revision = ctx + .db + .pkg_playerbots_quest_catalog() + .revision() + .find(CATALOG_REVISION) + .map_or_else( + || "unknown".to_string(), + |header| header.reference_source_revision, + ); + ctx.db + .pkg_playerbots_quest_objective() + .character_guid() + .find(character_guid) + .is_some_and(|row| { + row.quest_entry == admission.quest_entry + && row.target == admission.target + && row.actual_ender_kind == admission.actual_ender.kind + && row.actual_ender_entry == admission.actual_ender.entry + && row.actual_ender == admission.actual_ender + && row.destination == admission.destination + && row.destination_evidence_revision == admission.destination_evidence_revision + && row.catalog_revision == CATALOG_REVISION + && row.reference_source_revision == reference_source_revision + && row.content_revision == admission.content_revision + }) +} + +pub(super) fn clear_retained(ctx: &ReducerContext, character_guid: u64) { + ctx.db + .pkg_playerbots_quest_objective() + .character_guid() + .delete(character_guid); +} + +pub(super) fn active_wait_until(ctx: &ReducerContext, character_guid: u64) -> Option { + let admission = ctx + .db + .pkg_playerbots_quest_admission() + .character_guid() + .find(character_guid) + .filter(|row| row.state == QuestAdmissionState::Waiting)?; + ctx.db + .game_character_quest() + .by_character() + .filter(character_guid) + .any(|quest| { + quest.quest_entry == admission.considered_quest && !quest.rewarded && !quest.failed + }) + .then_some(admission.wait_until_micros) +} + +pub(super) fn record_admission( + ctx: &ReducerContext, + character_guid: u64, + considered_quest: u32, + selected_quest: Option, + refusal: Option<&AdmissionRefusal>, +) { + let (state, capability, detail, wait_until_micros) = match refusal { + None => (QuestAdmissionState::Admitted, None, String::new(), 0), + Some(AdmissionRefusal::Ineligible(detail)) => { + (QuestAdmissionState::Ineligible, None, detail.clone(), 0) + } + Some(AdmissionRefusal::Unsupported { capability, detail }) => ( + if selected_quest.is_some() { + QuestAdmissionState::Unsupported + } else { + QuestAdmissionState::Waiting + }, + Some(*capability), + detail.clone(), + ctx.timestamp + .to_micros_since_unix_epoch() + .saturating_add(WAIT_MICROS), + ), + }; + let row = PlayerbotsQuestAdmission { + character_guid, + considered_quest, + selected_quest, + state, + missing_capability: capability, + detail, + catalog_revision: CATALOG_REVISION, + observed_micros: ctx.timestamp.to_micros_since_unix_epoch(), + wait_until_micros, + }; + let rows = ctx.db.pkg_playerbots_quest_admission(); + if rows.character_guid().find(character_guid).is_some() { + rows.character_guid().update(row); + } else { + rows.insert(row); + } +} + +#[cfg_attr( + not(feature = "debug_reducers"), + allow( + dead_code, + reason = "live-target selection is driven by the durable catalog fixture" + ) +)] +pub(super) fn live_creature_target( + ctx: &ReducerContext, + character_guid: u64, + entry: u32, +) -> Option { + // Fixture-only. PB-007 must replace this by-entry scan with a partition-indexed bounded query + // before live target selection enters the production runner. + let character = ctx.db.game_world_entity().guid().find(character_guid)?; + ctx.db + .game_world_entity() + .by_entry() + .filter(entry) + .filter(|target| { + !target.dead + && (target.map_id, target.instance_id) == (character.map_id, character.instance_id) + }) + .min_by(|left, right| { + let left_distance = (left.x - character.x).powi(2) + (left.y - character.y).powi(2); + let right_distance = (right.x - character.x).powi(2) + (right.y - character.y).powi(2); + left_distance + .total_cmp(&right_distance) + .then_with(|| left.guid.cmp(&right.guid)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shipped_catalog_has_one_class_independent_order() { + let entries: Vec<_> = QUESTS.iter().map(|quest| quest.entry).collect(); + assert_eq!( + entries, + [783, 7, 5261, 33, 18, 3903, 3904, 3905, 40, 35, 37, 45] + ); + assert!(QUESTS.iter().all(|quest| quest.required_classes == 0)); + } + + #[test] + fn mixed_requirements_need_every_executor() { + let supported = [ + CatalogObjectiveKind::KillCreature, + CatalogObjectiveKind::CollectItem, + ]; + let mixed = [ + CatalogObjectiveKind::KillCreature, + CatalogObjectiveKind::Escort, + ]; + let has_executor = |kind| { + matches!( + kind, + CatalogObjectiveKind::TalkOnly + | CatalogObjectiveKind::KillCreature + | CatalogObjectiveKind::CollectItem + | CatalogObjectiveKind::UseGameObject + ) + }; + assert!(supported.into_iter().all(has_executor)); + assert!(!mixed.into_iter().all(has_executor)); + } + + #[test] + fn harvest_is_collect_item_backed_by_gameobject_loot() { + let objective = Q3904[0]; + assert_eq!(objective.kind, CatalogObjectiveKind::CollectItem); + assert_eq!(objective.executor, ObjectiveExecutor::GameObjectLoot); + assert_eq!(objective.target_entry, 11119); + assert_eq!(objective.source_entries, [161557]); + } +} diff --git a/playerbots/src/quest_catalog_fixture.rs b/playerbots/src/quest_catalog_fixture.rs new file mode 100644 index 0000000..b8553d2 --- /dev/null +++ b/playerbots/src/quest_catalog_fixture.rs @@ -0,0 +1,1370 @@ +//! Synthetic private-Shard staging for the catalog and its callable executors. + +use super::pkg_playerbots_bot; +use super::quest_catalog::{ + self, pkg_playerbots_catalog_objective, pkg_playerbots_quest_catalog, AdmissionRefusal, + CatalogEntityKind, CatalogObjectiveKind, ObjectiveExecutor, PlayerbotsCatalogObjective, + CATALOG_REVISION, +}; +use crate::import_meta::game_import_meta; // package-api: exempt operator fixture refuses imported content before staging +use crate::{ + game_character_quest, game_corpse_loot, game_creature_loot, game_creature_quest, + game_creature_spawn, game_creature_template, game_gameobject, game_gameobject_loot, + game_gameobject_quest, game_gameobject_template, game_item_instance, game_item_template, + game_quest_objective, game_quest_template, game_spell, game_spell_effect, game_world_entity, +}; +use spacetimedb::{reducer, table, ReducerContext, Table}; + +const FIXTURE_REVISION: &str = "playerbots-synthetic-quest-catalog-v1"; +const CHEST_ENTRY: u32 = 161557; +const CHEST_LOOT: u32 = 10119; +const DIRECT_GO_QUEST: u32 = 3904; +const FIXTURE_OWNERSHIP_ID: u8 = 1; +const RELATION_ID_BASE: u64 = 509_9000; +const OBJECTIVE_ID_BASE: u64 = 509_9100; +const CREATURE_LOOT_ID_BASE: u64 = 509_9200; +const GAMEOBJECT_LOOT_ID: u64 = 509_9210; +const INVENTORY_FILLER: u32 = 509_9400; +const SHARED_FIXTURE_HEAL: u32 = 5_090_100; + +const CREATURES: [u32; 12] = [823, 197, 196, 9296, 952, 241, 240, 261, 6, 299, 69, 38]; +const GAMEOBJECTS: [u32; 3] = [55, 56, CHEST_ENTRY]; +const ITEMS: [u32; 4] = [750, 752, 11119, 11125]; + +const QUESTS: &[(u32, u32, u32, u32, u32)] = &[ + (783, 1, 0, 823, 197), + (7, 1, 783, 197, 197), + (5261, 1, 783, 823, 196), + (33, 1, 5261, 196, 196), + (18, 2, 783, 823, 823), + (3903, 2, 33, 823, 9296), + (3904, 2, 3903, 9296, 9296), + (3905, 2, 3904, 9296, 952), + (40, 1, 0, 241, 240), + (35, 1, 40, 240, 261), + (37, 1, 35, 261, 55), + (45, 1, 37, 55, 56), +]; + +#[table(accessor = pkg_playerbots_quest_fixture_ownership)] +pub struct PlayerbotsQuestFixtureOwnership { + #[primary_key] + pub id: u8, + pub revision: String, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum FixtureStage { + New, + Existing, +} + +fn reject_imported_content(ctx: &ReducerContext) -> Result<(), String> { + if let Some(import) = ctx.db.game_import_meta().iter().next() { + return Err(format!( + "quest fixture refuses non-empty import catalogue ({})", + import.family + )); + } + Ok(()) +} + +fn require_fixture(ctx: &ReducerContext) -> Result<(), String> { + reject_imported_content(ctx)?; + let ownership = ctx + .db + .pkg_playerbots_quest_fixture_ownership() + .id() + .find(FIXTURE_OWNERSHIP_ID) + .ok_or("quest fixture has not claimed this database")?; + if ownership.revision != FIXTURE_REVISION { + return Err("quest fixture ownership revision differs".to_string()); + } + Ok(()) +} + +fn quest_offset(quest_entry: u32) -> u64 { + QUESTS + .iter() + .position(|quest| quest.0 == quest_entry) + .expect("fixture quest must be listed") as u64 +} + +fn relation_id(quest_entry: u32, role: u8) -> u64 { + RELATION_ID_BASE + quest_offset(quest_entry) * 2 + u64::from(role) +} + +fn objective_id(quest_entry: u32, index: u8) -> u64 { + OBJECTIVE_ID_BASE + quest_offset(quest_entry) * 4 + u64::from(index) +} + +fn creature_loot_id(source_entry: u32) -> u64 { + CREATURE_LOOT_ID_BASE + + match source_entry { + 299 => 0, + 69 => 1, + 38 => 2, + _ => unreachable!("fixture creature loot source must be listed"), + } +} + +fn creature_guid(entry: u32) -> u64 { + (0xF130u64 << 48) | (u64::from(entry) << 24) | 1 +} + +fn gameobject_guid(entry: u32) -> u64 { + (0xF110u64 << 48) | u64::from(entry) +} + +fn relation_exists( + ctx: &ReducerContext, + kind: CatalogEntityKind, + entry: u32, + quest_entry: u32, + role: u8, +) -> bool { + match kind { + CatalogEntityKind::Creature => ctx + .db + .game_creature_quest() + .by_creature() + .filter(entry) + .any(|row| row.quest_entry == quest_entry && row.role == role), + CatalogEntityKind::GameObject => ctx + .db + .game_gameobject_quest() + .by_gameobject() + .filter(entry) + .any(|row| row.quest_entry == quest_entry && row.role == role), + } +} + +fn stage_gate(ctx: &ReducerContext, character_guid: u64) -> Result { + reject_imported_content(ctx)?; + if let Some(ownership) = ctx + .db + .pkg_playerbots_quest_fixture_ownership() + .id() + .find(FIXTURE_OWNERSHIP_ID) + { + return if ownership.revision == FIXTURE_REVISION { + Ok(FixtureStage::Existing) + } else { + Err("quest fixture ownership revision differs".to_string()) + }; + } + + if ctx + .db + .game_world_entity() + .guid() + .find(character_guid) + .is_none() + { + return Err("Character missing".to_string()); + } + let source_item = ctx + .db + .game_item_instance() + .by_owner_guid() + .filter(character_guid) + .next() + .ok_or("starter item missing")?; + if ctx + .db + .game_item_template() + .entry() + .find(source_item.entry) + .is_none() + { + return Err("starter item template missing".to_string()); + } + if ctx + .db + .game_creature_template() + .entry() + .find(51000) + .is_none() + { + return Err("seed creature template missing".to_string()); + } + if ctx.db.game_spell().spell_id().find(2050).is_none() { + return Err("seed heal missing".to_string()); + } + for bot in ctx.db.pkg_playerbots_bot().iter() { + if ctx + .db + .game_world_entity() + .guid() + .find(bot.character_guid) + .is_none() + { + return Err("fixture bot is not in the world".to_string()); + } + } + let occupied = ITEMS + .iter() + .copied() + .chain([INVENTORY_FILLER]) + .find(|entry| ctx.db.game_item_template().entry().find(*entry).is_some()); + if let Some(entry) = occupied { + return Err(format!("quest fixture item entry {entry} already exists")); + } + for entry in CREATURES { + if ctx + .db + .game_creature_template() + .entry() + .find(entry) + .is_some() + || ctx + .db + .game_creature_spawn() + .guid() + .find(creature_guid(entry)) + .is_some() + || ctx + .db + .game_world_entity() + .guid() + .find(creature_guid(entry)) + .is_some() + { + return Err(format!( + "quest fixture creature entry {entry} already exists" + )); + } + } + for entry in GAMEOBJECTS { + if ctx + .db + .game_gameobject_template() + .entry() + .find(entry) + .is_some() + || ctx + .db + .game_gameobject() + .guid() + .find(gameobject_guid(entry)) + .is_some() + { + return Err(format!( + "quest fixture GameObject entry {entry} already exists" + )); + } + } + for (quest_entry, _, _, start, actual_ender) in QUESTS.iter().copied() { + if ctx + .db + .game_quest_template() + .entry() + .find(quest_entry) + .is_some() + || ctx + .db + .game_quest_objective() + .by_quest() + .filter(quest_entry) + .next() + .is_some() + { + return Err(format!("quest fixture quest {quest_entry} already exists")); + } + for (entry, role) in [ + (start, crate::quest::quest_role::START), + (actual_ender, crate::quest::quest_role::END), + ] { + let kind = if matches!(entry, 55 | 56) { + CatalogEntityKind::GameObject + } else { + CatalogEntityKind::Creature + }; + if relation_exists(ctx, kind, entry, quest_entry, role) { + return Err(format!( + "quest fixture relation for quest {quest_entry} already exists" + )); + } + let id = relation_id(quest_entry, role); + let reserved_occupied = match kind { + CatalogEntityKind::Creature => ctx.db.game_creature_quest().id().find(id).is_some(), + CatalogEntityKind::GameObject => { + ctx.db.game_gameobject_quest().id().find(id).is_some() + } + }; + if reserved_occupied { + return Err(format!("quest fixture relation id {id} is occupied")); + } + } + for index in 0..4 { + let id = objective_id(quest_entry, index); + if ctx.db.game_quest_objective().id().find(id).is_some() { + return Err(format!("quest fixture objective id {id} is occupied")); + } + } + } + for source in [299, 69, 38] { + if ctx + .db + .game_creature_loot() + .by_creature() + .filter(source) + .next() + .is_some() + { + return Err(format!( + "quest fixture creature loot for {source} already exists" + )); + } + let id = creature_loot_id(source); + if ctx.db.game_creature_loot().id().find(id).is_some() { + return Err(format!("quest fixture creature loot id {id} is occupied")); + } + } + if ctx + .db + .game_gameobject_loot() + .by_loot() + .filter(CHEST_LOOT) + .next() + .is_some() + || ctx + .db + .game_gameobject_loot() + .id() + .find(GAMEOBJECT_LOOT_ID) + .is_some() + { + return Err("quest fixture GameObject loot is occupied".to_string()); + } + if ctx + .db + .game_spell() + .spell_id() + .find(SHARED_FIXTURE_HEAL) + .is_some() + || ctx + .db + .game_spell_effect() + .by_spell() + .filter(SHARED_FIXTURE_HEAL) + .next() + .is_some() + { + return Err("shared playerbots fixture spell is occupied".to_string()); + } + for effect in ctx.db.game_spell_effect().by_spell().filter(2050u32) { + let id = (u64::from(SHARED_FIXTURE_HEAL) << 2) | u64::from(effect.effect_index); + if ctx.db.game_spell_effect().id().find(id).is_some() { + return Err(format!( + "shared playerbots fixture spell effect id {id} is occupied" + )); + } + } + Ok(FixtureStage::New) +} + +fn insert_creature(ctx: &ReducerContext, entry: u32, x: f32, y: f32, z: f32) -> Result<(), String> { + let mut template = ctx + .db + .game_creature_template() + .entry() + .find(51000) + .ok_or("seed creature template missing")?; + template.entry = entry; + template.name = format!("Catalog creature {entry}"); + template.faction_template = 14; + let template = ctx.db.game_creature_template().insert(template); + let guid = creature_guid(entry); + let spawn = ctx.db.game_creature_spawn().insert(crate::CreatureSpawn { + guid, + entry, + map_id: 0, + x, + y, + z, + orientation: 0.0, + respawn_at: crate::creatures::timer_never(ctx), + despawn_at: crate::creatures::timer_never(ctx), + movement_type: 0, + respawn_secs: 60, + life_seq: 1, + }); + crate::creatures::insert_creature_entity( + ctx, + crate::creatures::build_creature_entity(&spawn, &template, 0, 0), + ); + Ok(()) +} + +fn insert_gameobject( + ctx: &ReducerContext, + entry: u32, + type_id: u8, + data1: u32, + x: f32, + y: f32, + z: f32, +) { + ctx.db + .game_gameobject_template() + .insert(crate::GameObjectTemplate { + entry, + type_id, + display_id: 259, + name: format!("Catalog GameObject {entry}"), + data0: 0, + data1, + gather_skill_line: 0, + respawn_secs: 0, + gather_gray: 0, + lock_id: 0, + size: 1.0, + }); + let guid = gameobject_guid(entry); + ctx.db.game_gameobject().insert(crate::GameObject { + guid, + template_entry: entry, + map_id: 0, + x, + y, + z, + orientation: 0.0, + state: 0, + created_at: ctx.timestamp, + respawn_at_micros: 0, + instance_id: 0, + grid_x: lyracore_shared::spatial::grid_cell(x, y).0, + grid_y: lyracore_shared::spatial::grid_cell(x, y).1, + cell: lyracore_shared::spatial::cell_id_at(x, y), + rotation_0: 0.0, + rotation_1: 0.0, + rotation_2: 0.0, + rotation_3: 0.0, + }); +} + +fn clone_item(ctx: &ReducerContext, entry: u32, source_entry: u32) -> Result<(), String> { + let mut item = ctx + .db + .game_item_template() + .entry() + .find(source_entry) + .ok_or("starter item template missing")?; + item.entry = entry; + item.name = format!("Catalog item {entry}"); + item.max_stack = 20; + item.max_count = 0; + ctx.db.game_item_template().insert(item); + Ok(()) +} + +fn insert_quest( + ctx: &ReducerContext, + entry: u32, + min_level: u32, + prerequisite: u32, + start: u32, + actual_ender: u32, +) { + ctx.db.game_quest_template().insert(crate::QuestTemplate { + entry, + min_level, + quest_level: min_level.max(1), + title: format!("Catalog quest {entry}"), + reward_money: 1, + reward_xp: 1, + prev_quest_id: prerequisite, + required_races: 77, + required_classes: 0, + zone_or_sort: if entry >= 40 && entry < 100 { 12 } else { 9 }, + rew_rep_faction_1: 0, + rew_rep_value_1: 0, + rew_rep_faction_2: 0, + rew_rep_value_2: 0, + src_item: if entry == 3905 { 11125 } else { 0 }, + src_item_count: if entry == 3905 { 1 } else { 0 }, + repeatable: false, + next_quest_id: if entry == 783 { 7 } else { 0 }, + limit_time: 0, + reward_money_max_level: 0, + }); + let start_kind = if matches!(start, 55 | 56) { + CatalogEntityKind::GameObject + } else { + CatalogEntityKind::Creature + }; + let end_kind = if matches!(actual_ender, 55 | 56) { + CatalogEntityKind::GameObject + } else { + CatalogEntityKind::Creature + }; + insert_relation( + ctx, + start_kind, + start, + entry, + crate::quest::quest_role::START, + ); + insert_relation( + ctx, + end_kind, + actual_ender, + entry, + crate::quest::quest_role::END, + ); +} + +fn insert_relation( + ctx: &ReducerContext, + kind: CatalogEntityKind, + entry: u32, + quest_entry: u32, + role: u8, +) { + match kind { + CatalogEntityKind::Creature => { + let table = ctx.db.game_creature_quest(); + let id = relation_id(quest_entry, role); + table.id().delete(id); + table.insert(crate::CreatureQuest { + id, + creature_entry: entry, + quest_entry, + role, + }); + } + CatalogEntityKind::GameObject => { + let table = ctx.db.game_gameobject_quest(); + let id = relation_id(quest_entry, role); + table.id().delete(id); + table.insert(crate::GameObjectQuest { + id, + go_entry: entry, + quest_entry, + role, + }); + } + } +} + +fn insert_objective( + ctx: &ReducerContext, + quest_entry: u32, + index: u8, + kind: u8, + target_entry: u32, + required_count: u32, +) { + let id = objective_id(quest_entry, index); + ctx.db.game_quest_objective().id().delete(id); + ctx.db.game_quest_objective().insert(crate::QuestObjective { + id, + quest_entry, + obj_index: index, + kind, + target_entry, + required_count, + }); +} + +fn reward_prerequisite(ctx: &ReducerContext, guid: u64, quest_entry: u32) -> Result<(), String> { + if quest_entry == 0 + || ctx + .db + .game_character_quest() + .by_character() + .filter(guid) + .any(|row| row.quest_entry == quest_entry && row.rewarded) + { + return Ok(()); + } + let owner_identity = ctx + .db + .game_world_entity() + .guid() + .find(guid) + .ok_or("Character missing")? + .owner_identity; + ctx.db.game_character_quest().insert(crate::CharacterQuest { + id: 0, // Core allocates per-Character quest-log ids. + character_guid: guid, + owner_identity, + quest_entry, + counts: Vec::new(), + rewarded: true, + deadline_micros: 0, + failed: false, + }); + Ok(()) +} + +#[reducer] +pub fn playerbots_quest_fixture_stage( + ctx: &ReducerContext, + character_guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + if stage_gate(ctx, character_guid)? == FixtureStage::Existing { + return Ok(()); + } + ctx.db + .pkg_playerbots_quest_fixture_ownership() + .insert(PlayerbotsQuestFixtureOwnership { + id: FIXTURE_OWNERSHIP_ID, + revision: FIXTURE_REVISION.to_string(), + }); + super::fixture::playerbots_fixture_prepare(ctx)?; + let mut character = ctx + .db + .game_world_entity() + .guid() + .find(character_guid) + .ok_or("Character missing")?; + character.level = character.level.max(2); + ctx.db.game_world_entity().guid().update(character); + let character = ctx + .db + .game_world_entity() + .guid() + .find(character_guid) + .ok_or("Character missing after level update")?; + let source_item = ctx + .db + .game_item_instance() + .by_owner_guid() + .filter(character_guid) + .next() + .ok_or("starter item missing")? + .entry; + for entry in ITEMS { + clone_item(ctx, entry, source_item)?; + } + for (offset, entry) in CREATURES.into_iter().enumerate() { + insert_creature( + ctx, + entry, + character.x + 2.0, + character.y + offset as f32 * 0.05, + character.z, + )?; + } + insert_gameobject( + ctx, + 55, + crate::gameobject::go_type::QUESTGIVER, + 0, + character.x + 2.0, + character.y + 0.7, + character.z, + ); + insert_gameobject( + ctx, + 56, + crate::gameobject::go_type::QUESTGIVER, + 0, + character.x + 2.0, + character.y + 0.8, + character.z, + ); + insert_gameobject( + ctx, + CHEST_ENTRY, + crate::gameobject::go_type::CHEST, + CHEST_LOOT, + character.x + 2.0, + character.y + 0.9, + character.z, + ); + for (quest, min_level, prerequisite, start, actual_ender) in QUESTS.iter().copied() { + insert_quest(ctx, quest, min_level, prerequisite, start, actual_ender); + } + for (quest, kind, target, count) in [ + (7, crate::quest::objective_kind::KILL_CREATURE, 6, 10), + (33, crate::quest::objective_kind::COLLECT_ITEM, 750, 8), + (18, crate::quest::objective_kind::COLLECT_ITEM, 752, 12), + (3904, crate::quest::objective_kind::COLLECT_ITEM, 11119, 8), + (3905, crate::quest::objective_kind::COLLECT_ITEM, 11125, 1), + ] { + insert_objective(ctx, quest, 0, kind, target, count); + } + let creature_loot = ctx.db.game_creature_loot(); + for (source, item) in [(299, 750), (69, 750), (38, 752)] { + let id = creature_loot_id(source); + creature_loot.id().delete(id); + creature_loot.insert(crate::CreatureLoot { + id, + creature_entry: source, + item_entry: item, + chance_bp: 10_000, + count: if item == 750 { 8 } else { 12 }, + group_id: 0, + quest_only: true, + }); + } + let gameobject_loot = ctx.db.game_gameobject_loot(); + gameobject_loot.id().delete(GAMEOBJECT_LOOT_ID); + gameobject_loot.insert(crate::GameObjectLoot { + id: GAMEOBJECT_LOOT_ID, + loot_id: CHEST_LOOT, + item_entry: 11119, + chance_bp: 10_000, + count: 8, + group_id: 0, + quest_only: true, + }); + quest_catalog::refresh_catalog(ctx, "unknown"); + Ok(()) +} + +#[reducer] +pub fn playerbots_quest_fixture_admit_accept( + ctx: &ReducerContext, + character_guid: u64, + quest_entry: u32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + let prerequisite = ctx + .db + .game_quest_template() + .entry() + .find(quest_entry) + .ok_or("quest missing")? + .prev_quest_id; + reward_prerequisite(ctx, character_guid, prerequisite)?; + match quest_catalog::admit_available(ctx, character_guid, quest_entry) { + Ok(admission) => { + quest_catalog::record_admission( + ctx, + character_guid, + quest_entry, + Some(quest_entry), + None, + ); + super::actions::accept_quest(ctx, character_guid, admission.start.guid, quest_entry) + .map_err(Into::into) + } + Err(refusal) => { + quest_catalog::record_admission(ctx, character_guid, quest_entry, None, Some(&refusal)); + Err(match refusal { + AdmissionRefusal::Ineligible(detail) + | AdmissionRefusal::Unsupported { detail, .. } => detail, + }) + } + } +} + +#[reducer] +pub fn playerbots_quest_fixture_turn_in( + ctx: &ReducerContext, + character_guid: u64, + quest_entry: u32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + let admission = quest_catalog::admit_held(ctx, character_guid, quest_entry).map_err( + |refusal| match refusal { + AdmissionRefusal::Ineligible(detail) | AdmissionRefusal::Unsupported { detail, .. } => { + detail + } + }, + )?; + super::actions::turn_in_quest( + ctx, + character_guid, + admission.actual_ender.guid, + quest_entry, + 0, + ) + .map_err(Into::into) +} + +#[reducer] +pub fn playerbots_quest_fixture_kill( + ctx: &ReducerContext, + character_guid: u64, + creature_entry: u32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + let target = quest_catalog::live_creature_target(ctx, character_guid, creature_entry) + .ok_or("no live target")?; + super::actions::attack(ctx, character_guid, target.guid).map_err(String::from)?; + let (amount, _) = crate::combat::fold_incoming_damage(ctx, character_guid, target.guid, 10_000); + let damage = crate::combat::final_damage(ctx, target.guid, amount); + let outcome = crate::combat::apply_hit( + ctx, + character_guid, + target.guid, + damage, + crate::combat::Hit::weapon(crate::combat::HitSource::MainHand, false), + ); + if outcome.killed { + Ok(()) + } else { + Err("fixture hit was not lethal".to_string()) + } +} + +#[reducer] +pub fn playerbots_quest_fixture_take_creature_loot( + ctx: &ReducerContext, + character_guid: u64, + creature_entry: u32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + let guid = creature_guid(creature_entry); + super::actions::open_creature_loot(ctx, character_guid, guid, 33).map_err(String::from)?; + let slots: Vec<_> = ctx + .db + .game_corpse_loot() + .by_corpse() + .filter(guid) + .map(|row| row.slot) + .collect(); + if slots.is_empty() { + return Err("creature produced no loot".to_string()); + } + for slot in slots { + super::actions::take_loot(ctx, character_guid, guid, slot, 33).map_err(String::from)?; + } + Ok(()) +} + +#[reducer] +pub fn playerbots_quest_fixture_use_gameobject( + ctx: &ReducerContext, + character_guid: u64, + gameobject_entry: u32, + take_loot: bool, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + let guid = gameobject_guid(gameobject_entry); + super::actions::use_gameobject(ctx, character_guid, guid, DIRECT_GO_QUEST) + .map_err(String::from)?; + if take_loot { + let slots: Vec<_> = ctx + .db + .game_corpse_loot() + .by_corpse() + .filter(guid) + .map(|row| row.slot) + .collect(); + if slots.is_empty() { + return Err("GameObject produced no loot".to_string()); + } + for slot in slots { + super::actions::take_loot(ctx, character_guid, guid, slot, DIRECT_GO_QUEST) + .map_err(String::from)?; + } + } + Ok(()) +} + +#[reducer] +pub fn playerbots_quest_fixture_fill_inventory( + ctx: &ReducerContext, + character_guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + let source_entry = ctx + .db + .game_item_instance() + .by_owner_guid() + .filter(character_guid) + .next() + .ok_or("starter item missing")? + .entry; + clone_item(ctx, INVENTORY_FILLER, source_entry)?; + let templates = ctx.db.game_item_template(); + let mut filler = templates + .entry() + .find(INVENTORY_FILLER) + .ok_or("inventory filler template missing")?; + filler.max_stack = 1; + templates.entry().update(filler); + for _ in 0..64 { + if !crate::items::has_free_slot(ctx, character_guid) { + return Ok(()); + } + crate::items::grant_item(ctx, character_guid, INVENTORY_FILLER, 1)?; + } + Err("fixture did not fill bounded inventory space".to_string()) +} + +#[reducer] +pub fn playerbots_quest_fixture_try_take_gameobject_loot( + ctx: &ReducerContext, + character_guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + let guid = gameobject_guid(CHEST_ENTRY); + let slot = ctx + .db + .game_corpse_loot() + .by_corpse() + .filter(guid) + .next() + .ok_or("GameObject produced no loot")? + .slot; + let refusal = super::actions::take_loot(ctx, character_guid, guid, slot, DIRECT_GO_QUEST) + .expect_err("full inventory accepted GameObject loot"); + if refusal.kind != crate::actor::ActionRefusalKind::InventoryFull { + return Err(format!("expected inventory-full refusal, got {refusal}")); + } + Ok(()) +} + +#[reducer] +pub fn playerbots_quest_fixture_clear_filler_and_take_gameobject_loot( + ctx: &ReducerContext, + character_guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + let items = ctx.db.game_item_instance(); + for item in items + .by_owner_guid() + .filter(character_guid) + .filter(|item| item.entry == INVENTORY_FILLER) + .collect::>() + { + items.guid().delete(item.guid); + } + let guid = gameobject_guid(CHEST_ENTRY); + let slots: Vec<_> = ctx + .db + .game_corpse_loot() + .by_corpse() + .filter(guid) + .map(|row| row.slot) + .collect(); + if slots.is_empty() { + return Err("GameObject produced no loot".to_string()); + } + for slot in slots { + super::actions::take_loot(ctx, character_guid, guid, slot, DIRECT_GO_QUEST) + .map_err(String::from)?; + } + Ok(()) +} + +#[reducer] +pub fn playerbots_quest_fixture_try_use_gameobject( + ctx: &ReducerContext, + character_guid: u64, + gameobject_entry: u32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + let _ = super::actions::use_gameobject( + ctx, + character_guid, + gameobject_guid(gameobject_entry), + DIRECT_GO_QUEST, + ); + Ok(()) +} + +#[reducer] +pub fn playerbots_quest_fixture_direct_gameobject( + ctx: &ReducerContext, + character_guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + reward_prerequisite(ctx, character_guid, 3903)?; + let templates = ctx.db.game_gameobject_template(); + let mut template = templates + .entry() + .find(CHEST_ENTRY) + .ok_or("GameObject template missing")?; + template.type_id = crate::gameobject::go_type::GOOBER; + template.data1 = 0; + templates.entry().update(template); + insert_objective( + ctx, + DIRECT_GO_QUEST, + 0, + crate::quest::objective_kind::USE_GAMEOBJECT, + CHEST_ENTRY, + 1, + ); + quest_catalog::refresh_catalog(ctx, "unknown"); + let catalog = ctx.db.pkg_playerbots_catalog_objective(); + let id = u64::from(DIRECT_GO_QUEST) << 8; + let previous = catalog.id().find(id).ok_or("catalog objective missing")?; + catalog.id().update(PlayerbotsCatalogObjective { + kind: CatalogObjectiveKind::UseGameObject, + target_entry: CHEST_ENTRY, + required_count: 1, + executor: ObjectiveExecutor::SimpleGameObject, + source_kind: Some(CatalogEntityKind::GameObject), + source_entries: vec![CHEST_ENTRY], + ..previous + }); + playerbots_quest_fixture_admit_accept(ctx, character_guid, DIRECT_GO_QUEST) +} + +#[reducer] +pub fn playerbots_quest_fixture_mixed_unsupported( + ctx: &ReducerContext, + character_guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + reward_prerequisite(ctx, character_guid, 783)?; + insert_objective(ctx, 7, 1, crate::quest::objective_kind::KILL_CREATURE, 6, 1); + quest_catalog::refresh_catalog(ctx, "unknown"); + let catalog = ctx.db.pkg_playerbots_catalog_objective(); + let id = (u64::from(7u32) << 8) | 1; + catalog.id().delete(id); + catalog.insert(PlayerbotsCatalogObjective { + id, + quest_entry: 7, + objective_index: 1, + kind: CatalogObjectiveKind::Escort, + target_entry: 6, + required_count: 1, + executor: ObjectiveExecutor::Attack, + source_kind: Some(CatalogEntityKind::Creature), + source_entries: vec![6], + source_destinations: Vec::new(), + work_area: None, + destination_evidence_revision: FIXTURE_REVISION.to_string(), + catalog_revision: CATALOG_REVISION, + }); + let refusal = quest_catalog::admit_available(ctx, character_guid, 7) + .expect_err("mixed unsupported quest must be refused"); + quest_catalog::record_admission(ctx, character_guid, 7, None, Some(&refusal)); + Ok(()) +} + +#[reducer] +pub fn playerbots_quest_fixture_mixed_progress( + ctx: &ReducerContext, + character_guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + reward_prerequisite(ctx, character_guid, 783)?; + insert_objective( + ctx, + 7, + 0, + crate::quest::objective_kind::COLLECT_ITEM, + 750, + 8, + ); + insert_objective( + ctx, + 7, + 1, + crate::quest::objective_kind::COLLECT_ITEM, + 752, + 12, + ); + insert_objective(ctx, 7, 2, crate::quest::objective_kind::KILL_CREATURE, 6, 1); + crate::items::grant_item(ctx, character_guid, 750, 8)?; + crate::items::grant_item(ctx, character_guid, 752, 12)?; + let items = ctx.db.game_item_instance(); + let mut banked = items + .by_owner_guid() + .filter(character_guid) + .find(|item| item.entry == 752) + .ok_or("bank fixture item missing")?; + banked.slot = 39; + items.guid().update(banked); + + quest_catalog::refresh_catalog(ctx, "unknown"); + let objectives = ctx.db.pkg_playerbots_catalog_objective(); + let collect_carried = objectives + .id() + .find(u64::from(33u32) << 8) + .ok_or("quest 33 catalog objective missing")?; + let collect_banked = objectives + .id() + .find(u64::from(18u32) << 8) + .ok_or("quest 18 catalog objective missing")?; + let mut kill = objectives + .id() + .find(u64::from(7u32) << 8) + .ok_or("quest 7 catalog objective missing")?; + for row in objectives.by_quest().filter(7u32).collect::>() { + objectives.id().delete(row.id); + } + objectives.insert(PlayerbotsCatalogObjective { + id: u64::from(7u32) << 8, + quest_entry: 7, + objective_index: 0, + ..collect_carried + }); + objectives.insert(PlayerbotsCatalogObjective { + id: (u64::from(7u32) << 8) | 1, + quest_entry: 7, + objective_index: 1, + ..collect_banked + }); + kill.id = (u64::from(7u32) << 8) | 2; + kill.objective_index = 2; + kill.required_count = 1; + objectives.insert(kill); + + let admission = + quest_catalog::admit_available(ctx, character_guid, 7).map_err( + |refusal| match refusal { + AdmissionRefusal::Ineligible(detail) + | AdmissionRefusal::Unsupported { detail, .. } => detail, + }, + )?; + if admission.target.objective_index != 1 || admission.target.target_entry != 752 { + return Err("banked collect item changed the selected objective".to_string()); + } + quest_catalog::record_admission(ctx, character_guid, 7, Some(7), None); + super::actions::accept_quest(ctx, character_guid, admission.start.guid, 7).map_err(Into::into) +} + +#[reducer] +pub fn playerbots_quest_fixture_unsupported( + ctx: &ReducerContext, + character_guid: u64, + unsupported_kind: u8, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + if unsupported_kind == 4 { + reward_prerequisite(ctx, character_guid, 3903)?; + let templates = ctx.db.game_gameobject_template(); + let mut template = templates + .entry() + .find(CHEST_ENTRY) + .ok_or("GameObject template missing")?; + template.lock_id = 1; + templates.entry().update(template); + quest_catalog::refresh_catalog(ctx, "unknown"); + let refusal = quest_catalog::admit_available(ctx, character_guid, 3904) + .expect_err("locked GameObject quest must be refused"); + quest_catalog::record_admission(ctx, character_guid, 3904, None, Some(&refusal)); + return Ok(()); + } + reward_prerequisite(ctx, character_guid, 783)?; + let kind = match unsupported_kind { + 0 => CatalogObjectiveKind::ExploreAreaTrigger, + 1 => CatalogObjectiveKind::Escort, + 2 => CatalogObjectiveKind::ScriptedEvent, + 3 => CatalogObjectiveKind::Transport, + _ => return Err("unsupported fixture kind must be 0 through 4".to_string()), + }; + let catalog = ctx.db.pkg_playerbots_catalog_objective(); + let id = u64::from(7u32) << 8; + let previous = catalog.id().find(id).ok_or("catalog objective missing")?; + catalog + .id() + .update(PlayerbotsCatalogObjective { kind, ..previous }); + let refusal = quest_catalog::admit_available(ctx, character_guid, 7) + .expect_err("unsupported quest must be refused"); + quest_catalog::record_admission(ctx, character_guid, 7, None, Some(&refusal)); + Ok(()) +} + +#[reducer] +pub fn playerbots_quest_fixture_level_admission( + ctx: &ReducerContext, + character_guid: u64, + level: u32, + quest_entry: u32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + let rows = ctx.db.game_world_entity(); + let mut character = rows + .guid() + .find(character_guid) + .ok_or("Character missing")?; + character.level = level; + rows.guid().update(character); + match quest_catalog::admit_available(ctx, character_guid, quest_entry) { + Ok(admission) => quest_catalog::record_admission( + ctx, + character_guid, + quest_entry, + Some(admission.quest_entry), + None, + ), + Err(refusal) => { + quest_catalog::record_admission(ctx, character_guid, quest_entry, None, Some(&refusal)) + } + } + Ok(()) +} + +#[reducer] +pub fn playerbots_quest_fixture_held_becomes_unsupported( + ctx: &ReducerContext, + character_guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + let alternative = quest_catalog::admit_available(ctx, character_guid, 5261).map_err( + |refusal| match refusal { + AdmissionRefusal::Ineligible(detail) | AdmissionRefusal::Unsupported { detail, .. } => { + detail + } + }, + )?; + super::actions::accept_quest(ctx, character_guid, alternative.start.guid, 5261) + .map_err(String::from)?; + let catalog = ctx.db.pkg_playerbots_catalog_objective(); + let id = u64::from(7u32) << 8; + let previous = catalog.id().find(id).ok_or("catalog objective missing")?; + catalog.id().update(PlayerbotsCatalogObjective { + kind: CatalogObjectiveKind::Escort, + ..previous + }); + let selected = quest_catalog::reconcile_active(ctx, character_guid) + .ok_or("supported held alternative missing")?; + if selected.quest_entry != 5261 { + return Err(format!( + "expected quest 5261 after quest 7 became unsupported, got {}", + selected.quest_entry + )); + } + Ok(()) +} + +#[reducer] +pub fn playerbots_quest_fixture_lose_provided_item( + ctx: &ReducerContext, + character_guid: u64, + bank_item: bool, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + let items = ctx.db.game_item_instance(); + let mut item = items + .by_owner_guid() + .filter(character_guid) + .find(|item| item.entry == 11125) + .ok_or("provided delivery item missing")?; + if bank_item { + item.slot = 39; + items.guid().update(item); + } else { + items.guid().delete(item.guid); + } + if quest_catalog::reconcile_active(ctx, character_guid).is_some() { + return Err("held quest remained admitted without its provided item".to_string()); + } + Ok(()) +} + +#[reducer] +pub fn playerbots_quest_fixture_hide_live_target( + ctx: &ReducerContext, + creature_entry: u32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + crate::creatures::despawn_creature_entity(ctx, creature_guid(creature_entry)); + Ok(()) +} + +#[reducer] +pub fn playerbots_quest_fixture_deplete_gameobject( + ctx: &ReducerContext, + gameobject_entry: u32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + let rows = ctx.db.game_gameobject(); + let mut row = rows + .guid() + .find(gameobject_guid(gameobject_entry)) + .ok_or("GameObject missing")?; + row.state = 1; + rows.guid().update(row); + Ok(()) +} + +#[reducer] +pub fn playerbots_quest_fixture_move_gameobject( + ctx: &ReducerContext, + gameobject_entry: u32, + x: f32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + let rows = ctx.db.game_gameobject(); + let mut row = rows + .guid() + .find(gameobject_guid(gameobject_entry)) + .ok_or("GameObject missing")?; + row.x = x; + let (grid_x, grid_y) = lyracore_shared::spatial::grid_cell(row.x, row.y); + row.grid_x = grid_x; + row.grid_y = grid_y; + row.cell = lyracore_shared::spatial::grid_cell_id(grid_x, grid_y); + rows.guid().update(row); + Ok(()) +} + +#[reducer] +pub fn playerbots_quest_fixture_move_creature_spawn( + ctx: &ReducerContext, + creature_entry: u32, + x: f32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + let rows = ctx.db.game_creature_spawn(); + let mut row = rows + .guid() + .find(creature_guid(creature_entry)) + .ok_or("creature spawn missing")?; + row.x = x; + rows.guid().update(row); + Ok(()) +} + +#[reducer] +pub fn playerbots_quest_fixture_assert_no_live_target( + ctx: &ReducerContext, + character_guid: u64, + creature_entry: u32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + if quest_catalog::live_creature_target(ctx, character_guid, creature_entry).is_some() { + Err("live target still present".to_string()) + } else { + Ok(()) + } +} + +#[reducer] +pub fn playerbots_quest_fixture_refresh(ctx: &ReducerContext) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + quest_catalog::refresh_catalog(ctx, "unknown"); + Ok(()) +} + +#[reducer] +pub fn playerbots_quest_fixture_recheck_unchanged(ctx: &ReducerContext) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require_fixture(ctx)?; + let headers = ctx.db.pkg_playerbots_quest_catalog(); + let mut header = headers + .revision() + .find(CATALOG_REVISION) + .ok_or("catalog header missing")?; + header.refresh_after_micros = 0; + headers.revision().update(header); + quest_catalog::ensure_catalog(ctx); + Ok(()) +} diff --git a/playerbots/src/runner.rs b/playerbots/src/runner.rs index 17a12e0..dcb6bf2 100644 --- a/playerbots/src/runner.rs +++ b/playerbots/src/runner.rs @@ -45,6 +45,7 @@ pub struct Destination { pub enum ObjectiveKind { ReturnHome, Companion, + Quest, } #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] @@ -636,82 +637,130 @@ fn objective( state: &mut PlayerbotsRunner, now: i64, ) { - let (kind, leader_guid, mut destination) = if let Some(party) = party { - let destination = party.destination().or_else(|| { - state - .objective - .as_ref() - .filter(|objective| { - objective.kind == ObjectiveKind::Companion - && state.companion_leader_guid == Some(party.leader_guid) - }) - .map(|objective| objective.destination.clone()) - }); - ( - ObjectiveKind::Companion, - Some(party.leader_guid), - destination.unwrap_or(Destination { - map_id: me.map_id, - instance_id: me.instance_id, - x: me.x, - y: me.y, - z: me.z, - geometry_revision: geometry_revision(ctx, me.map_id), - }), - ) + state.deferred_destinations.retain(|d| d.until_micros > now); + let admission = if party.is_none() { + super::quest_catalog::reconcile_active(ctx, bot.character_guid) } else { - ( - ObjectiveKind::ReturnHome, - None, - Destination { - map_id: bot.home_map, - instance_id: 0, - x: bot.home_x, - y: bot.home_y, - z: bot.home_z, - geometry_revision: geometry_revision(ctx, bot.home_map), - }, - ) + None }; - destination.geometry_revision = geometry_revision(ctx, destination.map_id); - state.deferred_destinations.retain(|d| d.until_micros > now); - let same_identity = state.objective.as_ref().is_some_and(|objective| { - objective.kind == kind - && (kind != ObjectiveKind::Companion || state.companion_leader_guid == leader_guid) - }); - let replace = !same_identity - || (kind == ObjectiveKind::ReturnHome - && state - .objective - .as_ref() - .is_none_or(|objective| objective.destination != destination)); - if replace { - state.objective_sequence = state.objective_sequence.saturating_add(1); - state.objective = Some(Objective { - identity: state.objective_sequence, - kind, - destination, - stage: ObjectiveStage::Travelling, - deadline_micros: if kind == ObjectiveKind::Companion { - i64::MAX - } else { - now.saturating_add(OBJECTIVE_LIFETIME) - }, - last_verified_progress_micros: None, - started_micros: now, - catalog_revision: CATALOG_REVISION, + if let Some(admission) = admission { + let destination = Destination { + map_id: admission.destination.map_id, + instance_id: admission.destination.instance_id, + x: admission.destination.x, + y: admission.destination.y, + z: admission.destination.z, + geometry_revision: None, + }; + let changed = !super::quest_catalog::retained_matches(ctx, bot.character_guid, &admission) + || state.objective.as_ref().is_none_or(|objective| { + objective.kind != ObjectiveKind::Quest + || objective.destination != destination + || objective.catalog_revision != super::quest_catalog::CATALOG_REVISION + }); + if changed { + state.objective_sequence = state.objective_sequence.saturating_add(1); + let identity = state.objective_sequence; + state.objective = Some(Objective { + identity, + kind: ObjectiveKind::Quest, + destination, + stage: ObjectiveStage::Travelling, + deadline_micros: now.saturating_add(OBJECTIVE_LIFETIME), + last_verified_progress_micros: None, + started_micros: now, + catalog_revision: super::quest_catalog::CATALOG_REVISION, + }); + super::quest_catalog::retain(ctx, bot.character_guid, identity, admission); + state.companion_leader_guid = None; + state.companion_heal_target_guid = None; + state.retry_count = 0; + state.last_stall_check_micros = now; + } + } else { + if state + .objective + .as_ref() + .is_some_and(|objective| objective.kind == ObjectiveKind::Quest) + { + super::quest_catalog::clear_retained(ctx, bot.character_guid); + } + let (kind, leader_guid, mut destination) = if let Some(party) = party { + let destination = party.destination().or_else(|| { + state + .objective + .as_ref() + .filter(|objective| { + objective.kind == ObjectiveKind::Companion + && state.companion_leader_guid == Some(party.leader_guid) + }) + .map(|objective| objective.destination.clone()) + }); + ( + ObjectiveKind::Companion, + Some(party.leader_guid), + destination.unwrap_or(Destination { + map_id: me.map_id, + instance_id: me.instance_id, + x: me.x, + y: me.y, + z: me.z, + geometry_revision: geometry_revision(ctx, me.map_id), + }), + ) + } else { + ( + ObjectiveKind::ReturnHome, + None, + Destination { + map_id: bot.home_map, + instance_id: 0, + x: bot.home_x, + y: bot.home_y, + z: bot.home_z, + geometry_revision: geometry_revision(ctx, bot.home_map), + }, + ) + }; + destination.geometry_revision = geometry_revision(ctx, destination.map_id); + let same_identity = state.objective.as_ref().is_some_and(|objective| { + objective.kind == kind + && (kind != ObjectiveKind::Companion || state.companion_leader_guid == leader_guid) }); - state.companion_leader_guid = leader_guid; - state.companion_heal_target_guid = None; - state.retry_count = 0; - state.last_stall_check_micros = now; - } else if kind == ObjectiveKind::Companion { - if let Some(objective) = &mut state.objective { - objective.destination = destination; + let replace = !same_identity + || (kind == ObjectiveKind::ReturnHome + && state + .objective + .as_ref() + .is_none_or(|objective| objective.destination != destination)); + if replace { + state.objective_sequence = state.objective_sequence.saturating_add(1); + state.objective = Some(Objective { + identity: state.objective_sequence, + kind, + destination, + stage: ObjectiveStage::Travelling, + deadline_micros: if kind == ObjectiveKind::Companion { + i64::MAX + } else { + now.saturating_add(OBJECTIVE_LIFETIME) + }, + last_verified_progress_micros: None, + started_micros: now, + catalog_revision: CATALOG_REVISION, + }); + state.companion_leader_guid = leader_guid; + state.companion_heal_target_guid = None; + state.retry_count = 0; + state.last_stall_check_micros = now; + } else if kind == ObjectiveKind::Companion { + if let Some(objective) = &mut state.objective { + objective.destination = destination; + } } } if let Some(o) = &mut state.objective { - if o.kind == ObjectiveKind::ReturnHome + if o.kind != ObjectiveKind::Companion && o.stage == ObjectiveStage::Deferred && !state .deferred_destinations @@ -723,7 +772,7 @@ fn objective( state.retry_count = 0; state.last_stall_check_micros = now; } - if o.kind == ObjectiveKind::ReturnHome && o.stage == ObjectiveStage::Deferred { + if o.kind != ObjectiveKind::Companion && o.stage == ObjectiveStage::Deferred { if let Some(deferred) = state .deferred_destinations .iter() @@ -905,6 +954,11 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n let Some(destination) = state.objective.as_ref().map(|o| o.destination.clone()) else { return; }; + let quest_objective = state + .objective + .as_ref() + .is_some_and(|objective| objective.kind == ObjectiveKind::Quest); + let quest_wait = super::quest_catalog::active_wait_until(ctx, me.guid); let partition_ok = (destination.map_id, destination.instance_id) == (me.map_id, me.instance_id); let stop_distance = if party.is_some() { 3.05 } else { 2.05 }; let at_destination = partition_ok && distance(&me, &destination) <= stop_distance; @@ -914,7 +968,7 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n o.stage = ObjectiveStage::Completed; } else if o.stage == ObjectiveStage::Completed { o.stage = ObjectiveStage::Travelling; - if o.kind == ObjectiveKind::ReturnHome { + if o.kind != ObjectiveKind::Companion { o.deadline_micros = now.saturating_add(OBJECTIVE_LIFETIME); } state.last_stall_check_micros = now; @@ -1001,7 +1055,7 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n if party.is_none() { defense.continuers.push(travel_action.clone()); } - let mut survival = if partition_ok && !at_destination { + let mut survival = if !quest_objective && partition_ok && !at_destination { node(Action::Move(MoveTarget::Home), Reason::Survival, 900) } else { node(Action::Hold, Reason::Survival, 900) @@ -1061,7 +1115,14 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n strategies.push(selection.strategy); } else { companion_heal_target = None; - strategies.push(strategy(Trigger::Away, travel_action)); + if quest_objective || quest_wait.is_some_and(|until| until > now) { + strategies.push(strategy( + Trigger::Always, + node(Action::Hold, Reason::Quest, 110), + )); + } else { + strategies.push(strategy(Trigger::Away, travel_action)); + } } } strategies.push(strategy( @@ -1085,7 +1146,7 @@ fn run(ctx: &ReducerContext, bot: &PlayerbotsBot, mut state: PlayerbotsRunner, n .objective .as_ref() .filter(|o| { - o.kind == ObjectiveKind::ReturnHome + o.kind != ObjectiveKind::Companion && o.stage == ObjectiveStage::Travelling && now >= o.deadline_micros }) @@ -1245,7 +1306,7 @@ fn execute( if state .objective .as_ref() - .is_some_and(|objective| objective.kind == ObjectiveKind::ReturnHome) + .is_some_and(|objective| objective.kind != ObjectiveKind::Companion) { defer(state, now); } @@ -1262,7 +1323,7 @@ fn execute( if state .objective .as_ref() - .is_some_and(|objective| objective.kind == ObjectiveKind::ReturnHome) + .is_some_and(|objective| objective.kind != ObjectiveKind::Companion) { defer(state, now); }