From 2b095a74271c8e0f6ba99067e5bfbbba46b614cd Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Sun, 11 Oct 2026 22:18:22 +0200 Subject: [PATCH 1/2] feat(importer): load all creature definitions and map populations --- CONTEXT.md | 10 +- crates/lyracore-shared/src/chat.rs | 8 + crates/lyracore-shared/src/instance.rs | 44 +- docs/data-ingestion.md | 69 +- docs/instance-lifecycle.md | 13 +- docs/testing.md | 9 + gateway/src/codec/social.rs | 71 +- gateway/src/codec/social/subscriptions.rs | 128 ++- gateway/src/config.rs | 50 +- gateway/src/stdb/connection.rs | 25 +- .../stdb/world_view/cell_audience_tests.rs | 19 + importer/manifests/cmangos-classic-z2815.json | 5 + importer/scripts/import-manifest-smoke.sh | 6 +- importer/scripts/import-manifest.sh | 6 +- importer/scripts/import-world-sql-test.sh | 59 +- importer/scripts/import-world.sh | 70 +- importer/src/creature_population.rs | 352 ++++++++ importer/src/dbc.rs | 102 ++- importer/src/eventai.rs | 148 +++- importer/src/main.rs | 786 ++++++++++-------- importer/src/spell.rs | 4 +- importer/src/world_import_scope.rs | 115 ++- module/src/chat.rs | 30 +- module/src/combat/engage.rs | 19 +- module/src/combat/swing.rs | 3 + module/src/creatures/ai.rs | 5 +- module/src/creatures/cycle/ctx.rs | 23 +- module/src/creatures/cycle/harness.rs | 94 ++- .../cycle/harness/eventai_profile.rs | 105 +++ module/src/creatures/cycle/mod.rs | 6 +- module/src/creatures/eventai/combat.rs | 44 +- module/src/creatures/eventai/engine.rs | 78 ++ module/src/creatures/eventai/loader.rs | 20 +- module/src/creatures/eventai/mobility.rs | 33 + module/src/creatures/eventai/mod.rs | 2 +- module/src/creatures/eventai/model.rs | 8 + module/src/creatures/eventai/relay.rs | 16 +- module/src/creatures/eventai/tables.rs | 2 + module/src/creatures/mod.rs | 7 +- module/src/creatures/pet.rs | 4 +- module/src/creatures/population.rs | 461 ++++++++++ module/src/creatures/spawn.rs | 63 +- module/src/creatures/tick/lifecycle.rs | 45 +- module/src/debug/instance.rs | 2 + module/src/debug/mod.rs | 6 +- module/src/encounter.rs | 3 + module/src/graveyard.rs | 52 +- module/src/instance.rs | 282 ++++--- module/src/loot/tag.rs | 2 + module/src/package_import/creatures.rs | 6 + module/src/quest.rs | 2 + module/src/seed.rs | 6 + module/src/seed/fixtures.rs | 2 + module/src/threat.rs | 19 + module/src/tripwires.rs | 3 +- module/src/world.rs | 4 +- module/tests/full_world_eventai.rs | 121 +++ module/tests/full_world_population.rs | 224 +++++ 58 files changed, 3201 insertions(+), 700 deletions(-) create mode 100644 importer/src/creature_population.rs create mode 100644 module/src/creatures/population.rs create mode 100644 module/tests/full_world_eventai.rs create mode 100644 module/tests/full_world_population.rs diff --git a/CONTEXT.md b/CONTEXT.md index 4214a7557..4ea6f21bc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -70,9 +70,13 @@ The shard that hosts instanced maps. **World Import Profile**: A stable name for the import plan assigned to one World Shard or Instance Pool destination. The canonical profiles are `alliance-eastern`, `alliance-kalimdor`, `alliance-single`, -`starting-eastern`, `starting-kalimdor`, and `instances`. The starting profiles retain the Alliance -scope and add the Horde starting areas on the same continent. Canonical profiles share quest -definitions and scripted event requirements from both starting profiles and the Instance Pool. +`starting-eastern`, `starting-kalimdor`, `instances`, `full-world`, `full-eastern`, +`full-kalimdor`, and `full-instances`. Full profiles share every creature definition and build a shared quest catalogue from the +full source. Their spawn positions follow the selected maps; `full-world` accepts +every source map. Partitioned full profiles compile AI for local populations and their summoned +creatures. The starting profiles retain the Alliance scope and add the Horde starting areas on +the same continent. Smaller canonical profiles share quest definitions and scripted event +requirements from both starting profiles and the Instance Pool. Quest-giver relations stay within each destination's World Import Scope. **World Import Scope**: diff --git a/crates/lyracore-shared/src/chat.rs b/crates/lyracore-shared/src/chat.rs index cabca9e20..e703e5a04 100644 --- a/crates/lyracore-shared/src/chat.rs +++ b/crates/lyracore-shared/src/chat.rs @@ -46,6 +46,14 @@ pub mod broadcast_chat { pub const CREATURE_TEXT_EMOTE: u8 = 2; /// A Character's `/e` custom emote (cm:ChatHandler.cpp:172-226, cm:Player.cpp:16591-16599). pub const EMOTE: u8 = 3; + pub const CREATURE_BOSS_EMOTE: u8 = 4; + pub const CREATURE_WHISPER: u8 = 5; + pub const CREATURE_ZONE_YELL: u8 = 6; + pub const CREATURE_ZONE_EMOTE: u8 = 7; + + pub fn is_zone_wide(chat_type: u8) -> bool { + matches!(chat_type, CREATURE_ZONE_YELL | CREATURE_ZONE_EMOTE) + } /// `/e` and text-emote (`/wave`, `/dance`, …) proximity range: `ListenRange.TextEmote` /// (cm:mangosd.conf.dist.in:1092), same as `ListenRange.Say`. diff --git a/crates/lyracore-shared/src/instance.rs b/crates/lyracore-shared/src/instance.rs index d1d93de29..cd85b523d 100644 --- a/crates/lyracore-shared/src/instance.rs +++ b/crates/lyracore-shared/src/instance.rs @@ -1,19 +1,25 @@ -//! Shared dungeon-map classification and Instance Removal countdown. -//! The Module uses the map set to create dungeon Instances. The Gateway uses it to verify -//! that each dungeon has a Shard that hosts its population. - -/// The maps that are DUNGEONS — entering an areatrigger portal targeting one of these resolves-or- -/// creates an instance instead of landing at instance 0. -/// -/// Every entry MUST also have a `module::instance::entrance_fallback` arm (unit-pinned there) so a -/// reaped-instance login can never strand. -pub const DUNGEON_MAPS: &[u32] = &[36]; - -/// Is `map_id` a dungeon (instanced) map? See [`DUNGEON_MAPS`]. Pure. +//! Shared vanilla dungeon classification and Instance Removal countdown. + +/// Vanilla dungeon and raid maps. Battlegrounds use a separate population lifecycle. +/// The client Map.dbc instance types and ClassicDB instance_template identify this set. +pub const DUNGEON_MAPS: &[u32] = &[ + 33, 34, 36, 43, 47, 48, 70, 90, 109, 129, 189, 209, 229, 230, 249, 289, 309, 329, 349, 389, + 409, 429, 469, 509, 531, 533, +]; + pub fn is_dungeon_map(map_id: u32) -> bool { DUNGEON_MAPS.contains(&map_id) } +/// Source spawns on these maps remain dormant until their instance creates a population. +/// Battleground populations stay dormant until battleground creation is implemented. +/// Shared interiors, including the Deeprun Tram and faction halls, use instance zero. +pub const BATTLEGROUND_MAPS: &[u32] = &[30, 489, 529]; + +pub fn has_instance_population(map_id: u32) -> bool { + is_dungeon_map(map_id) || BATTLEGROUND_MAPS.contains(&map_id) +} + /// The Instance Removal countdown in milliseconds (cm:Player.cpp:17723). The Module schedules the /// expiry this far out, and the Gateway never shows the client more time than this. pub const INSTANCE_REMOVAL_MS: u32 = 60_000; @@ -23,8 +29,18 @@ mod tests { use super::*; #[test] - fn the_dungeon_set_holds_deadmines_and_no_open_world_map() { - assert!(is_dungeon_map(36), "Deadmines is the one imported dungeon"); + fn dungeons_include_vanilla_raids_but_exclude_battlegrounds_and_shared_interiors() { + assert!(is_dungeon_map(36), "Deadmines is instanced"); + for map_id in [33, 229, 249, 309, 409, 469, 509, 531, 533] { + assert!(is_dungeon_map(map_id)); + } + for map_id in [0, 1, 369, 449, 450] { + assert!(!has_instance_population(map_id)); + } + for map_id in [30, 489, 529] { + assert!(!is_dungeon_map(map_id)); + assert!(has_instance_population(map_id)); + } assert!(!is_dungeon_map(0), "Eastern Kingdoms is open world"); assert!(!is_dungeon_map(1), "Kalimdor is open world"); } diff --git a/docs/data-ingestion.md b/docs/data-ingestion.md index 6378f7003..31e3cb4ce 100644 --- a/docs/data-ingestion.md +++ b/docs/data-ingestion.md @@ -104,13 +104,11 @@ live subscriptions already *are* the visibility/broadcast layer (a creature is " language (Rust) end-to-end reusing the gtker crate family (incl. `wow_dbc`); our schema is already a subset so growth is append-only. -**Tradeoffs:** full-world volume is unproven for us (~1.4 GB MySQL as a size proxy); reducer -arg-size/exec limits **forbid streaming the whole dump through one call** → import MUST be externally -chunked/batched via a privileged admin/gateway SDK path; **seed-as-code (`seed.rs`) does NOT scale** -(~10k creatures / ~66k spawns / ~17k items / ~170k loot rows would wreck compile time + bloat the -wasm) → need a **data-bundle loader**, content versioned separately from code; migrations are -append-only (never reshape); we'd own an ETL + validation that mangos has 15 yrs of tooling for; -mangos column *semantics* don't always map 1:1 (e.g. multiplier-based stats vs our flat values). +**Tradeoffs:** production capacity needs measurement on the target topology. Imports use external +batches to stay within reducer argument and execution limits. The pinned source has 10,384 +creature definitions and 66,310 positions; expanded creature loot reaches 1,316,655 rows. +Content loads from data instead of compiled seed code. Migrations remain append-only, and source +semantics such as multiplier-based stats still need explicit mappings to the Module's fields. **Republish & data retention (re-verified 2026-06-17, Slice 2):** a plain `spacetime publish` (no `-c`) is **never destructive** — it either auto-migrates while RETAINING all data, or it *aborts* @@ -204,18 +202,51 @@ Ship **zero** third-party-derived data. Three streams, each with its own provena - **P5 — Quests** (+ the npc/gossip relations they need): densest behavioral content; depends on creatures/items/gossip. -**Per-shard scope.** Once the world is sharded by continent, the ETL's unit of work is one map into -one database — the importer takes the target database, the map id, and a bounding box, and its map -fence plus the surrounding pre- and post-flight guards keep any one database from holding two -continents' spatial rows. Non-spatial content (spells, items, quests, the DBC reference tables) is -duplicated on every shard by design. - -Canonical World Import Profiles build quest definitions from the union of the starting profiles -and the Instance Pool. This keeps quest text, objectives, and scripted event requirements -available after Transfer. Quest reimports retain summoned givers and reapply the `creature-ai` -Package Deltas for event requirements. Quest-giver relations still follow the destination's World Import Scope. Legacy map and box imports retain -their selected quest scope. Existing canonical imports need the `quests` and `items` families -reimported on every World Shard and Instance Pool so the shared quest rewards have item templates. +**Per-shard scope.** World Import Profiles assign spatial rows to a World Shard or Instance Pool. +`full-eastern` owns maps 0, 369 and 449; `full-kalimdor` owns maps 1 and 450; +`full-instances` owns the dungeon, raid and battleground maps. Each full profile imports every +creature definition and builds a shared quest catalogue from the full source. Runnable AI follows local creature +populations and their summon dependencies, so its spatial references stay on their owning Shard. +Quest-giver relations follow local spawns. +`full-world` imports every source map into one database. Legacy map and box imports retain their +selected scope. Reimports replace each selected family; two runs do not combine spatial scopes. + +For a complete source import, supply the dump and client data together: + +```sh +cargo run -p lyracore-importer -- \ + --dump .import/classic-db-full.sql --dbc /path/to/client/Data \ + --world-profile full-world +``` + +This prints the plan. Add `--apply --server --db ` to load it. Full profiles +load the client reference catalogues from the same dump and client inputs, including taxi mount +models. They do not require a separate `--taxi-mounts` argument. The script also loads spells, +talents, terrain and navigation: + +```sh +WORLD_PROFILE=full-world DUMP=.import/classic-db-full.sql DBC=/path/to/client/Data \ + DB=local-full-world SPACETIME_SERVER=http://127.0.0.1:3000 \ + bash importer/scripts/import-world.sh +``` + +All authored creature positions are retained, including nonselectable creatures and population +alternatives. Population metadata retains entry choices, count limits, chances and event links. +The Module selects an initial population from that metadata. Selections last until reimport or +Instance teardown; source pool rotation after deaths is not yet implemented. Positive game-event +members and groups with world-state conditions remain dormant until an event system can evaluate +them. Negative event members use the inactive-event state. Conditional squad positions with no +SQL entry choices remain inspectable source rows with entry zero and never become live entities. + +Dungeon, raid and battleground source rows stay dormant in Instance zero. Creating a dungeon or +raid Instance copies its selected creatures and patrols into that Instance. The copies respawn +and are removed with the Instance. This supplies map populations; it does not supply missing +encounter scripts, raid lockouts or battleground rules. Continent terrain covers the full client +grid; imported spawn coverage does not imply complete collision coverage for every interior. + +The EventAI compatibility gate still applies to the full catalogue. A larger scope does not +approve dropped rules or unknown behavior. Existing smaller profiles retain their shared starting +quest catalogue and their local quest-giver relations. ## Creature EventAI family diff --git a/docs/instance-lifecycle.md b/docs/instance-lifecycle.md index f2a3b6fe3..6498f3331 100644 --- a/docs/instance-lifecycle.md +++ b/docs/instance-lifecycle.md @@ -19,9 +19,16 @@ after 30 minutes empty, or on the next empty observation when `reset_requested` clears the stamp. Teardown removes population, encounter state, the dedicated tick row, Character bindings, and finally the lease. -Instance creature populations have no authored spawn rows of their own. Trash does not respawn, -corpses remain until reap, and waypoint, wander, and return-home movement is unavailable. Aggro, -assist, chase, flee, and casting use entity and template state and remain active. +Each Instance copies its selected source spawn rows and waypoint routes. The copies own their death and +respawn timers, movement home, and life sequence. Reaping an Instance deletes those copies, +including dead creatures awaiting respawn. Imported dungeon and battleground source rows stay +dormant at instance zero. Battleground creation remains unimplemented. The Deeprun Tram and faction +halls use shared populations at instance zero. + +Dungeon exits and graveyard release use imported Area Trigger, area, and graveyard metadata. +Missing exit metadata sends a reaped-instance login to the Character's home. The small fixture keeps +its Deadmines bootstrap fallback. Raid admission uses map capacity; saved raid lockouts and encounter +scripts remain separate gameplay work. Creature copies use `encounter::wave_guid` with bit 23 set in the low field. This separates their identities from imported spawns and encounter wave allocation. GameObject copies use diff --git a/docs/testing.md b/docs/testing.md index 45f477733..3d5955a5d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -141,6 +141,15 @@ For example: LYRACORE_CLIENT_DATA=/games/WoW-1.12.1/Data cargo test -p lyracore-importer terrain -- --ignored ``` +The complete creature import test loads the pinned dump and client catalogues into a private local +SpacetimeDB node, checks every source position, waits for the shared-world population, and reimports +the populated roster: + +```bash +LYRACORE_CLASSIC_DB_SQL=/path/to/classic.sql LYRACORE_TEST_DBC=/path/to/client/Data \ + cargo test -p lyracore-importer full_world_source_loads_every_definition_and_spawn_locally -- --ignored +``` + Run the terrain check before an `--apply` after you change a profile anchor. A Package test runner sets `LYRACORE_TEST_CORE` to the Core checkout it builds against. diff --git a/gateway/src/codec/social.rs b/gateway/src/codec/social.rs index 395d89591..2aa944e26 100644 --- a/gateway/src/codec/social.rs +++ b/gateway/src/codec/social.rs @@ -210,20 +210,35 @@ pub fn build_chat_message_to( sender_name, target: Guid::new(target_guid), }, - (1, Some(sender_name)) => SMSG_MESSAGECHAT_ChatType::MonsterYell { - sender1: sender, - sender_name, - target: Guid::new(target_guid), - }, + (1 | broadcast_chat::CREATURE_ZONE_YELL, Some(sender_name)) => { + SMSG_MESSAGECHAT_ChatType::MonsterYell { + sender1: sender, + sender_name, + target: Guid::new(target_guid), + } + } // `MonsterEmote` carries one guid and it names the SPEAKER, paired with the speaker's own // name. The variant has no addressee field on the wire, so an emote's addressed target is // not expressible here and is dropped rather than misfiled into the speaker slot. - (broadcast_chat::CREATURE_TEXT_EMOTE, Some(monster_name)) => { - SMSG_MESSAGECHAT_ChatType::MonsterEmote { + (broadcast_chat::CREATURE_WHISPER, Some(monster_name)) => { + SMSG_MESSAGECHAT_ChatType::MonsterWhisper { monster: sender, monster_name, } } + (broadcast_chat::CREATURE_BOSS_EMOTE, Some(monster_name)) => { + SMSG_MESSAGECHAT_ChatType::RaidBossEmote { + monster: sender, + monster_name, + } + } + ( + broadcast_chat::CREATURE_TEXT_EMOTE | broadcast_chat::CREATURE_ZONE_EMOTE, + Some(monster_name), + ) => SMSG_MESSAGECHAT_ChatType::MonsterEmote { + monster: sender, + monster_name, + }, (1, None) => SMSG_MESSAGECHAT_ChatType::Yell { chat_credit: sender, speech_bubble_credit: sender, @@ -243,7 +258,10 @@ pub fn build_chat_message_to( chat_type: kind, language: if matches!( chat_type, - broadcast_chat::CREATURE_TEXT_EMOTE | broadcast_chat::EMOTE + broadcast_chat::CREATURE_TEXT_EMOTE + | broadcast_chat::CREATURE_BOSS_EMOTE + | broadcast_chat::CREATURE_WHISPER + | broadcast_chat::EMOTE ) { Language::Universal } else { @@ -481,6 +499,43 @@ mod tests { assert_eq!(weird.language, Language::Universal); } + #[test] + fn creature_speech_keeps_its_wire_kinds_and_languages() { + let make = |kind| { + build_chat_message_to( + 11, + Some("Herald".into()), + 77, + kind, + 7, + "Attention".into(), + 0, + ) + }; + let whisper = make(broadcast_chat::CREATURE_WHISPER); + assert!( + matches!(whisper.chat_type, SMSG_MESSAGECHAT_ChatType::MonsterWhisper { monster, .. } if monster.guid() == 11) + ); + assert_eq!(whisper.language, Language::Universal); + let boss = make(broadcast_chat::CREATURE_BOSS_EMOTE); + assert!(matches!( + boss.chat_type, + SMSG_MESSAGECHAT_ChatType::RaidBossEmote { .. } + )); + assert_eq!(boss.language, Language::Universal); + let yell = make(broadcast_chat::CREATURE_ZONE_YELL); + assert!( + matches!(yell.chat_type, SMSG_MESSAGECHAT_ChatType::MonsterYell { target, .. } if target.guid() == 77) + ); + assert_eq!(yell.language, Language::Common); + let emote = make(broadcast_chat::CREATURE_ZONE_EMOTE); + assert!(matches!( + emote.chat_type, + SMSG_MESSAGECHAT_ChatType::MonsterEmote { .. } + )); + assert_eq!(emote.language, Language::Common); + } + /// A player `/e` row (no creature name — EventAI never emits EMOTE) builds `Emote { sender2 }` /// naming the speaker, in Universal whatever language byte the row carries /// (cm:Chat.cpp:3660-3662, cm:Player.cpp:16594). diff --git a/gateway/src/codec/social/subscriptions.rs b/gateway/src/codec/social/subscriptions.rs index d394d261f..e00066865 100644 --- a/gateway/src/codec/social/subscriptions.rs +++ b/gateway/src/codec/social/subscriptions.rs @@ -23,11 +23,13 @@ pub(crate) const SAY_RANGE_SQ: f32 = SAY_RANGE_YD * SAY_RANGE_YD; #[cfg(test)] pub(crate) const YELL_RANGE_SQ: f32 = YELL_RANGE_YD * YELL_RANGE_YD; -/// How far a `game_chat_event` line of this kind carries. Every kind on that table is -/// range-bound: SAY, `/e` and the creature text emote at SAY range, YELL at its own; party, guild -/// and whispers ride other tables. +/// The distance used for local speech. Zone speech and creature whispers use their own audience. pub(crate) fn chat_range_yd(chat_type: u8) -> f32 { - if chat_type == lyracore_shared::chat::broadcast_chat::YELL { + if matches!( + chat_type, + lyracore_shared::chat::broadcast_chat::YELL + | lyracore_shared::chat::broadcast_chat::CREATURE_BOSS_EMOTE + ) { YELL_RANGE_YD } else { SAY_RANGE_YD @@ -421,6 +423,11 @@ pub(crate) fn chat_event_outbound( self_guid: u64, row: &ChatEvent, ) -> Vec { + if row.chat_type == lyracore_shared::chat::broadcast_chat::CREATURE_WHISPER + && row.target_guid != self_guid + { + return Vec::new(); + } let guard = coord.0.coord(); let entities = guard.conn.db.game_world_entity(); let speaker = entities.guid().find(&row.sender_guid); @@ -431,18 +438,7 @@ pub(crate) fn chat_event_outbound( let Some(listener) = entities.guid().find(&self_guid) else { return Vec::new(); }; - let range_yd = chat_range_yd(row.chat_type); - if !chat_in_range( - speaker.map_id, - speaker.instance_id, - speaker.x, - speaker.y, - listener.map_id, - listener.instance_id, - listener.x, - listener.y, - range_yd * range_yd, - ) { + if !creature_chat_reaches(row.chat_type, speaker, &listener) { return Vec::new(); } if row.chat_type == lyracore_shared::chat::broadcast_chat::EMOTE @@ -478,6 +474,92 @@ pub(crate) fn chat_event_outbound( )] } +fn creature_chat_reaches(chat_type: u8, speaker: &WorldEntity, listener: &WorldEntity) -> bool { + if chat_type == lyracore_shared::chat::broadcast_chat::CREATURE_WHISPER { + speaker.map_id == listener.map_id && speaker.instance_id == listener.instance_id + } else if lyracore_shared::chat::broadcast_chat::is_zone_wide(chat_type) { + speaker.map_id == listener.map_id + && speaker.instance_id == listener.instance_id + && speaker.zone_id == listener.zone_id + } else { + let range = chat_range_yd(chat_type); + chat_in_range( + speaker.map_id, + speaker.instance_id, + speaker.x, + speaker.y, + listener.map_id, + listener.instance_id, + listener.x, + listener.y, + range * range, + ) + } +} + +#[cfg(test)] +mod creature_chat_tests { + use super::*; + use crate::codec::entity::subscriptions::tests::player_entity; + use lyracore_shared::chat::broadcast_chat::*; + + #[test] + fn zone_speech_reaches_distant_listeners_only_in_the_same_zone_and_instance() { + let speaker = WorldEntity { + map_id: 0, + zone_id: 12, + instance_id: 0, + ..player_entity() + }; + let mut listener = WorldEntity { + x: 5_000.0, + ..speaker.clone() + }; + for mode in [CREATURE_ZONE_YELL, CREATURE_ZONE_EMOTE] { + assert!(creature_chat_reaches(mode, &speaker, &listener)); + listener.zone_id = 14; + assert!(!creature_chat_reaches(mode, &speaker, &listener)); + listener.zone_id = 12; + listener.instance_id = 2; + assert!(!creature_chat_reaches(mode, &speaker, &listener)); + listener.instance_id = 0; + listener.map_id = 1; + assert!(!creature_chat_reaches(mode, &speaker, &listener)); + listener.map_id = 0; + } + assert!(!creature_chat_reaches(YELL, &speaker, &listener)); + } + + #[test] + fn boss_emotes_use_yell_distance() { + let speaker = WorldEntity { + x: 0.0, + y: 0.0, + ..player_entity() + }; + let mut listener = WorldEntity { + x: 250.0, + ..speaker.clone() + }; + assert!(creature_chat_reaches( + CREATURE_BOSS_EMOTE, + &speaker, + &listener + )); + assert!(!creature_chat_reaches( + CREATURE_TEXT_EMOTE, + &speaker, + &listener + )); + listener.x = 301.0; + assert!(!creature_chat_reaches( + CREATURE_BOSS_EMOTE, + &speaker, + &listener + )); + } +} + /// The chat tag a nearby line carries: a player speaker's Away Status, and none for a creature or /// a speaker whose entity is already gone. pub(crate) fn nearby_chat_tag(speaker: Option<&WorldEntity>) -> u8 { @@ -1074,12 +1156,22 @@ pub(crate) fn emote_appeared( } } -/// A chat line names its speaker and nothing else; the speaker's indexed cell anchors it, widened -/// to the kind's yard range (a yell carries 300 yd, four cells past the box). +/// Creature whispers select their recipient; zone speech reaches the shard's viewers before the +/// zone gate. Local speech uses the speaker's cell and the chat kind's distance. pub(crate) fn chat_audience(view: &WorldView, shard: ShardId, row: &ChatEvent) -> Vec> { + if row.chat_type == lyracore_shared::chat::broadcast_chat::CREATURE_WHISPER { + return view + .viewers_on_shard(shard) + .into_iter() + .filter(|viewer| viewer.self_guid == row.target_guid) + .collect(); + } let key = view .spatial .entity_cell_on_shard(EntityLayer::WorldEntity, row.sender_guid, shard); + if lyracore_shared::chat::broadcast_chat::is_zone_wide(row.chat_type) { + return view.viewers_on_shard(shard); + } let span = cells_within(crate::codec::social::subscriptions::chat_range_yd( row.chat_type, )); diff --git a/gateway/src/config.rs b/gateway/src/config.rs index a5c19d9c4..bf7bf8ca4 100644 --- a/gateway/src/config.rs +++ b/gateway/src/config.rs @@ -371,6 +371,8 @@ impl ShardMap { /// /// # Inputs /// + /// - `imported_maps` records source coverage across connected Shards. Every imported dungeon + /// participates even when its route uses the default or a wildcard. /// - `hosts_instances(db)` — that database's `game_config.hosts_instances`, or `None` when the /// row is missing/unreadable. `None` reads as **true**, mirroring the module's own /// `hosts_instance_populations` default: a database that predates the column behaves as it did @@ -382,6 +384,7 @@ impl ShardMap { /// the same *symptom* as `NoHost`, deliberately NOT the same severity, see that variant. pub fn check_instance_hosting( &self, + imported_maps: &[u32], hosts_instances: impl Fn(&str) -> Option, connected: impl Fn(&str) -> bool, ) -> InstanceHosting { @@ -392,8 +395,15 @@ impl ShardMap { // trick, same expression). Bucket 0 is probed as instance id 0, which is the OPEN WORLD id // and never a real instance — that is fine and deliberately not special-cased: `resolve` // reads `instance_id % INSTANCE_BUCKETS` and nothing else, so 0 and 8 are the same probe. + // Check imported dungeon maps even when a default or wildcard rule owns them. + // The bootstrap fixture still requires Deadmines before its first source import. let owners = lyracore_shared::instance::DUNGEON_MAPS .iter() + .filter(|&&map_id| { + map_id == 36 + || imported_maps.contains(&map_id) + || self.rules.iter().any(|rule| rule.map_id == Some(map_id)) + }) .flat_map(|&map_id| (0..INSTANCE_BUCKETS).map(move |bucket| (map_id, bucket))); let mut load_not_moved: Option = None; for (map_id, instance_id) in owners { @@ -992,7 +1002,7 @@ mod shard_map_tests { fn no_shard_map_plus_hosting_off_is_fatal_and_names_both_remedies() { let single = ShardMap::parse("lyracore", ""); let verdict = - single.check_instance_hosting(flags(&[("lyracore", false)]), |d| d == "lyracore"); + single.check_instance_hosting(&[], flags(&[("lyracore", false)]), |d| d == "lyracore"); let msg = match &verdict { InstanceHosting::NoHost(m) => m.clone(), other => panic!( @@ -1028,13 +1038,13 @@ mod shard_map_tests { let single = ShardMap::parse("lyracore", ""); let up = |d: &str| d == "lyracore"; assert_eq!( - single.check_instance_hosting(flags(&[("lyracore", true)]), up), + single.check_instance_hosting(&[], flags(&[("lyracore", true)]), up), InstanceHosting::Consistent, "a single database that hosts its own dungeons is correctly configured — saying \ ANYTHING here is noise the detector was built to remove" ); assert_eq!( - single.check_instance_hosting(|_| None, up), + single.check_instance_hosting(&[], |_| None, up), InstanceHosting::Consistent, "a database with no game_config row reads as HOSTING (the module's own unwrap_or(true)) \ — an unreadable config must never fabricate a fatal" @@ -1050,6 +1060,7 @@ mod shard_map_tests { let phase_a = ShardMap::parse("lyracore", "36:*=lyracore-instances"); let up = |_: &str| true; match phase_a.check_instance_hosting( + &[], flags(&[("lyracore", true), ("lyracore-instances", true)]), up, ) { @@ -1067,6 +1078,7 @@ mod shard_map_tests { } assert_eq!( phase_a.check_instance_hosting( + &[], flags(&[("lyracore", false), ("lyracore-instances", true)]), up ), @@ -1084,6 +1096,7 @@ mod shard_map_tests { fn a_shard_map_that_routes_everything_except_the_dungeon_is_still_fatal() { let m = ShardMap::parse("lyracore", "1:*=lyracore-kalimdor"); let verdict = m.check_instance_hosting( + &[], flags(&[("lyracore", false), ("lyracore-kalimdor", true)]), |_| true, ); @@ -1102,6 +1115,7 @@ mod shard_map_tests { fn one_unhosted_pool_member_out_of_eight_is_still_fatal() { let m = ShardMap::parse("lyracore", "36:1=instances-1, 36:*=instances-0"); let verdict = m.check_instance_hosting( + &[], flags(&[ ("lyracore", false), ("instances-0", true), @@ -1135,6 +1149,7 @@ mod shard_map_tests { fn an_unreachable_instances_shard_is_reported_but_does_not_stop_the_gateway() { let phase_a = ShardMap::parse("lyracore", "36:*=lyracore-instances"); let verdict = phase_a.check_instance_hosting( + &[], flags(&[("lyracore", false), ("lyracore-instances", true)]), |d| d == "lyracore", // the instances database never connected ); @@ -1170,6 +1185,7 @@ mod shard_map_tests { let up = |d: &str| matches!(d, "lyracore" | "lyracore-instances" | "lyracore-realm"); assert_eq!( live.check_instance_hosting( + &[], flags(&[ ("lyracore", false), // the world shard does not host populations ("lyracore-instances", true), // the instances shard does @@ -1183,6 +1199,7 @@ mod shard_map_tests { // realm-core's flag is irrelevant either way — it owns no map, so flipping it changes nothing. assert_eq!( live.check_instance_hosting( + &[], flags(&[ ("lyracore", false), ("lyracore-instances", true), @@ -1556,7 +1573,7 @@ mod shard_map_tests { // And the hosting check is inert rather than merely quiet. assert_eq!( - m.check_instance_hosting(|_| Some(true), |_| true), + m.check_instance_hosting(&[], |_| Some(true), |_| true), InstanceHosting::Consistent, "a single-database realm hosting its own instances has nothing to report" ); @@ -1797,4 +1814,29 @@ mod admission_limit_tests { use std::os::unix::ffi::OsStrExt; assert!(parse_admission_limit("LIMIT", Some(OsStr::from_bytes(&[0xff]))).is_err()); } + #[test] + fn imported_raid_maps_require_hosts_through_default_and_wildcard_routes() { + for rules in ["36:*=instances", "36:*=instances, *:*=world"] { + let map = crate::config::ShardMap::parse("world", rules); + let verdict = + map.check_instance_hosting(&[533], |shard| Some(shard == "instances"), |_| true); + assert!( + matches!(verdict, crate::config::InstanceHosting::NoHost(ref reason) if reason.contains("533")), + "{rules}: {verdict:?}" + ); + assert!(matches!( + map.check_instance_hosting(&[], |shard| Some(shard == "instances"), |_| true), + crate::config::InstanceHosting::Consistent + )); + } + } + + #[test] + fn configured_raid_routes_require_population_hosts() { + let map = crate::config::ShardMap::parse("lyracore", "36:*=instances, 533:*=raids"); + assert!(matches!( + map.check_instance_hosting(&[], |db| Some(db != "raids"), |_| true), + crate::config::InstanceHosting::NoHost(_) + )); + } } diff --git a/gateway/src/stdb/connection.rs b/gateway/src/stdb/connection.rs index 56e8799f9..fd7d5c0de 100644 --- a/gateway/src/stdb/connection.rs +++ b/gateway/src/stdb/connection.rs @@ -1074,6 +1074,7 @@ fn coordinator_queries(sharded_tables: bool) -> Vec<&'static str> { "SELECT * FROM game_meeting_stone_seeker", "SELECT * FROM game_config", "SELECT * FROM game_creature_template", + "SELECT * FROM game_creature_spawn_map", "SELECT * FROM game_area", "SELECT * FROM game_taxi_node", "SELECT * FROM game_taxi_path", @@ -1543,6 +1544,13 @@ pub(crate) fn recv_reducer_on( mod coordinator_query_tests { use super::{call_pipe_queries, coordinator_queries, CALL_PIPE_LIVENESS_QUERY}; + #[test] + fn coordinator_subscribes_source_map_coverage_in_each_topology() { + for sharded in [false, true] { + assert!(coordinator_queries(sharded).contains(&"SELECT * FROM game_creature_spawn_map")); + } + } + #[test] fn coordinator_subscribes_bot_intents_in_each_topology() { for sharded in [false, true] { @@ -2495,7 +2503,22 @@ impl Coordinator { .and_then(|inner| inner.coord().conn.db.game_config().id().find(&0)) .map(|cfg| cfg.hosts_instances) }; - map.check_instance_hosting(hosts_instances, |d| conns.contains_key(d)) + let mut imported_maps: Vec = conns + .values() + .flat_map(|inner| { + inner + .coord() + .conn + .db + .game_creature_spawn_map() + .iter() + .map(|map| map.map_id) + .collect::>() + }) + .collect(); + imported_maps.sort_unstable(); + imported_maps.dedup(); + map.check_instance_hosting(&imported_maps, hosts_instances, |d| conns.contains_key(d)) .enforce() .map_err(|msg| anyhow!("{msg}"))?; ensure_guid_ranges(&conns, &map); diff --git a/gateway/src/stdb/world_view/cell_audience_tests.rs b/gateway/src/stdb/world_view/cell_audience_tests.rs index 9fcad00bd..09cd51b9e 100644 --- a/gateway/src/stdb/world_view/cell_audience_tests.rs +++ b/gateway/src/stdb/world_view/cell_audience_tests.rs @@ -1407,3 +1407,22 @@ fn motion_delivery_silence_is_scoped_to_the_shard_and_current_window() { assert!(!resumed.since(silent).is_silent()); assert!(!stopped.delivery.snapshot().since(resumed).is_silent()); } + +#[test] +fn a_creature_whisper_selects_only_its_distant_recipient() { + let mut rng = Rng::new(0x5A11_0090); + let realm = spread_realm(&mut rng); + let recipient = &realm.viewers[0]; + let speaker = realm.actor(&mut rng); + let mut row = chat( + speaker, + lyracore_shared::chat::broadcast_chat::CREATURE_WHISPER, + ); + row.target_guid = recipient.self_guid; + assert_eq!( + sessions(chat_audience(&realm.view, 0, &row)), + HashSet::from([recipient.session]) + ); + row.target_guid = u64::MAX; + assert!(chat_audience(&realm.view, 0, &row).is_empty()); +} diff --git a/importer/manifests/cmangos-classic-z2815.json b/importer/manifests/cmangos-classic-z2815.json index a08ff4436..1d361a462 100644 --- a/importer/manifests/cmangos-classic-z2815.json +++ b/importer/manifests/cmangos-classic-z2815.json @@ -68,6 +68,7 @@ 17, 18, 19, + 20, 21, 22, 23, @@ -82,7 +83,9 @@ 34, 36, 37, + 38, 39, + 40, 41, 42, 43, @@ -185,6 +188,7 @@ "normalized_reserved_event_flag", "receive_emote_repeatable", "normalized_force_cast", + "empty_text_has_no_effect", "normalized_no_effect", "force_target_self_implies_triggered", "normalized_random_phase_range", @@ -196,6 +200,7 @@ "resolved" ], "normalizations": [ + {"dimension": "action", "raw_value": "1", "reason": "empty_text_has_no_effect"}, {"dimension": "event_flag", "raw_value": "0", "reason": "receive_emote_repeatable"}, {"dimension": "event_flag", "raw_value": "2", "reason": "normalized_reserved_event_flag"}, {"dimension": "event_flag", "raw_value": "1027", "reason": "normalized_reserved_event_flag"}, diff --git a/importer/scripts/import-manifest-smoke.sh b/importer/scripts/import-manifest-smoke.sh index 2380c02f3..876e425ba 100755 --- a/importer/scripts/import-manifest-smoke.sh +++ b/importer/scripts/import-manifest-smoke.sh @@ -80,6 +80,10 @@ done echo "[smoke] canonical profile vocabulary:" for profile in $WORLD_PROFILES; do case "$profile" in + full-world) maps="$PROFILE_FULL_WORLD_MAPS" ;; + full-eastern) maps="$PROFILE_FULL_EASTERN_MAPS" ;; + full-kalimdor) maps="$PROFILE_FULL_KALIMDOR_MAPS" ;; + full-instances) maps="$PROFILE_FULL_INSTANCES_MAPS" ;; alliance-eastern) maps="$PROFILE_ALLIANCE_EASTERN_MAPS" ;; alliance-kalimdor) maps="$PROFILE_ALLIANCE_KALIMDOR_MAPS" ;; alliance-single) maps="$PROFILE_ALLIANCE_SINGLE_MAPS" ;; @@ -88,7 +92,7 @@ for profile in $WORLD_PROFILES; do esac [ -n "$maps" ] && echo " ok $profile owns map(s) $maps" || { echo " FAIL $profile has no planned maps"; fail=1; } done -for profile in alliance-eastern alliance-kalimdor alliance-single instances; do +for profile in full-world full-eastern full-kalimdor full-instances alliance-eastern alliance-kalimdor alliance-single instances; do case " $WORLD_PROFILES " in *" $profile "*) ;; *) echo " FAIL missing canonical profile $profile"; fail=1 ;; esac done diff --git a/importer/scripts/import-manifest.sh b/importer/scripts/import-manifest.sh index 16f0b24a9..e15c78728 100644 --- a/importer/scripts/import-manifest.sh +++ b/importer/scripts/import-manifest.sh @@ -38,7 +38,11 @@ # PROFILE VOCABULARY: WORLD_PROFILE selects one canonical WorldImportProfile in the importer. These # names and planned maps intentionally carry no rectangles or samples: world_import_scope.rs is the # sole catalogue for spatial scope. The map lists are only the script's post-import ownership check. -WORLD_PROFILES="alliance-eastern alliance-kalimdor alliance-single instances" +WORLD_PROFILES="full-world full-eastern full-kalimdor full-instances alliance-eastern alliance-kalimdor alliance-single instances" +PROFILE_FULL_EASTERN_MAPS="0 369 449" +PROFILE_FULL_KALIMDOR_MAPS="1 450" +PROFILE_FULL_INSTANCES_MAPS="30 33 34 36 43 47 48 70 90 109 129 189 209 229 230 249 289 309 329 349 389 409 429 469 489 509 529 531 533" +PROFILE_FULL_WORLD_MAPS="$PROFILE_FULL_EASTERN_MAPS $PROFILE_FULL_KALIMDOR_MAPS $PROFILE_FULL_INSTANCES_MAPS" PROFILE_ALLIANCE_EASTERN_MAPS="0 369" PROFILE_ALLIANCE_KALIMDOR_MAPS="1" PROFILE_ALLIANCE_SINGLE_MAPS="0 1 36 369" diff --git a/importer/scripts/import-world-sql-test.sh b/importer/scripts/import-world-sql-test.sh index d06e76519..b721b7237 100644 --- a/importer/scripts/import-world-sql-test.sh +++ b/importer/scripts/import-world-sql-test.sh @@ -34,8 +34,8 @@ check() { # check return 0 fi } -contains() { grep -q -- "$2" "$1"; } -not_contains() { ! grep -q -- "$2" "$1"; } +contains() { grep -Fq -- "$2" "$1"; } +not_contains() { ! grep -Fq -- "$2" "$1"; } count_matches() { grep -c -- "$2" "$1" 2>/dev/null || true; } # never fails the -e-less caller on "0 matches" sandbox="$(mktemp -d "${TMPDIR:-/tmp}/lyracore-import-world-sql-test.XXXXXX")" @@ -95,6 +95,10 @@ case "${1:-}" in case "$a" in *SELECT*|*select*) query="$a" ;; esac done case "$query" in + *"SELECT map_id FROM "*) + for map_id in ${STUB_MAP_IDS:-0}; do echo " $map_id"; done + ;; + *"SELECT quest_entry FROM "*|*"SELECT safe_loc_id FROM "*) echo " 42" ;; *map_id*|*"COUNT(*)"*) echo " 0" ;; *) i=0 @@ -129,6 +133,7 @@ run_import() { # run_import [extra env assignments already exported by the calle STUB_LOG="$stub_log" \ STUB_MODE="${STUB_MODE:-succeed}" \ STUB_ROW_COUNT="${STUB_ROW_COUNT:-20}" \ + STUB_MAP_IDS="${STUB_MAP_IDS:-0}" \ SPACETIME_SERVER="${SPACETIME_SERVER:-}" \ WORLD_PROFILE="${WORLD_PROFILE:-}" \ ALLOW_MAP_SWITCH="${ALLOW_MAP_SWITCH:-}" \ @@ -218,6 +223,56 @@ check "the Kalimdor profile runs its Teldrassil named check" \ check "the Kalimdor profile runs its Darkshore named check" \ contains "$out" 'Darkshore follow-on correct-map quest-giver spawn' +# Full profiles keep map ownership and load client catalogues in the dump pass. +echo +echo "import-world.sh: full profile ownership and catalogue passes" +for profile in full-world full-eastern full-kalimdor full-instances; do + case "$profile" in + full-world) owned_maps="0 1 30 33 34 36 43 47 48 70 90 109 129 189 209 229 230 249 289 309 329 349 369 389 409 429 449 450 469 489 509 529 531 533" ;; + full-eastern) owned_maps="0 369 449" ;; + full-kalimdor) owned_maps="1 450" ;; + full-instances) owned_maps="30 33 34 36 43 47 48 70 90 109 129 189 209 229 230 249 289 309 329 349 389 409 429 469 489 509 529 531 533" ;; + esac + : >"$stub_log" + STUB_MODE=succeed STUB_ROW_COUNT=150 WORLD_PROFILE="$profile" STUB_MAP_IDS="$owned_maps" run_import + check "$profile accepts all of its spatial maps" not_contains "$out" '[world] ABORT' + check "$profile retains the final map ownership assertion" contains "$out" 'ok one continent per database: spatial rows only on map(s)' + check "$profile does not report its own maps as contamination" not_contains "$out" 'cross-continent contamination' + check "$profile passes the name to the dump importer" \ + grep -q -- "lyracore-importer .*--dump .*--world-profile $profile " "$stub_log" + dump_passes="$(count_matches "$stub_log" '^lyracore-importer .*--dump ' )" + check "$profile runs exactly one dump and client catalogue pass" test "$dump_passes" -eq 1 + check "$profile reads client catalogues during that dump pass" \ + grep -q -- "lyracore-importer .*--dump .*--dbc .*--world-profile $profile " "$stub_log" + check "$profile skips the duplicate standalone client catalogue pass" not_contains "$stub_log" '--taxi-mounts' + plain_spell_passes="$(count_matches "$stub_log" '^lyracore-importer .*--spells --apply$')" + check "$profile skips the duplicate standalone spell catalogue pass" test "$plain_spell_passes" -eq 0 + check "$profile keeps curated spell and trainer overlays" grep -q -- 'lyracore-importer .*--dbc .*--spells --only .*--trainer ' "$stub_log" + check "$profile keeps the talent pass" grep -q -- 'lyracore-importer .*--dbc .*--talents --apply' "$stub_log" + check "$profile does not recreate the scope with legacy flags" \ + bash -c '! grep "lyracore-importer .*--world-profile $1 " "$0" | grep -qE -- "--map |--box |--include-map |--include-creatures "' "$stub_log" "$profile" + if [ "$profile" = full-instances ]; then + check "$profile skips continent terrain" not_contains "$stub_log" '--terrain ' + check "$profile skips continent navigation" not_contains "$stub_log" '--nav ' + check "$profile skips open-world corridor assertions" contains "$out" 'open-world corridor checks SKIPPED' + else + check "$profile imports terrain with its scope" grep -q -- "lyracore-importer .*--terrain .*--world-profile $profile --apply" "$stub_log" + check "$profile imports navigation with its scope" grep -q -- "lyracore-importer .*--nav .*--world-profile $profile --apply" "$stub_log" + fi +done + +for profile in full-eastern full-kalimdor full-instances; do + case "$profile" in + full-eastern) foreign_map=1 ;; + full-kalimdor) foreign_map=0 ;; + full-instances) foreign_map=1 ;; + esac + : >"$stub_log" + STUB_MODE=succeed STUB_ROW_COUNT=150 WORLD_PROFILE="$profile" STUB_MAP_IDS="$foreign_map" run_import + check "$profile stops before importing over a foreign map" test "$code" -ne 0 + check "$profile does not call the importer after an ownership failure" not_contains "$stub_log" 'lyracore-importer ' +done + # ===================================================================================================== # 5. python3 absent — the coverage audit must degrade explicitly, never crash mid-output (defect c) -- # ===================================================================================================== diff --git a/importer/scripts/import-world.sh b/importer/scripts/import-world.sh index ea511e1c8..f02aee57d 100755 --- a/importer/scripts/import-world.sh +++ b/importer/scripts/import-world.sh @@ -97,6 +97,10 @@ PROFILE_FLAGS=() PROFILE_MAPS="" if [ -n "$WORLD_PROFILE" ]; then case "$WORLD_PROFILE" in + full-world) PROFILE_MAPS="$PROFILE_FULL_WORLD_MAPS"; MAP=0 ;; + full-eastern) PROFILE_MAPS="$PROFILE_FULL_EASTERN_MAPS"; MAP=0 ;; + full-kalimdor) PROFILE_MAPS="$PROFILE_FULL_KALIMDOR_MAPS"; MAP=1 ;; + full-instances) PROFILE_MAPS="$PROFILE_FULL_INSTANCES_MAPS"; MAP=36 ;; alliance-eastern) PROFILE_MAPS="$PROFILE_ALLIANCE_EASTERN_MAPS"; MAP=0 ;; alliance-kalimdor) PROFILE_MAPS="$PROFILE_ALLIANCE_KALIMDOR_MAPS"; MAP=1 ;; alliance-single) PROFILE_MAPS="$PROFILE_ALLIANCE_SINGLE_MAPS"; MAP=0 ;; @@ -255,7 +259,7 @@ CENTER_FLAGS=(); [ -n "$CENTER" ] && CENTER_FLAGS=(--center "$CENTER") if [ -n "$WORLD_PROFILE" ]; then SCOPE_FLAGS=("${PROFILE_FLAGS[@]}") else - SCOPE_FLAGS=(--map "$MAP" --box "$BOX" "${INC_MAP_FLAGS[@]}" "${INC_CREATURE_FLAGS[@]}" "${CENTER_FLAGS[@]}") + SCOPE_FLAGS=(--map "$MAP" --box "$BOX" ${INC_MAP_FLAGS[@]+"${INC_MAP_FLAGS[@]}"} ${INC_CREATURE_FLAGS[@]+"${INC_CREATURE_FLAGS[@]}"} ${CENTER_FLAGS[@]+"${CENTER_FLAGS[@]}"}) fi # Every map whose spatial rows this run is ALLOWED to leave in $DB. Sorted LEXICALLY, not numerically: # `comm` below merges as text, and a numeric sort ({36,209}) is not a lexical one ({209,36}) — mixing the @@ -403,7 +407,7 @@ run_checked_step "world content for $WORLD_PROFILE into $DB" \ # code path, with the same filename-axis arbitration and the same self-checks. Map 36 remains outside # this terrain path because one height per cell cannot represent its stacked floors. The one extra # operator obligation on another continent is $CENTER (above) — the self-checks sample it. -if [ "$WORLD_PROFILE" = instances ]; then +if { [ "$WORLD_PROFILE" = instances ] || [ "$WORLD_PROFILE" = full-instances ]; }; then echo "[world] terrain heightmap ETL SKIPPED — map 36 needs a floor-preserving representation" else if [ -n "$WORLD_PROFILE" ]; then terrain_scope="--world-profile $WORLD_PROFILE"; else terrain_scope="--map $MAP --box $BOX${CENTER:+ --center $CENTER}"; fi @@ -417,8 +421,8 @@ fi # game_nav_chunk (walkability + LoS obstruction). Same box; --center feeds its own walkable # self-check, and the rotation-convention calibration hard-fails on transform drift. M2 parse # warnings (a handful of decorative props) are expected; the grep keeps summary lines only. -if [ "$WORLD_PROFILE" = instances ]; then - echo "[world] nav grid ETL SKIPPED — instances owns only WMO map 36" +if { [ "$WORLD_PROFILE" = instances ] || [ "$WORLD_PROFILE" = full-instances ]; }; then + echo "[world] nav grid ETL SKIPPED — instance profiles use WMO maps" else if [ -n "$WORLD_PROFILE" ]; then nav_scope="--world-profile $WORLD_PROFILE"; else nav_scope="--map $MAP --box $BOX${CENTER:+ --center $CENTER}"; fi echo "[world] nav grid ETL $nav_scope" @@ -429,9 +433,14 @@ else fi # The standalone DBC pass loads character creation, faction and taxi catalogues. # Taxi mount entries resolve through the full dump, independent of the imported creature slice. -echo "[world] character-creation + faction DBC tables (start items / race+class combos / race display / factions)" -run_checked_step "standalone DBC catalogue import" "dbc: loaded|error|abort" 6 \ - ./target/debug/lyracore-importer --db "$DB" --server "$SPACETIME_SERVER" --dbc "$DBC" --taxi-mounts "$DUMP" --apply || exit 1 +case "$WORLD_PROFILE" in + full-*) echo "[world] client catalogues loaded with the world content" ;; + *) + echo "[world] character-creation + faction DBC tables" + run_checked_step "standalone DBC catalogue import" "dbc: loaded|error|abort" 6 \ + ./target/debug/lyracore-importer --db "$DB" --server "$SPACETIME_SERVER" --dbc "$DBC" --taxi-mounts "$DUMP" --apply || exit 1 + ;; +esac # Real talent trees (TalentTab.dbc + Talent.dbc → game_talent_tab/game_talent). # WITHOUT this the tables hold only the 8 demo talents, whose ids don't match what the 5875 client # sends on a talent click → talent selection silently fails (the client renders the real trees from its @@ -440,19 +449,24 @@ echo "[world] real talent trees (TalentTab.dbc + Talent.dbc → game_talent)" run_checked_step "talents into $DB" "loaded [0-9]+ tabs|error|abort" 2 \ ./target/debug/lyracore-importer --db "$DB" --server "$SPACETIME_SERVER" \ --dbc "$DBC" --talents --apply || exit 1 -echo "[world] full Spell.dbc import (every non-zero row)" -# Full de-curated import: EVERY non-zero Spell.dbc row (header+effects), no --only allowlist. The -# long tail (raid/other-class/PvP spells with no Rust kind mapping yet) lands as E_SCRIPTED no-ops — -# that's the intended end state, NOT a gap; mapping the long tail of spell effects to typed kinds is -# separate, later work. The -# wholesale clear in spell_delete_statements (importer/src/spell.rs) is guarded at -# SYNTHETIC_SPELL_ID_FLOOR (50000) so the module's synthetic/test fixture spells (Combat Insight -# 50000, Test PW:Shield 50072, Minor Healing 50110, Test Regeneration 50137 — none re-created by any -# import script) survive this reload. The curated class import below still runs for its `--trainer` -# offering bindings. The full catalogue already contains every imported caster spell. -run_checked_step "full spell catalogue into $DB" "imported [0-9]|WARNING|error|abort" 3 \ - ./target/debug/lyracore-importer --db "$DB" --server "$SPACETIME_SERVER" \ - --dbc "$DBC" --spells --apply || exit 1 +case "$WORLD_PROFILE" in + full-*) echo "[world] spell catalogue loaded before world AI definitions" ;; + *) + echo "[world] full Spell.dbc import (every non-zero row)" + # Full de-curated import: EVERY non-zero Spell.dbc row (header+effects), no --only allowlist. The + # long tail (raid/other-class/PvP spells with no Rust kind mapping yet) lands as E_SCRIPTED no-ops — + # that's the intended end state, NOT a gap; mapping the long tail of spell effects to typed kinds is + # separate, later work. The + # wholesale clear in spell_delete_statements (importer/src/spell.rs) is guarded at + # SYNTHETIC_SPELL_ID_FLOOR (50000) so the module's synthetic/test fixture spells (Combat Insight + # 50000, Test PW:Shield 50072, Minor Healing 50110, Test Regeneration 50137 — none re-created by any + # import script) survive this reload. The curated class import below still runs for its `--trainer` + # offering bindings. The full catalogue already contains every imported caster spell. + run_checked_step "full spell catalogue into $DB" "imported [0-9]|WARNING|error|abort" 3 \ + ./target/debug/lyracore-importer --db "$DB" --server "$SPACETIME_SERVER" \ + --dbc "$DBC" --spells --apply || exit 1 + ;; +esac # NOTE the explicit `DB=` prefix, and do not drop it: `DB` is a plain shell variable here (not # exported) and the child script defaults to `lyracore`, so without the prefix a # `DB= import-world.sh` run writes its curated spells into the DEFAULT database instead of the @@ -534,19 +548,19 @@ chk() { # label expected-min actual # PROFILE ASSERTION HELPERS BEGIN — extracted by import-manifest-smoke.sh. chk0() { case "$WORLD_PROFILE" in - alliance-eastern|alliance-single) chk "$@" ;; + alliance-eastern|alliance-single|full-eastern|full-world) chk "$@" ;; "") [ "$MAP" = 0 ] && [ "${SLICE:-0}" = 0 ] && chk "$@" ;; esac return 0 } chk36() { case "$WORLD_PROFILE" in - alliance-single|instances) chk "$@" ;; + alliance-single|instances|full-world|full-instances) chk "$@" ;; "") case " ${INCLUDE_MAPS//,/ } " in *" 36 "*) chk "$@" ;; esac ;; esac return 0 } -chk_open_world() { [ "$WORLD_PROFILE" != instances ] && chk "$@"; return 0; } +chk_open_world() { [ "$WORLD_PROFILE" != instances ] && [ "$WORLD_PROFILE" != full-instances ] && chk "$@"; return 0; } # PROFILE ASSERTION HELPERS END n() { # [count-pattern] — the verification stage's row-COUNT chokepoint (#440): routes # through $SPACETIME_SERVER via q(), and on a FAILED query records it via sql_note_failure @@ -667,7 +681,7 @@ verify_graveyard_zone() { # label zone-id } verify_profile_corridors() { case "$WORLD_PROFILE" in - alliance-eastern|alliance-single) + alliance-eastern|alliance-single|full-eastern|full-world) verify_named_corridor "Dun Morogh start" 0 658 "$FLOOR_DUN_MOROGH_START" chk "Dun Morogh Paladin trainer (Bromos Grummner 926)" 1 "$(n "SELECT spell_id FROM game_trainer_spell WHERE trainer_entry=926")" chk "Dun Morogh vendor (Durnan Furcutter 836)" 1 "$(n "SELECT item_entry FROM game_npc_vendor WHERE creature_entry=836")" @@ -681,7 +695,7 @@ verify_profile_corridors() { ;; esac case "$WORLD_PROFILE" in - alliance-kalimdor|alliance-single) + alliance-kalimdor|alliance-single|full-kalimdor|full-world) verify_named_corridor "Teldrassil start" 1 2079 "$FLOOR_TELDRASSIL_START" chk "Teldrassil Warrior trainer (Alyissia 3593)" 1 "$(n "SELECT spell_id FROM game_trainer_spell WHERE trainer_entry=3593")" verify_named_corridor "Darkshore follow-on" 1 10219 "$FLOOR_DARKSHORE_CONTENT" @@ -710,13 +724,13 @@ if [ -z "$WORLD_PROFILE" ] && [ "$SLICE" != 0 ]; then echo " .. named Human and Deadmines checks SKIPPED — custom legacy slice" elif [ "$WORLD_PROFILE" = alliance-kalimdor ]; then echo " .. named Human and Deadmines checks SKIPPED — alliance-kalimdor does not own them" -elif [ "$WORLD_PROFILE" = instances ]; then - echo " .. open-world corridor checks SKIPPED — instances owns only map 36" +elif { [ "$WORLD_PROFILE" = instances ] || [ "$WORLD_PROFILE" = full-instances ]; }; then + echo " .. open-world corridor checks SKIPPED — instance profiles have no continent terrain" fi [ "$MAP" != 0 ] || verify_taxi_fixture_anchors verify_alliance_creation_data [ -z "$WORLD_PROFILE" ] || verify_profile_corridors -if [ "$WORLD_PROFILE" != instances ]; then +if [ "$WORLD_PROFILE" != instances ] && [ "$WORLD_PROFILE" != full-instances ]; then chk0 "Goldshire anchor trainers spawned {328,377,906,913,917,927} (NOT total coverage — see the service-coverage audit below)" "$FLOOR_CLASS_TRAINERS" \ "$(n "SELECT guid FROM game_world_entity WHERE owner_guid=0 AND (entry=328 OR entry=377 OR entry=906 OR entry=913 OR entry=917 OR entry=927)" '[0-9]{6,}')" chk "class trainer offerings (learn_skill_line=0)" "$FLOOR_TRAINER_OFFERINGS_CLASS" "$(n "SELECT spell_id FROM game_trainer_spell WHERE learn_skill_line = 0")" diff --git a/importer/src/creature_population.rs b/importer/src/creature_population.rs new file mode 100644 index 000000000..150a9f439 --- /dev/null +++ b/importer/src/creature_population.rs @@ -0,0 +1,352 @@ +//! Preserve source positions and the choices that govern their first population. + +use std::collections::{BTreeMap, BTreeSet, HashSet}; + +use anyhow::{bail, Context, Result}; + +use crate::{ + cr, creature_row_kept_in_scope, field, parse_complete_table, sql_str, world_guid, + CreatureSpawnRow, WorldImportScope, +}; + +pub(crate) struct PopulationPlan { + pub(crate) spawns: Vec, + pub(crate) definition_rows: Vec, + pub(crate) membership_rows: Vec, + pub(crate) referenced_entries: HashSet, + pub(crate) conditional_spawns: usize, + pub(crate) event_spawns: usize, +} + +impl PopulationPlan { + pub(crate) fn append_statements(&self, statements: &mut Vec) { + statements + .push("DELETE FROM game_creature_population_selection WHERE population_id > 0".into()); + statements.push("DELETE FROM game_creature_population_member WHERE source_guid > 0".into()); + statements.push("DELETE FROM game_creature_population WHERE population_id > 0".into()); + crate::push_insert( + statements, + "game_creature_population", + "population_id,max_count,world_state_condition,world_state_expression,flags,entries", + &self.definition_rows, + ); + crate::push_insert( + statements, + "game_creature_population_member", + "source_guid,population_id,entry,choices,chance,event_ids", + &self.membership_rows, + ); + } +} + +struct Group { + max_count: u32, + condition: u32, + expression: u32, + flags: u32, + entries: Vec<(u32, u32, u32, u32)>, +} + +fn number(row: &[String], column: usize, table: &str) -> Result { + field(row, column) + .parse() + .map_err(|_| anyhow::anyhow!("{table}: invalid column {column} in row {row:?}")) +} + +fn source_groups(dump: &str) -> Result> { + let mut groups = BTreeMap::new(); + for row in parse_complete_table(dump, "spawn_group")? { + if number::(&row, 2, "spawn_group")? != 0 { + continue; + } + let id: u64 = number(&row, 0, "spawn_group")?; + if groups + .insert( + id, + Group { + max_count: number(&row, 3, "spawn_group")?, + condition: number(&row, 4, "spawn_group")?, + expression: number(&row, 5, "spawn_group")?, + flags: number(&row, 6, "spawn_group")?, + entries: Vec::new(), + }, + ) + .is_some() + { + bail!("duplicate creature spawn group {id}"); + } + } + for row in parse_complete_table(dump, "spawn_group_entry")? { + let id = number(&row, 0, "spawn_group_entry")?; + let Some(group) = groups.get_mut(&id) else { + continue; + }; + let entry: u32 = number(&row, 1, "spawn_group_entry")?; + let min: u32 = number(&row, 2, "spawn_group_entry")?; + let max: u32 = number(&row, 3, "spawn_group_entry")?; + let chance: u32 = number(&row, 4, "spawn_group_entry")?; + if entry == 0 || chance > 100 || (max > 0 && min > max) { + bail!("invalid spawn_group_entry for group {id}, entry {entry}"); + } + if group.entries.iter().any(|candidate| candidate.0 == entry) { + bail!("duplicate spawn_group_entry for group {id}, entry {entry}"); + } + if group + .entries + .iter() + .map(|candidate| candidate.3) + .sum::() + + chance + > 100 + { + bail!("spawn_group_entry chances exceed 100 for group {id}"); + } + group.entries.push((entry, min, max, chance)); + } + Ok(groups) +} + +pub(crate) fn collect(dump: &str, scope: &WorldImportScope) -> Result { + let groups = source_groups(dump)?; + let mut memberships = BTreeMap::new(); + for row in parse_complete_table(dump, "spawn_group_spawn")? { + let id = number(&row, 0, "spawn_group_spawn")?; + if !groups.contains_key(&id) { + continue; + } + let guid: u64 = number(&row, 1, "spawn_group_spawn")?; + let chance: u32 = number(&row, 3, "spawn_group_spawn")?; + if chance > 100 { + bail!("spawn_group_spawn {guid}: chance {chance} exceeds 100"); + } + if memberships.insert(guid, (id, chance)).is_some() { + bail!("creature source guid {guid} belongs to several groups"); + } + } + let mut choices: BTreeMap> = BTreeMap::new(); + for row in parse_complete_table(dump, "creature_spawn_entry")? { + let guid = number(&row, 0, "creature_spawn_entry")?; + let entry = number(&row, 1, "creature_spawn_entry")?; + if entry == 0 { + bail!("creature_spawn_entry {guid}: entry zero"); + } + choices.entry(guid).or_default().insert(entry); + } + let mut events: BTreeMap> = BTreeMap::new(); + for row in parse_complete_table(dump, "game_event_creature")? { + let guid = number(&row, 0, "game_event_creature")?; + let event = number(&row, 1, "game_event_creature")?; + if event == 0 { + bail!("game_event_creature {guid}: event zero"); + } + events.entry(guid).or_default().insert(event); + } + let templates: BTreeSet = parse_complete_table(dump, "creature_template")? + .iter() + .map(|row| number(row, 0, "creature_template")) + .collect::>()?; + let mut plan = PopulationPlan { + spawns: Vec::new(), + definition_rows: Vec::new(), + membership_rows: Vec::new(), + referenced_entries: HashSet::new(), + conditional_spawns: 0, + event_spawns: 0, + }; + let mut used_groups = BTreeSet::new(); + let mut seen_guids = BTreeSet::new(); + for row in parse_complete_table(dump, "creature")? { + let map: i64 = number(&row, cr::MAP, "creature")?; + let x: f64 = number(&row, cr::X, "creature")?; + let y: f64 = number(&row, cr::Y, "creature")?; + let z: f64 = number(&row, cr::Z, "creature")?; + let raw_entry: u64 = number(&row, cr::ID, "creature")?; + if !creature_row_kept_in_scope(scope, map, x, y, z, raw_entry) { + continue; + } + let guid: u64 = number(&row, cr::GUID, "creature")?; + if guid == 0 || guid > 0xff_ffff || !seen_guids.insert(guid) { + bail!("invalid or duplicate creature source guid {guid}"); + } + if !(x.is_finite() && y.is_finite() && z.is_finite()) { + bail!("creature {guid}: non-finite position"); + } + let member = memberships.get(&guid); + let group = member.and_then(|(id, _)| groups.get(id)); + let conditional = group.is_some_and(|g| g.condition != 0 || g.expression != 0); + let direct_choices = choices.get(&guid); + let entry = if raw_entry != 0 { + raw_entry + } else { + direct_choices.and_then(|entries| entries.first()).copied() + .or_else(|| group.and_then(|g| g.entries.first().map(|e| e.0))) + .map(u64::from) + .or_else(|| conditional.then_some(0)) + .with_context(|| format!("creature {guid}: entry zero has no creature_spawn_entry, spawn_group_entry, or conditional source"))? + }; + let mut referenced = Vec::new(); + if raw_entry != 0 { + referenced.push(raw_entry as u32); + } + if let Some(entries) = direct_choices { + referenced.extend(entries); + } + if let Some(group) = group { + referenced.extend(group.entries.iter().map(|e| e.0)); + } + for candidate in referenced { + plan.referenced_entries.insert(u64::from(candidate)); + if !templates.contains(&candidate) { + bail!( + "creature {guid}: population references missing creature_template {candidate}" + ); + } + } + let orientation: f64 = number(&row, cr::O, "creature")?; + if !orientation.is_finite() { + bail!("creature {guid}: non-finite orientation"); + } + plan.spawns.push(( + guid, + entry, + x, + y, + z, + orientation, + number(&row, cr::MOVEMENT_TYPE, "creature")?, + number(&row, cr::SPAWN_TIME_SECS_MIN, "creature")?, + map, + )); + plan.conditional_spawns += usize::from(conditional); + let event_ids = events.get(&guid); + plan.event_spawns += usize::from(event_ids.is_some_and(|ids| ids.iter().any(|id| *id > 0))); + if member.is_none() && direct_choices.is_none() && event_ids.is_none() { + continue; + } + let (population_id, chance) = member.copied().unwrap_or(((1_u64 << 32) + guid, 0)); + used_groups.insert(population_id); + let choices = direct_choices + .map(|entries| { + entries + .iter() + .map(u32::to_string) + .collect::>() + .join(",") + }) + .unwrap_or_default(); + let event_ids = event_ids + .map(|ids| ids.iter().map(i32::to_string).collect::>().join(",")) + .unwrap_or_default(); + plan.membership_rows.push(format!( + "({},{population_id},{raw_entry},{},{chance},{})", + world_guid(entry, guid), + sql_str(&choices), + sql_str(&event_ids) + )); + } + for id in used_groups { + let (max, condition, expression, flags, entries) = if let Some(group) = groups.get(&id) { + ( + group.max_count, + group.condition, + group.expression, + group.flags, + group + .entries + .iter() + .map(|(e, min, max, chance)| format!("{e},{min},{max},{chance}")) + .collect::>() + .join(";"), + ) + } else { + (1, 0, 0, 0, String::new()) + }; + plan.definition_rows.push(format!( + "({id},{max},{condition},{expression},{flags},{})", + sql_str(&entries) + )); + } + eprintln!("creature populations: {} source positions retained, {} groups or random/event slots; {} positions require world-state conditions; {} positions have positive game-event membership", plan.spawns.len(), plan.definition_rows.len(), plan.conditional_spawns, plan.event_spawns); + Ok(plan) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn full_scope() -> WorldImportScope { + WorldImportScope::canonical(crate::WorldImportProfile::parse("full-world").unwrap()) + .unwrap() + } + + #[test] + fn rejects_duplicate_entries_and_excess_total_chance_before_emitting_sql() { + let scope = WorldImportScope::canonical(crate::WorldImportProfile::FullWorld).unwrap(); + for (entries, reason) in [ + ("(1,10,0,0,0),(1,10,0,0,0)", "duplicate spawn_group_entry"), + ("(1,10,0,0,60),(1,20,0,0,50)", "chances exceed 100"), + ] { + let dump = format!("INSERT INTO `spawn_group` VALUES (1,'fixture',0,0,0,0,0,''); INSERT INTO `spawn_group_entry` VALUES {entries};"); + let error = collect(&dump, &scope).err().unwrap(); + assert!(error.to_string().contains(reason), "{error}"); + } + } + + #[test] + fn capped_groups_keep_all_positions_and_every_entry_choice() { + let dump = "INSERT INTO `creature_template` VALUES (10),(20); + INSERT INTO `creature` VALUES (1,0,0,0,1,2,3,0,60,60,0,0),(2,0,0,0,4,5,6,0,60,60,0,0); + INSERT INTO `spawn_group` VALUES (7,'rare',0,1,0,0,0,0); + INSERT INTO `spawn_group_spawn` VALUES (7,1,0,0),(7,2,0,0); + INSERT INTO `spawn_group_entry` VALUES (7,10,0,1,25),(7,20,0,1,0);"; + let plan = collect(dump, &full_scope()).unwrap(); + assert_eq!(plan.spawns.len(), 2); + assert_eq!(plan.membership_rows.len(), 2); + assert_eq!(plan.referenced_entries, HashSet::from([10, 20])); + assert!(plan.definition_rows[0].contains("10,0,1,25;20,0,1,0")); + } + + #[test] + fn conditional_source_without_sql_entry_choices_remains_inspectable() { + let dump = "INSERT INTO `creature` VALUES (11559,0,0,0,1,2,3,0,60,60,0,0); + INSERT INTO `spawn_group` VALUES (19980,'emissary',0,0,19998,0,10,0); + INSERT INTO `spawn_group_spawn` VALUES (19980,11559,0,0);"; + let plan = collect(dump, &full_scope()).unwrap(); + assert_eq!(plan.spawns.len(), 1); + assert_eq!(plan.spawns[0].1, 0); + assert_eq!(plan.conditional_spawns, 1); + assert_eq!(plan.membership_rows.len(), 1); + } + + #[test] + fn missing_entry_dependency_names_the_source_guid() { + let dump = "INSERT INTO `creature` VALUES (123,0,0,0,1,2,3,0,60,60,0,0);"; + let error = collect(dump, &full_scope()).err().unwrap().to_string(); + assert!(error.contains("creature 123"), "{error}"); + } + + #[test] + fn missing_template_dependency_names_the_source_guid_and_entry() { + let dump = "INSERT INTO `creature` VALUES (123,456,0,0,1,2,3,0,60,60,0,0);"; + let error = collect(dump, &full_scope()).err().unwrap().to_string(); + assert!(error.contains("creature 123"), "{error}"); + assert!(error.contains("missing creature_template 456"), "{error}"); + } + + #[test] + fn event_source_keeps_the_sign_of_each_membership() { + let dump = "INSERT INTO `creature_template` VALUES (10); + INSERT INTO `creature` VALUES (1,10,0,0,1,2,3,0,60,60,0,0),(2,10,0,0,4,5,6,0,60,60,0,0); + INSERT INTO `game_event_creature` VALUES (1,4),(2,-4);"; + let plan = collect(dump, &full_scope()).unwrap(); + assert_eq!(plan.spawns.len(), 2); + assert_eq!(plan.event_spawns, 1); + assert!(plan.membership_rows.iter().any(|row| row.contains("'-4'"))); + } + + #[test] + fn rejects_incomplete_population_source() { + let scope = WorldImportScope::quest_catalogue().unwrap(); + assert!(collect("INSERT INTO `spawn_group` VALUES (1", &scope).is_err()); + } +} diff --git a/importer/src/dbc.rs b/importer/src/dbc.rs index 022c96f04..b5bebab88 100644 --- a/importer/src/dbc.rs +++ b/importer/src/dbc.rs @@ -166,6 +166,28 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { let mount_dump = crate::read_dump(args.taxi_mounts.as_deref().context( "standalone --dbc requires --taxi-mounts to resolve taxi mount displays", )?)?; + run_from_dump(data_dir, args, &mount_dump) +} + +/// A complete world import shares its source dump with the client catalogues. +pub(crate) fn run_from_dump(data_dir: &str, args: &Args, mount_dump: &str) -> Result<()> { + let statements = catalogue_statements(data_dir, mount_dump)?; + if args.apply { + crate::run_sql_statements(args, &statements, "dbc")?; + eprintln!( + "dbc: loaded client catalogues ({} SQL statements)", + statements.len() + ); + } else { + println!("-- DBC catalogue: {} SQL statements", statements.len()); + for statement in &statements { + println!("{statement};"); + } + } + Ok(()) +} + +pub(crate) fn catalogue_statements(data_dir: &str, mount_dump: &str) -> Result> { let dir = Path::new(data_dir); let mut chain = open_chain(dir)?; eprintln!("dbc: opened MPQ chain from {}", dir.display()); @@ -249,7 +271,7 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { &taxi_nodes, &taxi_paths, &taxi_path_nodes, - &mount_dump, + mount_dump, &cdi.rows().iter().map(|row| row.id.id).collect(), )?; for warning in taxi_warnings { @@ -275,59 +297,8 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { let (sa_stmts, sa_count) = skill_ability_sql(&skill_abilities); let (sav_stmts, sav_count) = skill_availability_sql(&skill_availability); - if args.apply { - crate::run_sql_statements(args, &property_stmts, "itemproperties")?; - crate::run_sql_statements(args, &faction_stmts, "faction")?; - eprintln!("dbc: loaded {fn_count} faction templates into game_faction_template."); - crate::run_sql_statements(args, &gf_stmts, "game_faction")?; - eprintln!("dbc: loaded {gf_count} factions into game_faction."); - crate::run_sql_statements(args, &auction_house_stmts, "auctionhouse")?; - eprintln!("dbc: loaded {auction_house_count} auction houses into game_auction_house."); - crate::run_sql_statements(args, &mail_template_stmts, "mailtemplate")?; - eprintln!("dbc: loaded {mail_template_count} mail templates into game_mail_template."); - crate::run_sql_statements(args, &cbi_stmts, "charbaseinfo")?; - eprintln!("dbc: loaded {cbi_count} (race,class) combos into game_char_base_info."); - crate::run_sql_statements(args, &race_stmts, "race")?; - eprintln!("dbc: loaded {race_count} races into game_race_info."); - crate::run_sql_statements(args, &si_stmts, "startitem")?; - eprintln!("dbc: loaded {si_count} starting items into game_start_item."); - crate::run_sql_statements(args, &sl_stmts, "skillline")?; - eprintln!("dbc: loaded {sl_count} skill lines into game_skill_line."); - crate::run_sql_statements(args, &sa_stmts, "skillability")?; - eprintln!("dbc: loaded {sa_count} skill abilities into game_skill_ability."); - crate::run_sql_statements(args, &sav_stmts, "skillavailability")?; - eprintln!("dbc: loaded {sav_count} availability rows into game_skill_availability."); - crate::run_sql_statements(args, &area_stmts, "area")?; - eprintln!("dbc: loaded {area_count} areas into game_area."); - crate::run_sql_statements(args, &trigger_stmts, "areatrigger")?; - eprintln!("dbc: loaded {trigger_count} area triggers into game_area_trigger."); - crate::run_sql_statements(args, &graveyard_stmts, "graveyard")?; - eprintln!("dbc: loaded {graveyard_count} graveyards into game_graveyard."); - crate::run_sql_statements(args, &family_stmts, "creaturefamily")?; - eprintln!("dbc: loaded {family_count} creature families into game_creature_family."); - crate::run_sql_statements(args, &lock_stmts, "lock")?; - eprintln!("dbc: loaded {lock_count} lock indices into game_lock."); - crate::run_sql_statements(args, &taxi_stmts, "taxi")?; - eprintln!( - "dbc: loaded {} taxi nodes, {} directed paths, and {} ordered path points (plus reserved fixture).", - taxi_counts.nodes, taxi_counts.paths, taxi_counts.path_nodes - ); - } else { - println!("-- DRY RUN: load {fn_count} faction templates + {gf_count} factions + {auction_house_count} auction houses + {cbi_count} (race,class) combos + {race_count} races:"); - println!("{};", faction_stmts[0]); - if let Some(ins) = faction_stmts.get(1) { - println!( - "{}… ; -- (+{} faction INSERT chunk(s))", - &ins[..ins.len().min(90)], - faction_stmts.len() - 1 - ); - } - for s in cbi_stmts.iter().chain(race_stmts.iter()) { - println!("{}…", &s[..s.len().min(110)]); - } - eprintln!("dbc: dry-run — re-run with --apply to load."); - } - + println!("FactionTemplate: {fn_count} templates; Faction: {gf_count} factions"); + println!("CharBaseInfo: {cbi_count} combinations; ChrRaces: {race_count} races; CharStartOutfit: {si_count} items"); // Skill-import coverage prints (always printed — informational, not gated on --apply like the // SQL-preview lines above). println!("SkillLine: {sl_count} lines"); @@ -357,7 +328,28 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { "TaxiNodes/TaxiPath/TaxiPathNode: {} nodes, {} directed paths, {} ordered points", taxi_counts.nodes, taxi_counts.paths, taxi_counts.path_nodes ); - Ok(()) + Ok([ + property_stmts, + faction_stmts, + gf_stmts, + auction_house_stmts, + mail_template_stmts, + cbi_stmts, + race_stmts, + si_stmts, + sl_stmts, + sa_stmts, + sav_stmts, + area_stmts, + trigger_stmts, + graveyard_stmts, + family_stmts, + lock_stmts, + taxi_stmts, + ] + .into_iter() + .flatten() + .collect()) } /// Clear+reload SQL for `game_auction_house` from `AuctionHouse.dbc`. The DBC owns the house id, diff --git a/importer/src/eventai.rs b/importer/src/eventai.rs index 52dd8bd09..d1f017375 100644 --- a/importer/src/eventai.rs +++ b/importer/src/eventai.rs @@ -83,6 +83,7 @@ const ACTION_SPAWN: u32 = 12; const ACTION_THREAT_SINGLE: u32 = 13; const ACTION_THREAT_ALL_PCT: u32 = 14; const ACTION_QUEST_EVENT: u32 = 15; +const ACTION_AUTO_ATTACK: u32 = 20; const ACTION_COMBAT_MOVEMENT: u32 = 21; const ACTION_SET_PHASE: u32 = 22; const ACTION_INCREMENT_PHASE: u32 = 23; @@ -97,7 +98,9 @@ const ACTION_KILLED_MONSTER: u32 = 33; const ACTION_SET_INSTANCE_DATA: u32 = 34; const ACTION_SET_INSTANCE_DATA_GUID: u32 = 35; const ACTION_DIE: u32 = 37; +const ACTION_ZONE_COMBAT: u32 = 38; const ACTION_CALL_FOR_HELP: u32 = 39; +const ACTION_SET_SHEATH: u32 = 40; const ACTION_FORCE_DESPAWN: u32 = 41; const ACTION_SET_DEATH_PREVENTION: u32 = 42; const ACTION_THROW_AI_EVENT: u32 = 45; @@ -179,6 +182,8 @@ pub(crate) struct EventAiPlan { pub(crate) relay_definition_rows: Vec, pub(crate) relay_definition_batches: Vec, pub(crate) forced_template_entries: BTreeSet, + pub(crate) forced_gameobject_entries: BTreeSet, + pub(crate) world_state_rows: Vec, coverage: Coverage, } @@ -196,7 +201,8 @@ impl EventAiPlan { + self.broadcast_rows.len() + self.summon_rows.len() + self.quest_event_entries.len() - + self.relay_definition_rows.len()) as u64 + + self.relay_definition_rows.len() + + self.world_state_rows.len()) as u64 } pub(crate) fn quest_event_requirement_rows(&self) -> Vec { @@ -1898,11 +1904,8 @@ impl RelaySource { closure } - /// Every creature template the accepted relay closure names. An accepted action already forces - /// its summon template into the World Import Scope, and a relay leaf carries the same weight: a - /// step that names a template the run never writes would ship against content the shard lacks. - /// Steps the source loader skips are excluded, matching `encode_closure`. - fn closure_template_entries(&self, roots: &BTreeSet) -> BTreeSet { + /// Static definitions needed by accepted relay steps, excluding source-loader skips. + fn closure_entries(&self, roots: &BTreeSet, kind: &str) -> BTreeSet { let mut entries = BTreeSet::new(); for relay_id in self.closure(roots) { let Some(steps) = self.definitions.get(&relay_id) else { @@ -1913,7 +1916,7 @@ impl RelaySource { continue; }; for dependency in dependencies { - if dependency.kind == "creature_template" { + if dependency.kind == kind { entries.insert(dependency.raw_value); } } @@ -3677,8 +3680,20 @@ impl EventAiSource { rule.actions.iter().filter(|action| action[0] != 0).count() as u64; } - plan.forced_template_entries - .extend(self.relays.closure_template_entries(&accepted_relay_roots)); + plan.forced_template_entries.extend( + self.relays + .closure_entries(&accepted_relay_roots, "creature_template"), + ); + plan.forced_gameobject_entries.extend( + self.relays + .closure_entries(&accepted_relay_roots, "gameobject_template"), + ); + plan.world_state_rows = self + .relays + .closure_entries(&accepted_relay_roots, "world_state") + .into_iter() + .map(|id| format!("({id})")) + .collect(); plan.relay_definition_rows = self.relays.encode_closure(&accepted_relay_roots); let relay_catalogue_version = RelaySource::encoded_catalogue_version(&plan.relay_definition_rows); @@ -3706,7 +3721,11 @@ impl EventAiSource { "({id},{male},{female},{chat_type},{language},{delay1},{emote1},{delay2},{emote2},{delay3},{emote3})", male = sql_text(&text.male), female = sql_text(&text.female), - chat_type = text.chat_type, + chat_type = match text.chat_type { + 3 => lyracore_shared::chat::broadcast_chat::CREATURE_BOSS_EMOTE, + 4 => lyracore_shared::chat::broadcast_chat::CREATURE_WHISPER, + other => other, + }, language = text.language, delay1 = text.emotes[0].0, emote1 = text.emotes[0].1, @@ -4249,7 +4268,8 @@ fn parse_summons(dump: &str, coverage: &mut Coverage) -> BTreeMap().ok()?, field(&row, 3).parse::().ok()?, field(&row, 4).parse::().ok()?, - source_u32(field(&row, 5))?.checked_mul(1_000)?, + // The source column says seconds, but the summon API consumes it as milliseconds. + source_u32(field(&row, 5))?, )) }; let Some((x, y, z, orientation, lifetime_ms)) = parsed() else { @@ -4408,7 +4428,7 @@ fn map_event( }, EVENT_TARGET_HP => percentage("target-health"), EVENT_TARGET_CASTING => Some("target-casting".to_string()), - EVENT_FRIENDLY_HP if params[1] > 0 && params[4] <= 1 => Some(format!( + EVENT_FRIENDLY_HP if params[4] <= 1 => Some(format!( "friendly-health:{}:{}:{}", params[0], params[1], params[4] )), @@ -4609,6 +4629,17 @@ fn map_action( normalizations: Vec::new(), }) } + ACTION_AUTO_ATTACK if action[2] == 0 && action[3] == 0 => Ok(movement_action( + kind, + format!("auto-attack:{}", u8::from(action[1] != 0)), + None, + )), + ACTION_ZONE_COMBAT if action[1..] == [0, 0, 0] => { + Ok(movement_action(kind, "zone-combat".to_string(), None)) + } + ACTION_SET_SHEATH if action[1] <= 2 && action[2] == 0 && action[3] == 0 => { + Ok(movement_action(kind, format!("sheath:{}", action[1]), None)) + } ACTION_EMOTE => Ok(NativeAction { encoded: format!("emote:{}:self", action[1]), raw_kind: kind, @@ -4742,8 +4773,14 @@ fn map_speech_action( .filter(|id| *id != 0) .collect::>(); let mut failures = Vec::new(); - if texts.is_empty() { - failures.push(MappingFailure::source("action", kind as u64, "empty_text")); + if action[1] == 0 { + let mut mapped = movement_action(kind, "no-effect:empty-text".to_string(), None); + mapped.normalizations.push(SourceNormalization { + dimension: "action", + raw_value: u64::from(kind), + reason: "empty_text_has_no_effect", + }); + return Ok(mapped); } for id in &texts { if !broadcasts.contains_key(id) { @@ -5896,7 +5933,7 @@ fn speech_mode( .filter_map(|id| broadcasts.get(id).map(|text| text.chat_type)) .collect::>(); for mode in &modes { - if *mode > 2 { + if !matches!(*mode, 0..=4 | 6 | 7) { failures.push(MappingFailure::dependency( "broadcast_text", u64::from(*mode), @@ -5915,6 +5952,10 @@ fn speech_mode( // broadcast row's own chat type, so this mode is the fallback when that row cannot be read. // Encoding it as `say` made the fallback disagree with the source. Some(2) => "emote", + Some(3) => "boss-emote", + Some(4) => "whisper", + Some(6) => "zone-yell", + Some(7) => "zone-emote", _ => "say", } } @@ -6059,6 +6100,83 @@ mod tests { })); } + #[test] + fn summon_lifetimes_keep_the_source_milliseconds() { + let source = parse("INSERT INTO `creature_ai_summons` VALUES (2,-422.562,69.0183,-57.8122,2.72416,86400000,'Dreamscythe');"); + assert_eq!(source.summon_locations[&2].lifetime_ms, 86_400_000); + } + + #[test] + fn zero_radius_friendly_heal_remains_self_only() { + let source = parse(&dump(&[rule( + 482003, + 100, + EVENT_FRIENDLY_HP, + 100, + 1025, + [800, 0, 0, 0, 0, 0], + [[ACTION_CAST as i64, 6063, 12, 0], [0; 4], [0; 4]], + )])); + assert_eq!( + map_event(&source.rules[0], &source.event_predicates).unwrap(), + "friendly-health:800:0:0" + ); + } + + #[test] + fn full_world_combat_actions_keep_source_order() { + let source = parse(&dump(&[rule( + 1448901, + 100, + EVENT_AGGRO, + 100, + 0, + [0; 6], + [[20, 0, 0, 0], [40, 2, 0, 0], [38, 0, 0, 0]], + )])); + let (entries, guids, templates) = scope(); + let plan = source.assemble(&entries, &guids, &templates); + assert_eq!(plan.coverage.dropped_rules, 0); + assert!(plan.definition_rows[0].contains("auto-attack:0+sheath:2+zone-combat")); + } + + #[test] + fn empty_text_preserves_its_sibling_actions() { + let source = parse(&dump(&[rule( + 911901, + 100, + EVENT_AGGRO, + 100, + 0, + [0; 6], + [[1, 0, 0, 0], [22, 1, 0, 0], [0; 4]], + )])); + let (entries, guids, templates) = scope(); + let plan = source.assemble(&entries, &guids, &templates); + assert_eq!(plan.coverage.dropped_rules, 0); + assert!(plan.definition_rows[0].contains("no-effect:empty-text+phase:1")); + } + + #[test] + fn source_boss_emote_does_not_use_the_character_emote_discriminant() { + let sql = dump(&[rule( + 1430417, + 100, + EVENT_AGGRO, + 100, + 0, + [0; 6], + [[1, 900, 0, 0], [0; 4], [0; 4]], + )]) + .replace("(900,'Hello','Hello',0,", "(900,'Hello','Hello',3,"); + let source = parse(&sql); + let (entries, guids, templates) = scope(); + let plan = source.assemble(&entries, &guids, &templates); + assert_eq!(plan.coverage.dropped_rules, 0); + assert!(plan.definition_rows[0].contains("speak:boss-emote:")); + assert!(plan.broadcast_rows[0].contains("'Hello','Hello',4,")); + } + #[test] fn source_profile_pins_product_identity_and_loader_contract() { let profile = source_profile(SOURCE_PROFILE_NAME).unwrap(); diff --git a/importer/src/main.rs b/importer/src/main.rs index 5f11a3893..192b68e4a 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -9,7 +9,8 @@ //! defaulting to a dry run that prints the plan and writes nothing): //! --dump cmangos creature/etc ETL //! [--world-profile alliance-eastern|alliance-kalimdor| -//! alliance-single|starting-eastern|starting-kalimdor|instances] +//! alliance-single|starting-eastern|starting-kalimdor|instances| +//! full-world|full-eastern|full-kalimdor|full-instances] //! [--eventai-profile cmangos-classic-z2815] //! [advanced: --map 0 --center X,Y,Z --radius 180] //! [--print-extents derives --box from the dump's real spawn @@ -51,6 +52,7 @@ //! importer --dump .import/classic-db-full.sql --map 0 --print-extents mod collision; +mod creature_population; mod dbc; mod eventai; mod eventai_presentation; @@ -535,24 +537,6 @@ mod qr { pub const CREATURE: usize = 0; // id = creature_template.entry (or gameobject_template.entry for the GO tables) pub const QUEST: usize = 1; } -mod sg { - // spawn_group: Id, Name, Type, MaxCount, WorldState, WorldStateExpression, Flags, StringId - pub const ID: usize = 0; - pub const TYPE: usize = 2; // 0 = creature, 1 = gameobject - pub const MAX_COUNT: usize = 3; // 0 = unlimited; >0 = cap across all member guids -} -mod sgs { - // spawn_group_spawn: GroupId, Guid, SlotId, Chance - pub const GROUP_ID: usize = 0; - pub const GUID: usize = 1; -} -mod cse { - // creature_spawn_entry: guid, entry — maps id=0 pool slots to concrete creature_template entries. - // Each guid may have multiple entries (the live server picks randomly); we pick the first (deterministic). - pub const GUID: usize = 0; - pub const ENTRY: usize = 1; -} - const HIGHGUID_UNIT: u64 = 0xF130; /// Our deterministic creature guid from the cmangos entry + spawn guid (HIGHGUID_UNIT-encoded, like @@ -611,7 +595,6 @@ const GO_QUESTGIVER: u8 = lyracore_shared::constants::go_type::QUESTGIVER; // sh const GO_CHEST: u8 = 3; const GO_GOOBER: u8 = 10; const GO_MEETINGSTONE: u8 = lyracore_shared::constants::go_type::MEETINGSTONE; -const RELAY_TRAP_TEMPLATE_ENTRY: u64 = 180_391; /// Synthetic — must equal module `go_type::GATHER`. Vanilla's REAL type 25 is /// GAMEOBJECT_TYPE_FISHINGHOLE; see the TYPE-25 COLLISION GUARD in `classify_go_type`. const GO_GATHER: u8 = 25; @@ -3946,16 +3929,53 @@ pub(crate) struct DumpPlan { pub(crate) stamps: Vec<(&'static str, u64)>, } -/// UNIT_FLAG_NOT_SELECTABLE. A template carrying it is an invisible server-side trigger or event -/// creature (Scourge Invasion necropolises, Lunar Festival markers, World Trigger). Its spawns sit -/// inside the box but must never appear in regular gameplay. -const UNIT_FLAG_NOT_SELECTABLE: u32 = 0x0200_0000; - -/// Whether a `creature_template` row may import at all. The template block, the packed spawn -/// payload, and the EventAI scope all read this one predicate, so an excluded entry can never -/// import through one path while another skips it. -fn template_is_importable(row: &[String]) -> bool { - field(row, ct::UNIT_FLAGS).parse::().unwrap_or(0) & UNIT_FLAG_NOT_SELECTABLE == 0 +impl DumpPlan { + /// GameObject dependencies precede AI; creatures can raise spawn events only after AI is ready. + fn reducer_batches<'a>(&'a self, args: &Args) -> Result> { + let mut batches = Vec::new(); + if family_active(args, "gameobjects") { + for (index, batch) in self.go_batches.iter().enumerate() { + batches.push(( + if index == 0 { + "import_gameobjects" + } else { + "import_gameobjects_append" + }, + batch.as_str(), + )); + } + } + if family_active(args, "creature-ai") { + let catalogue = self + .eventai_relay_definition_batches + .first() + .context("EventAI relay plan has no atomic catalogue")?; + batches.push(("import_creature_ai_relay_definitions", catalogue.as_str())); + for (index, batch) in self.eventai_definition_batches.iter().enumerate() { + batches.push(( + if index == 0 { + "import_creature_ai_definitions" + } else { + "import_creature_ai_definitions_append" + }, + batch.as_str(), + )); + } + } + if family_active(args, "creatures") { + for (index, batch) in self.spawn_batches.iter().enumerate() { + batches.push(( + if index == 0 { + "import_creature_spawns" + } else { + "import_creature_spawns_append" + }, + batch.as_str(), + )); + } + } + Ok(batches) + } } /// Build the full `--dump` ETL plan. The `--family` split lives entirely in here, via @@ -3973,45 +3993,31 @@ fn build_dump_plan( ) -> Result { let scope = args.world_import_scope()?; eprintln!("world import scope: {}", scope.name()); - for slice in &scope.bounded_slices { - let (x0, x1, y0, y1) = slice.bounds; - let (sx, sy, sz) = slice.sample; - eprintln!( - " bounded slice {}: map {} x[{x0:.0},{x1:.0}] y[{y0:.0},{y1:.0}], sample ({sx:.2},{sy:.2},{sz:.2})", - slice.name, slice.map_id - ); - } - for map in &scope.whole_maps { - eprintln!(" whole map: {map}"); - } - - let spawns = collect_creature_spawns(dump, &scope); + let population = creature_population::collect(dump, &scope)?; + let spawns = &population.spawns; let ScopeEntries { entries, eventai, eventai_manifest, - } = resolve_scope_entries(dump, args, &scope, &spawns, ScopePurpose::Import)?; + } = resolve_scope_entries(dump, args, &scope, &population, ScopePurpose::Import)?; let CreatureTemplates { templates, creature_loot_ids, creature_pickpocket_ids, creature_skin_ids, - excluded_entries, class_trainer_count, } = build_creature_templates(dump, &entries, display_scales); let (creature_casts, creature_rotation_rows) = build_creature_cast_rows(dump, &entries); let SpawnPayload { guid_of, packed_spawns, - excluded_count, - } = build_packed_spawn_payload(&spawns, &excluded_entries); - let waypoints = build_waypoint_rows(dump, &spawns, &guid_of); + } = build_packed_spawn_payload(spawns); + let waypoints = build_waypoint_rows(dump, spawns, &guid_of); eprintln!( - "mapped: {} templates ({class_trainer_count} class-gated trainers), {} spawns ({} excluded), {} waypoints", + "mapped: {} templates ({class_trainer_count} class-gated trainers), {} spawns, {} waypoints", templates.len(), - spawns.len() - excluded_count, - excluded_count, + spawns.len(), waypoints.len() ); @@ -4021,7 +4027,7 @@ fn build_dump_plan( traps: go_trap_rows, meeting_stones: go_meeting_stone_rows, chest_loot_ids: chest_loot_ids_used, - } = gameobject_template_rows(&go_spawns); + } = gameobject_template_rows(&go_spawns, &eventai.forced_gameobject_entries)?; let trainer_rows = build_trainer_spell_rows(dump, &entries, profession_tier_values); // A transferred Character needs the same quest text and objectives on every destination. @@ -4045,13 +4051,22 @@ fn build_dump_plan( let mut obtainable_items = creature_drop_item_set(dump, quest_loot_ids); obtainable_items.extend(gameobject_loot_item_set(dump, &quest_gameobjects.used_go)); obtainable_items.extend(vendor_stock_item_set(dump, quest_entries)); + let local_creature_entries = &population.referenced_entries; + let giver_entries = if args + .world_profile + .is_some_and(WorldImportProfile::imports_all_templates) + { + local_creature_entries + } else { + &entries + }; let quests = build_quests( dump, quest_entries, &quest_gameobjects.used_go, &quest_gameobjects.goober_entries, &obtainable_items, - Some((&entries, &go_spawns.used_go)), + Some((giver_entries, &go_spawns.used_go)), )?; eprintln!( "mapped: {} quests, {} text, {} objectives, {} cast objectives, {} reward items, {} choice rewards, \ @@ -4151,6 +4166,9 @@ fn build_dump_plan( 0 }; push_world_content_statements(args, content, &mut stmts); + if family_active(args, "creatures") { + population.append_statements(&mut stmts); + } push_quest_and_gameobject_statements(args, content, &mut stmts); push_creature_behaviour_statements(args, content, &mut stmts); let (globals_row_count, spellmeta_row_count) = push_global_statements(args, dump, &mut stmts)?; @@ -4224,7 +4242,6 @@ struct CreatureTemplates { creature_loot_ids: std::collections::HashMap, creature_pickpocket_ids: std::collections::HashMap, creature_skin_ids: std::collections::HashMap, - excluded_entries: std::collections::HashSet, class_trainer_count: usize, } @@ -4232,7 +4249,6 @@ struct CreatureTemplates { struct SpawnPayload { guid_of: std::collections::HashMap, packed_spawns: String, - excluded_count: usize, } /// One imported `gameobject_template`, reduced to the columns the per-type row builder needs. @@ -4278,10 +4294,23 @@ fn build_quest_catalogue( args: &Args, display_scales: &Option>, ) -> Result { - let mut scope = WorldImportScope::quest_catalogue()?; + let mut scope = if args + .world_profile + .is_some_and(WorldImportProfile::imports_all_templates) + { + WorldImportScope::canonical(WorldImportProfile::FullWorld)? + } else { + WorldImportScope::quest_catalogue()? + }; scope.extend_forced_creatures(&args.include_creatures); - let spawns = collect_creature_spawns(dump, &scope); - let content = resolve_scope_entries(dump, args, &scope, &spawns, ScopePurpose::QuestCatalogue)?; + let population = creature_population::collect(dump, &scope)?; + let content = resolve_scope_entries( + dump, + args, + &scope, + &population, + ScopePurpose::QuestCatalogue, + )?; let templates = build_creature_templates(dump, &content.entries, display_scales); Ok(QuestCatalogue { entries: content.entries, @@ -4317,144 +4346,6 @@ struct MappedContent<'a> { quest_event_requirements: &'a [String], } -/// Every creature spawn the World Import Scope keeps: the cmangos `creature` rows inside the scope, -/// with id=0 pool slots resolved through `creature_spawn_entry` and `spawn_group` MaxCount caps -/// applied. -fn collect_creature_spawns(dump: &str, scope: &WorldImportScope) -> Vec { - // 0b) creature_spawn_entry: resolves id=0 pool slots to concrete creature_template entries. - // cmangos places id=0 in `creature` for spawn-pool points; the real entry is in creature_spawn_entry. - // Each guid may map to multiple entries (random pick at runtime). We pick the first for determinism. - let mut spawn_entry_map: std::collections::HashMap = std::collections::HashMap::new(); - for row in parse_table(dump, "creature_spawn_entry") { - let guid: u64 = field(&row, cse::GUID).parse().unwrap_or(0); - let entry: u64 = field(&row, cse::ENTRY).parse().unwrap_or(0); - if guid > 0 && entry > 0 { - // keep first entry seen (stable across re-imports; dump order is deterministic) - spawn_entry_map.entry(guid).or_insert(entry); - } - } - eprintln!( - "creature_spawn_entry: {} guid→entry mappings loaded", - spawn_entry_map.len() - ); - - // 1) spawns in range → the content slice. - // db_guid, entry, x,y,z,o, movement_type, respawn_secs, map (map END-appended, - // an --include-map row carries its OWN map into the packed payload, not args.map). - let mut spawns: Vec = Vec::new(); - let mut pool_resolved = 0u32; - let mut pool_skipped = 0u32; - for row in parse_table(dump, "creature") { - let map: i64 = field(&row, cr::MAP).parse().unwrap_or(-1); - let x: f64 = field(&row, cr::X).parse().unwrap_or(0.0); - let y: f64 = field(&row, cr::Y).parse().unwrap_or(0.0); - let z: f64 = field(&row, cr::Z).parse().unwrap_or(0.0); - let db_guid: u64 = field(&row, cr::GUID).parse().unwrap_or(0); - let raw_entry: u64 = field(&row, cr::ID).parse().unwrap_or(0); - // A forced entry bypasses bounded geometry but not the scope's map fence. Keeping the spawn - // makes its entry flow into every creature-scoped family below. - if !creature_row_kept_in_scope(scope, map, x, y, z, raw_entry) { - continue; - } - // cmangos uses id=0 for random spawn-POOL slots; the concrete entry is in creature_spawn_entry. - // Resolve via the map (first entry wins for determinism). Skip only if unresolvable. - let entry = if raw_entry == 0 { - match spawn_entry_map.get(&db_guid) { - Some(&e) => { - pool_resolved += 1; - e - } - None => { - pool_skipped += 1; - continue; - } - } - } else { - raw_entry - }; - let o: f64 = field(&row, cr::O).parse().unwrap_or(0.0); - // cmangos MovementType (0 idle / 1 random-wander / 2 waypoint); default IDLE if absent/garbage - // so a missing cell never wrongly animates a creature. - let mt: u8 = field(&row, cr::MOVEMENT_TYPE).parse().unwrap_or(0); - // Per-spawn cmangos respawn timer (the MIN leg of spawntimesecsmin/max, the - // shorter, more common bound). 0/garbage → 0, which the module's decay pass reads as "not - // imported" and falls back to the flat legacy timer (baseline-safe). - let respawn_secs: u32 = field(&row, cr::SPAWN_TIME_SECS_MIN).parse().unwrap_or(0); - spawns.push((db_guid, entry, x, y, z, o, mt, respawn_secs, map)); - } - if pool_resolved > 0 || pool_skipped > 0 { - eprintln!("creature id=0 pool slots: {} resolved via creature_spawn_entry, {} unresolvable (no entry row) → skipped", pool_resolved, pool_skipped); - } - - // 1b) spawn_group pool dedup: cmangos pools named/rare creatures via spawn_group (MaxCount=1) over - // several placed guids. Without this, every placed point spawns simultaneously — Hogger x5, etc. - // Parse spawn_group (creature groups only, MaxCount>0) and spawn_group_spawn (guid→groupId map), - // then for each pool present in the collected spawns, keep only MaxCount members (first by - // appearance order in the dump, which is deterministic). - { - // group_id → max_count (only creature groups with MaxCount > 0) - let mut group_max_count: std::collections::HashMap = - std::collections::HashMap::new(); - for row in parse_table(dump, "spawn_group") { - let sg_type: i32 = field(&row, sg::TYPE).parse().unwrap_or(-1); - if sg_type != 0 { - continue; // skip gameobject groups (type=1) - } - let max_count: u32 = field(&row, sg::MAX_COUNT).parse().unwrap_or(0); - if max_count == 0 { - continue; // 0 = unlimited — no cap - } - let id: u64 = field(&row, sg::ID).parse().unwrap_or(0); - group_max_count.insert(id, max_count); - } - - if !group_max_count.is_empty() { - // guid → group_id (only for groups that have a MaxCount cap) - let mut guid_to_group: std::collections::HashMap = - std::collections::HashMap::new(); - for row in parse_table(dump, "spawn_group_spawn") { - let group_id: u64 = field(&row, sgs::GROUP_ID).parse().unwrap_or(0); - if !group_max_count.contains_key(&group_id) { - continue; - } - let guid: u64 = field(&row, sgs::GUID).parse().unwrap_or(0); - guid_to_group.insert(guid, group_id); - } - - if !guid_to_group.is_empty() { - // Count how many spawns from each pool are retained so far, drop excess. - let mut group_seen: std::collections::HashMap = - std::collections::HashMap::new(); - let before = spawns.len(); - spawns.retain( - |(db_guid, _entry, _x, _y, _z, _o, _mt, _respawn_secs, _map)| { - if let Some(&group_id) = guid_to_group.get(db_guid) { - let max = *group_max_count.get(&group_id).unwrap_or(&1); - let seen = group_seen.entry(group_id).or_insert(0); - if *seen < max { - *seen += 1; - true // keep this spawn - } else { - false // pool already at capacity - } - } else { - true // not in any capped pool — keep unconditionally - } - }, - ); - let dropped = before - spawns.len(); - if dropped > 0 { - eprintln!( - "spawn_group: dropped {dropped} pooled duplicate spawns (MaxCount cap)" - ); - } - } - } - } - - spawns -} - /// The creature template entries this run imports, and the EventAI plan built against them. Accepted /// EventAI summons force their summoned templates into the entry set, to a fixpoint, so a summoned-only /// creature still gets a template. @@ -4462,10 +4353,31 @@ fn resolve_scope_entries( dump: &str, args: &Args, scope: &WorldImportScope, - spawns: &[CreatureSpawnRow], + population: &creature_population::PopulationPlan, purpose: ScopePurpose, ) -> Result { - let mut entries: std::collections::HashSet = spawns.iter().map(|s| s.1).collect(); + let spawns = &population.spawns; + let mut entries = population.referenced_entries.clone(); + let mut all_templates = std::collections::HashSet::new(); + if args + .world_profile + .is_some_and(WorldImportProfile::imports_all_templates) + { + for row in parse_complete_table(dump, "creature_template")? { + let entry = field(&row, ct::ENTRY) + .parse::() + .context("invalid creature entry")?; + if entry == 0 { + bail!("creature definition has reserved entry zero"); + } + all_templates.insert(entry); + } + } + if args.world_profile == Some(WorldImportProfile::FullWorld) + || purpose == ScopePurpose::QuestCatalogue + { + entries.extend(&all_templates); + } // Spell summons and the wisp body have no placed spawn. const SPELL_TEMPLATE_ENTRIES: &[u64] = &[416, 1860, 1863, 5676, 5677, 12861]; entries.extend(SPELL_TEMPLATE_ENTRIES.iter().copied()); @@ -4493,12 +4405,13 @@ fn resolve_scope_entries( .into_iter() .filter_map(|row| { let entry = field(&row, ct::ENTRY).parse().ok()?; - template_is_importable(&row).then_some(entry) + Some(entry) }) .collect(); // Canonical quest reimports must retain summoned givers discovered by a full import. let (eventai, eventai_manifest) = if args.world_profile.is_some() || family_active(args, "creature-ai") + || family_active(args, "gameobjects") { let source = eventai::parse(dump); let plan = loop { @@ -4533,6 +4446,7 @@ fn resolve_scope_entries( } else { (eventai::EventAiPlan::default(), None) }; + entries.extend(all_templates); for slice in &scope.bounded_slices { let count = spawns .iter() @@ -4561,8 +4475,7 @@ fn resolve_scope_entries( }) } -/// The `game_creature_template` rows for the scope's entries. Also collects the loot ids and the -/// excluded (`UNIT_FLAG_NOT_SELECTABLE`) entries the spawn payload and the loot families read. +/// Creature definitions and the loot ids used by the selected catalogue. fn build_creature_templates( dump: &str, entries: &std::collections::HashSet, @@ -4578,9 +4491,6 @@ fn build_creature_templates( std::collections::HashMap::new(); let mut creature_skin_ids: std::collections::HashMap = std::collections::HashMap::new(); - // Entries `template_is_importable` rejects: their template is skipped here and their spawns are - // suppressed from the packed payload below. - let mut excluded_entries: std::collections::HashSet = std::collections::HashSet::new(); // How many imported templates carry a nonzero `trainer_class`. Reported below so an operator can // confirm the columns populated: a wrong index reads a real neighbouring cell, which is the one // failure mode here that produces plausible data instead of an error. @@ -4590,10 +4500,6 @@ fn build_creature_templates( if !entries.contains(&entry) { continue; } - if !template_is_importable(&row) { - excluded_entries.insert(entry); - continue; - } let loot_id: u64 = field(&row, ct::LOOT_ID).parse().unwrap_or(0); if loot_id > 0 { creature_loot_ids.insert(entry, loot_id); @@ -4695,7 +4601,6 @@ fn build_creature_templates( creature_loot_ids, creature_pickpocket_ids, creature_skin_ids, - excluded_entries, class_trainer_count, } } @@ -4795,33 +4700,16 @@ fn build_creature_cast_rows( } /// The packed spawn payload the `import_creature_spawns` reducer loads. -fn build_packed_spawn_payload( - spawns: &[CreatureSpawnRow], - excluded_entries: &std::collections::HashSet, -) -> SpawnPayload { +fn build_packed_spawn_payload(spawns: &[CreatureSpawnRow]) -> SpawnPayload { // 3) spawn payload for the `import_creature_spawns` reducer — NOT SQL. `game_creature_spawn` has // Timestamp columns (respawn_at/despawn_at) and SpacetimeDB 2.5 SQL has no Timestamp literal, // so spawns load through a reducer (which stamps `ctx.timestamp`). Rows `;`-separated, fields // `,`: guid,entry,map,x,y,z,orientation,movement_type,respawn_secs. (u64 guids exceed JSON's safe-integer // range, so this is one delimited string — never a per-field JSON number that would lose precision.) - let guid_of: std::collections::HashMap = spawns - .iter() - .filter(|s| !excluded_entries.contains(&s.1)) - .map(|s| (s.0, world_guid(s.1, s.0))) - .collect(); - let excluded_count = spawns - .iter() - .filter(|s| excluded_entries.contains(&s.1)) - .count(); - if excluded_count > 0 { - eprintln!( - "filter: excluded {} event/trigger spawns ({} template entries with UNIT_FLAG_NOT_SELECTABLE)", - excluded_count, excluded_entries.len() - ); - } + let guid_of: std::collections::HashMap = + spawns.iter().map(|s| (s.0, world_guid(s.1, s.0))).collect(); let packed_spawns: String = spawns .iter() - .filter(|s| !excluded_entries.contains(&s.1)) .map(|(db_guid, entry, x, y, z, o, mt, respawn_secs, map)| { // `map` is the ROW's own map: an --include-map spawn (Deadmines 36) // must land on ITS map, not the primary --map's. The dump Z rides through UNALTERED on @@ -4841,7 +4729,6 @@ fn build_packed_spawn_payload( SpawnPayload { guid_of, packed_spawns, - excluded_count, } } @@ -5069,8 +4956,7 @@ fn collect_gameobject_spawns(dump: &str, scope: &WorldImportScope) -> Gameobject } } -/// The rows the `gameobjects` family loads for the spawned gameobjects, and the CHEST loot ids they -/// reference. +/// GameObject definitions for local spawns and accepted relay dependencies, plus chest loot IDs. struct GameobjectTemplateRows { templates: Vec, traps: Vec, @@ -5079,7 +4965,10 @@ struct GameobjectTemplateRows { chest_loot_ids: Vec, } -fn gameobject_template_rows(spawns: &GameobjectSpawns) -> GameobjectTemplateRows { +fn gameobject_template_rows( + spawns: &GameobjectSpawns, + required_entries: &std::collections::BTreeSet, +) -> Result { let GameobjectSpawns { go_meta, dropped_type25, @@ -5087,21 +4976,20 @@ fn gameobject_template_rows(spawns: &GameobjectSpawns) -> GameobjectTemplateRows used_go, .. } = spawns; - // Template rows for the GOs actually spawned, split by type (`go_template_row`) — a SQL INSERT must - // name EVERY column (#[default] is NOT applied on INSERT, only on migration). Also builds the per-type coverage histogram + the CHEST lootId set (widened to EVERY - // imported chest, not a curated allowlist and its scoping now spans the whole live set). + // SQL INSERT must name every column; migration defaults do not apply to imported rows. let mut go_template_rows: Vec = Vec::new(); let mut go_trap_rows: Vec = Vec::new(); let mut go_meeting_stone_rows: Vec = Vec::new(); let mut chest_loot_ids_used: Vec = Vec::new(); let mut go_type_histogram: std::collections::BTreeMap = std::collections::BTreeMap::new(); - let mut used_sorted: Vec = used_go.iter().copied().collect(); - // The pinned relay closure activates this trap by database GUID. Its static authority must be - // present even when the world profile does not include the trap's spawn. - if go_meta.contains_key(&RELAY_TRAP_TEMPLATE_ENTRY) { - used_sorted.push(RELAY_TRAP_TEMPLATE_ENTRY); + for entry in required_entries { + if !go_meta.contains_key(entry) { + bail!("relay requires GameObject definition {entry}, which cannot be imported"); + } } + let mut used_sorted: Vec = used_go.iter().copied().collect(); + used_sorted.extend(required_entries); used_sorted.sort_unstable(); used_sorted.dedup(); for entry in used_sorted { @@ -5173,12 +5061,12 @@ fn gameobject_template_rows(spawns: &GameobjectSpawns) -> GameobjectTemplateRows ); } - GameobjectTemplateRows { + Ok(GameobjectTemplateRows { templates: go_template_rows, traps: go_trap_rows, meeting_stones: go_meeting_stone_rows, chest_loot_ids: chest_loot_ids_used, - } + }) } /// The `game_trainer_spell` rows for the scope's trainers: every cmangos offering, plus the @@ -5671,6 +5559,13 @@ fn push_creature_behaviour_statements( let quest_events_active = family_active(args, "creature-ai") || (args.world_profile.is_some() && family_active(args, "quests")); if family_active(args, "creature-ai") { + stmts.push("DELETE FROM game_world_state_name WHERE variable_id >= 0".into()); + push_insert( + stmts, + "game_world_state_name", + "variable_id", + &eventai.world_state_rows, + ); stmts.push("DELETE FROM game_creature_ai_broadcast_text WHERE id > 0".into()); stmts.push("DELETE FROM game_creature_ai_summon WHERE id > 0".into()); } @@ -5867,6 +5762,22 @@ fn family_stamps( stamps } +fn run_full_world_catalogues(dump: &str, args: &Args) -> Result<()> { + if args + .world_profile + .is_some_and(WorldImportProfile::imports_all_templates) + && args.family.is_none() + { + let data_dir = args + .dbc + .as_deref() + .context("a complete world import requires --dbc ")?; + dbc::run_from_dump(data_dir, args, dump)?; + spell::run_spells(data_dir, args)?; + } + Ok(()) +} + fn main() -> Result<()> { let args = parse_args()?; @@ -5984,12 +5895,14 @@ fn main() -> Result<()> { let plan = build_dump_plan(&dump, &args, &display_scales, &profession_tier_values)?; + run_full_world_catalogues(&dump, &args)?; + if !args.apply { if let Some(manifest) = &plan.eventai_manifest { println!("-- EventAI Compatibility Manifest\n{manifest}\n"); } println!( - "-- DRY RUN: {} SQL statements, {} EventAI definition batch(es) for {} definitions and {} instructions, {} relay definition batch(es) for {} definitions, {} creature-spawn batch(es) for {} spawns, and {} gameobject batch(es) (of {SPAWN_BATCH}).\n", + "-- DRY RUN: {} SQL statements, {} EventAI definition batch(es) for {} definitions and {} instructions, {} relay definition batch(es) for {} definitions, {} creature-spawn batch(es) for {} spawns, and {} gameobject batch(es) for {} spawns (of {SPAWN_BATCH}).\n", plan.stmts.len(), plan.eventai_definition_batches.len(), plan.eventai_definition_count, @@ -5998,7 +5911,8 @@ fn main() -> Result<()> { plan.eventai_relay_definition_count, plan.spawn_batches.len(), plan.spawn_row_count, - plan.go_batches.len() + plan.go_batches.len(), + plan.go_row_count ); for s in &plan.stmts { println!("{s};\n"); @@ -6035,73 +5949,9 @@ fn main() -> Result<()> { bail!("no spawns matched the filter — check --box / --radius / --center"); } - // Apply static catalogues first, then native EventAI definitions, then spawn batches. Definitions - // are ready before a new creature can raise its spawn edge. run_sql_statements(&args, &plan.stmts, "creatures")?; - if family_active(&args, "creature-ai") { - eprintln!( - "reducer: loading {} relay definitions in {} batch(es)…", - plan.eventai_relay_definition_count, - plan.eventai_relay_definition_batches.len() - ); - let relay_catalogue = plan - .eventai_relay_definition_batches - .first() - .context("EventAI relay plan has no atomic catalogue")?; - call_reducer( - &args, - "import_creature_ai_relay_definitions", - relay_catalogue, - ) - .context("EventAI relay catalogue")?; - eprintln!( - "reducer: loading {} EventAI definitions with {} instructions in {} batch(es)…", - plan.eventai_definition_count, - plan.eventai_instruction_count, - plan.eventai_definition_batches.len() - ); - for (index, batch) in plan.eventai_definition_batches.iter().enumerate() { - let reducer = if index == 0 { - "import_creature_ai_definitions" - } else { - "import_creature_ai_definitions_append" - }; - call_reducer(&args, reducer, batch) - .with_context(|| format!("EventAI definition batch {index}"))?; - } - } - if family_active(&args, "creatures") { - eprintln!( - "reducer: loading {} spawns in {} batch(es)…", - plan.spawn_row_count, - plan.spawn_batches.len() - ); - for (i, batch) in plan.spawn_batches.iter().enumerate() { - let reducer = if i == 0 { - "import_creature_spawns" - } else { - "import_creature_spawns_append" - }; - call_reducer(&args, reducer, batch).with_context(|| format!("batch {i}"))?; - } - } - // Gameobject (GOOBER) spawns via the import_gameobjects reducer (game_gameobject has a Timestamp). - // Same batch-0-clears / append-rest pattern as creatures. // note: an empty slice skips the GO clear - // (the reducer errors on an empty payload) — fine in practice (Elwynn always has GOOBERs). - if !plan.go_batches.is_empty() && family_active(&args, "gameobjects") { - eprintln!( - "reducer: loading {} gameobjects in {} batch(es)…", - plan.go_row_count, - plan.go_batches.len() - ); - for (i, batch) in plan.go_batches.iter().enumerate() { - let reducer = if i == 0 { - "import_gameobjects" - } else { - "import_gameobjects_append" - }; - call_reducer(&args, reducer, batch).with_context(|| format!("batch {i}"))?; - } + for (reducer, batch) in plan.reducer_batches(&args)? { + call_reducer(&args, reducer, batch).with_context(|| format!("{reducer} batch"))?; } eprintln!("import applied."); @@ -6306,22 +6156,49 @@ mod tests { } #[test] - fn gameobject_family_emits_the_reachable_relay_trap_profile() { - let dump = "INSERT INTO `gameobject_template` VALUES \ - (180391,6,640,'Fel Rune',0,0,0,1,0,0,0,24425,0,0);"; + fn relay_imports_its_named_world_state() { + let dump = format!( + "INSERT INTO `creature_template` VALUES {}; \ + INSERT INTO `creature` VALUES (1,100,0,1,-8949.95,-132.493,83.5312,0,300,300,0,0); \ + INSERT INTO `worldstate_name` VALUES (19990,'Relay state'); \ + INSERT INTO `creature_ai_scripts` VALUES {}; \ + INSERT INTO `dbscripts_on_relay` VALUES (1,0,0,53,0,0,0,0,0,0,19990,1,0,0,0,0,0,0,0,0,0,'set state');", + creature_template_row(100, 0), + eventai_rule_row(10,100,4,0,100,0,[0;6],[[53,1,0,0],[0;4],[0;4]]) + ); + let plan = build_dump_plan(&dump, &test_args(), &None, &None).unwrap(); + assert_eq!(plan.eventai_relay_definition_count, 1); + assert!(plan + .stmts + .iter() + .any(|sql| sql == "INSERT INTO game_world_state_name (variable_id) VALUES (19990)")); + } + + #[test] + fn relay_imports_an_unplaced_gameobject_definition() { + let dump = format!( + "INSERT INTO `creature_template` VALUES {}; \ + INSERT INTO `creature` VALUES (1,100,0,1,-8949.95,-132.493,83.5312,0,300,300,0,0); \ + INSERT INTO `gameobject_template` VALUES (777,6,640,'Relay trap',0,0,0,1,0,0,0,24425,0,0); \ + INSERT INTO `spell_template` VALUES (24425); \ + INSERT INTO `creature_ai_scripts` VALUES {}; \ + INSERT INTO `dbscripts_on_relay` VALUES (1,0,0,13,0,0,0,777,20,1025,0,0,0,0,0,0,0,0,0,0,0,'activate');", + creature_template_row(100, 0), + eventai_rule_row(10,100,4,0,100,0,[0;6],[[53,1,0,0],[0;4],[0;4]]) + ); let mut args = test_args(); args.family = Some("gameobjects".to_string()); - let plan = build_dump_plan(dump, &args, &None, &None).unwrap(); - - assert!(plan.stmts.iter().any(|statement| { - statement.starts_with("INSERT INTO game_gameobject_trap (entry,spell_id,cooldown_secs)") - && statement.contains("(180391,24425,0)") - })); - assert!(plan.stmts.iter().any(|statement| { - statement.starts_with("INSERT INTO game_gameobject_template") - && statement.contains("(180391,6,640,'Fel Rune'") - })); - assert_eq!(plan.stamps, vec![("gameobjects", 2)]); + let plan = build_dump_plan(&dump, &args, &None, &None).unwrap(); + assert_eq!(plan.eventai_relay_definition_count, 1); + assert!(plan.go_batches.is_empty()); + assert!(plan.stmts.iter().any(|sql| sql + .starts_with("INSERT INTO game_gameobject_template") + && sql.contains("(777,6,640,'Relay trap'"))); + assert!(plan + .stmts + .iter() + .any(|sql| sql.starts_with("INSERT INTO game_gameobject_trap") + && sql.contains("(777,24425,0)"))); } #[test] @@ -8070,7 +7947,11 @@ mod tests { #[test] fn canonical_profiles_share_supported_quest_definitions() { let mut catalogue = None; - for profile in WorldImportProfile::NAMES { + for profile in WorldImportProfile::NAMES.iter().filter(|name| { + !WorldImportProfile::parse(name) + .unwrap() + .imports_all_templates() + }) { let plan = quest_catalogue_plan(profile); let templates = plan .stmts @@ -8150,7 +8031,10 @@ mod tests { for role in [0, 1] { assert_eq!( relations.contains(&format!(",{giver},{quest},{role})")), - *profile == owner, + *profile == owner + || *profile == "full-world" + || (*profile == "full-eastern" && giver == 600) + || (*profile == "full-kalimdor" && giver == 700), "{profile} Horde giver {giver}: {relations}" ); } @@ -8160,20 +8044,31 @@ mod tests { relations.contains(&format!(",100,500,{role})")), matches!( *profile, - "alliance-eastern" | "starting-eastern" | "alliance-single" + "alliance-eastern" + | "starting-eastern" + | "alliance-single" + | "full-world" + | "full-eastern" ), "{profile} Eastern giver role {role}" ); assert_eq!( relations.contains(&format!(",400,502,{role})")), - matches!(*profile, "instances" | "alliance-single"), + matches!( + *profile, + "instances" | "alliance-single" | "full-world" | "full-instances" + ), "{profile} Instance giver role {role}" ); assert_eq!( go_relations.contains(&format!(",300,501,{role})")), matches!( *profile, - "alliance-kalimdor" | "starting-kalimdor" | "alliance-single" + "alliance-kalimdor" + | "starting-kalimdor" + | "alliance-single" + | "full-world" + | "full-kalimdor" ), "{profile} Kalimdor giver role {role}" ); @@ -8181,6 +8076,102 @@ mod tests { } } + #[test] + fn full_profiles_share_the_complete_quest_catalogue() { + let mut expected = None; + for profile in [ + "full-world", + "full-eastern", + "full-kalimdor", + "full-instances", + ] { + let plan = quest_catalogue_plan(profile); + let shared: Vec<_> = plan + .stmts + .iter() + .filter(|sql| { + !sql.contains("game_creature_quest") && !sql.contains("game_gameobject_quest") + }) + .collect(); + assert!( + shared + .iter() + .any(|sql| sql.starts_with("INSERT INTO game_quest_template") + && sql.contains("(503,")), + "{profile} excludes an outlying quest" + ); + let shared: Vec = shared.into_iter().cloned().collect(); + if let Some(expected) = &expected { + assert_eq!(&shared, expected, "{profile}"); + } else { + expected = Some(shared); + } + } + } + + #[test] + fn full_profiles_import_unplaced_definitions_without_foreign_spawns() { + let dump = format!( + "INSERT INTO `creature_template` VALUES {},{},{}; \ + INSERT INTO `creature` VALUES \ + (1,100,0,1,-9000,0,80,0,300,300,0,0),\ + (2,200,1,1,10300,800,1326,0,300,300,0,0);", + creature_template_row(100, 0), + creature_template_row(200, 0), + creature_template_row(300, 0), + ); + let dump = format!("{dump} INSERT INTO `creature_ai_summons` VALUES (700,1,2,3,4,5); INSERT INTO `creature_ai_scripts` VALUES {},{},{};", + eventai_rule_row(10,100,0,0,100,0,[0,1,0,1,0,0],[[32,300,0,700],[0;4],[0;4]]), + eventai_rule_row(20,200,0,0,100,0,[0,1,0,1,0,0],[[22,1,0,0],[0;4],[0;4]]), + eventai_rule_row(30,300,0,0,100,0,[0,1,0,1,0,0],[[22,1,0,0],[0;4],[0;4]])); + for (profile, expected_spawns) in [ + ("full-world", 2), + ("full-eastern", 1), + ("full-kalimdor", 1), + ("full-instances", 0), + ] { + let args = parse_args_from([ + "--dump", + "synthetic.sql", + "--family", + "creatures", + "--world-profile", + profile, + ]) + .unwrap(); + let plan = build_dump_plan(&dump, &args, &None, &None).unwrap(); + assert_eq!(plan.spawn_row_count, expected_spawns, "{profile}"); + let ai = plan.eventai_definition_batches.join("\n"); + assert_eq!( + ai.contains("entry:100@"), + matches!(profile, "full-world" | "full-eastern") + ); + assert_eq!( + ai.contains("entry:200@"), + matches!(profile, "full-world" | "full-kalimdor") + ); + assert_eq!( + ai.contains("entry:300@"), + matches!(profile, "full-world" | "full-eastern"), + "summoned creature AI must follow its summoner" + ); + + let templates = plan + .stmts + .iter() + .filter(|sql| sql.starts_with("INSERT INTO game_creature_template")) + .cloned() + .collect::>() + .join(";"); + for entry in [100, 200, 300] { + assert!( + templates.contains(&format!("({entry},")), + "{profile} template {entry}" + ); + } + } + } + #[test] fn legacy_imports_keep_the_selected_quest_scope() { let mut args = test_args(); @@ -8756,6 +8747,118 @@ mod tests { } } + #[test] + #[ignore = "requires pinned ClassicDB, owned client data, and local SpacetimeDB 2.7.1"] + fn full_world_source_loads_every_definition_and_spawn_locally() { + let path = std::env::var("LYRACORE_CLASSIC_DB_SQL").unwrap(); + let client = std::env::var("LYRACORE_TEST_DBC").unwrap(); + let dump = read_dump(&path).unwrap(); + let args = parse_args_from([ + "--dump", + path.as_str(), + "--dbc", + client.as_str(), + "--world-profile", + "full-world", + ]) + .unwrap(); + let display_scales = Some(dbc::load_display_scales(&client).unwrap()); + let tiers = Some(dbc::profession_tier_values(&client).unwrap()); + let plan = build_dump_plan(&dump, &args, &display_scales, &tiers).unwrap(); + let manifest: serde_json::Value = + serde_json::from_str(plan.eventai_manifest.as_deref().unwrap()).unwrap(); + assert_eq!(manifest["apply_ready"], true, "{manifest:#}"); + assert_eq!(manifest["counts"]["dropped_rules"], 0); + assert_eq!(manifest["counts"]["unapproved_result_groups"], 0); + assert_eq!(plan.spawn_row_count, 66_310); + assert_eq!( + plan.stamps + .iter() + .find(|(family, _)| *family == "creatures") + .unwrap() + .1, + 10_384 + ); + let mut node = standalone_support::Standalone::start("full-world-source"); + node.publish_module(); + node.assert_call("claim_operator", &[]); + node.assert_call("install_guid_range", &["0"]); + for statement in dbc::catalogue_statements(&client, &dump).unwrap() { + node.assert_sql(&statement); + } + for statement in spell::build_spell_sql(std::path::Path::new(&client), &[], &[]) + .unwrap() + .0 + { + node.assert_sql(&statement); + } + for (index, statement) in plan.stmts.iter().enumerate() { + node.assert_sql(statement); + if index % 100 == 0 { + eprintln!( + "full-world static catalogue: {index}/{} statements", + plan.stmts.len() + ); + } + } + for (index, (reducer, batch)) in plan.reducer_batches(&args).unwrap().iter().enumerate() { + if index % 50 == 0 { + eprintln!("full-world reducer batch {index}: {reducer}"); + } + node.assert_call(reducer, &[&serde_json::to_string(batch).unwrap()]); + } + assert_eq!( + node.query_rows("SELECT entry FROM game_creature_template") + .len(), + 10_384 + ); + let sources = node.query_rows("SELECT guid, map_id FROM game_creature_spawn WHERE source_guid = 0 AND instance_id = 0"); + assert_eq!(sources.len(), 66_310); + let maps: std::collections::BTreeSet<_> = sources + .iter() + .map(|row| row["map_id"].parse::().unwrap()) + .collect(); + assert_eq!(maps.len(), 34); + for map in lyracore_shared::instance::DUNGEON_MAPS + .iter() + .chain(lyracore_shared::instance::BATTLEGROUND_MAPS) + { + assert!(node + .query_rows(&format!( + "SELECT guid FROM game_world_entity WHERE map_id = {map} AND instance_id = 0" + )) + .is_empty()); + } + assert!( + standalone_support::poll_until(std::time::Duration::from_secs(180), || { + std::thread::sleep(std::time::Duration::from_secs(2)); + let live = node + .query_rows("SELECT guid FROM game_world_entity WHERE instance_id = 0") + .len(); + eprintln!("full-world live population: {live}"); + live > 45_000 + }), + "shared-world population did not materialize within three minutes" + ); + eprintln!("full-world reimport: resetting the populated roster"); + for (index, batch) in plan.spawn_batches.iter().enumerate() { + let reducer = if index == 0 { + "import_creature_spawns" + } else { + "import_creature_spawns_append" + }; + node.assert_call(reducer, &[&serde_json::to_string(batch).unwrap()]); + } + assert_eq!( + node.query_rows( + "SELECT guid FROM game_creature_spawn WHERE source_guid = 0 AND instance_id = 0" + ) + .len(), + 66_310 + ); + eprintln!("full-world source verified: 10,384 templates, 66,310 positions, 34 maps"); + } + #[test] #[ignore = "requires pinned LYRACORE_CLASSIC_DB_SQL and build 5875 LYRACORE_TEST_DBC"] fn pinned_items_plan_counts_templates_and_property_catalogues() { @@ -9580,10 +9683,11 @@ mod tests { assert_eq!(plan.stamps, vec![("creature-ai", 8)]); assert_eq!(plan.eventai_definition_count, 3); assert_eq!(plan.eventai_instruction_count, 17); - assert_eq!(plan.stmts.len(), 5, "{:?}", plan.stmts); + assert_eq!(plan.stmts.len(), 6, "{:?}", plan.stmts); assert_eq!( - &plan.stmts[..3], + &plan.stmts[..4], [ + "DELETE FROM game_world_state_name WHERE variable_id >= 0", "DELETE FROM game_creature_ai_broadcast_text WHERE id > 0", "DELETE FROM game_creature_ai_summon WHERE id > 0", "DELETE FROM game_quest_event_requirement WHERE id > 0", @@ -9591,7 +9695,8 @@ mod tests { "the family replaces its dependency catalogues" ); assert!(plan.stmts.iter().all(|statement| { - statement.contains("game_creature_ai_broadcast_text") + statement.contains("game_world_state_name") + || statement.contains("game_creature_ai_broadcast_text") || statement.contains("game_creature_ai_summon") || statement.contains("game_quest_event_requirement") })); @@ -9706,16 +9811,13 @@ mod tests { } #[test] - fn a_not_selectable_template_imports_neither_its_template_row_nor_its_eventai_rules() { - // Template eligibility is one predicate. If the EventAI scope and the template block each - // kept their own copy, an excluded trigger creature would still get EventAI rules naming a - // template that never imported. + fn nonselectable_creatures_keep_their_spawns_flags_and_eventai_rules() { let dump = format!( "x INSERT INTO `creature` VALUES (1,100,0,1,-8949.95,-132.493,83.5312,0,300,300,0,0); \ INSERT INTO `creature_template` VALUES {}; \ INSERT INTO `broadcast_text` VALUES {}; \ INSERT INTO `creature_ai_scripts` VALUES {}; y", - creature_template_row_with_unit_flags(100, UNIT_FLAG_NOT_SELECTABLE), + creature_template_row_with_unit_flags(100, 0x0200_0000), eventai_broadcast_row(900, "Trigger", "Trigger"), eventai_rule_row( 10, @@ -9730,17 +9832,18 @@ mod tests { ); let plan = build_dump_plan(&dump, &test_args(), &None, &None).expect("full plan"); assert!( - !plan - .stmts + plan.stmts .iter() .any(|statement| statement.starts_with("INSERT INTO game_creature_template")), "{:?}", plan.stmts ); + assert_eq!(plan.spawn_row_count, 1); + assert!(plan.stmts.iter().any(|sql| sql.contains(",33554432,"))); assert!( plan.eventai_definition_batches .iter() - .all(|batch| !batch.contains("entry:100@")), + .any(|batch| batch.contains("entry:100@")), "{:?}", plan.eventai_definition_batches ); @@ -9951,7 +10054,6 @@ mod tests { ("missing_summon_creature", 999), ("missing_summon_location", 999), ("malformed_rule", 1), - ("empty_text", 1), ] { assert_eq!(plan.dropped(reason, value), 1, "{reason}/{value}"); } diff --git a/importer/src/spell.rs b/importer/src/spell.rs index bfd6ca361..9712293e1 100644 --- a/importer/src/spell.rs +++ b/importer/src/spell.rs @@ -885,7 +885,7 @@ fn supported_effect_default(effect_id: i32, kind: u8, is_negative: bool) -> u8 { /// Coverage accounting accumulated across the whole import — drives the COVERAGE REPORT. #[derive(Default)] -struct Coverage { +pub(crate) struct Coverage { spells: usize, effects: usize, real: usize, // effects mapped to a real (non-E_SCRIPTED) kind @@ -1771,7 +1771,7 @@ fn assemble_spell_sql( } /// The (clear+)reload SQL plus the dry-run diagnostics: the shape every existing caller expects. -fn build_spell_sql( +pub(crate) fn build_spell_sql( data_dir: &Path, only: &[u32], trainers: &[(u32, Vec)], diff --git a/importer/src/world_import_scope.rs b/importer/src/world_import_scope.rs index f04887c21..f551460dc 100644 --- a/importer/src/world_import_scope.rs +++ b/importer/src/world_import_scope.rs @@ -2,6 +2,10 @@ use anyhow::{bail, Context, Result}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum WorldImportProfile { + FullWorld, + FullEastern, + FullKalimdor, + FullInstances, AllianceEastern, AllianceKalimdor, AllianceSingle, @@ -12,6 +16,10 @@ pub(crate) enum WorldImportProfile { impl WorldImportProfile { pub(crate) const NAMES: &[&str] = &[ + "full-world", + "full-eastern", + "full-kalimdor", + "full-instances", "alliance-eastern", "alliance-kalimdor", "alliance-single", @@ -22,6 +30,10 @@ impl WorldImportProfile { pub(crate) fn parse(name: &str) -> Result { match name { + "full-world" => Ok(Self::FullWorld), + "full-eastern" => Ok(Self::FullEastern), + "full-kalimdor" => Ok(Self::FullKalimdor), + "full-instances" => Ok(Self::FullInstances), "alliance-eastern" => Ok(Self::AllianceEastern), "alliance-kalimdor" => Ok(Self::AllianceKalimdor), "alliance-single" => Ok(Self::AllianceSingle), @@ -37,6 +49,10 @@ impl WorldImportProfile { pub(crate) fn name(self) -> &'static str { match self { + Self::FullWorld => "full-world", + Self::FullEastern => "full-eastern", + Self::FullKalimdor => "full-kalimdor", + Self::FullInstances => "full-instances", Self::AllianceEastern => "alliance-eastern", Self::AllianceKalimdor => "alliance-kalimdor", Self::AllianceSingle => "alliance-single", @@ -45,11 +61,19 @@ impl WorldImportProfile { Self::Instances => "instances", } } + + pub(crate) fn imports_all_templates(self) -> bool { + matches!( + self, + Self::FullWorld | Self::FullEastern | Self::FullKalimdor | Self::FullInstances + ) + } } #[derive(Clone, Debug)] pub(crate) struct WorldImportScope { name: String, + all_maps: bool, pub(crate) bounded_slices: Vec, pub(crate) whole_maps: Vec, pub(crate) instance_vmap_slices: Vec, @@ -182,6 +206,12 @@ impl WorldImportScope { }; let (bounded_slices, whole_maps, instance_vmap_slices, forced_creature_entries) = match profile { + WorldImportProfile::FullWorld + | WorldImportProfile::FullEastern + | WorldImportProfile::FullKalimdor + | WorldImportProfile::FullInstances => { + return Self::full(profile); + } WorldImportProfile::AllianceEastern => { (eastern()?, vec![369], vec![], eastern_forced_creatures()) } @@ -233,6 +263,56 @@ impl WorldImportScope { ) } + fn full(profile: WorldImportProfile) -> Result { + // The client continent grid is 64 tiles of 533 1/3 yards, centred on zero. + // Stay inside its outer edge so inclusive cell ranges remain in 0..1024. + let bounds = (-17_066.66, 17_066.66, -17_066.66, 17_066.66); + let mut slices = Vec::new(); + if matches!( + profile, + WorldImportProfile::FullWorld | WorldImportProfile::FullEastern + ) { + slices.push(BoundedMapSlice::rectangle( + "eastern-kingdoms", + 0, + bounds, + (-8_949.95, -132.493, 83.5312), + )?); + } + if matches!( + profile, + WorldImportProfile::FullWorld | WorldImportProfile::FullKalimdor + ) { + slices.push(BoundedMapSlice::rectangle( + "kalimdor", + 1, + bounds, + (-514.34, -4_344.96, 38.31), + )?); + } + let maps = match profile { + WorldImportProfile::FullEastern => vec![369, 449], + WorldImportProfile::FullKalimdor => vec![450], + _ => lyracore_shared::instance::DUNGEON_MAPS + .iter() + .copied() + .chain(lyracore_shared::instance::BATTLEGROUND_MAPS.iter().copied()) + .map(i64::from) + .collect(), + }; + let instance_slices = if matches!( + profile, + WorldImportProfile::FullWorld | WorldImportProfile::FullInstances + ) { + Self::canonical(WorldImportProfile::Instances)?.instance_vmap_slices + } else { + Vec::new() + }; + let mut scope = Self::new(profile.name(), slices, maps, instance_slices, vec![])?; + scope.all_maps = profile == WorldImportProfile::FullWorld; + Ok(scope) + } + #[allow(clippy::too_many_arguments)] pub(crate) fn legacy( map_id: i64, @@ -296,6 +376,7 @@ impl WorldImportScope { } Ok(Self { name: name.into(), + all_maps: false, bounded_slices, whole_maps, instance_vmap_slices, @@ -314,7 +395,8 @@ impl WorldImportScope { } pub(crate) fn contains(&self, map_id: i64, x: f64, y: f64, z: f64) -> bool { - self.whole_maps.contains(&map_id) + (self.all_maps && map_id >= 0) + || self.whole_maps.contains(&map_id) || self .bounded_slices .iter() @@ -322,7 +404,8 @@ impl WorldImportScope { } pub(crate) fn contains_map(&self, map_id: i64) -> bool { - self.whole_maps.contains(&map_id) + (self.all_maps && map_id >= 0) + || self.whole_maps.contains(&map_id) || self .bounded_slices .iter() @@ -584,6 +667,34 @@ mod tests { assert!(instances.forced_creature_entries.is_empty()); } + #[test] + fn full_profiles_partition_spawns_and_share_all_definitions() { + let all = WorldImportScope::canonical(WorldImportProfile::FullWorld).unwrap(); + assert!(all.contains(999, 50_000.0, 50_000.0, 0.0)); + assert!(!all.contains(-1, 0.0, 0.0, 0.0)); + let eastern = WorldImportScope::canonical(WorldImportProfile::FullEastern).unwrap(); + let kalimdor = WorldImportScope::canonical(WorldImportProfile::FullKalimdor).unwrap(); + let instances = WorldImportScope::canonical(WorldImportProfile::FullInstances).unwrap(); + for map in [0, 1, 369, 449, 450] + .into_iter() + .chain(lyracore_shared::instance::DUNGEON_MAPS.iter().copied()) + .chain(lyracore_shared::instance::BATTLEGROUND_MAPS.iter().copied()) + { + let owners = [&eastern, &kalimdor, &instances] + .iter() + .filter(|scope| scope.contains(i64::from(map), 0.0, 0.0, 0.0)) + .count(); + assert_eq!(owners, 1, "map {map}"); + } + for scope in [&all, &eastern, &kalimdor] { + for slice in &scope.bounded_slices { + let (x0, x1, y0, y1) = + crate::terrain::slice_cell_range(Some(slice.bounds), slice.sample, 0.0); + assert_eq!((x0, x1, y0, y1), (0, 1023, 0, 1023)); + } + } + } + #[test] fn starting_profiles_preserve_alliance_content_and_add_horde_starts() { for (base, expanded, additions) in [ diff --git a/module/src/chat.rs b/module/src/chat.rs index d38753889..1d5cfa18e 100644 --- a/module/src/chat.rs +++ b/module/src/chat.rs @@ -2,12 +2,14 @@ //! player's `CMSG_MESSAGECHAT` or `CMSG_TEXT_EMOTE` becomes a broadcast event row that the Gateway //! turns into `SMSG_MESSAGECHAT` / `SMSG_TEXT_EMOTE` (+ `SMSG_EMOTE` animation). Say, yell and `/e` //! rows carry no range: the Gateway scopes them to the speaker's surroundings when it relays them. -//! Whisper, party and channel chat are not here. They are Realm Chat Lines (`crate::realm_chat`), -//! committed on Realm-core with their whole audience. Chat Channels themselves live in +//! Character whispers, party and channel chat are Realm Chat Lines (`crate::realm_chat`), +//! committed on Realm-core with their whole audience. Creature whispers stay on the World Shard. +//! Chat Channels themselves live in //! `crate::channel`, and Away Status in `crate::away`. [event] use spacetimedb::{table, Identity, ReducerContext, Table, Timestamp}; +use crate::game_world_entity; use lyracore_shared::social::ContactRefusal; // `game_character_contact` is defined further down in THIS module — its `#[table]` accessor @@ -51,7 +53,19 @@ pub struct ChatEvent { } pub(crate) fn is_supported_chat_type(chat_type: u8) -> bool { - matches!(chat_type, CHAT_SAY | CHAT_YELL | CHAT_TEXT_EMOTE) + use lyracore_shared::chat::broadcast_chat::{ + CREATURE_BOSS_EMOTE, CREATURE_WHISPER, CREATURE_ZONE_EMOTE, CREATURE_ZONE_YELL, + }; + matches!( + chat_type, + CHAT_SAY + | CHAT_YELL + | CHAT_TEXT_EMOTE + | CREATURE_BOSS_EMOTE + | CREATURE_WHISPER + | CREATURE_ZONE_YELL + | CREATURE_ZONE_EMOTE + ) } /// Trim + length-cap a chat line, returning `None` when nothing is left (so an empty/whitespace line @@ -122,6 +136,16 @@ pub(crate) fn apply_send_chat_to( if !is_supported_chat_type(chat_type) { return Err(format!("unsupported chat type {chat_type}")); } + if chat_type == lyracore_shared::chat::broadcast_chat::CREATURE_WHISPER + && !ctx + .db + .game_world_entity() + .guid() + .find(target_guid) + .is_some_and(|target| target.is_player()) + { + return Err("creature whisper requires a Character recipient".to_string()); + } write_chat_event(ctx, sender, target_guid, chat_type, language, message) } diff --git a/module/src/combat/engage.rs b/module/src/combat/engage.rs index 46da5a6d3..34c011ab3 100644 --- a/module/src/combat/engage.rs +++ b/module/src/combat/engage.rs @@ -146,6 +146,14 @@ pub(crate) fn disengage(ctx: &ReducerContext, guid: u64) { // Threat is part of being in combat: leaving it clears `guid` from the threat tables — its OWN table // (a creature that evaded/fled/died forgets everyone) AND its entries on every other creature's table // (a player who died/logged out is forgotten by every mob). One symmetric clear, the canonical place. + use crate::game_threat; + let threat_members = ctx + .db + .game_threat() + .by_creature() + .filter(&guid) + .map(|entry| entry.source_guid) + .collect::>(); crate::threat::clear_for_unit(ctx, guid); clear_target(ctx, guid); // Drop the stale target on every creature that was attacking `guid`. `clear_target` skips players and @@ -158,6 +166,7 @@ pub(crate) fn disengage(ctx: &ReducerContext, guid: u64) { // vanilla drops the player's combat the moment the mob evades, not ~COMBAT_DROP_MS later (the // decay window still covers DoT/spell-only combat via the cycle's combat exit, unchanged). let mut freed = attackers_of_guid; + freed.extend(threat_members); freed.push(guid); let entities = ctx.db.game_world_entity(); for g in freed { @@ -240,7 +249,15 @@ pub(crate) fn break_own_attacks(ctx: &ReducerContext, guid: u64) { /// Is `guid` in any melee engagement, attacking or attacked? pub(crate) fn is_engaged(ctx: &ReducerContext, guid: u64) -> bool { let melee = ctx.db.game_melee_attack(); - melee.attacker_guid().find(guid).is_some() || melee.by_target().filter(&guid).next().is_some() + use crate::game_threat; + melee.attacker_guid().find(guid).is_some() + || melee.by_target().filter(&guid).next().is_some() + || ctx + .db + .game_threat() + .by_source() + .filter(&guid) + .any(|entry| melee.attacker_guid().find(entry.creature_guid).is_some()) } fn may_harm_decision(same_unit: bool, active_duel: bool, friendly: bool) -> bool { diff --git a/module/src/combat/swing.rs b/module/src/combat/swing.rs index a93ae4f2e..5a3c13c8b 100644 --- a/module/src/combat/swing.rs +++ b/module/src/combat/swing.rs @@ -324,6 +324,9 @@ fn resolve_swing(ctx: &ReducerContext) { // un-CC'd swing reaches the identical range/timer/roll path it did before — byte-for-byte. A // POLYMORPHED or FEARED attacker is action-blocked too, so it can't swing either (a feared unit // routs without retaliating). + if !attacker.is_player() && !crate::creatures::melee_enabled(ctx, attacker.guid) { + continue; + } if crate::spell::is_action_blocked(ctx, atk.attacker_guid) { continue; } diff --git a/module/src/creatures/ai.rs b/module/src/creatures/ai.rs index 4a8222285..fdaa883ae 100644 --- a/module/src/creatures/ai.rs +++ b/module/src/creatures/ai.rs @@ -439,9 +439,8 @@ pub(crate) const MOVE_TICK_SECS: f32 = 0.5; /// divisor must use this so they stay in lockstep with `MOVE_TICK_SECS` (0.5s = 500_000us). pub(crate) const MOVE_TICK_MICROS: i64 = 500_000; -/// The expensive O(N) "sensing" passes (aggro/cast/threat/regen/decay/respawn/wander) run once every -/// Nth movement tick, so their per-tick scans + tick-quantized effects (HP-regen amount, wander chance, -/// respawn timers) keep their original ~4s cadence while movement runs every 0.5s. 8 × 0.5s = 4s. +/// Aggro, casts, threat, regen and wandering keep a four-second sense cadence. +/// Movement and due-time population maintenance run every half-second catch-all firing. pub(crate) const SENSE_EVERY_N_TICKS: i64 = 8; /// The target SENSE period in micros (~4s) — `MOVE_TICK_MICROS × SENSE_EVERY_N_TICKS`. A tick row of diff --git a/module/src/creatures/cycle/ctx.rs b/module/src/creatures/cycle/ctx.rs index b16dbcc3a..562028a97 100644 --- a/module/src/creatures/cycle/ctx.rs +++ b/module/src/creatures/cycle/ctx.rs @@ -601,7 +601,8 @@ impl EngageSink for CtxWorld<'_> { } fn engagements(&self) -> Vec { let entities = self.ctx.db.game_world_entity(); - self.ctx + let mut engagements: Vec = self + .ctx .db .game_melee_attack() .iter() @@ -621,7 +622,25 @@ impl EngageSink for CtxWorld<'_> { && attacker.is_some_and(|e| e.is_player()), }) }) - .collect() + .collect(); + use crate::game_threat; + let threats = self.ctx.db.game_threat(); + let members = engagements + .iter() + .flat_map(|engagement| { + threats + .by_creature() + .filter(&engagement.attacker) + .map(|threat| Engagement { + attacker: engagement.attacker, + victim: threat.source_guid, + instance_id: engagement.instance_id, + player_never_swung: false, + }) + }) + .collect::>(); + engagements.extend(members); + engagements } fn enter_combat(&mut self, guid: u64) { crate::combat::enter_combat(self.ctx, guid); diff --git a/module/src/creatures/cycle/harness.rs b/module/src/creatures/cycle/harness.rs index b7c3815e6..1d31fbaff 100644 --- a/module/src/creatures/cycle/harness.rs +++ b/module/src/creatures/cycle/harness.rs @@ -288,6 +288,8 @@ struct Scenario { eventai_forced_despawns: RefCell>, eventai_ai_events: RefCell>, eventai_stand_states: RefCell>, + eventai_auto_attacks: RefCell>, + eventai_sheaths: RefCell>, eventai_react_states: RefCell>, eventai_encounter_notifications: RefCell>, eventai_relay_starts: RefCell>, @@ -2081,7 +2083,14 @@ impl EventAiWorld for Scenario { } fn eventai_engage_assist(&mut self, creature_guid: u64, victim_guid: u64) { - EngageSink::engage(self, creature_guid, victim_guid, Pull::Assisted); + if !self + .fights + .borrow() + .iter() + .any(|fight| fight.attacker == creature_guid) + { + EngageSink::engage(self, creature_guid, victim_guid, Pull::Assisted); + } } fn eventai_claim_summon_sequence(&mut self, _lifetime_ms: u32) -> u64 { @@ -2217,6 +2226,48 @@ impl EventAiWorld for Scenario { true } + fn eventai_set_auto_attack(&mut self, creature_guid: u64, enabled: bool) -> bool { + if self.eventai_unit(creature_guid).is_none() { + return false; + } + self.eventai_auto_attacks + .borrow_mut() + .insert(creature_guid, enabled); + true + } + fn eventai_set_sheath(&mut self, creature_guid: u64, state: u8) -> bool { + if self.eventai_unit(creature_guid).is_none() { + return false; + } + self.eventai_sheaths + .borrow_mut() + .insert(creature_guid, state); + true + } + fn eventai_combat_candidates(&self, creature_guid: u64) -> Vec { + let Some(creature) = self.eventai_unit(creature_guid) else { + return Vec::new(); + }; + self.eventai_units_near(&creature, f32::MAX) + .into_iter() + .filter(|target| { + target.is_player + && !target.dead + && target.map_id == creature.map_id + && target.instance_id == creature.instance_id + && self.eventai_factions_hostile( + creature.faction_template, + target.faction_template, + ) + }) + .collect() + } + fn eventai_engage_without_threat(&mut self, creature_guid: u64, target_guid: u64) { + self.threat + .borrow_mut() + .entry((creature_guid, target_guid)) + .or_insert(0); + } fn eventai_set_stand_state(&mut self, creature_guid: u64, stand_state: u8) -> bool { if self.eventai_unit(creature_guid).is_none() { return false; @@ -2646,7 +2697,8 @@ impl EngageSink for Scenario { /// row, so a scenario that puts a creature in an instance puts its fight there too. fn engagements(&self) -> Vec { let creatures = self.creatures.borrow(); - self.fights + let mut engagements: Vec = self + .fights .borrow() .iter() .map(|f| Engagement { @@ -2655,7 +2707,23 @@ impl EngageSink for Scenario { .map_or(f.instance_id, |c| c.instance_id), ..*f }) - .collect() + .collect(); + let threats = self.threat.borrow(); + let members = engagements + .iter() + .flat_map(|engagement| { + threats + .keys() + .filter(|(creature, _)| *creature == engagement.attacker) + .map(|(_, target)| Engagement { + victim: *target, + player_never_swung: false, + ..*engagement + }) + }) + .collect::>(); + engagements.extend(members); + engagements } fn enter_combat(&mut self, guid: u64) { self.flagged.borrow_mut().push(guid); @@ -3829,10 +3897,10 @@ fn a_leg_whose_mover_despawned_is_reaped() { } #[test] -fn world_maintenance_and_package_passes_run_only_on_the_catch_all_sense_firing() { +fn world_maintenance_and_package_passes_run_on_every_catch_all_firing() { for (sense, scope, maintenance, packages) in [ (true, catch_all(), 1, 1), - (false, catch_all(), 0, 1), + (false, catch_all(), 1, 1), (true, TickScope::Only(7), 0, 0), ] { let mut w = wolf_mid_flight(HALF_WAY); @@ -5847,18 +5915,22 @@ fn movement_runs_every_firing_while_sensing_holds_the_four_second_cadence() { 60_000, ); let mut moved = 0u64; + let mut sensed = 0u64; for k in 0..firings { let tick = w.firing(k * interval_micros as u64, interval_micros, catch_all()); let outcome = run_cycle(&mut w, tick); moved += outcome.rows_visited[0].1; + sensed += u64::from( + outcome + .rows_visited + .iter() + .any(|(pass, _)| *pass == "aggro"), + ); } assert_eq!( - (moved, w.maintenance_runs.get()), - (firings, 2), - "a tighter row must smooth movement latency WITHOUT multiplying the sensing scans \ - or the effects quantized to them — regen amount, wander chance and respawn timers \ - all assume the ~4s sensing cadence, whatever the row's own interval \ - ({interval_micros}us row)" + (moved, u64::from(w.maintenance_runs.get()), sensed), + (firings, firings, 2), + "movement and due-time maintenance follow each firing; sensing stays at four seconds" ); settled_at.push(w.at(WOLF).at); } diff --git a/module/src/creatures/cycle/harness/eventai_profile.rs b/module/src/creatures/cycle/harness/eventai_profile.rs index c01856bf0..3ad3a795f 100644 --- a/module/src/creatures/cycle/harness/eventai_profile.rs +++ b/module/src/creatures/cycle/harness/eventai_profile.rs @@ -341,3 +341,108 @@ fn random_home_and_random_current_keep_distinct_anchors() { ); assert_eq!(IdleSink::wander_radius(&scenario, CREATURE), 7.0); } + +#[test] +fn an_empty_text_action_does_not_cancel_sibling_actions() { + let mut scenario = world().eventai_native_definition(definition( + EventAiSubject::Entry(ENTRY), + 700, + InstructionSelection::All, + vec![ + CreatureInstruction::EmptyTextNoEffect, + CreatureInstruction::SetPhase(SetPhaseInstruction { phase: 3 }), + ], + )); + dispatch(&mut scenario, CREATURE); + assert_eq!(scenario.eventai_creature_state(CREATURE).phase, 3); +} + +#[test] +fn archer_actions_disable_melee_and_draw_the_ranged_weapon() { + let mut scenario = world().eventai_native_definition(definition( + EventAiSubject::Entry(ENTRY), + 701, + InstructionSelection::All, + vec![ + CreatureInstruction::SetAutoAttack(false), + CreatureInstruction::SetSheath(2), + ], + )); + dispatch(&mut scenario, CREATURE); + assert_eq!( + scenario.eventai_auto_attacks.borrow().get(&CREATURE), + Some(&false) + ); + assert_eq!(scenario.eventai_sheaths.borrow().get(&CREATURE), Some(&2)); +} + +#[test] +fn a_zone_combat_pulse_keeps_distant_instance_members_in_combat() { + let far = TARGET + 1; + let other_instance = TARGET + 2; + let mut scenario = world() + .tweak(CREATURE, |unit| { + unit.map_id = 36; + unit.instance_id = 91; + }) + .tweak_player(TARGET, |unit| { + unit.map_id = 36; + unit.instance_id = 91; + }) + .player(far, point(2_000.0)) + .tweak_player(far, |unit| { + unit.map_id = 36; + unit.instance_id = 91; + }) + .player(other_instance, point(1.0)) + .tweak_player(other_instance, |unit| { + unit.map_id = 36; + unit.instance_id = 92; + }) + .eventai_native_definition(definition( + EventAiSubject::Entry(ENTRY), + 702, + InstructionSelection::All, + vec![CreatureInstruction::ZoneCombat], + )); + EngageSink::engage(&mut scenario, CREATURE, TARGET, Pull::Noticed); + assert_eq!(scenario.threat.borrow().get(&(CREATURE, far)), Some(&0)); + assert!(!scenario + .threat + .borrow() + .contains_key(&(CREATURE, other_instance))); + scenario.now_micros.set(20_000_000); + let scope = TickScope::CatchAll { + dedicated: HashSet::new(), + }; + super::super::combat_entry(&mut scenario, &scope); + assert_eq!(scenario.combat_flags.borrow().get(&far), Some(&26_000)); +} + +#[test] +fn creature_whispers_require_and_keep_a_character_recipient() { + let mode = lyracore_shared::chat::broadcast_chat::CREATURE_WHISPER; + for (target, delivered) in [ + (InstructionTarget::CurrentOpponent, true), + (InstructionTarget::SelfActor, false), + ] { + let mut scenario = world() + .eventai_broadcast(11929, "Use the console", mode) + .eventai_native_definition(definition( + EventAiSubject::Entry(ENTRY), + 703, + InstructionSelection::All, + vec![CreatureInstruction::Speak(SpeakInstruction { + mode: SpeechMode::Whisper, + broadcast_ids: vec![11929], + legacy_text: String::new(), + target, + })], + )); + dispatch(&mut scenario, CREATURE); + assert_eq!(!scenario.eventai_speech().is_empty(), delivered); + if delivered { + assert_eq!(scenario.eventai_speech_targets(), vec![(CREATURE, TARGET)]); + } + } +} diff --git a/module/src/creatures/cycle/mod.rs b/module/src/creatures/cycle/mod.rs index 025161248..e8a871d12 100644 --- a/module/src/creatures/cycle/mod.rs +++ b/module/src/creatures/cycle/mod.rs @@ -777,10 +777,10 @@ pub(crate) fn run_cycle(w: &mut W, tick: TickContext) -> Cycle rows.push(("advance", advance_legs(w, &tick) as u64)); rows.push(("patrol", patrol(w, &tick, &active) as u64)); + if global { + rows.extend(w.run_due_world_maintenance()); + } if tick.sense { - if global { - rows.extend(w.run_due_world_maintenance()); - } let (pulls, seen) = aggro(w, &active); rows.push(("aggro", seen as u64)); rows.push(("assist", assist(w, &active, pulls) as u64)); diff --git a/module/src/creatures/eventai/combat.rs b/module/src/creatures/eventai/combat.rs index a5a7faca4..8cd7e127a 100644 --- a/module/src/creatures/eventai/combat.rs +++ b/module/src/creatures/eventai/combat.rs @@ -15,7 +15,7 @@ use super::{ SpellCastTarget, SpellCasterAdmission, SpellCasterRole, SpellStartMode, SpellTargetRole, }; use crate::creatures::ai::{rout_close_ms, rout_window_open, TickScope}; -use crate::{game_creature_ai_definition, game_world_entity}; +use crate::{game_creature_ai_definition, game_creature_spawn, game_world_entity}; pub(super) fn cycle_contexts( world: &W, @@ -627,11 +627,27 @@ pub(super) fn definition_for(ctx: &ReducerContext, creature_guid: u64) -> EventA rules: row.rules, }); } - if let Some(row) = definitions + let source_guid = ctx + .db + .game_creature_spawn() + .guid() + .find(creature_guid) + .map_or(0, |spawn| spawn.source_guid); + let guid_definition = definitions .by_guid() .filter(&creature_guid) .min_by_key(|row| row.id) - { + .or_else(|| { + (source_guid != 0) + .then(|| { + definitions + .by_guid() + .filter(&source_guid) + .min_by_key(|row| row.id) + }) + .flatten() + }); + if let Some(row) = guid_definition { rows.push(EventAiDefinition { subject: EventAiSubject::Guid(row.creature_guid), revision: DefinitionRevision { @@ -1007,3 +1023,25 @@ fn distance_sq(first: &EventAiUnit, second: &EventAiUnit) -> f32 { let (dx, dy, dz) = (second.x - first.x, second.y - first.y, second.z - first.z); dx * dx + dy * dy + dz * dz } + +/// The source zone-combat pulse admits living Characters throughout a dungeon instance. +pub(super) fn zone_combat(world: &mut W, creature_guid: u64) -> ActionResult { + let Some(creature) = world + .eventai_unit(creature_guid) + .filter(|unit| !unit.dead && !unit.is_player) + else { + return ActionResult::Refused; + }; + if !crate::instance::is_dungeon_map(creature.map_id) { + return ActionResult::Applied; + } + let mut targets = world.eventai_combat_candidates(creature_guid); + targets.sort_by(|first, second| compare_distance(&creature, first, second)); + for target in &targets { + world.eventai_engage_without_threat(creature_guid, target.guid); + } + if let Some(target) = targets.first() { + world.eventai_engage_assist(creature_guid, target.guid); + } + ActionResult::Applied +} diff --git a/module/src/creatures/eventai/engine.rs b/module/src/creatures/eventai/engine.rs index 5c833aef3..d41af2e0a 100644 --- a/module/src/creatures/eventai/engine.rs +++ b/module/src/creatures/eventai/engine.rs @@ -144,6 +144,10 @@ pub(crate) trait EventAiWorld { kind: super::AiEventKind, radius_yd: u32, ) -> bool; + fn eventai_set_auto_attack(&mut self, creature_guid: u64, enabled: bool) -> bool; + fn eventai_combat_candidates(&self, creature_guid: u64) -> Vec; + fn eventai_engage_without_threat(&mut self, creature_guid: u64, target_guid: u64); + fn eventai_set_sheath(&mut self, creature_guid: u64, state: u8) -> bool; fn eventai_set_stand_state(&mut self, creature_guid: u64, stand_state: u8) -> bool; fn eventai_set_react_state( &mut self, @@ -508,6 +512,14 @@ fn execute_instruction( ActionResult::Refused } } + CreatureInstruction::SetAutoAttack(enabled) => { + action_outcome(world.eventai_set_auto_attack(context.creature_guid, *enabled)) + } + CreatureInstruction::ZoneCombat => super::combat::zone_combat(world, context.creature_guid), + CreatureInstruction::SetSheath(state) => { + action_outcome(world.eventai_set_sheath(context.creature_guid, *state)) + } + CreatureInstruction::EmptyTextNoEffect => ActionResult::Refused, CreatureInstruction::MissingTextTemplateNoEffect(_) => ActionResult::Applied, CreatureInstruction::ScaleSelectedThreat(_) | CreatureInstruction::ScaleAllThreat(_) => { super::threat::execute(world, context, instruction, linked_choice) @@ -577,6 +589,10 @@ fn speak( SpeechMode::Say => CHAT_SAY, SpeechMode::Yell => CHAT_YELL, SpeechMode::Emote => CHAT_TEXT_EMOTE, + SpeechMode::BossEmote => lyracore_shared::chat::broadcast_chat::CREATURE_BOSS_EMOTE, + SpeechMode::ZoneYell => lyracore_shared::chat::broadcast_chat::CREATURE_ZONE_YELL, + SpeechMode::ZoneEmote => lyracore_shared::chat::broadcast_chat::CREATURE_ZONE_EMOTE, + SpeechMode::Whisper => lyracore_shared::chat::broadcast_chat::CREATURE_WHISPER, }; let ids = &speech.broadcast_ids; let picked = match ids.len() { @@ -602,6 +618,13 @@ fn speak( ) } }; + if chat_type == lyracore_shared::chat::broadcast_chat::CREATURE_WHISPER + && !world + .eventai_unit(target_guid) + .is_some_and(|unit| unit.is_player) + { + return false; + } let spoken = world.eventai_deliver_line( context.creature_guid, target_guid, @@ -790,6 +813,10 @@ fn rule_uses_linked_random(rule: &EventAiRule) -> bool { | CreatureInstruction::SetStandState(_) | CreatureInstruction::SetReactState(_) | CreatureInstruction::RemoveGuardians(_) + | CreatureInstruction::SetAutoAttack(_) + | CreatureInstruction::ZoneCombat + | CreatureInstruction::SetSheath(_) + | CreatureInstruction::EmptyTextNoEffect | CreatureInstruction::MissingTextTemplateNoEffect(_) | CreatureInstruction::ScaleAllThreat(_) | CreatureInstruction::Presentation(_) @@ -1191,6 +1218,7 @@ impl EventAiWorld for DatabaseWorld<'_> { definition_revision: 0, active_object: false, react_state: 2, + melee_enabled: true, }); } } @@ -1237,6 +1265,7 @@ impl EventAiWorld for DatabaseWorld<'_> { let row = match self.state_row(creature_guid) { Some(mut row) => { row.phase = 0; + row.melee_enabled = true; row.ranged_distance = 0.0; row.ranged_angle = 0.0; row.ranged_posture_active = false; @@ -1254,6 +1283,7 @@ impl EventAiWorld for DatabaseWorld<'_> { definition_revision: revision.value, active_object: false, react_state: 2, + melee_enabled: true, }), }; CreatureState::from(row) @@ -1430,6 +1460,7 @@ impl EventAiWorld for DatabaseWorld<'_> { definition_revision: 0, active_object: false, react_state: 2, + melee_enabled: true, }); } } @@ -1545,6 +1576,45 @@ impl EventAiWorld for DatabaseWorld<'_> { .is_ok() } + fn eventai_set_auto_attack(&mut self, creature_guid: u64, enabled: bool) -> bool { + super::mobility::set_auto_attack(self.ctx, creature_guid, enabled) + } + + fn eventai_set_sheath(&mut self, creature_guid: u64, state: u8) -> bool { + self.ctx + .db + .game_world_entity() + .guid() + .find(creature_guid) + .filter(|entity| !entity.is_player()) + .is_some_and(|entity| crate::world::apply_set_sheathed(self.ctx, entity, state).is_ok()) + } + + fn eventai_combat_candidates(&self, creature_guid: u64) -> Vec { + let entities = self.ctx.db.game_world_entity(); + let Some(creature) = entities.guid().find(creature_guid) else { + return Vec::new(); + }; + entities + .by_entry() + .filter(&0u32) + .filter(|target| { + target.is_player() + && !target.dead + && crate::helpers::character_by_guid(self.ctx, target.guid) + .is_none_or(|character| character.gm_level == 0) + && target.map_id == creature.map_id + && target.instance_id == creature.instance_id + && crate::combat::may_harm(self.ctx, &creature, target) + }) + .map(|target| unit_of(&target)) + .collect() + } + + fn eventai_engage_without_threat(&mut self, creature_guid: u64, target_guid: u64) { + crate::threat::engage_without_threat(self.ctx, creature_guid, target_guid); + } + fn eventai_set_stand_state(&mut self, creature_guid: u64, stand_state: u8) -> bool { crate::creatures::presentation::apply_relay_stand_state( self.ctx, @@ -1623,3 +1693,11 @@ impl EventAiWorld for DatabaseWorld<'_> { } } } + +fn action_outcome(applied: bool) -> ActionResult { + if applied { + ActionResult::Applied + } else { + ActionResult::Refused + } +} diff --git a/module/src/creatures/eventai/loader.rs b/module/src/creatures/eventai/loader.rs index 0f1c1bf2c..36d9a3257 100644 --- a/module/src/creatures/eventai/loader.rs +++ b/module/src/creatures/eventai/loader.rs @@ -497,6 +497,10 @@ fn parse_instruction(encoded: &str) -> Result { "say" => SpeechMode::Say, "yell" => SpeechMode::Yell, "emote" => SpeechMode::Emote, + "boss-emote" => SpeechMode::BossEmote, + "zone-yell" => SpeechMode::ZoneYell, + "zone-emote" => SpeechMode::ZoneEmote, + "whisper" => SpeechMode::Whisper, value => return Err(format!("unknown speech mode: {value}")), }, broadcast_ids, @@ -679,6 +683,16 @@ fn parse_instruction(encoded: &str) -> Result { creature_entry: parse_u32(entry)?, }, )), + ["auto-attack", enabled] => Ok(CreatureInstruction::SetAutoAttack(parse_bool(enabled)?)), + ["zone-combat"] => Ok(CreatureInstruction::ZoneCombat), + ["sheath", state] => { + let state = parse_u8(state)?; + if state > 2 { + return Err("invalid sheath state".to_string()); + } + Ok(CreatureInstruction::SetSheath(state)) + } + ["no-effect", "empty-text"] => Ok(CreatureInstruction::EmptyTextNoEffect), ["no-effect", "missing-text-template", template] => Ok( CreatureInstruction::MissingTextTemplateNoEffect(MissingTextTemplateNoEffect { template_id: nonzero_u32(template, "missing text template")?, @@ -1406,12 +1420,16 @@ mod tests { ("speak:say:self:901", SpeechMode::Say), ("speak:yell:self:901", SpeechMode::Yell), ("speak:emote:self:901", SpeechMode::Emote), + ("speak:boss-emote:self:901", SpeechMode::BossEmote), + ("speak:zone-yell:self:901", SpeechMode::ZoneYell), + ("speak:zone-emote:self:901", SpeechMode::ZoneEmote), + ("speak:whisper:spawner:901", SpeechMode::Whisper), ] { let CreatureInstruction::Speak(speech) = parse_instruction(encoded).unwrap() else { panic!("{encoded} did not parse as a speak instruction"); }; assert_eq!(speech.mode, expected, "{encoded}"); } - assert!(parse_instruction("speak:whisper:self:901").is_err()); + assert!(parse_instruction("speak:unknown:self:901").is_err()); } } diff --git a/module/src/creatures/eventai/mobility.rs b/module/src/creatures/eventai/mobility.rs index b746c7a32..a6f4e0041 100644 --- a/module/src/creatures/eventai/mobility.rs +++ b/module/src/creatures/eventai/mobility.rs @@ -165,6 +165,7 @@ fn default_state(ctx: &ReducerContext, creature_guid: u64) -> CreatureAiState { definition_revision: 0, active_object: false, react_state: 2, + melee_enabled: true, } } @@ -479,6 +480,8 @@ pub(super) fn place_summon( movement_type: crate::creatures::MOVEMENT_IDLE, respawn_secs: u32::MAX, life_seq: 0, + source_guid: 0, + instance_id: summoner.instance_id, }; let entity = crate::creatures::build_creature_entity( &spawn, @@ -722,6 +725,36 @@ fn despawn_temporary_summon(ctx: &ReducerContext, creature_guid: u64) { crate::creatures::despawn_creature_entity(ctx, creature_guid); } +pub(crate) fn melee_enabled(ctx: &ReducerContext, creature_guid: u64) -> bool { + ctx.db + .game_creature_ai_state() + .creature_guid() + .find(creature_guid) + .is_none_or(|state| state.melee_enabled) +} + +pub(super) fn set_auto_attack(ctx: &ReducerContext, creature_guid: u64, enabled: bool) -> bool { + if !ctx + .db + .game_world_entity() + .guid() + .find(creature_guid) + .is_some_and(|entity| !entity.is_player() && !entity.dead) + { + return false; + } + let table = ctx.db.game_creature_ai_state(); + let existing = table.creature_guid().find(creature_guid); + let mut state = existing.unwrap_or_else(|| default_state(ctx, creature_guid)); + state.melee_enabled = enabled; + if table.creature_guid().find(creature_guid).is_some() { + table.creature_guid().update(state); + } else { + table.insert(state); + } + true +} + #[cfg(test)] mod tests { use super::*; diff --git a/module/src/creatures/eventai/mod.rs b/module/src/creatures/eventai/mod.rs index bee114cd2..e31d14303 100644 --- a/module/src/creatures/eventai/mod.rs +++ b/module/src/creatures/eventai/mod.rs @@ -44,7 +44,7 @@ pub(crate) use loader::replace_definition_for_debug; pub(crate) use mobility::summon_lifetime_after; #[cfg(feature = "debug_reducers")] pub(crate) use mobility::verify_summon_expiry_boundaries_for_debug; -pub(crate) use mobility::{drop_summon_expiry, ranged_posture, react_state}; +pub(crate) use mobility::{drop_summon_expiry, melee_enabled, ranged_posture, react_state}; pub use mobility::{CreatureAiForcedDespawn, CreatureAiSummonExpiry, CreatureAiSummonOrigin}; #[cfg(feature = "debug_reducers")] diff --git a/module/src/creatures/eventai/model.rs b/module/src/creatures/eventai/model.rs index 52f254523..640c775c6 100644 --- a/module/src/creatures/eventai/model.rs +++ b/module/src/creatures/eventai/model.rs @@ -344,6 +344,10 @@ pub enum SpeechMode { /// and reaches clients as a monster emote rather than speech. Appended last so an existing /// encoded rule keeps its variant index. Emote, + BossEmote, + ZoneYell, + ZoneEmote, + Whisper, } #[derive(spacetimedb::SpacetimeType, Clone, Debug, Eq, PartialEq)] @@ -617,6 +621,10 @@ pub enum CreatureInstruction { NotifyEncounter(NotifyEncounterInstruction), StartRelay(StartRelayInstruction), AttackStart(InstructionTarget), + SetAutoAttack(bool), + ZoneCombat, + SetSheath(u8), + EmptyTextNoEffect, } #[derive(spacetimedb::SpacetimeType, Clone, Debug, Eq, PartialEq)] diff --git a/module/src/creatures/eventai/relay.rs b/module/src/creatures/eventai/relay.rs index f299e6d42..8573b0b7d 100644 --- a/module/src/creatures/eventai/relay.rs +++ b/module/src/creatures/eventai/relay.rs @@ -2155,12 +2155,13 @@ pub(crate) fn cancel_relay_runs_for_instance(ctx: &ReducerContext, instance_id: /// the corpse, and the corpse window is the whole point. Respawn is the boundary no run may cross. pub(crate) fn cancel_relay_runs_for_new_life(ctx: &ReducerContext, source_guid: u64) { let arrivals = ctx.db.game_creature_ai_relay_arrival(); + let mut removed_reference = false; for row in arrivals .by_source() .filter(&source_guid) .collect::>() { - arrivals.scheduled_id().delete(row.scheduled_id); + removed_reference |= arrivals.scheduled_id().delete(row.scheduled_id); } let run_ids = ctx .db @@ -2169,21 +2170,25 @@ pub(crate) fn cancel_relay_runs_for_new_life(ctx: &ReducerContext, source_guid: .filter(&source_guid) .map(|run| run.id) .collect::>(); + removed_reference |= !run_ids.is_empty(); for run_id in run_ids { cancel_run_tree(ctx, run_id); } - reap_unused_definitions(ctx); + if removed_reference { + reap_unused_definitions(ctx); + } } pub(crate) fn cancel_relay_runs_for_source(ctx: &ReducerContext, source_guid: u64) { let arrivals = ctx.db.game_creature_ai_relay_arrival(); + let mut removed_reference = false; for row in arrivals .by_source() .filter(&source_guid) .filter(|row| source_lifetime_ends(row.lifetime)) .collect::>() { - arrivals.scheduled_id().delete(row.scheduled_id); + removed_reference |= arrivals.scheduled_id().delete(row.scheduled_id); } let run_ids = ctx .db @@ -2193,10 +2198,13 @@ pub(crate) fn cancel_relay_runs_for_source(ctx: &ReducerContext, source_guid: u6 .filter(|run| source_lifetime_ends(run.lifetime)) .map(|run| run.id) .collect::>(); + removed_reference |= !run_ids.is_empty(); for run_id in run_ids { cancel_run_tree(ctx, run_id); } - reap_unused_definitions(ctx); + if removed_reference { + reap_unused_definitions(ctx); + } } pub(crate) fn reap_unused_definitions(ctx: &ReducerContext) { diff --git a/module/src/creatures/eventai/tables.rs b/module/src/creatures/eventai/tables.rs index dfcd302b7..8b68c4b8c 100644 --- a/module/src/creatures/eventai/tables.rs +++ b/module/src/creatures/eventai/tables.rs @@ -130,6 +130,8 @@ pub struct CreatureAiState { /// Passive 0, defensive 1, aggressive 2. Fresh EventAI creatures are aggressive. #[default(2u8)] pub react_state: u8, + #[default(true)] + pub melee_enabled: bool, } /// Timing and consumption state for one creature and one effective source rule. Module only. diff --git a/module/src/creatures/mod.rs b/module/src/creatures/mod.rs index 9705106b9..99ef99b8e 100644 --- a/module/src/creatures/mod.rs +++ b/module/src/creatures/mod.rs @@ -21,6 +21,7 @@ mod hunter_pet; mod pet; mod pet_care; mod pet_progression; +pub(crate) mod population; pub(crate) mod presentation; mod spawn; pub(crate) mod tick; @@ -38,9 +39,9 @@ pub(crate) use eventai::{ cancel_relay_runs_for_source, creature_ai_on_aggro, creature_ai_on_creature_death, creature_ai_on_creature_spawn, creature_ai_on_unit_death, current_definition_revision, drop_summon_expiry, eventai_on_evade, eventai_on_reached_home, eventai_on_receive_emote, - eventai_on_spell_hit, finish_death_dispatch, place_temporary_summon, react_state, - reset_creature_lifecycle, reset_engagement, seed_on_aggro_fixtures, CreatureInstruction, - CreatureReactState, SummonLocation, + eventai_on_spell_hit, finish_death_dispatch, melee_enabled, place_temporary_summon, + react_state, reset_creature_lifecycle, reset_engagement, seed_on_aggro_fixtures, + CreatureInstruction, CreatureReactState, SummonLocation, }; #[cfg(feature = "debug_reducers")] pub(crate) use eventai::{run_relay_arrival, start_imported_relay}; diff --git a/module/src/creatures/pet.rs b/module/src/creatures/pet.rs index 5b6286479..683a415a4 100644 --- a/module/src/creatures/pet.rs +++ b/module/src/creatures/pet.rs @@ -271,8 +271,10 @@ fn build_pet_entity(ctx: &ReducerContext, owner: &WorldEntity, entry: u32) -> Op movement_type: 0, // IDLE — irrelevant (a pet has no spawn row, so the wander/return passes skip it) respawn_secs: 0, // irrelevant — this transient spawn is never inserted, so no decay/respawn pass ever reads it life_seq: 0, + source_guid: 0, + instance_id: owner.instance_id, }; - let mut pet = build_creature_entity(&spawn, &tmpl, ctx.random(), 0); + let mut pet = build_creature_entity(&spawn, &tmpl, ctx.random(), owner.instance_id); pet.owner_guid = owner.guid; // THIS is what makes it a pet (the cycle's pet phase keys on owner_guid != 0) Some(pet) } diff --git a/module/src/creatures/population.rs b/module/src/creatures/population.rs new file mode 100644 index 000000000..48360d967 --- /dev/null +++ b/module/src/creatures/population.rs @@ -0,0 +1,461 @@ +//! Source population choices survive import; each Instance keeps its first selection. + +use std::collections::{BTreeMap, BTreeSet}; + +use spacetimedb::{log, table, ReducerContext, SpacetimeType, Table}; + +use super::{game_creature_template, CreatureSpawn}; + +#[table(accessor = game_creature_population)] +pub struct CreaturePopulation { + #[primary_key] + pub population_id: u64, + pub max_count: u32, + pub world_state_condition: u32, + pub world_state_expression: u32, + pub flags: u32, + /// Rows of entry,min_count,max_count,chance. Zero chance shares the remaining probability. + pub entries: String, +} + +#[table(accessor = game_creature_population_member, index(accessor = by_population, btree(columns = [population_id])))] +#[derive(Clone)] +pub struct CreaturePopulationMember { + #[primary_key] + pub source_guid: u64, + pub population_id: u64, + pub entry: u32, + pub choices: String, + pub chance: u32, + /// Positive ids require an active game event; negative ids remove a spawn during that event. + pub event_ids: String, +} + +#[derive(Clone, SpacetimeType)] +pub struct PopulationAssignment { + pub source_guid: u64, + pub entry: u32, +} + +#[table(accessor = game_creature_population_selection, index(accessor = by_instance, btree(columns = [instance_id])))] +pub struct CreaturePopulationSelection { + #[primary_key] + pub id: String, + pub population_id: u64, + pub instance_id: u64, + pub assignments: Vec, +} + +#[derive(Clone)] +struct EntryChoice { + entry: u32, + minimum: u32, + maximum: u32, + chance: u32, +} + +struct Member { + guid: u64, + entry: u32, + choices: Vec, + chance: u32, + requires_event: bool, +} + +fn numbers(text: &str) -> Result, String> { + if text.is_empty() { + return Ok(Vec::new()); + } + text.split(',') + .map(|field| { + field + .parse() + .map_err(|_| format!("invalid population number {field:?}")) + }) + .collect() +} + +fn decode_entries(text: &str) -> Result, String> { + text.split(';') + .filter(|row| !row.is_empty()) + .map(|row| { + let values = numbers::(row)?; + let [entry, minimum, maximum, chance] = values.as_slice() else { + return Err(format!("population entry needs four fields: {row}")); + }; + if *entry == 0 || *chance > 100 || (*maximum != 0 && minimum > maximum) { + return Err(format!("invalid population entry {row}")); + } + Ok(EntryChoice { + entry: *entry, + minimum: *minimum, + maximum: *maximum, + chance: *chance, + }) + }) + .collect() +} + +fn decode_member(member: &CreaturePopulationMember) -> Result { + let choices = numbers::(&member.choices)?; + let events = numbers::(&member.event_ids)?; + if member.chance > 100 || choices.contains(&0) || events.contains(&0) { + return Err(format!("invalid population member {}", member.source_guid)); + } + Ok(Member { + guid: member.source_guid, + entry: member.entry, + choices, + chance: member.chance, + requires_event: events.iter().any(|event| *event > 0), + }) +} + +/// Check source references before the first spawn batch changes the live population. +pub(crate) fn check_definitions(ctx: &ReducerContext) -> Result<(), String> { + let templates = ctx.db.game_creature_template(); + let populations = ctx.db.game_creature_population(); + for population in populations.iter() { + let entries = decode_entries(&population.entries)?; + let mut seen = BTreeSet::new(); + let chance_sum: u32 = entries.iter().map(|entry| entry.chance).sum(); + if chance_sum > 100 { + return Err(format!( + "population {}: entry chance sum exceeds 100", + population.population_id + )); + } + for entry in entries { + if !seen.insert(entry.entry) || templates.entry().find(entry.entry).is_none() { + return Err(format!( + "population {}: duplicate or missing creature entry {}", + population.population_id, entry.entry + )); + } + } + } + for member in ctx.db.game_creature_population_member().iter() { + let source = decode_member(&member)?; + let Some(population) = populations.population_id().find(member.population_id) else { + return Err(format!( + "population member {} references missing group {}", + member.source_guid, member.population_id + )); + }; + let entries = decode_entries(&population.entries)?; + if source.entry == 0 + && source.choices.is_empty() + && entries.is_empty() + && population.world_state_condition == 0 + && population.world_state_expression == 0 + { + return Err(format!( + "population member {} has no creature entry", + source.guid + )); + } + for entry in source + .choices + .iter() + .copied() + .chain((source.entry != 0).then_some(source.entry)) + { + if templates.entry().find(entry).is_none() { + return Err(format!( + "population member {} references missing creature entry {entry}", + source.guid + )); + } + } + } + Ok(()) +} + +fn choose_index(len: usize, draw: &mut impl FnMut() -> u64) -> usize { + // Every caller checks for an empty candidate list first. + (draw() % len as u64) as usize +} + +fn select( + population: &CreaturePopulation, + members: &[Member], + entries: &[EntryChoice], + mut draw: impl FnMut() -> u64, +) -> Vec { + if population.world_state_condition != 0 || population.world_state_expression != 0 { + return Vec::new(); + } + let mut eligible: Vec<&Member> = members + .iter() + .filter(|member| { + !member.requires_event + && (member.chance == 0 || draw() % 100 < u64::from(member.chance)) + }) + .collect(); + for index in (1..eligible.len()).rev() { + let other = choose_index(index + 1, &mut draw); + eligible.swap(index, other); + } + if population.max_count > 0 { + eligible.truncate(population.max_count as usize); + } + // Fixed and independent random entries consume group quotas before group-selected entries. + eligible.sort_by_key(|member| member.entry == 0 && member.choices.is_empty()); + let mut counts = BTreeMap::::new(); + let mut selected = Vec::new(); + for member in eligible { + let entry = if !member.choices.is_empty() { + Some(member.choices[choose_index(member.choices.len(), &mut draw)]) + } else if member.entry != 0 { + Some(member.entry) + } else { + let available: Vec<&EntryChoice> = entries + .iter() + .filter(|entry| { + entry.maximum == 0 + || counts.get(&entry.entry).copied().unwrap_or(0) < entry.maximum + }) + .collect(); + let minimum: Vec<&EntryChoice> = available + .iter() + .copied() + .filter(|entry| counts.get(&entry.entry).copied().unwrap_or(0) < entry.minimum) + .collect(); + if !minimum.is_empty() { + Some(minimum[choose_index(minimum.len(), &mut draw)].entry) + } else { + let mut roll = (draw() % 100 + 1) as u32; + let explicit = available.iter().find(|entry| { + if entry.chance == 0 { + return false; + } + if roll <= entry.chance { + return true; + } + roll -= entry.chance; + false + }); + if let Some(entry) = explicit { + Some(entry.entry) + } else { + let equal: Vec<_> = + available.iter().filter(|entry| entry.chance == 0).collect(); + if equal.is_empty() { + None + } else { + Some(equal[choose_index(equal.len(), &mut draw)].entry) + } + } + } + }; + if let Some(entry) = entry { + *counts.entry(entry).or_default() += 1; + selected.push(PopulationAssignment { + source_guid: member.guid, + entry, + }); + } + } + selected +} + +pub(crate) fn entry_for_spawn(ctx: &ReducerContext, spawn: &CreatureSpawn) -> Option { + let source_guid = if spawn.source_guid == 0 { + spawn.guid + } else { + spawn.source_guid + }; + let Some(member) = ctx + .db + .game_creature_population_member() + .source_guid() + .find(source_guid) + else { + return (spawn.entry != 0).then_some(spawn.entry); + }; + let selections = ctx.db.game_creature_population_selection(); + let id = format!("{}:{}", member.population_id, spawn.instance_id); + if let Some(selection) = selections.id().find(&id) { + return selection + .assignments + .iter() + .find(|assignment| assignment.source_guid == source_guid) + .map(|assignment| assignment.entry); + } + let definition = ctx + .db + .game_creature_population() + .population_id() + .find(member.population_id)?; + let parsed = (|| { + let entries = decode_entries(&definition.entries)?; + let members = ctx + .db + .game_creature_population_member() + .by_population() + .filter(member.population_id) + .map(|member| decode_member(&member)) + .collect::, _>>()?; + Ok::<_, String>(select(&definition, &members, &entries, || { + ctx.random::() + })) + })(); + let assignments = match parsed { + Ok(assignments) => assignments, + Err(error) => { + log::error!( + "population {} cannot select creatures: {error}", + member.population_id + ); + return None; + } + }; + let entry = assignments + .iter() + .find(|assignment| assignment.source_guid == source_guid) + .map(|assignment| assignment.entry); + selections.insert(CreaturePopulationSelection { + id, + population_id: member.population_id, + instance_id: spawn.instance_id, + assignments, + }); + entry +} + +pub(crate) fn clear_instance(ctx: &ReducerContext, instance_id: u64) { + let selections = ctx.db.game_creature_population_selection(); + let ids: Vec = selections + .by_instance() + .filter(instance_id) + .map(|selection| selection.id) + .collect(); + for id in ids { + selections.id().delete(id); + } +} + +pub(crate) fn clear_all(ctx: &ReducerContext) { + let selections = ctx.db.game_creature_population_selection(); + let ids: Vec = selections.iter().map(|selection| selection.id).collect(); + for id in ids { + selections.id().delete(id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn definition(max_count: u32, entries: &str) -> CreaturePopulation { + CreaturePopulation { + population_id: 1, + max_count, + world_state_condition: 0, + world_state_expression: 0, + flags: 0, + entries: entries.to_owned(), + } + } + + fn member(guid: u64, entry: u32) -> Member { + Member { + guid, + entry, + choices: Vec::new(), + chance: 0, + requires_event: false, + } + } + + #[test] + fn group_selection_obeys_entry_minimum_and_maximum() { + let group = definition(3, "10,2,2,0;20,1,1,0"); + let members = vec![member(1, 0), member(2, 0), member(3, 0), member(4, 0)]; + let assignments = select( + &group, + &members, + &decode_entries(&group.entries).unwrap(), + || 0, + ); + assert_eq!(assignments.len(), 3); + assert_eq!(assignments.iter().filter(|a| a.entry == 10).count(), 2); + assert_eq!(assignments.iter().filter(|a| a.entry == 20).count(), 1); + } + + #[test] + fn fixed_members_consume_minimum_before_group_choices() { + let group = definition(0, "10,1,1,0;20,1,1,0"); + let assignments = select( + &group, + &[member(1, 0), member(2, 10)], + &decode_entries(&group.entries).unwrap(), + || 0, + ); + assert_eq!( + assignments + .iter() + .find(|a| a.source_guid == 1) + .unwrap() + .entry, + 20 + ); + } + + #[test] + fn explicit_entry_probability_leaves_remaining_share_to_equal_choices() { + let group = definition(1, "10,0,0,25;20,0,0,0"); + let entries = decode_entries(&group.entries).unwrap(); + assert_eq!( + select(&group, &[member(1, 0)], &entries, || 24)[0].entry, + 10 + ); + assert_eq!( + select(&group, &[member(1, 0)], &entries, || 25)[0].entry, + 20 + ); + } + + #[test] + fn conditional_populations_remain_dormant() { + let mut group = definition(0, ""); + group.world_state_condition = 19998; + assert!(select(&group, &[member(1, 0)], &[], || 0).is_empty()); + } + + #[test] + fn positive_event_membership_and_failed_slot_chance_remain_dormant() { + let group = definition(0, ""); + let mut event = member(1, 10); + event.requires_event = true; + let mut chance = member(2, 20); + chance.chance = 10; + let selected = select(&group, &[event, chance, member(3, 30)], &[], || 50); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].source_guid, 3); + } + + #[test] + fn negative_event_membership_spawns_when_no_events_are_active() { + let source = CreaturePopulationMember { + source_guid: 1, + population_id: 1, + entry: 10, + choices: String::new(), + chance: 0, + event_ids: "-4".to_owned(), + }; + let member = decode_member(&source).unwrap(); + let selected = select(&definition(0, ""), &[member], &[], || 0); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].entry, 10); + } + + #[test] + fn independent_random_slots_can_choose_each_definition() { + let group = definition(0, ""); + let mut slot = member(1, 0); + slot.choices = vec![10, 20]; + assert_eq!(select(&group, &[slot], &[], || 1)[0].entry, 20); + } +} diff --git a/module/src/creatures/spawn.rs b/module/src/creatures/spawn.rs index d7c3f7775..a19aba115 100644 --- a/module/src/creatures/spawn.rs +++ b/module/src/creatures/spawn.rs @@ -452,6 +452,13 @@ pub(crate) fn timer_never(ctx: &spacetimedb::ReducerContext) -> spacetimedb::Tim .unwrap_or(ctx.timestamp) } +/// Maps represented by the current source import. Runtime copies do not change this catalogue. +#[table(accessor = game_creature_spawn_map, public)] +pub struct CreatureSpawnMap { + #[primary_key] + pub map_id: u32, +} + #[table( accessor = game_creature_spawn, public, @@ -461,8 +468,12 @@ pub(crate) fn timer_never(ctx: &spacetimedb::ReducerContext) -> spacetimedb::Tim // indexes, so the gateway is unaffected). index(accessor = by_respawn_at, btree(columns = [respawn_at])), index(accessor = by_despawn_at, btree(columns = [despawn_at])), - index(accessor = by_entry_map, btree(columns = [entry, map_id])) + index(accessor = by_entry_map, btree(columns = [entry, map_id])), + index(accessor = by_source_guid, btree(columns = [source_guid])), + index(accessor = by_instance, btree(columns = [instance_id])), + index(accessor = by_map_instance, btree(columns = [map_id, instance_id])) )] +#[derive(Clone)] pub struct CreatureSpawn { #[primary_key] pub guid: u64, @@ -500,6 +511,12 @@ pub struct CreatureSpawn { /// END-appended + defaulted (migration rule). #[default(0u64)] pub life_seq: u64, + /// Zero for source rows and shared-world populations; nonzero for an Instance copy. + #[default(0u64)] + pub instance_id: u64, + /// The authored spawn guid for a runtime copy. Zero means this row is the source. + #[default(0u64)] + pub source_guid: u64, } /// Read stored spawn evidence for one creature entry without walking the world spawn table. @@ -985,6 +1002,8 @@ mod tests { #[reducer] pub fn import_creature_spawns(ctx: &ReducerContext, packed: String) -> Result<(), String> { crate::helpers::require_operator(ctx)?; + super::population::check_definitions(ctx)?; + super::population::clear_all(ctx); let entities = ctx.db.game_world_entity(); let spawns = ctx.db.game_creature_spawn(); @@ -995,19 +1014,25 @@ pub fn import_creature_spawns(ctx: &ReducerContext, packed: String) -> Result<() .map(|e| e.guid) .collect(); for g in creature_guids { - entities.guid().delete(g); + crate::creatures::despawn_creature_entity(ctx, g); } - let spawn_guids: Vec = spawns.iter().map(|s| s.guid).collect(); - for g in spawn_guids { - spawns.guid().delete(g); + let spawn_guids: Vec<_> = spawns.iter().map(|s| (s.guid, s.source_guid)).collect(); + for (guid, source_guid) in spawn_guids { + if source_guid != 0 { + crate::instance::remove_spawn_waypoints(ctx, guid); + } + spawns.guid().delete(guid); } // The threat tables are keyed by the creature guids we just deleted — wipe them so a re-import // doesn't orphan stale aggro rows (the fresh roster starts with empty threat). crate::threat::clear_all(ctx); - // 2) Load the (first/only) batch. respawn_at = now (in the past by the next tick) so the respawn - // pass re-creates each live entity from this spawn + its template immediately; it disarms the - // timer as it does. despawn_at starts NOT-ARMED — nothing has died yet. + let maps = ctx.db.game_creature_spawn_map(); + for map in maps.iter().collect::>() { + maps.map_id().delete(map.map_id); + } + + // Shared-world spawns arm their first population. Instance source rows stay dormant. if load_spawn_batch(ctx, &packed)? == 0 { return Err("import payload was empty".to_string()); } @@ -1018,7 +1043,7 @@ pub fn import_creature_spawns(ctx: &ReducerContext, packed: String) -> Result<() /// clears the roster ONCE + loads the first batch), so a whole zone can load across several calls (a /// single `spacetime call` string arg can't hold ~2k spawns). note: the importer sends batch 0 via /// `import_creature_spawns` and the remaining batches via this; the respawn tick then builds the -/// entities for every loaded spawn. No reset here — re-calling this alone never wipes the roster. +/// shared-world entities. Instance source rows stay dormant until creation. No reset here. #[reducer] pub fn import_creature_spawns_append(ctx: &ReducerContext, packed: String) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -1048,29 +1073,37 @@ fn load_spawn_batch(ctx: &ReducerContext, packed: &str) -> Result { let pu32 = |s: &str| s.parse::().map_err(|_| format!("bad u32: {s}")); let pu8 = |s: &str| s.parse::().map_err(|_| format!("bad u8: {s}")); let pf32 = |s: &str| s.parse::().map_err(|_| format!("bad f32: {s}")); + let map_id = pu32(f[2])?; let spawn = CreatureSpawn { guid: pu64(f[0])?, entry: pu32(f[1])?, - map_id: pu32(f[2])?, + map_id, x: pf32(f[3])?, y: pf32(f[4])?, z: pf32(f[5])?, orientation: pf32(f[6])?, - // respawn_at STAYS ARMED (`now`): this is how an imported creature first appears — - // `pass_respawn` sees a due timer with no live entity and builds the entity from this - // row. It disarms the timer once it has (see `timer_never`), so the row leaves the - // range scan after one tick instead of sitting in it forever. - respawn_at: now, + // Instance maps retain source rows without creating an instance-zero population. + respawn_at: if lyracore_shared::instance::has_instance_population(map_id) { + timer_never(ctx) + } else { + now + }, despawn_at: timer_never(ctx), // not a corpse — nothing to decay yet movement_type: pu8(f[7])?, respawn_secs: if f.len() == 9 { pu32(f[8])? } else { 0 }, life_seq: 0, + source_guid: 0, + instance_id: 0, }; // try_insert: a duplicate guid within the payload (shouldn't happen — guids are unique per // spawn) fails the run cleanly rather than panicking the reducer. spawns .try_insert(spawn) .map_err(|e| format!("duplicate spawn guid {}: {e}", f[0]))?; + let maps = ctx.db.game_creature_spawn_map(); + if maps.map_id().find(map_id).is_none() { + maps.insert(CreatureSpawnMap { map_id }); + } loaded += 1; } Ok(loaded) diff --git a/module/src/creatures/tick/lifecycle.rs b/module/src/creatures/tick/lifecycle.rs index 24378e433..a677bfa57 100644 --- a/module/src/creatures/tick/lifecycle.rs +++ b/module/src/creatures/tick/lifecycle.rs @@ -186,13 +186,47 @@ pub(crate) fn pass_respawn(ctx: &ReducerContext) -> usize { let mut visited = 0usize; // RANGE SCAN (see `pass_decay`): only spawns whose timer has actually fired. A live creature's // `respawn_at` sits in the far future (`creatures::timer_never`), so it is not visited at all. + const MAX_RESPAWNS_PER_TICK: usize = 256; let due: Vec = spawns .by_respawn_at() .filter(..=now_ts) + .take(MAX_RESPAWNS_PER_TICK) .inspect(|_| visited += 1) - .filter(|s| entities.guid().find(s.guid).is_none()) .collect(); - for spawn in due { + let mut next_waypoint_id = None; + for mut spawn in due { + let guid = spawn.guid; + // Disarm legacy source timers too, so they leave the due-time index after one visit. + if (spawn.instance_id == 0 + && lyracore_shared::instance::has_instance_population(spawn.map_id)) + || entities.guid().find(guid).is_some() + { + spawn.respawn_at = crate::creatures::timer_never(ctx); + spawns.guid().update(spawn); + continue; + } + let Some(entry) = crate::creatures::population::entry_for_spawn(ctx, &spawn) else { + spawn.respawn_at = crate::creatures::timer_never(ctx); + spawns.guid().update(spawn); + continue; + }; + if spawn.source_guid == 0 && entry != spawn.entry { + spawn.respawn_at = crate::creatures::timer_never(ctx); + spawns.guid().update(spawn.clone()); + spawn.source_guid = guid; + spawn.guid = (guid & !(0xFF_FFFFu64 << 24)) | (u64::from(entry) << 24); + spawn.entry = entry; + if spawns.guid().find(spawn.guid).is_some() { + continue; + } + if let Err(reason) = + crate::instance::copy_spawn_waypoints(ctx, guid, spawn.guid, &mut next_waypoint_id) + { + spacetimedb::log::error!("creature source {guid} cannot copy its patrol: {reason}"); + continue; + } + spawns.insert(spawn.clone()); + } let guid = spawn.guid; crate::combat::clear_lethal_damage_floor(ctx, guid); // The new life reuses this guid, so nothing an earlier life scheduled may reach it. @@ -211,7 +245,7 @@ pub(crate) fn pass_respawn(ctx: &ReducerContext) -> usize { if let Some(tmpl) = templates.entry().find(spawn.entry) { super::spawn::insert_creature_entity( ctx, - build_creature_entity(&spawn, &tmpl, ctx.random(), 0), + build_creature_entity(&spawn, &tmpl, ctx.random(), spawn.instance_id), ); } } @@ -221,9 +255,8 @@ pub(crate) fn pass_respawn(ctx: &ReducerContext) -> usize { /// GATHER-node respawn (the POOL model): for every DEPLETED node (state==1) /// whose armed respawn time has elapsed, EITHER re-roll its pool (a POOLED point — the node ROAMS to a /// different inactive point, holding MAX_ACTIVE) OR flip it state 1→0 in place (a STANDALONE node, the -/// pool_id==0 path). Runs in the SENSE block (~4s) — -/// respawns are minutes-scale, so the slow cadence is ample and a 500ms scan would be wasteful, never -/// late by more than a sense tick. `respawn_at_micros` is a defaulted u64 (NOT a Timestamp — that table +/// pool_id==0 path). Runs on each catch-all firing through the due-time index. +/// `respawn_at_micros` is a defaulted u64 (NOT a Timestamp — that table /// is live/populated, so a Timestamp column would abort the publish; teleport-core lesson). The due /// decision is the unit-tested `gameobject::respawn_due` predicate. Filters to depleted-only inline; /// collect-then-mutate so we never write `game_gameobject` while iterating it. diff --git a/module/src/debug/instance.rs b/module/src/debug/instance.rs index 14f8a0b4e..85afe8497 100644 --- a/module/src/debug/instance.rs +++ b/module/src/debug/instance.rs @@ -445,6 +445,8 @@ pub fn debug_assert_floor_snap( movement_type: crate::creatures::MOVEMENT_IDLE, respawn_secs: 0, life_seq: 0, + source_guid: 0, + instance_id: 0, }; let entity = build_creature_entity(&spawn, &tmpl, 0, 0); ctx.db.game_creature_spawn().insert(spawn); diff --git a/module/src/debug/mod.rs b/module/src/debug/mod.rs index 51bb9811e..2b4168423 100644 --- a/module/src/debug/mod.rs +++ b/module/src/debug/mod.rs @@ -298,12 +298,14 @@ pub fn debug_spawn_at_feet( .iter() .filter(|s| s.entry == creature_entry) .map(|s| s.guid & 0x00FF_FFFF) + .filter(|low| low & crate::instance::INSTANCE_POP_LOW_BAND == 0) .max() .unwrap_or(0); let entity_max = entities .iter() .filter(|e| e.entry == creature_entry && !e.is_player()) .map(|e| e.guid & 0x00FF_FFFF) + .filter(|low| low & crate::instance::INSTANCE_POP_LOW_BAND == 0) .max() .unwrap_or(0); let next_low = spawn_max.max(entity_max) + 1; @@ -334,11 +336,13 @@ pub fn debug_spawn_at_feet( movement_type: crate::creatures::MOVEMENT_IDLE, respawn_secs: 0, // not imported — falls back to the flat legacy respawn timer (a debug spawn's death is a one-off harness action) life_seq: 0, + source_guid: 0, + instance_id: player.instance_id, }; // Build the live entity from a reference (fixed roll 0 → deterministic min level/health) BEFORE // moving the spawn into its table, so we never depend on the row type deriving Clone. The // entity insert fires the gateway's on_insert → CREATE_OBJECT so the creature appears at feet. - let entity = build_creature_entity(&spawn, &tmpl, 0, 0); + let entity = build_creature_entity(&spawn, &tmpl, 0, player.instance_id); ctx.db.game_creature_spawn().insert(spawn); crate::creatures::insert_creature_entity(ctx, entity); log::info!( diff --git a/module/src/encounter.rs b/module/src/encounter.rs index b66fecb39..58e34ab01 100644 --- a/module/src/encounter.rs +++ b/module/src/encounter.rs @@ -613,6 +613,7 @@ pub(crate) fn spawn_wave( .iter() .filter(|s| s.entry == entry) .map(|s| s.guid & 0x00FF_FFFF) + .filter(|low| low & crate::instance::INSTANCE_POP_LOW_BAND == 0) .max() .unwrap_or(0) + 1; @@ -637,6 +638,8 @@ pub(crate) fn spawn_wave( movement_type: crate::creatures::MOVEMENT_IDLE, // holds its post until aggro respawn_secs: u32::MAX, // "never" — see the fn doc life_seq: 0, + source_guid: 0, + instance_id, }; let entity = crate::creatures::build_creature_entity(&spawn, &tmpl, ctx.random(), instance_id); diff --git a/module/src/graveyard.rs b/module/src/graveyard.rs index 4f84bb28b..6fed081bb 100644 --- a/module/src/graveyard.rs +++ b/module/src/graveyard.rs @@ -116,12 +116,7 @@ pub(crate) fn instance_release_zone(map_id: u32) -> Option { crate::instance::dungeon(map_id).map(|d| d.release_zone) } -/// Static release floor for an instance map when NOTHING is imported (this sandbox's default -/// state): Deadmines releases at Sentinel Hill (the Westfall graveyard nearest the Moonbrook -/// entrance in spirit — the exact safe loc is `[V]`, same provenance caveat as the const itself). -/// NEVER fall through to `nearest(px, py)` for an instance map — cross-map 2-D distance against -/// the static consts is meaningless (see the section comment above). A field read -/// off `crate::instance::DUNGEON_MAPS` (see [`instance_release_zone`]). +/// Bootstrap release location when the fixture has no imported graveyards. pub(crate) fn instance_static_fallback(map_id: u32) -> Option { crate::instance::dungeon(map_id).map(|d| { let (map, x, y, z, o) = d.release_fallback; @@ -233,11 +228,48 @@ pub(crate) fn resolve_graveyard( ) -> Graveyard { let team = team_for_race(race); - if let Some(zone_id) = instance_release_zone(map_id) { - let linked = zone_linked(ctx, zone_id, team, None); - if let Some(g) = pick_instance_graveyard(&linked, instance_static_fallback(map_id), x, y) { - return g; + if crate::instance::is_dungeon_map(map_id) { + use crate::game_area; + let entrance = crate::instance::imported_entrance_fallback(ctx, map_id); + let mut zones: Vec = ctx + .db + .game_area() + .iter() + .filter(|area| area.map_id == map_id && area.parent_area_id == 0) + .map(|area| area.id) + .collect(); + if let Some(zone) = instance_release_zone(map_id) { + zones.push(zone); } + zones.sort_unstable(); + zones.dedup(); + let linked: Vec = zones + .into_iter() + .flat_map(|zone| zone_linked(ctx, zone, team, entrance.map(|point| point.0))) + .filter(|graveyard| !lyracore_shared::instance::has_instance_population(graveyard.map)) + .collect(); + // Dungeon coordinates cannot be compared with positions on the outside map. + if let Some((outside_map, outside_x, outside_y, outside_z, outside_o)) = entrance { + let fallback = nearest_of(&all_on_map(ctx, outside_map), outside_x, outside_y) + .or_else(|| instance_static_fallback(map_id)); + if let Some(graveyard) = + pick_instance_graveyard(&linked, fallback, outside_x, outside_y) + { + return graveyard; + } + return Graveyard { + map: outside_map, + x: outside_x, + y: outside_y, + z: outside_z, + o: outside_o, + }; + } + return linked + .into_iter() + .next() + .or_else(|| instance_static_fallback(map_id)) + .unwrap_or(NORTHSHIRE); } let linked = match crate::terrain::zone_id_at(ctx, map_id, x, y) { Some(zone_id) => zone_linked(ctx, zone_id, team, Some(map_id)), diff --git a/module/src/instance.rs b/module/src/instance.rs index 01e3be863..ff0458aae 100644 --- a/module/src/instance.rs +++ b/module/src/instance.rs @@ -6,30 +6,15 @@ use spacetimedb::{log, reducer, table, ReducerContext, ScheduleAt, Table, Timest use crate::{ game_config, game_corpse, game_creature_move_schedule, game_creature_spawn, - game_creature_template, game_encounter_spawn, game_gameobject, game_gameobject_template, - game_group, game_group_member, game_world_entity, + game_creature_template, game_creature_waypoint, game_encounter_spawn, game_gameobject, + game_gameobject_template, game_group, game_group_member, game_world_entity, }; // =========================================================================================== // Constants / policy // =========================================================================================== -/// The maps that are DUNGEONS — entering an areatrigger portal targeting one of these -/// resolves-or-creates a `game_instance` instead of landing at instance 0. -/// -/// MOVED to `lyracore_shared::instance`: the GATEWAY needs the same set to check, at -/// startup, that the database which will own a dungeon's instances actually hosts instance -/// populations (`hosts_instances`) — and a set the two tiers could disagree about is exactly how -/// the empty dungeons stayed invisible. Re-exported here so every call site and every tripwire in -/// this crate keeps naming it locally. Every entry MUST have an [`entrance_fallback`] arm -/// (unit-pinned below) so a reaped-instance login can never strand. pub(crate) use lyracore_shared::instance::is_dungeon_map; -/// The set itself has no non-test caller in this crate (`is_dungeon_map` is the read path); it is -/// imported under a distinct name — the module reserves `DUNGEON_MAPS` locally for the -/// dungeon-detail table below — for the cross-tier consistency pin, which is the invariant that -/// keeps the shared set safe to extend from either tier. -#[cfg(test)] -pub(crate) use lyracore_shared::instance::DUNGEON_MAPS as SHARED_DUNGEON_MAP_IDS; pub(crate) struct DungeonMap { pub(crate) map_id: u32, @@ -47,40 +32,105 @@ pub(crate) struct DungeonMap { pub(crate) release_fallback: (u32, f32, f32, f32, f32), } -/// The dungeon-detail table — one entry per dungeon, pinned 1:1 against -/// [`lyracore_shared::instance::DUNGEON_MAPS`] by -/// `every_shared_dungeon_map_has_a_dungeon_maps_record` below (the test-time tripwire that replaced -/// the old runtime `warn!`). -/// -/// `[V]` Map 36 → Deadmines: entrance ≈ Moonbrook village, Westfall (map 0, ~(-11080, 1520, 46)) — -/// approximate vanilla coords, no dump/client in this sandbox to confirm; the fail-safe is inherent -/// (any error of tens of yards still lands in open-world Westfall — never stranded, never inside WMO -/// geometry). Confirm against your dump's areatrigger_teleport EXIT row (~1448) target and tighten. -/// Release zone 1581 (The Deadmines, cmangos AreaTable — CONFIRM against your own dump's -/// `game_graveyard_zone` rows; a wrong id here just means the zone-linked lookup resolves nothing -/// and the static fallback below applies) and release fallback Sentinel Hill are the same `[V]` -/// estimates the earlier per-site consts always carried. -pub(crate) const DUNGEON_MAPS: &[DungeonMap] = &[DungeonMap { +/// Bootstrap fallback for a fixture without imported portal or graveyard metadata. +/// Full imports resolve exits and release locations from the Operator's source data. +pub(crate) const BOOTSTRAP_DUNGEONS: &[DungeonMap] = &[DungeonMap { map_id: 36, entrance: (0, -11080.0, 1520.0, 46.0, 0.0), // [V] Moonbrook, Westfall release_zone: 1581, // [V] The Deadmines release_fallback: (0, -10650.0, 1180.0, 34.0, 0.0), // [V] Sentinel Hill }]; -/// Look up `map_id`'s full dungeon record, or `None` if it is not a dungeon. Pure — the one read -/// path every former match site (entrance / release-zone / release-fallback) now goes through. pub(crate) fn dungeon(map_id: u32) -> Option<&'static DungeonMap> { - DUNGEON_MAPS.iter().find(|d| d.map_id == map_id) + BOOTSTRAP_DUNGEONS.iter().find(|d| d.map_id == map_id) } -/// The open-world position OUTSIDE a dungeon's entrance — see [`DungeonMap::entrance`]. Kept as its -/// own function: every call site outside this module (`world.rs`'s stranding guard, the tests below) -/// still names it, and a thin field-read is clearer at the call site than repeating -/// `dungeon(m).map(|d| d.entrance)`. pub(crate) fn entrance_fallback(map_id: u32) -> Option<(u32, f32, f32, f32, f32)> { dungeon(map_id).map(|d| d.entrance) } +/// Prefer a source-defined exit. An entrance volume also gives a valid outside-map fallback +/// when a dungeon uses a scripted exit instead of an Area Trigger teleport. +pub(crate) fn imported_entrance_fallback( + ctx: &ReducerContext, + map_id: u32, +) -> Option<(u32, f32, f32, f32, f32)> { + use crate::{game_area_trigger, game_areatrigger_teleport}; + let triggers = ctx.db.game_area_trigger(); + let portals = ctx.db.game_areatrigger_teleport(); + let exit = portals + .iter() + .filter(|portal| !lyracore_shared::instance::has_instance_population(portal.target_map)) + .filter(|portal| { + triggers + .id() + .find(portal.trigger_id) + .is_some_and(|trigger| trigger.map_id == map_id) + }) + .min_by_key(|portal| portal.trigger_id) + .map(|portal| (portal.target_map, portal.x, portal.y, portal.z, portal.o)); + exit.or_else(|| { + portals + .iter() + .filter(|portal| portal.target_map == map_id) + .filter_map(|portal| triggers.id().find(portal.trigger_id)) + .filter(|trigger| !lyracore_shared::instance::has_instance_population(trigger.map_id)) + .min_by_key(|trigger| trigger.id) + .map(|trigger| (trigger.map_id, trigger.x, trigger.y, trigger.z, 0.0)) + }) + .or_else(|| entrance_fallback(map_id)) +} + +/// Copy a source patrol in source order. Each population operation reserves waypoint ids once. +pub(crate) fn copy_spawn_waypoints( + ctx: &ReducerContext, + source_guid: u64, + copied_guid: u64, + next_id: &mut Option, +) -> Result<(), String> { + let waypoints = ctx.db.game_creature_waypoint(); + let mut route: Vec<_> = waypoints.by_creature().filter(source_guid).collect(); + if route.is_empty() { + return Ok(()); + } + route.sort_by_key(|waypoint| waypoint.id); + let next = next_id.get_or_insert_with(|| { + waypoints + .iter() + .map(|waypoint| waypoint.id) + .max() + .unwrap_or(0) + }); + for mut waypoint in route { + *next = next.checked_add(1).ok_or("waypoint id exhausted")?; + waypoint.id = *next; + waypoint.creature_guid = copied_guid; + waypoints.insert(waypoint); + } + Ok(()) +} + +pub(crate) fn remove_spawn_waypoints(ctx: &ReducerContext, guid: u64) { + let waypoints = ctx.db.game_creature_waypoint(); + for waypoint in waypoints.by_creature().filter(guid).collect::>() { + waypoints.id().delete(waypoint.id); + } +} + +/// Updating or removing an authored source retires each of its runtime copies. +pub(crate) fn remove_spawn_copies(ctx: &ReducerContext, source_guid: u64) { + let spawns = ctx.db.game_creature_spawn(); + for spawn in spawns + .by_source_guid() + .filter(source_guid) + .collect::>() + { + crate::creatures::despawn_creature_entity(ctx, spawn.guid); + remove_spawn_waypoints(ctx, spawn.guid); + spawns.guid().delete(spawn.guid); + } +} + /// Reap an instance after it has been EMPTY this long (30min const). Vanilla /// keeps an untouched instance alive ~1h; 30min is the item's chosen constant (deviation noted). pub(crate) const INSTANCE_EMPTY_REAP_MICROS: i64 = 30 * 60 * 1_000_000; @@ -137,12 +187,8 @@ pub(crate) fn empty_reap_micros(hosts_populations: bool) -> i64 { /// giant commit). const REAP_MAX_PER_FIRING: usize = 4; -/// Bit 23 of the 24-bit unit-guid low: the per-instance POPULATION band. Disjoint by construction -/// from imported/seeded spawn-row lows AND from `encounter::spawn_wave`'s allocator (which maxes -/// over spawn rows only — an instance-population entity has no spawn row, so without this band a -/// wave of the same entry could re-allocate a live population guid and panic the insert). -/// Relies on spawn-row lows staying < 2^23 (cmangos creature db guids are ~10^5; `[V]` for exotic -/// dumps — a violation shows up as a loud duplicate-PK insert failure, never silent corruption). +/// Bit 23 separates Instance population copies from imported spawns and encounter summons. +/// Both live and respawning copies reserve their sequence until the Instance is reaped. pub(crate) const INSTANCE_POP_LOW_BAND: u64 = 0x80_0000; /// Bit 46 of a gameobject guid low: the per-instance GO-COPY band. Below `gameobject::POOL_TAG` @@ -278,11 +324,18 @@ pub(crate) fn route_instance( } } -/// The 5-player dungeon cap, enforced at trigger time. A Party is capped at 5 already; a Raid of -/// more than 5 is refused here on purpose. cmangos checks instance occupancy instead -/// (cm:Player.cpp:20055-20062), which LyraCore does not reproduce. Pure. -pub(crate) fn party_size_allows_entry(member_count: usize) -> bool { - member_count <= crate::group::GROUP_MAX_MEMBERS +/// Admission uses the dungeon or raid capacity. Occupancy and saved raid lockouts remain separate. +fn instance_capacity(map_id: u32) -> usize { + match map_id { + 249 | 409 | 469 | 531 | 533 => lyracore_shared::group::RAID_MAX_MEMBERS, + 309 | 509 => 20, + 229 => 10, + _ => crate::group::GROUP_MAX_MEMBERS, + } +} + +pub(crate) fn party_size_allows_entry(map_id: u32, member_count: usize) -> bool { + member_count <= instance_capacity(map_id) } /// What the reaper does to one instance this firing, given its observed occupancy. Pure — @@ -333,14 +386,12 @@ pub(crate) fn occupancy_action( /// alive-or-ghost is orthogonal and preserved per the `pending_ghost` rules at the call site. #[derive(Debug, PartialEq)] pub(crate) enum StrandingFallback { - /// A known dungeon map → its entrance const, instance 0. + /// A dungeon entrance on its outside map, at instance zero. Entrance(u32, f32, f32, f32, f32), /// A NON-dungeon map (the dev-map fixture case): the position is fine open-world terrain — /// stay in place, drop to instance 0. InPlaceOpenWorld, - /// A dungeon map with no entrance arm (a [`lyracore_shared::instance::DUNGEON_MAPS`] entry missing its - /// [`entrance_fallback`] — unit-pinned to never happen, but never strand if it does): - /// hearthstone home, instance 0. + /// A dungeon with no imported entrance or bootstrap fallback returns to the Character's home. HearthstoneHome, } @@ -438,10 +489,10 @@ pub(crate) fn resolve_or_create_instance( .as_ref() .map(|g| crate::group::members_of(ctx, g.group_id).len()) .unwrap_or(1); - if !party_size_allows_entry(member_count) { + if !party_size_allows_entry(target_map, member_count) { return Err(format!( "party of {member_count} exceeds the {}-player dungeon cap", - crate::group::GROUP_MAX_MEMBERS + instance_capacity(target_map) )); } @@ -515,12 +566,12 @@ pub(crate) fn admit_existing_party_instance( .game_group_member() .by_group() .filter(&group.group_id) - .take(crate::group::GROUP_MAX_MEMBERS + 1) + .take(instance_capacity(target_map) + 1) .count(); - if !party_size_allows_entry(member_count) { + if !party_size_allows_entry(target_map, member_count) { return Err(format!( "party of {member_count} exceeds the {}-player dungeon cap", - crate::group::GROUP_MAX_MEMBERS + instance_capacity(target_map) )); } let instance = ctx @@ -586,8 +637,8 @@ pub(crate) fn hosts_instance_populations(ctx: &ReducerContext) -> bool { /// Create a live instance of `map_id` owned by `party_id` (0 = solo): insert the `game_instance` /// row, spawn the per-instance creature population, and copy the map's interactive gameobjects. The -/// creature tick rides the global catch-all row (perf catalog 1.3). Entity-only population — see the module doc's -/// respawn-within-a-run decision. Returns the new instance id. +/// creature tick rides the global catch-all row. Copies retain independent death and respawn timers. +/// Returns the new instance id. pub(crate) fn create_instance( ctx: &ReducerContext, map_id: u32, @@ -647,6 +698,16 @@ pub(crate) fn create_instance_with_id( } } + // A dead copy awaiting respawn still owns its guid. + for spawn in ctx.db.game_creature_spawn().by_instance().filter(1u64..) { + let low = spawn.guid & 0xFF_FFFF; + if low & INSTANCE_POP_LOW_BAND != 0 { + let slot = max_band_low.entry(spawn.entry).or_insert(0); + *slot = (*slot).max(low & (INSTANCE_POP_LOW_BAND - 1)); + } + } + let mut next_waypoint_id = None; + let tracked_wave_guids: std::collections::HashSet = ctx .db .game_encounter_spawn() @@ -656,28 +717,39 @@ pub(crate) fn create_instance_with_id( let spawns: Vec = ctx .db .game_creature_spawn() - .iter() - .filter(|s| s.map_id == map_id && !tracked_wave_guids.contains(&s.guid)) + .by_map_instance() + .filter((map_id, 0u64)) + .filter(|s| !tracked_wave_guids.contains(&s.guid)) .collect(); let templates = ctx.db.game_creature_template(); let mut spawned = 0u32; for spawn in &spawns { - let Some(tmpl) = templates.entry().find(spawn.entry) else { - continue; // template-less spawn row: the open-world respawn pass skips it too + let mut copy = spawn.clone(); + copy.instance_id = instance_id; + copy.source_guid = spawn.guid; + let Some(entry) = crate::creatures::population::entry_for_spawn(ctx, ©) else { + continue; + }; + copy.entry = entry; + let Some(tmpl) = templates.entry().find(entry) else { + continue; }; - let slot = max_band_low.entry(spawn.entry).or_insert(0); + let slot = max_band_low.entry(entry).or_insert(0); *slot += 1; if *slot >= INSTANCE_POP_LOW_BAND { - // 2^23 live copies of ONE entry — unreachable in practice; fail LOUD, never wrap into - // the spawn-row namespace. return Err(format!( - "instance population low exhausted for entry {}", - spawn.entry + "instance population low exhausted for entry {entry}" )); } - let mut entity = - crate::creatures::build_creature_entity(spawn, &tmpl, ctx.random(), instance_id); - entity.guid = crate::encounter::wave_guid(spawn.entry, INSTANCE_POP_LOW_BAND | *slot); + copy.guid = crate::encounter::wave_guid(entry, INSTANCE_POP_LOW_BAND | *slot); + copy.life_seq = 1; + copy.respawn_at = crate::creatures::timer_never(ctx); + copy.despawn_at = crate::creatures::timer_never(ctx); + let entity = + crate::creatures::build_creature_entity(©, &tmpl, ctx.random(), instance_id); + let copied_guid = copy.guid; + ctx.db.game_creature_spawn().insert(copy); + copy_spawn_waypoints(ctx, spawn.guid, copied_guid, &mut next_waypoint_id)?; crate::creatures::insert_creature_entity(ctx, entity); spawned += 1; } @@ -972,6 +1044,14 @@ pub(crate) fn teardown_instance_inner(ctx: &ReducerContext, instance_id: u64, de crate::creatures::despawn_creature_entity(ctx, *guid); } + let spawns = ctx.db.game_creature_spawn(); + let copies: Vec<_> = spawns.by_instance().filter(instance_id).collect(); + for spawn in copies { + remove_spawn_waypoints(ctx, spawn.guid); + spawns.guid().delete(spawn.guid); + } + crate::creatures::population::clear_instance(ctx, instance_id); + // 2. Player corpses left in the instance (a ghost who never corpse-ran back). The owner's // stranding outcome is the vanilla one: nothing left to reclaim — spirit healer only // (documented on `player_login`'s fallback and in the runbook). @@ -1254,43 +1334,10 @@ mod tests { use super::*; #[test] - fn every_dungeon_map_has_an_entrance_fallback_arm() { - // The stranding guard's invariant: a reaped-instance login on ANY dungeon map must have an - // entrance to fall back to (the HearthstoneHome branch is the never-strand net for a - // violation of exactly this pin). Checked against the SHARED (cross-tier) id set, not the - // module-local DUNGEON_MAPS table — see `every_shared_dungeon_map_has_a_dungeon_maps_record` - // for why that distinction is the actual tripwire. - for &m in SHARED_DUNGEON_MAP_IDS { - assert!( - entrance_fallback(m).is_some(), - "dungeon map {m} has no entrance_fallback arm — logins after a reap would divert to hearthstone" - ); - } - // Entrance consts always land at instance-0-capable maps (the open world), never a dungeon. - let (m, _, _, _, _) = entrance_fallback(36).unwrap(); - assert!( - !is_dungeon_map(m), - "an entrance fallback must land on an open-world map" - ); - } - - #[test] - fn every_shared_dungeon_map_has_a_dungeon_maps_record() { - for &m in SHARED_DUNGEON_MAP_IDS { - assert!( - dungeon(m).is_some(), - "map {m} is in lyracore_shared::instance::DUNGEON_MAPS (the gateway's \ - hosting-check set) but has no module::instance::DUNGEON_MAPS record" - ); - } - for d in DUNGEON_MAPS { - assert!( - is_dungeon_map(d.map_id), - "map {} has a module::instance::DUNGEON_MAPS record but is missing from \ - lyracore_shared::instance::DUNGEON_MAPS — its instance-hosting would never be \ - checked at Gateway startup", - d.map_id - ); + fn bootstrap_entrances_are_outside_their_dungeon() { + for dungeon in BOOTSTRAP_DUNGEONS { + assert!(is_dungeon_map(dungeon.map_id)); + assert!(!is_dungeon_map(dungeon.entrance.0)); } } @@ -1308,13 +1355,26 @@ mod tests { assert_eq!(route_instance(None, None), InstanceRoute::Create); } + #[test] + fn raid_admission_uses_its_map_capacity() { + assert!(party_size_allows_entry(533, 40)); + assert!(!party_size_allows_entry(533, 41)); + assert!(party_size_allows_entry(309, 20)); + assert!(!party_size_allows_entry(309, 21)); + assert!(party_size_allows_entry(229, 10)); + assert!(!party_size_allows_entry(229, 11)); + } + #[test] fn party_size_cap_admits_up_to_five_and_refuses_six() { for n in 1..=5 { - assert!(party_size_allows_entry(n), "a party of {n} fits a 5-man"); + assert!( + party_size_allows_entry(36, n), + "a party of {n} fits a 5-man" + ); } assert!( - !party_size_allows_entry(6), + !party_size_allows_entry(36, 6), "a 6th player must be refused at the portal" ); } diff --git a/module/src/loot/tag.rs b/module/src/loot/tag.rs index 8cc1bb79f..0fdf4274e 100644 --- a/module/src/loot/tag.rs +++ b/module/src/loot/tag.rs @@ -905,6 +905,8 @@ fn insert_fixture_entity( movement_type: crate::creatures::MOVEMENT_IDLE, respawn_secs: 0, life_seq: 0, + source_guid: 0, + instance_id: 0, }; let mut entity = crate::creatures::build_creature_entity(&spawn, template, 0, 0); entity.owner_guid = owner_guid; diff --git a/module/src/package_import/creatures.rs b/module/src/package_import/creatures.rs index fb5a366b9..595097619 100644 --- a/module/src/package_import/creatures.rs +++ b/module/src/package_import/creatures.rs @@ -157,6 +157,7 @@ pub(super) fn clear_package_range(ctx: &ReducerContext) { .filter(|guid| is_package_spawn_guid(*guid)) .collect(); for guid in package_guids { + crate::instance::remove_spawn_copies(ctx, guid); crate::creatures::despawn_creature_entity(ctx, guid); spawns.guid().delete(guid); } @@ -231,6 +232,7 @@ pub(super) fn write_row(ctx: &ReducerContext, row: &TracedRow) -> Result<(), Str // `import_creature_spawns` reset, one creature wide, through the canonical despawn checklist so // no engagement, threat or motion row outlives the creature it named. if row.table() == ClaimTable::CreatureSpawn { + crate::instance::remove_spawn_copies(ctx, row.row_id()); crate::creatures::despawn_creature_entity(ctx, row.row_id()); } Ok(()) @@ -348,6 +350,8 @@ fn blank_spawn(ctx: &ReducerContext, key: PrimaryKey) -> CreatureSpawn { movement_type: 0, respawn_secs: 0, life_seq: 0, + source_guid: 0, + instance_id: 0, } } @@ -516,6 +520,8 @@ mod tests { movement_type: 0, respawn_secs: 0, life_seq: 0, + source_guid: 0, + instance_id: 0, }; for column in ClaimTable::CreatureSpawn.columns() { apply_spawn_field(&mut spawn, column.name, &some_value(*column)).expect("setter"); diff --git a/module/src/quest.rs b/module/src/quest.rs index b2a576379..4aabdf2ba 100644 --- a/module/src/quest.rs +++ b/module/src/quest.rs @@ -2417,6 +2417,8 @@ pub fn debug_verify_raid_quest_credit_fixture(ctx: &ReducerContext) -> Result<() movement_type: crate::creatures::MOVEMENT_IDLE, respawn_secs: 0, life_seq: 0, + source_guid: 0, + instance_id: player.instance_id, }; let wolf_entity = crate::creatures::build_creature_entity(&spawn, &wolf_template, 0, player.instance_id); diff --git a/module/src/seed.rs b/module/src/seed.rs index d14b8b16e..e23b41b90 100644 --- a/module/src/seed.rs +++ b/module/src/seed.rs @@ -327,6 +327,8 @@ fn seed_map0_demo_content(ctx: &ReducerContext) { movement_type: crate::creatures::MOVEMENT_RANDOM, // the demo chicken loiters near its post (critter) respawn_secs: 0, // 0 ⇒ the flat fallback respawn timer life_seq: 0, + source_guid: 0, + instance_id: 0, }); crate::creatures::insert_creature_entity( ctx, @@ -451,6 +453,8 @@ fn seed_map0_demo_content(ctx: &ReducerContext) { movement_type: crate::creatures::MOVEMENT_RANDOM, respawn_secs: 0, // 0 ⇒ the flat fallback respawn timer life_seq: 0, + source_guid: 0, + instance_id: 0, }); crate::creatures::insert_creature_entity( ctx, @@ -488,6 +492,8 @@ fn seed_map0_demo_content(ctx: &ReducerContext) { movement_type: crate::creatures::MOVEMENT_IDLE, // a trainer stands at its post respawn_secs: 0, // 0 ⇒ the flat fallback respawn timer life_seq: 0, + source_guid: 0, + instance_id: 0, }); crate::creatures::insert_creature_entity( ctx, diff --git a/module/src/seed/fixtures.rs b/module/src/seed/fixtures.rs index c3afa7775..3de78a181 100644 --- a/module/src/seed/fixtures.rs +++ b/module/src/seed/fixtures.rs @@ -354,6 +354,8 @@ pub(crate) fn seed_taxi_fixture(ctx: &ReducerContext) { movement_type: crate::creatures::MOVEMENT_IDLE, respawn_secs: 0, life_seq: 0, + source_guid: 0, + instance_id: 0, }); crate::creatures::insert_creature_entity( ctx, diff --git a/module/src/threat.rs b/module/src/threat.rs index bbc419c94..b32c36421 100644 --- a/module/src/threat.rs +++ b/module/src/threat.rs @@ -509,6 +509,25 @@ pub fn debug_add_threat( Ok(()) } +/// Admit a hostile unit to a creature's combat without changing its accumulated threat. +pub(crate) fn engage_without_threat(ctx: &ReducerContext, creature_guid: u64, source_guid: u64) { + let table = ctx.db.game_threat(); + if !table + .by_creature() + .filter(&creature_guid) + .any(|row| row.source_guid == source_guid) + { + table.insert(ThreatEntry { + id: 0, + creature_guid, + source_guid, + threat: 0, + }); + } + crate::combat::enter_combat(ctx, creature_guid); + crate::combat::enter_combat(ctx, source_guid); +} + #[cfg(test)] mod tests { use super::*; diff --git a/module/src/tripwires.rs b/module/src/tripwires.rs index 641dcbf4d..11500909a 100644 --- a/module/src/tripwires.rs +++ b/module/src/tripwires.rs @@ -191,6 +191,7 @@ pub(crate) mod character_owned_tripwire { ( &[ "game_creature_spawn", + "game_creature_population_member", "game_creature_spline", "game_encounter_spawn", ], @@ -876,7 +877,7 @@ mod partition_discipline_tripwire { ("module/src/package_import/creatures.rs", 1, "a Package Delta apply clears the whole Package creature range before it writes, so a Package that left the enabled set takes its spawns with it — realm-wide reconciliation, and the band has no index"), ("module/src/package_import/gameobjects.rs", 1, "the same whole-band clear for the Package gameobject range"), // Deliberately cross-partition: these ARE the code that manages partitions. - ("module/src/instance.rs", 8, "instance population spawn/teardown + the reaper's occupied-instance census — classifying every partition at once is the job"), + ("module/src/instance.rs", 7, "instance population spawn/teardown + the reaper's occupied-instance census — classifying every partition at once is the job"), ("module/src/encounter.rs", 2, "guid high-water marks over spawn/gameobject TEMPLATE rows when allocating a wave guid, not live-world reads"), // Unindexed lookups whose target is co-located with the caller anyway. ("module/src/creatures/pet.rs", 1, "`nearest_hostile_near` — KNOWN DEBT, a textbook `entities_near` radius search kept only because pets are rare (one per online warlock)"), diff --git a/module/src/world.rs b/module/src/world.rs index a443b2817..f6f252f2a 100644 --- a/module/src/world.rs +++ b/module/src/world.rs @@ -1015,7 +1015,7 @@ pub(crate) fn apply_player_login( { let reaped_id = character.pending_instance_id; match crate::instance::stranding_fallback( - crate::instance::entrance_fallback(character.map_id), + crate::instance::imported_entrance_fallback(ctx, character.map_id), crate::instance::is_dungeon_map(character.map_id), ) { crate::instance::StrandingFallback::Entrance(m, x, y, z, o) => { @@ -1028,7 +1028,7 @@ pub(crate) fn apply_player_login( crate::instance::StrandingFallback::InPlaceOpenWorld => {} crate::instance::StrandingFallback::HearthstoneHome => { spacetimedb::log::warn!( - "player_login: dungeon map {} has no entrance_fallback arm — diverting {} to hearthstone", + "player_login: dungeon map {} has no imported entrance; moving {} to hearthstone", character.map_id, character.guid ); character.map_id = character.home_map; diff --git a/module/tests/full_world_eventai.rs b/module/tests/full_world_eventai.rs new file mode 100644 index 000000000..0992e0a9d --- /dev/null +++ b/module/tests/full_world_eventai.rs @@ -0,0 +1,121 @@ +mod support; + +use std::time::{Duration, Instant}; +use support::{poll_until, Standalone, POLL_TIMEOUT}; + +const ENTRY: u32 = 51_000; +const CREATURE: u64 = (0xF130_u64 << 48) | ((ENTRY as u64) << 24) | 1; + +fn definition(enabled: bool) -> String { + let subject = format!("entry:{ENTRY}"); + let rules = format!( + "900001,health:0:100,100,4294967295,once,all,ordinary,any-posture,auto-attack:{}+sheath:2", + u8::from(enabled) + ); + let mut hasher = blake3::Hasher::new(); + hasher.update(b"lyracore-eventai-definition-v1"); + hasher.update(format!("{subject}@{rules}").as_bytes()); + let revision = u64::from_le_bytes(hasher.finalize().as_bytes()[..8].try_into().unwrap()); + format!("{subject}@{revision}@{rules}") +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn authored_auto_attack_switch_controls_real_scheduled_swings() { + let mut shard = Standalone::start("full-world-eventai"); + shard.publish_module(); + shard.assert_call("debug_spawn_player_entity", &["1"]); + let creature = shard + .query_rows(&format!( + "SELECT * FROM game_world_entity WHERE guid = {CREATURE}" + )) + .remove(0); + shard.assert_call( + "debug_teleport", + &[ + "1", + &creature["map_id"], + &creature["x"], + &creature["y"], + &creature["z"], + "0", + ], + ); + let stage = |enabled| { + shard.assert_call( + "debug_stage_eventai_revision_fixture", + &[&CREATURE.to_string(), "1", &definition(enabled)], + ) + }; + stage(false); + let state_query = format!( + "SELECT melee_enabled FROM game_creature_ai_state WHERE creature_guid = {CREATURE}" + ); + assert!(poll_until(POLL_TIMEOUT, || shard + .query_rows(&state_query) + .first() + .is_some_and(|row| row["melee_enabled"] == "false"))); + let swing_query = + format!("SELECT last_swing_ms FROM game_melee_attack WHERE attacker_guid = {CREATURE}"); + let last_swing = shard.query_rows(&swing_query).remove(0)["last_swing_ms"].clone(); + let deadline = Instant::now() + Duration::from_secs(3); + while Instant::now() < deadline { + assert_eq!( + shard.query_rows(&swing_query)[0]["last_swing_ms"], + last_swing + ); + std::thread::sleep(Duration::from_millis(100)); + } + let creature = shard + .query_rows(&format!( + "SELECT unit_bytes_2 FROM game_world_entity WHERE guid = {CREATURE}" + )) + .remove(0); + assert_eq!(creature["unit_bytes_2"].parse::().unwrap() & 0xff, 2); + stage(true); + assert!(poll_until(POLL_TIMEOUT, || shard.query_rows(&swing_query) + [0]["last_swing_ms"] + != "0")); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn instance_copies_inherit_the_source_guid_spawn_rule() { + let mut shard = Standalone::start("full-world-guid-ai"); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("install_guid_range", &["0"]); + let source = (0xF130u64 << 48) | (u64::from(ENTRY) << 24) | 1_000_036; + let subject = format!("guid:{source}"); + let rules = "900002,spawn:always,100,4294967295,once,all,ordinary,any-posture,sheath:2"; + let mut hasher = blake3::Hasher::new(); + hasher.update(b"lyracore-eventai-definition-v1"); + hasher.update(format!("{subject}@{rules}").as_bytes()); + let revision = u64::from_le_bytes(hasher.finalize().as_bytes()[..8].try_into().unwrap()); + shard.assert_call( + "import_creature_ai_definitions", + &[&format!("{subject}@{revision}@{rules}")], + ); + shard.assert_call( + "import_creature_spawns", + &[&format!("{source},{ENTRY},36,10,20,30,0,0,300")], + ); + for instance in [901, 902] { + shard.assert_call( + "ensure_instance", + &[&instance.to_string(), "36", "0", &support::actor("1")], + ); + let rows = shard.query_rows(&format!( + "SELECT guid FROM game_creature_spawn WHERE instance_id = {instance}" + )); + assert_eq!(rows.len(), 1); + let guid = &rows[0]["guid"]; + assert_ne!(guid, &source.to_string()); + let entity = shard + .query_rows(&format!( + "SELECT unit_bytes_2 FROM game_world_entity WHERE guid = {guid}" + )) + .remove(0); + assert_eq!(entity["unit_bytes_2"].parse::().unwrap() & 0xff, 2); + } +} diff --git a/module/tests/full_world_population.rs b/module/tests/full_world_population.rs new file mode 100644 index 000000000..22f463200 --- /dev/null +++ b/module/tests/full_world_population.rs @@ -0,0 +1,224 @@ +//! Source populations stay dormant until an Instance creates independent spawn and patrol copies. + +mod support; + +use std::time::Duration; + +use lyracore_shared::instance::{BATTLEGROUND_MAPS, DUNGEON_MAPS}; +use support::{actor, poll_until, Standalone}; + +fn source_guid(map_id: u32) -> u64 { + (0xF130u64 << 48) | (51000u64 << 24) | (1_000_000 + u64::from(map_id)) +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn all_dungeon_populations_have_independent_spawn_and_patrol_lifecycles() { + let mut shard = Standalone::start("full-world-population"); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("install_guid_range", &["0"]); + shard.assert_call("debug_seed_scenario_fixtures", &[]); + let maps: Vec = DUNGEON_MAPS + .iter() + .chain(BATTLEGROUND_MAPS) + .copied() + .chain([0, 1, 369, 449, 450]) + .collect(); + let packed = maps + .iter() + .map(|&map_id| format!("{},51000,{map_id},10,20,30,0,2,1", source_guid(map_id))) + .collect::>() + .join(";"); + shard.assert_call( + "import_creature_spawns", + &[&serde_json::to_string(&packed).unwrap()], + ); + let mut imported_maps: Vec = shard + .query_rows("SELECT map_id FROM game_creature_spawn_map") + .iter() + .map(|row| row["map_id"].parse().unwrap()) + .collect(); + imported_maps.sort_unstable(); + let mut expected_maps = maps.clone(); + expected_maps.sort_unstable(); + assert_eq!(imported_maps, expected_maps); + assert!(poll_until(Duration::from_secs(10), || { + shard + .query_rows("SELECT guid FROM game_world_entity WHERE entry = 51000") + .len() + == 5 + })); + for map_id in DUNGEON_MAPS.iter().chain(BATTLEGROUND_MAPS) { + assert!( + shard + .query_rows(&format!( + "SELECT guid FROM game_world_entity WHERE guid = {}", + source_guid(*map_id) + )) + .is_empty(), + "map {map_id} source became an instance-zero entity" + ); + } + for (index, &map_id) in DUNGEON_MAPS.iter().enumerate() { + let waypoint_id = 5_090_000 + index * 1000; + shard.assert_sql(&format!( + "INSERT INTO game_creature_waypoint (id, creature_guid, x, y, z) VALUES ({waypoint_id}, {}, 11, 20, 30)", + source_guid(map_id) + )); + let instance_id = 1000 + index; + shard.assert_call( + "ensure_instance", + &[ + &instance_id.to_string(), + &map_id.to_string(), + "0", + &actor("1"), + ], + ); + let copies = shard.query_rows(&format!( + "SELECT guid, instance_id, respawn_secs FROM game_creature_spawn WHERE instance_id = {instance_id}" + )); + assert_eq!(copies.len(), 1, "map {map_id}: {copies:?}"); + let guid = &copies[0]["guid"]; + assert_ne!(guid, &source_guid(map_id).to_string()); + assert_eq!(copies[0]["respawn_secs"], "1"); + assert_eq!( + shard + .query_rows(&format!( + "SELECT id FROM game_creature_waypoint WHERE creature_guid = {guid}" + )) + .len(), + 1 + ); + } + let instance_id = 1000 + + DUNGEON_MAPS + .iter() + .position(|map_id| *map_id == 533) + .unwrap(); + let query = format!("SELECT guid, instance_id, life_seq FROM game_creature_spawn WHERE instance_id = {instance_id}"); + let copy = shard.query_rows(&query).remove(0); + let guid = ©["guid"]; + shard.assert_call("debug_kill_creature", &["1", guid]); + assert!( + poll_until(Duration::from_secs(90), || { + shard + .query_rows(&query) + .first() + .is_some_and(|row| row["life_seq"] == "2") + }), + "Instance creature did not respawn" + ); + let live = shard.query_rows(&format!( + "SELECT instance_id, dead FROM game_world_entity WHERE guid = {guid}" + )); + assert_eq!(live[0]["instance_id"], instance_id.to_string()); + assert_eq!(live[0]["dead"], "false"); + shard.assert_call("debug_reap_instance", &[&instance_id.to_string()]); + assert!(shard.query_rows(&query).is_empty()); + assert!(shard + .query_rows(&format!( + "SELECT id FROM game_creature_waypoint WHERE creature_guid = {guid}" + )) + .is_empty()); + assert_eq!( + shard + .query_rows(&format!( + "SELECT guid FROM game_creature_spawn WHERE guid = {}", + source_guid(533) + )) + .len(), + 1 + ); + shard.assert_sql("INSERT INTO game_area (id,map_id,parent_area_id,area_bit,flags,exploration_level,faction_group,name) VALUES (5099000,33,0,0,0,0,0,'Fixture dungeon')"); + shard.assert_sql("INSERT INTO game_area_trigger (id,map_id,x,y,z,radius,box_length,box_width,box_height,box_yaw) VALUES (5099000,33,10,20,30,1,0,0,0,0)"); + shard.assert_sql("INSERT INTO game_areatrigger_teleport (trigger_id,target_map,x,y,z,o,name) VALUES (5099000,0,100,200,30,0,'Fixture exit')"); + shard.assert_sql("INSERT INTO game_graveyard (id,map_id,x,y,z,o,name) VALUES (5099001,0,10,20,30,0,'Near dungeon coordinates'),(5099002,0,110,200,30,0,'Near outside entrance')"); + shard.assert_sql("INSERT INTO game_graveyard_zone (row_id,safe_loc_id,zone_id,faction) VALUES (5099001,5099001,5099000,0),(5099002,5099002,5099000,0)"); + shard.assert_call("debug_spawn_player_entity", &["1"]); + shard.assert_call("debug_teleport", &["1", "33", "10", "20", "30", "0"]); + shard.assert_call("debug_spawn_player_entity", &["1"]); + shard.assert_call("debug_set_health", &["1", "0"]); + shard.assert_call("debug_repop", &["1"]); + let ghost = shard + .query_rows("SELECT map_id,x,y FROM game_character WHERE guid = 1") + .remove(0); + assert_eq!(ghost["map_id"], "0"); + assert_eq!(ghost["x"].parse::().unwrap(), 110.0); + assert_eq!(ghost["y"].parse::().unwrap(), 200.0); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_selected_world_entry_keeps_its_source_and_uses_its_own_guid_and_patrol() { + let mut shard = Standalone::start("world-source-choice"); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("install_guid_range", &["0"]); + shard.assert_call("debug_seed_scenario_fixtures", &[]); + let source = source_guid(0); + let selected = (source & !(0xFF_FFFFu64 << 24)) | (51001u64 << 24); + shard.assert_sql("INSERT INTO game_creature_population (population_id,max_count,world_state_condition,world_state_expression,flags,entries) VALUES (5099000,1,0,0,0,'')"); + shard.assert_sql(&format!("INSERT INTO game_creature_population_member (source_guid,population_id,entry,choices,chance,event_ids) VALUES ({source},5099000,0,'51001',0,'')")); + shard.assert_sql(&format!("INSERT INTO game_creature_waypoint (id,creature_guid,x,y,z) VALUES (5099000,{source},11,20,30)")); + let packed = serde_json::to_string(&format!("{source},51000,0,10,20,30,0,2,1")).unwrap(); + for _ in 0..2 { + shard.assert_call("import_creature_spawns", &[&packed]); + assert!(poll_until(Duration::from_secs(10), || { + !shard + .query_rows(&format!( + "SELECT guid FROM game_world_entity WHERE guid = {selected}" + )) + .is_empty() + })); + assert!(shard + .query_rows(&format!( + "SELECT guid FROM game_world_entity WHERE guid = {source}" + )) + .is_empty()); + let rows = shard.query_rows(&format!( + "SELECT entry,source_guid FROM game_creature_spawn WHERE guid = {selected}" + )); + assert_eq!(rows[0]["entry"], "51001"); + assert_eq!(rows[0]["source_guid"], source.to_string()); + assert_eq!( + shard + .query_rows(&format!( + "SELECT guid FROM game_creature_spawn WHERE guid = {source}" + )) + .len(), + 1 + ); + assert_eq!( + shard + .query_rows(&format!( + "SELECT id FROM game_creature_waypoint WHERE creature_guid = {selected}" + )) + .len(), + 1 + ); + assert_eq!( + shard + .query_rows(&format!( + "SELECT id FROM game_creature_waypoint WHERE creature_guid = {source}" + )) + .len(), + 1 + ); + } + let extra = + serde_json::to_string(&format!("{},51000,533,10,20,30,0,0,1", source_guid(533))).unwrap(); + shard.assert_call("import_creature_spawns_append", &[&extra]); + let mut maps: Vec = shard + .query_rows("SELECT map_id FROM game_creature_spawn_map") + .iter() + .map(|row| row["map_id"].parse().unwrap()) + .collect(); + maps.sort_unstable(); + assert_eq!(maps, [0, 533]); + shard.assert_call("import_creature_spawns", &[&packed]); + let maps = shard.query_rows("SELECT map_id FROM game_creature_spawn_map"); + assert_eq!(maps.len(), 1); + assert_eq!(maps[0]["map_id"], "0"); +} From 8b2a8e570f1f8160a61ec955dde9aced6d342c39 Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Mon, 12 Oct 2026 11:59:07 +0200 Subject: [PATCH 2/2] fix(importer): preserve pooled spawns and summoned quest givers --- docs/data-ingestion.md | 6 + gateway/src/codec/social/subscriptions.rs | 11 + gateway/src/world/benilla_tests/durable.rs | 17 +- gateway/tests/schema_parity.rs | 4 + importer/src/creature_population.rs | 303 +++++++++++++++++++-- importer/src/main.rs | 74 ++--- module/src/chat.rs | 8 +- module/src/creatures/mod.rs | 6 +- module/src/creatures/population.rs | 89 +++++- module/src/creatures/spawn.rs | 2 +- module/src/lib.rs | 8 +- module/src/tables.rs | 8 +- module/tests/full_world_eventai.rs | 60 +++- module/tests/full_world_population.rs | 76 +++++- 14 files changed, 587 insertions(+), 85 deletions(-) diff --git a/docs/data-ingestion.md b/docs/data-ingestion.md index 31e3cb4ce..5bc2a51b2 100644 --- a/docs/data-ingestion.md +++ b/docs/data-ingestion.md @@ -232,6 +232,9 @@ WORLD_PROFILE=full-world DUMP=.import/classic-db-full.sql DBC=/path/to/client/Da All authored creature positions are retained, including nonselectable creatures and population alternatives. Population metadata retains entry choices, count limits, chances and event links. +Legacy creature pools retain fractional chances and template-based membership. Equal-sized, +equal-chance nested pools with single-selection limits flatten to the same initial distribution. +Other nesting and mixed creature/GameObject pools stop the import before it writes data. The Module selects an initial population from that metadata. Selections last until reimport or Instance teardown; source pool rotation after deaths is not yet implemented. Positive game-event members and groups with world-state conditions remain dormant until an event system can evaluate @@ -243,6 +246,9 @@ raid Instance copies its selected creatures and patrols into that Instance. The and are removed with the Instance. This supplies map populations; it does not supply missing encounter scripts, raid lockouts or battleground rules. Continent terrain covers the full client grid; imported spawn coverage does not imply complete collision coverage for every interior. +Zone-wide creature speech requires imported terrain and area metadata to resolve its audience. +Reimport clears creature copies in existing Instances but retains their leases. Recreate those +Instances before resuming play. The EventAI compatibility gate still applies to the full catalogue. A larger scope does not approve dropped rules or unknown behavior. Existing smaller profiles retain their shared starting diff --git a/gateway/src/codec/social/subscriptions.rs b/gateway/src/codec/social/subscriptions.rs index e00066865..38930ac11 100644 --- a/gateway/src/codec/social/subscriptions.rs +++ b/gateway/src/codec/social/subscriptions.rs @@ -480,6 +480,7 @@ fn creature_chat_reaches(chat_type: u8, speaker: &WorldEntity, listener: &WorldE } else if lyracore_shared::chat::broadcast_chat::is_zone_wide(chat_type) { speaker.map_id == listener.map_id && speaker.instance_id == listener.instance_id + && speaker.zone_id != 0 && speaker.zone_id == listener.zone_id } else { let range = chat_range_yd(chat_type); @@ -528,6 +529,16 @@ mod creature_chat_tests { listener.map_id = 0; } assert!(!creature_chat_reaches(YELL, &speaker, &listener)); + let unresolved = WorldEntity { + zone_id: 0, + ..speaker + }; + listener.zone_id = 0; + assert!(!creature_chat_reaches( + CREATURE_ZONE_YELL, + &unresolved, + &listener + )); } #[test] diff --git a/gateway/src/world/benilla_tests/durable.rs b/gateway/src/world/benilla_tests/durable.rs index 0e835c81d..75d5fd016 100644 --- a/gateway/src/world/benilla_tests/durable.rs +++ b/gateway/src/world/benilla_tests/durable.rs @@ -81,12 +81,17 @@ impl Realm { self.coordinator .provision_account("OBSERVER", &[0; 32], &[0; 32]) .unwrap(); - let account = self - .coordinator - .account_by_username("OBSERVER") - .unwrap() - .unwrap() - .id; + let deadline = Instant::now() + Duration::from_secs(5); + let account = loop { + if let Some(account) = self.coordinator.account_by_username("OBSERVER").unwrap() { + break account.id; + } + assert!( + Instant::now() < deadline, + "Account projection did not arrive" + ); + std::thread::sleep(Duration::from_millis(10)); + }; assert_eq!( self.coordinator .create_character( diff --git a/gateway/tests/schema_parity.rs b/gateway/tests/schema_parity.rs index e9712198e..909290cb3 100644 --- a/gateway/tests/schema_parity.rs +++ b/gateway/tests/schema_parity.rs @@ -766,6 +766,9 @@ parity_test!(parity_game_hunter_pet_protocol, "game_hunter_pet_protocol", lyraco parity_test!(parity_game_config, "game_config", lyracore_module::ServerConfig, bindings::server_config_type::ServerConfig, { id, xp_rate, nav_enabled, hosts_instances, bots_idle, vmap_enabled, nav_coverage_enabled, }); +parity_test!(parity_game_creature_spawn_map, "game_creature_spawn_map", lyracore_module::CreatureSpawnMap, bindings::creature_spawn_map_type::CreatureSpawnMap, { + map_id, +}); parity_test!(parity_game_creature_template, "game_creature_template", lyracore_module::CreatureTemplate, bindings::creature_template_type::CreatureTemplate, { entry, name, subname, display_id, level, health, faction_template, npc_flags, unit_flags, creature_type, creature_family, type_flags, rank, scale, base_attack_time_ms, money_min, @@ -1165,6 +1168,7 @@ const MANIFEST_TABLES: &[&str] = &[ "game_hunter_pet_protocol", "game_config", "game_creature_template", + "game_creature_spawn_map", "game_taxi_node", "game_taxi_path", "game_taxi_path_node", diff --git a/importer/src/creature_population.rs b/importer/src/creature_population.rs index 150a9f439..0196c8c72 100644 --- a/importer/src/creature_population.rs +++ b/importer/src/creature_population.rs @@ -27,13 +27,13 @@ impl PopulationPlan { crate::push_insert( statements, "game_creature_population", - "population_id,max_count,world_state_condition,world_state_expression,flags,entries", + "population_id,max_count,world_state_condition,world_state_expression,flags,entries,is_pool", &self.definition_rows, ); crate::push_insert( statements, "game_creature_population_member", - "source_guid,population_id,entry,choices,chance,event_ids", + "source_guid,population_id,entry,choices,chance_bp,event_ids", &self.membership_rows, ); } @@ -45,6 +45,7 @@ struct Group { expression: u32, flags: u32, entries: Vec<(u32, u32, u32, u32)>, + is_pool: bool, } fn number(row: &[String], column: usize, table: &str) -> Result { @@ -69,6 +70,7 @@ fn source_groups(dump: &str) -> Result> { expression: number(&row, 5, "spawn_group")?, flags: number(&row, 6, "spawn_group")?, entries: Vec::new(), + is_pool: false, }, ) .is_some() @@ -106,8 +108,213 @@ fn source_groups(dump: &str) -> Result> { Ok(groups) } +struct Pool { + limit: u32, + members: BTreeMap, + children: Vec<(u64, u32)>, +} + +fn chance_bp(row: &[String], column: usize, table: &str) -> Result { + let chance: f64 = number(row, column, table)?; + if !chance.is_finite() || !(0.0..=100.0).contains(&chance) { + bail!("{table}: invalid chance {chance}"); + } + Ok((chance * 100.0).round() as u32) +} + +/// Equal-sized, equal-chance single-selection children have the same initial distribution +/// as one flat pool. Other nesting needs a hierarchical selector and fails before import. +fn flatten_pool( + id: u64, + pools: &BTreeMap, + visiting: &mut BTreeSet, +) -> Result> { + let pool = pools + .get(&id) + .with_context(|| format!("missing pool_template {id}"))?; + if !visiting.insert(id) { + bail!("cyclic creature pool {id}"); + } + let mut members = pool.members.clone(); + if !pool.children.is_empty() { + if pool.limit != 1 || !members.is_empty() { + bail!("creature pool {id}: unsupported nested limits or direct members"); + } + let mut size = None; + for &(child, chance) in &pool.children { + let child_pool = pools + .get(&child) + .with_context(|| format!("missing pool_template {child}"))?; + let leaves = flatten_pool(child, pools, visiting)?; + if chance != 0 + || child_pool.limit != 1 + || leaves.values().any(|chance| *chance != 0) + || size.is_some_and(|size| size != leaves.len()) + { + bail!("creature pool {id}: nested choices are not equal single selections"); + } + size = Some(leaves.len()); + for (guid, chance) in leaves { + if members.insert(guid, chance).is_some() { + bail!("duplicate creature {guid} in pool {id}"); + } + } + } + } + visiting.remove(&id); + Ok(members) +} + +fn add_legacy_pools( + dump: &str, + groups: &mut BTreeMap, + memberships: &mut BTreeMap, + events: &mut BTreeMap>, +) -> Result<()> { + let mut pools = BTreeMap::new(); + for row in parse_complete_table(dump, "pool_template")? { + let id = number(&row, 0, "pool_template")?; + if pools + .insert( + id, + Pool { + limit: number(&row, 1, "pool_template")?, + members: BTreeMap::new(), + children: Vec::new(), + }, + ) + .is_some() + { + bail!("duplicate pool_template {id}"); + } + } + let creatures = parse_complete_table(dump, "creature")?; + let mut by_entry = BTreeMap::>::new(); + let mut maps = BTreeMap::::new(); + for row in &creatures { + let guid = number(row, cr::GUID, "creature")?; + by_entry + .entry(number(row, cr::ID, "creature")?) + .or_default() + .push(guid); + maps.insert(guid, number(row, cr::MAP, "creature")?); + } + let mut used = BTreeSet::new(); + let mut seen = BTreeSet::new(); + for table in ["pool_creature", "pool_creature_template"] { + for row in parse_complete_table(dump, table)? { + let source = number(&row, 0, table)?; + let id = number(&row, 1, table)?; + let chance = chance_bp(&row, 2, table)?; + let pool = pools + .get_mut(&id) + .with_context(|| format!("{table}: missing pool_template {id}"))?; + let guids = if table == "pool_creature" { + vec![source] + } else { + by_entry.get(&source).cloned().unwrap_or_default() + }; + for guid in guids { + if !maps.contains_key(&guid) || !seen.insert(guid) { + bail!("{table}: missing or multiply pooled creature {guid}"); + } + pool.members.insert(guid, chance); + used.insert(id); + } + } + } + let mut parents = BTreeMap::new(); + for row in parse_complete_table(dump, "pool_pool")? { + let child = number(&row, 0, "pool_pool")?; + let parent = number(&row, 1, "pool_pool")?; + let chance = chance_bp(&row, 2, "pool_pool")?; + if parents.insert(child, parent).is_some() { + bail!("pool {child} has several parents"); + } + if let Some(pool) = pools.get_mut(&parent) { + pool.children.push((child, chance)); + } + } + let mut roots = BTreeSet::new(); + for mut id in used { + let mut chain = BTreeSet::new(); + while let Some(parent) = parents.get(&id) { + if !chain.insert(id) { + bail!("cyclic creature pool {id}"); + } + id = *parent; + } + roots.insert(id); + } + let mut object_pools = BTreeSet::new(); + for table in ["pool_gameobject", "pool_gameobject_template"] { + for row in parse_complete_table(dump, table)? { + let mut id = number(&row, 1, table)?; + let mut chain = BTreeSet::new(); + while let Some(parent) = parents.get(&id) { + if !chain.insert(id) { + bail!("cyclic GameObject pool {id}"); + } + id = *parent; + } + object_pools.insert(id); + } + } + for root in roots { + if object_pools.contains(&root) { + bail!("pool {root} mixes creatures and GameObjects"); + } + let members = flatten_pool(root, &pools, &mut BTreeSet::new())?; + let pool = &pools[&root]; + if pool.limit == 0 { + bail!("creature pool {root}: zero limit is unsupported"); + } + let map_ids: BTreeSet<_> = members.keys().map(|guid| maps[guid]).collect(); + if map_ids.len() != 1 { + bail!("creature pool {root} spans several maps"); + } + if pool.limit == 1 + && members.values().all(|chance| *chance != 0) + && members.values().sum::() != 10_000 + { + bail!("creature pool {root}: explicit chances must sum to 100 percent"); + } + let positive_events: BTreeSet = members + .keys() + .filter_map(|guid| events.get(guid)) + .flatten() + .copied() + .filter(|event| *event > 0) + .collect(); + let population_id = (2_u64 << 32) + root; + if groups + .insert( + population_id, + Group { + max_count: pool.limit, + condition: 0, + expression: 0, + flags: 0, + entries: Vec::new(), + is_pool: true, + }, + ) + .is_some() + { + bail!("population id collision for pool {root}"); + } + for (guid, chance) in members { + if memberships.insert(guid, (population_id, chance)).is_some() { + bail!("creature {guid} belongs to both a spawn group and a pool"); + } + events.entry(guid).or_default().extend(&positive_events); + } + } + Ok(()) +} + pub(crate) fn collect(dump: &str, scope: &WorldImportScope) -> Result { - let groups = source_groups(dump)?; + let mut groups = source_groups(dump)?; let mut memberships = BTreeMap::new(); for row in parse_complete_table(dump, "spawn_group_spawn")? { let id = number(&row, 0, "spawn_group_spawn")?; @@ -119,7 +326,7 @@ pub(crate) fn collect(dump: &str, scope: &WorldImportScope) -> Result 100 { bail!("spawn_group_spawn {guid}: chance {chance} exceeds 100"); } - if memberships.insert(guid, (id, chance)).is_some() { + if memberships.insert(guid, (id, chance * 100)).is_some() { bail!("creature source guid {guid} belongs to several groups"); } } @@ -141,6 +348,7 @@ pub(crate) fn collect(dump: &str, scope: &WorldImportScope) -> Result = parse_complete_table(dump, "creature_template")? .iter() .map(|row| number(row, 0, "creature_template")) @@ -245,24 +453,26 @@ pub(crate) fn collect(dump: &str, scope: &WorldImportScope) -> Result>() - .join(";"), - ) - } else { - (1, 0, 0, 0, String::new()) - }; + let (max, condition, expression, flags, entries, is_pool) = + if let Some(group) = groups.get(&id) { + ( + group.max_count, + group.condition, + group.expression, + group.flags, + group + .entries + .iter() + .map(|(e, min, max, chance)| format!("{e},{min},{max},{chance}")) + .collect::>() + .join(";"), + group.is_pool, + ) + } else { + (1, 0, 0, 0, String::new(), false) + }; plan.definition_rows.push(format!( - "({id},{max},{condition},{expression},{flags},{})", + "({id},{max},{condition},{expression},{flags},{},{is_pool})", sql_str(&entries) )); } @@ -349,4 +559,55 @@ mod tests { let scope = WorldImportScope::quest_catalogue().unwrap(); assert!(collect("INSERT INTO `spawn_group` VALUES (1", &scope).is_err()); } + + #[test] + fn template_and_nested_pools_keep_limits_and_fractional_chances() { + let dump = "INSERT INTO `creature_template` VALUES (10),(20); + INSERT INTO `creature` VALUES (1,10,0,0,1,2,3,0,60,60,0,0),(2,10,0,0,4,5,6,0,60,60,0,0),(3,20,0,0,4,5,6,0,60,60,0,0),(4,20,0,0,4,5,6,0,60,60,0,0); + INSERT INTO `pool_template` VALUES (1,1,'root'),(2,1,'child'),(3,1,'child'); + INSERT INTO `pool_creature_template` VALUES (10,2,0,''),(20,3,0,''); + INSERT INTO `pool_pool` VALUES (2,1,0,''),(3,1,0,'');"; + let plan = collect(dump, &full_scope()).unwrap(); + assert_eq!(plan.spawns.len(), 4); + assert_eq!(plan.definition_rows, ["(8589934593,1,0,0,0,'',true)"]); + assert_eq!(plan.membership_rows.len(), 4); + let unequal = dump.replace("(3,1,'child')", "(3,2,'child')"); + assert!(collect(&unequal, &full_scope()) + .err() + .unwrap() + .to_string() + .contains("nested choices")); + let fractional = dump.split("INSERT INTO `pool_template`").next().unwrap().to_owned() + + "INSERT INTO `pool_template` VALUES (1,1,'rare'); INSERT INTO `pool_creature` VALUES (1,1,7.5,''),(2,1,0,''); INSERT INTO `game_event_creature` VALUES (1,27);"; + let plan = collect(&fractional, &full_scope()).unwrap(); + assert!(plan + .membership_rows + .iter() + .any(|row| row.contains(",750,'27')"))); + assert!(plan + .membership_rows + .iter() + .all(|row| row.ends_with("'27')"))); + } + + #[test] + #[ignore = "requires pinned LYRACORE_CLASSIC_DB_SQL"] + fn pinned_legacy_pools_cover_every_direct_and_template_position() { + let dump = crate::read_dump(&std::env::var("LYRACORE_CLASSIC_DB_SQL").unwrap()).unwrap(); + let plan = collect(&dump, &full_scope()).unwrap(); + assert_eq!(plan.spawns.len(), 66_310); + assert_eq!( + plan.definition_rows + .iter() + .filter(|row| row.ends_with(",true)")) + .count(), + 270 + ); + let members = plan + .membership_rows + .iter() + .filter(|row| row.split(',').nth(1).unwrap().parse::().unwrap() >= (2_u64 << 32)) + .count(); + assert_eq!(members, 998); + } } diff --git a/importer/src/main.rs b/importer/src/main.rs index 192b68e4a..841345299 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -4051,12 +4051,13 @@ fn build_dump_plan( let mut obtainable_items = creature_drop_item_set(dump, quest_loot_ids); obtainable_items.extend(gameobject_loot_item_set(dump, &quest_gameobjects.used_go)); obtainable_items.extend(vendor_stock_item_set(dump, quest_entries)); - let local_creature_entries = &population.referenced_entries; + let mut local_creature_entries = population.referenced_entries.clone(); + local_creature_entries.extend(&eventai.forced_template_entries); let giver_entries = if args .world_profile .is_some_and(WorldImportProfile::imports_all_templates) { - local_creature_entries + &local_creature_entries } else { &entries }; @@ -7834,38 +7835,45 @@ mod tests { quest_template_row(500, "Placed giver"), quest_template_row(501, "Summon giver"), eventai_rule_row(10,100,0,0,100,0,[0,1,0,1,0,0],[[32,200,0,700],[0;4],[0;4]]), ); - let mut args = parse_args_from([ - "--dump", - "synthetic.sql", - "--world-profile", + for profile in [ "starting-eastern", - ]) - .unwrap(); - let full = build_dump_plan(&dump, &args, &None, &None).unwrap(); - args.family = Some("quests".into()); - let partial = build_dump_plan(&dump, &args, &None, &None).unwrap(); - let quests = |p: &DumpPlan| { - p.stmts - .iter() - .filter(|s| s.starts_with("INSERT INTO game_quest_template")) - .cloned() - .collect::>() - }; - assert!(quests(&full).join("").contains("(501,")); - assert_eq!( - quests(&full), - quests(&partial), - "quest reimport must preserve shared definitions" - ); - let givers = |plan: &DumpPlan| { - plan.stmts - .iter() - .filter(|statement| statement.starts_with("INSERT INTO game_creature_quest")) - .cloned() - .collect::>() - }; - assert!(givers(&full).join("").contains(",200,501,0)")); - assert_eq!(givers(&full), givers(&partial)); + "full-world", + "full-eastern", + "full-instances", + ] { + let dump = if profile == "full-instances" { + dump.replace("(1,100,0,1,", "(1,100,36,1,") + } else { + dump.clone() + }; + let mut args = + parse_args_from(["--dump", "synthetic.sql", "--world-profile", profile]).unwrap(); + let full = build_dump_plan(&dump, &args, &None, &None).unwrap(); + args.family = Some("quests".into()); + let partial = build_dump_plan(&dump, &args, &None, &None).unwrap(); + let quests = |p: &DumpPlan| { + p.stmts + .iter() + .filter(|s| s.starts_with("INSERT INTO game_quest_template")) + .cloned() + .collect::>() + }; + assert!(quests(&full).join("").contains("(501,")); + assert_eq!( + quests(&full), + quests(&partial), + "quest reimport must preserve shared definitions" + ); + let givers = |plan: &DumpPlan| { + plan.stmts + .iter() + .filter(|statement| statement.starts_with("INSERT INTO game_creature_quest")) + .cloned() + .collect::>() + }; + assert!(givers(&full).join("").contains(",200,501,0)")); + assert_eq!(givers(&full), givers(&partial)); + } } #[test] diff --git a/module/src/chat.rs b/module/src/chat.rs index 1d5cfa18e..666723d32 100644 --- a/module/src/chat.rs +++ b/module/src/chat.rs @@ -155,7 +155,7 @@ pub(crate) fn apply_send_chat_to( /// reach), message normalization, and the EMOTE-always-Universal rule (cm:Player.cpp:16594). fn write_chat_event( ctx: &ReducerContext, - sender: crate::WorldEntity, + mut sender: crate::WorldEntity, target_guid: u64, chat_type: u8, language: u8, @@ -172,6 +172,12 @@ fn write_chat_event( } else { language }; + if lyracore_shared::chat::broadcast_chat::is_zone_wide(chat_type) { + // Terrain can arrive after the creature import, and creatures can move between zones. + sender.zone_id = + crate::terrain::zone_id_at(ctx, sender.map_id, sender.x, sender.y).unwrap_or(0); + sender = ctx.db.game_world_entity().guid().update(sender); + } ctx.db.game_chat_event().insert(ChatEvent { id: 0, sender_guid: sender.guid, diff --git a/module/src/creatures/mod.rs b/module/src/creatures/mod.rs index 99ef99b8e..36e138c4c 100644 --- a/module/src/creatures/mod.rs +++ b/module/src/creatures/mod.rs @@ -96,9 +96,9 @@ pub use spawn::{ game_creature_spawn, game_creature_spell, game_creature_template, game_creature_waypoint, game_gossip_menu, game_gossip_menu_profile, game_gossip_menu_profile_option, game_gossip_option, game_npc_text, game_npc_text_slot, CreatureCast, CreatureFamily, - CreatureGossipMenuOverride, CreatureSpawn, CreatureSpell, CreatureTemplate, CreatureWaypoint, - GossipMenu, GossipMenuProfile, GossipMenuProfileOption, GossipOption, NpcText, NpcTextSlot, - MOVEMENT_IDLE, MOVEMENT_RANDOM, + CreatureGossipMenuOverride, CreatureSpawn, CreatureSpawnMap, CreatureSpell, CreatureTemplate, + CreatureWaypoint, GossipMenu, GossipMenuProfile, GossipMenuProfileOption, GossipOption, + NpcText, NpcTextSlot, MOVEMENT_IDLE, MOVEMENT_RANDOM, }; #[cfg(test)] diff --git a/module/src/creatures/population.rs b/module/src/creatures/population.rs index 48360d967..77c43d774 100644 --- a/module/src/creatures/population.rs +++ b/module/src/creatures/population.rs @@ -16,6 +16,8 @@ pub struct CreaturePopulation { pub flags: u32, /// Rows of entry,min_count,max_count,chance. Zero chance shares the remaining probability. pub entries: String, + #[default(false)] + pub is_pool: bool, } #[table(accessor = game_creature_population_member, index(accessor = by_population, btree(columns = [population_id])))] @@ -26,7 +28,7 @@ pub struct CreaturePopulationMember { pub population_id: u64, pub entry: u32, pub choices: String, - pub chance: u32, + pub chance_bp: u32, /// Positive ids require an active game event; negative ids remove a spawn during that event. pub event_ids: String, } @@ -58,7 +60,7 @@ struct Member { guid: u64, entry: u32, choices: Vec, - chance: u32, + chance_bp: u32, requires_event: bool, } @@ -99,14 +101,14 @@ fn decode_entries(text: &str) -> Result, String> { fn decode_member(member: &CreaturePopulationMember) -> Result { let choices = numbers::(&member.choices)?; let events = numbers::(&member.event_ids)?; - if member.chance > 100 || choices.contains(&0) || events.contains(&0) { + if member.chance_bp > 10_000 || choices.contains(&0) || events.contains(&0) { return Err(format!("invalid population member {}", member.source_guid)); } Ok(Member { guid: member.source_guid, entry: member.entry, choices, - chance: member.chance, + chance_bp: member.chance_bp, requires_event: events.iter().any(|event| *event > 0), }) } @@ -176,6 +178,46 @@ fn choose_index(len: usize, draw: &mut impl FnMut() -> u64) -> usize { (draw() % len as u64) as usize } +fn select_pool( + limit: u32, + members: &[Member], + draw: &mut impl FnMut() -> u64, +) -> Vec { + let mut eligible: Vec<_> = members + .iter() + .filter(|member| !member.requires_event) + .collect(); + for index in (1..eligible.len()).rev() { + let other = choose_index(index + 1, draw); + eligible.swap(index, other); + } + if limit == 1 { + // The source compares every explicit chance with the same roll, then falls back to + // an equal-chance member or the first explicit member. It is not a cumulative roll. + let roll = draw() % 10_000; + let chosen = eligible + .iter() + .find(|member| member.chance_bp != 0 && roll < u64::from(member.chance_bp)) + .or_else(|| eligible.iter().find(|member| member.chance_bp == 0)) + .or_else(|| eligible.first()) + .copied(); + eligible = chosen.into_iter().collect(); + } else { + eligible.truncate(limit as usize); + } + eligible + .into_iter() + .map(|member| PopulationAssignment { + source_guid: member.guid, + entry: if member.choices.is_empty() { + member.entry + } else { + member.choices[choose_index(member.choices.len(), draw)] + }, + }) + .collect() +} + fn select( population: &CreaturePopulation, members: &[Member], @@ -185,11 +227,14 @@ fn select( if population.world_state_condition != 0 || population.world_state_expression != 0 { return Vec::new(); } + if population.is_pool { + return select_pool(population.max_count, members, &mut draw); + } let mut eligible: Vec<&Member> = members .iter() .filter(|member| { !member.requires_event - && (member.chance == 0 || draw() % 100 < u64::from(member.chance)) + && (member.chance_bp == 0 || draw() % 10_000 < u64::from(member.chance_bp)) }) .collect(); for index in (1..eligible.len()).rev() { @@ -355,6 +400,7 @@ mod tests { world_state_expression: 0, flags: 0, entries: entries.to_owned(), + is_pool: false, } } @@ -363,11 +409,36 @@ mod tests { guid, entry, choices: Vec::new(), - chance: 0, + chance_bp: 0, requires_event: false, } } + #[test] + fn legacy_pool_uses_one_fractional_roll_then_the_default() { + let mut rare = member(1, 10); + rare.chance_bp = 750; + let members = [rare, member(2, 20)]; + assert_eq!(select_pool(1, &members, &mut || 749)[0].entry, 10); + assert_eq!(select_pool(1, &members, &mut || 750)[0].entry, 20); + assert_eq!(select_pool(1, &members, &mut || 9_999)[0].entry, 20); + } + + #[test] + fn multi_member_pool_ignores_explicit_chances_and_preserves_event_gates() { + let members: Vec<_> = (1..=8) + .map(|guid| { + let mut member = member(guid, 10); + member.chance_bp = 75; + member.requires_event = guid > 6; + member + }) + .collect(); + let selected = select_pool(4, &members, &mut || 9_999); + assert_eq!(selected.len(), 4); + assert!(selected.iter().all(|choice| choice.source_guid <= 6)); + } + #[test] fn group_selection_obeys_entry_minimum_and_maximum() { let group = definition(3, "10,2,2,0;20,1,1,0"); @@ -429,8 +500,8 @@ mod tests { let mut event = member(1, 10); event.requires_event = true; let mut chance = member(2, 20); - chance.chance = 10; - let selected = select(&group, &[event, chance, member(3, 30)], &[], || 50); + chance.chance_bp = 1_000; + let selected = select(&group, &[event, chance, member(3, 30)], &[], || 5_000); assert_eq!(selected.len(), 1); assert_eq!(selected[0].source_guid, 3); } @@ -442,7 +513,7 @@ mod tests { population_id: 1, entry: 10, choices: String::new(), - chance: 0, + chance_bp: 0, event_ids: "-4".to_owned(), }; let member = decode_member(&source).unwrap(); diff --git a/module/src/creatures/spawn.rs b/module/src/creatures/spawn.rs index a19aba115..053090d4a 100644 --- a/module/src/creatures/spawn.rs +++ b/module/src/creatures/spawn.rs @@ -755,7 +755,7 @@ pub(crate) fn build_creature_entity( sheet_crit_bp: 0, bank_bag_slots: 0, // a creature owns no bank slots mount_display_id: 0, // creatures do not use the player taxi presentation field - zone_id: 0, // unresolved: nothing routes zone-scoped delivery to a creature + zone_id: 0, // resolved at emission for zone-wide creature speech sheet_ranged_ap: 0, sheet_ranged_dmg_min: 0, sheet_ranged_dmg_max: 0, diff --git a/module/src/lib.rs b/module/src/lib.rs index 4a334df85..16b04fed2 100644 --- a/module/src/lib.rs +++ b/module/src/lib.rs @@ -564,10 +564,10 @@ pub use tables::{ CreatureAiRuleState, CreatureAiSpellMetadata, CreatureAiState, CreatureAiSummon, CreatureAiSummonExpiry, CreatureAiSummonOrigin, CreatureCast, CreatureFamily, CreatureGossipMenuOverride, CreatureMoveEvent, CreatureMoveSchedule, CreaturePresentation, - CreatureRelayTemporaryFaction, CreatureSpawn, CreatureSpell, CreatureSpline, CreatureTemplate, - CreatureWaypoint, GossipMenu, GossipMenuProfile, GossipMenuProfileOption, GossipOption, - HunterPet, HunterPetProtocol, LivePetKind, NpcText, NpcTextSlot, PetCareSchedule, PetCommand, - RelayArrival, RelayContinuation, RelayDefinition, RelayRun, + CreatureRelayTemporaryFaction, CreatureSpawn, CreatureSpawnMap, CreatureSpell, CreatureSpline, + CreatureTemplate, CreatureWaypoint, GossipMenu, GossipMenuProfile, GossipMenuProfileOption, + GossipOption, HunterPet, HunterPetProtocol, LivePetKind, NpcText, NpcTextSlot, PetCareSchedule, + PetCommand, RelayArrival, RelayContinuation, RelayDefinition, RelayRun, }; pub use tables::{game_creature_dead_callback_cast_admission, CreatureDeadCallbackCastAdmission}; pub use tables::{game_creature_distraction, CreatureDistraction}; diff --git a/module/src/tables.rs b/module/src/tables.rs index 69e9d7bf0..cfb223b42 100644 --- a/module/src/tables.rs +++ b/module/src/tables.rs @@ -74,10 +74,10 @@ pub use crate::creatures::{ CreatureAiRuleState, CreatureAiSpellMetadata, CreatureAiState, CreatureAiSummon, CreatureAiSummonExpiry, CreatureAiSummonOrigin, CreatureCast, CreatureFamily, CreatureGossipMenuOverride, CreatureMoveEvent, CreatureMoveSchedule, CreaturePresentation, - CreatureRelayTemporaryFaction, CreatureSpawn, CreatureSpell, CreatureSpline, CreatureTemplate, - CreatureWaypoint, GossipMenu, GossipMenuProfile, GossipMenuProfileOption, GossipOption, - HunterPet, HunterPetProtocol, LivePetKind, NpcText, NpcTextSlot, PetCareSchedule, PetCommand, - RelayArrival, RelayContinuation, RelayDefinition, RelayRun, + CreatureRelayTemporaryFaction, CreatureSpawn, CreatureSpawnMap, CreatureSpell, CreatureSpline, + CreatureTemplate, CreatureWaypoint, GossipMenu, GossipMenuProfile, GossipMenuProfileOption, + GossipOption, HunterPet, HunterPetProtocol, LivePetKind, NpcText, NpcTextSlot, PetCareSchedule, + PetCommand, RelayArrival, RelayContinuation, RelayDefinition, RelayRun, }; #[cfg(feature = "debug_reducers")] pub use crate::debug::{ diff --git a/module/tests/full_world_eventai.rs b/module/tests/full_world_eventai.rs index 0992e0a9d..79f188f48 100644 --- a/module/tests/full_world_eventai.rs +++ b/module/tests/full_world_eventai.rs @@ -7,11 +7,15 @@ const ENTRY: u32 = 51_000; const CREATURE: u64 = (0xF130_u64 << 48) | ((ENTRY as u64) << 24) | 1; fn definition(enabled: bool) -> String { - let subject = format!("entry:{ENTRY}"); let rules = format!( "900001,health:0:100,100,4294967295,once,all,ordinary,any-posture,auto-attack:{}+sheath:2", u8::from(enabled) ); + packed_definition(&rules) +} + +fn packed_definition(rules: &str) -> String { + let subject = format!("entry:{ENTRY}"); let mut hasher = blake3::Hasher::new(); hasher.update(b"lyracore-eventai-definition-v1"); hasher.update(format!("{subject}@{rules}").as_bytes()); @@ -19,6 +23,60 @@ fn definition(enabled: bool) -> String { format!("{subject}@{revision}@{rules}") } +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn zone_speech_resolves_terrain_imported_after_the_creature_spawn() { + let mut shard = Standalone::start("full-world-zone-speech"); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("debug_spawn_player_entity", &["1"]); + let creature = shard + .query_rows(&format!( + "SELECT * FROM game_world_entity WHERE guid = {CREATURE}" + )) + .remove(0); + assert_eq!(creature["zone_id"], "0"); + shard.assert_call( + "debug_teleport", + &[ + "1", + &creature["map_id"], + &creature["x"], + &creature["y"], + &creature["z"], + "0", + ], + ); + shard.assert_sql("INSERT INTO game_creature_ai_broadcast_text (id,male_text,female_text,chat_type,language_id,emote_delay_1_ms,emote_id_1,emote_delay_2_ms,emote_id_2,emote_delay_3_ms,emote_id_3) VALUES (5099000,'Zone speech','',6,0,0,0,0,0,0,0)"); + let rules = "900003,health:0:100,100,4294967295,repeat:500:500,all,ordinary,any-posture,auto-attack:0+speak:zone-yell:self:5099000"; + shard.assert_call( + "debug_stage_eventai_revision_fixture", + &[&CREATURE.to_string(), "1", &packed_definition(rules)], + ); + let cell_x = lyracore_shared::terrain::cell_index(creature["x"].parse().unwrap()).unwrap(); + let cell_y = lyracore_shared::terrain::cell_index(creature["y"].parse().unwrap()).unwrap(); + for zone in [5099001, 5099002] { + shard.assert_sql(&format!("INSERT INTO game_area (id,map_id,parent_area_id,area_bit,flags,exploration_level,faction_group,name) VALUES ({zone},{},0,0,0,0,0,'Speech zone')", creature["map_id"])); + let heights = vec!["0"; 145].join(":"); + shard.assert_call( + "import_terrain_chunks", + &[&format!( + "{},{cell_x},{cell_y},{},0,0,{zone},{heights}", + creature["map_id"], creature["z"] + )], + ); + assert!(poll_until(POLL_TIMEOUT, || shard.query_rows(&format!( + "SELECT zone_id FROM game_world_entity WHERE guid = {CREATURE}" + ))[0]["zone_id"] + == zone.to_string())); + } + assert!(!shard + .query_rows(&format!( + "SELECT id FROM game_chat_event WHERE sender_guid = {CREATURE} AND chat_type = 6" + )) + .is_empty()); +} + #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn authored_auto_attack_switch_controls_real_scheduled_swings() { diff --git a/module/tests/full_world_population.rs b/module/tests/full_world_population.rs index 22f463200..930f66965 100644 --- a/module/tests/full_world_population.rs +++ b/module/tests/full_world_population.rs @@ -11,6 +11,78 @@ fn source_guid(map_id: u32) -> u64 { (0xF130u64 << 48) | (51000u64 << 24) | (1_000_000 + u64::from(map_id)) } +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn legacy_pools_choose_one_position_and_keep_instance_lives_independent() { + let mut shard = Standalone::start("legacy-creature-pools"); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("install_guid_range", &["0"]); + let mut packed = Vec::new(); + for (pool, map, count, event) in [(1069, 0, 8, ""), (46330, 230, 4, ""), (1163, 0, 3, "27")] { + let population = (2_u64 << 32) + pool; + shard.assert_sql(&format!("INSERT INTO game_creature_population (population_id,max_count,world_state_condition,world_state_expression,flags,entries,is_pool) VALUES ({population},1,0,0,0,'',true)")); + for index in 0..count { + let guid = (0xF130u64 << 48) | (51000u64 << 24) | (pool * 100 + index); + shard.assert_sql(&format!("INSERT INTO game_creature_population_member (source_guid,population_id,entry,choices,chance_bp,event_ids) VALUES ({guid},{population},51000,'',0,'{event}')")); + packed.push(format!("{guid},51000,{map},10,20,30,0,0,1")); + } + } + shard.assert_call("import_creature_spawns", &[&packed.join(";")]); + assert_eq!( + shard + .query_rows("SELECT guid FROM game_creature_spawn WHERE source_guid = 0") + .len(), + 15 + ); + assert!(poll_until(Duration::from_secs(10), || shard + .query_rows("SELECT guid FROM game_world_entity WHERE entry = 51000") + .len() + == 1)); + let mut copies = Vec::new(); + for instance in [901, 902] { + shard.assert_call( + "ensure_instance", + &[&instance.to_string(), "230", "0", &actor("1")], + ); + let rows = shard.query_rows(&format!( + "SELECT guid FROM game_creature_spawn WHERE instance_id = {instance}" + )); + assert_eq!(rows.len(), 1); + copies.push(rows[0]["guid"].clone()); + } + assert_ne!(copies[0], copies[1]); + shard.assert_call("debug_kill_creature", &["1", &copies[0]]); + assert!(poll_until(Duration::from_secs(90), || { + let survivor = shard.query_rows(&format!( + "SELECT dead FROM game_world_entity WHERE guid = {}", + copies[1] + )); + assert_eq!(survivor[0]["dead"], "false"); + shard.query_rows(&format!( + "SELECT life_seq FROM game_creature_spawn WHERE guid = {}", + copies[0] + ))[0]["life_seq"] + == "2" + })); + shard.assert_call("debug_reap_instance", &["901"]); + assert!(shard + .query_rows(&format!( + "SELECT guid FROM game_world_entity WHERE guid = {}", + copies[0] + )) + .is_empty()); + assert_eq!( + shard + .query_rows(&format!( + "SELECT guid FROM game_world_entity WHERE guid = {}", + copies[1] + )) + .len(), + 1 + ); +} + #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn all_dungeon_populations_have_independent_spawn_and_patrol_lifecycles() { @@ -159,8 +231,8 @@ fn a_selected_world_entry_keeps_its_source_and_uses_its_own_guid_and_patrol() { shard.assert_call("debug_seed_scenario_fixtures", &[]); let source = source_guid(0); let selected = (source & !(0xFF_FFFFu64 << 24)) | (51001u64 << 24); - shard.assert_sql("INSERT INTO game_creature_population (population_id,max_count,world_state_condition,world_state_expression,flags,entries) VALUES (5099000,1,0,0,0,'')"); - shard.assert_sql(&format!("INSERT INTO game_creature_population_member (source_guid,population_id,entry,choices,chance,event_ids) VALUES ({source},5099000,0,'51001',0,'')")); + shard.assert_sql("INSERT INTO game_creature_population (population_id,max_count,world_state_condition,world_state_expression,flags,entries,is_pool) VALUES (5099000,1,0,0,0,'',false)"); + shard.assert_sql(&format!("INSERT INTO game_creature_population_member (source_guid,population_id,entry,choices,chance_bp,event_ids) VALUES ({source},5099000,0,'51001',0,'')")); shard.assert_sql(&format!("INSERT INTO game_creature_waypoint (id,creature_guid,x,y,z) VALUES (5099000,{source},11,20,30)")); let packed = serde_json::to_string(&format!("{source},51000,0,10,20,30,0,2,1")).unwrap(); for _ in 0..2 {