diff --git a/CHANGELOG.md b/CHANGELOG.md index ef1144d7d..7db49975d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ and is not reconstructed here. under `packages/*/src/` against that list and fails on a path outside it, naming the Package, the file, the line and the path. A Package that needs a path off the surface writes `// package-api: exempt ` on the line that names it. +- **Package fixtures.** A Module built with `debug_reducers` adds `crate::package_fixture`, a + Package API root for a Package's own debug fixtures. It applies real Core damage, removes a live + Character as a logout does, refuses imported content, reads the top threat target, casts through + the client cast Gates and declares the next creature movement tick. A Package names it only from + a file gated on `debug_reducers`, and the lint refuses it anywhere else. - **Package-owned Accounts.** `package_account::create_package_character` lets a Package create a Character with no Session. It applies the name, race and class Refusals of client creation, and puts the Character on an Account without credentials. `game_package_account` records which diff --git a/CONTEXT.md b/CONTEXT.md index 3f2a25a81..b8fb3a68b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -917,6 +917,12 @@ outside it, so a core refactor breaks a Package at compile time rather than on a compatibility contract, never a sandbox: compiled Package code is trusted either way. _Avoid_: SDK, plugin API, public API, allowlist +**Package Fixture**: +Package code that stages or observes state for the Package's own durable tests. It compiles only +with `debug_reducers` and reaches Core through `crate::package_fixture`, the one Package API root a +release build does not have. +_Avoid_: test harness, debug hook, test helper + **Package Config**: A row of `game_package_config`, keyed by `(package_name, key)`: one durable value a Package reads and the Operator edits. A Package seeds its own defaults idempotently, from its own ensure/init diff --git a/docs/package-api.md b/docs/package-api.md index d1ed19c26..ac8e98188 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -200,6 +200,26 @@ caller keeps its own fallback, which is what makes a Runtime Script an override dependency. Core hook events reach bound scripts on their own; a Package fires only its own Package Events. +### Package fixtures (debug only) + +`crate::package_fixture` exists only in a Module built with `debug_reducers`. It holds the setup and +observation steps a Package's own debug fixtures need. A release build has no such root. + +| operation | does | +|---|---| +| `apply_damage(ctx, target_guid, amount, attacker_guid)` | deals main-hand damage through the real Core damage pipeline, capped one below the target's health | +| `remove_live_character(ctx, character_guid)` | removes a live Character from the world as a logout does; `on_logout` fires and the Character row stays | +| `require_no_imported_content(ctx)` | refuses when the Shard holds imported content; the temporary weather seed a fresh Module stamps does not count | +| `top_threat_target(ctx, creature_guid)` | reads the highest-threat living source on the creature's map and instance | +| `client_cast(ctx, caster_guid, spell_id, target_guid)` | casts through the same Gates a client cast passes | +| `declare_next_movement_tick(ctx, delay)` | makes the next creature movement tick fire once, `delay` from now; refuses unless the catch-all tick is the only movement schedule | + +A fixture reads the navigation revision through `nav::inputs(ctx, map_id).imported_revision`. + +Name this root only from a Package file whose first non-blank line is +`#![cfg(feature = "debug_reducers")]`. The lint refuses it anywhere else, and no exemption clears +it, because a release build would compile that file without the root. + ### Tables A Package declares its own tables with `#[table(accessor = pkg__, ..)]`, the naming @@ -224,6 +244,8 @@ package_config quest script_binding spell stats terrain transfer world xp ``` +Debug only: `package_fixture`, in a file gated on `debug_reducers` (see Package fixtures above). + Plus, at the crate root: any `game_*` name (a table accessor or registration marker), any `pkg_*` name (a Package's own generated root module), any type name in UpperCamelCase (a row or payload type), `character_owned!`, `encounter_package!`, and `CHARACTER_OWNED_TABLES`. diff --git a/module/build.rs b/module/build.rs index 78145a908..b7a6b38e7 100644 --- a/module/build.rs +++ b/module/build.rs @@ -62,7 +62,9 @@ //! The same pass also lints each package file against the Package API surface //! (`PACKAGE_API_ROOTS`, documented at `docs/package-api.md`): a path that reaches the crate root //! outside it fails the build naming the Package, the file, the line and the path, unless the line -//! carries `// package-api: exempt `. Core `src/` is never linted. +//! carries `// package-api: exempt `. A debug root (`PACKAGE_API_DEBUG_ROOTS`) is on the +//! surface only in a file that starts with the debug cfg above, and no exemption clears it. Core +//! `src/` is never linted. //! //! EVALUATED AND REJECTED: replacing the marker scan with an explicit per-package `register()` //! convention. The registries are const fn-pointer arrays (no allocator-dependent init order in @@ -287,6 +289,11 @@ const PACKAGE_API_ROOTS: &[&str] = &[ "xp", ]; +/// Roots on the surface only in a Package file gated by `DEBUG_REDUCERS_FILE_CFG`. Core compiles +/// them only with `debug_reducers`, so naming one anywhere else would break a release build. An +/// exemption cannot clear one for the same reason. +const PACKAGE_API_DEBUG_ROOTS: &[&str] = &["package_fixture"]; + /// Crate-root names on the surface that are neither a module nor a type: the two marker macros the /// `game_` prefix below does not already cover, and the generated character-owned table manifest. const PACKAGE_API_ROOT_ITEMS: &[&str] = &[ @@ -1255,11 +1262,15 @@ const DEBUG_REDUCERS_FILE_CFG: &str = "#![cfg(feature = \"debug_reducers\")]"; /// general Rust `cfg` expressions. A parent module may repeat the gate, but the file owns the /// registry contract so recursive discovery can decide without reconstructing the module tree. fn registry_file_enabled(source: &str, debug_reducers: bool) -> bool { - debug_reducers - || source - .lines() - .find(|line| !line.trim().is_empty()) - .is_none_or(|line| line.trim() != DEBUG_REDUCERS_FILE_CFG) + debug_reducers || !debug_only_file(source) +} + +/// Whether the file's first non-blank line is `DEBUG_REDUCERS_FILE_CFG`. +fn debug_only_file(source: &str) -> bool { + source + .lines() + .find(|line| !line.trim().is_empty()) + .is_some_and(|line| line.trim() == DEBUG_REDUCERS_FILE_CFG) } /// Whether `root`, the first segment of a crate-root path, is on the Package API surface. @@ -1267,7 +1278,8 @@ fn registry_file_enabled(source: &str, debug_reducers: bool) -> bool { /// Three families beyond the listed roots: `game_*` covers the table accessor traits and the /// `game_hook!`/`game_tick_pass!` markers, `pkg_*` covers a Package's own generated root (and its /// siblings'), and an UpperCamelCase name is a row or payload type re-exported at the crate root. -fn on_package_api(root: &str) -> bool { +/// A debug root is on the surface only in a debug-only file. +fn on_package_api(root: &str, debug_only_file: bool) -> bool { let upper_camel = root.starts_with(|c: char| c.is_ascii_uppercase()) && root.chars().any(|c| c.is_ascii_lowercase()); PACKAGE_API_ROOTS.contains(&root) @@ -1275,6 +1287,7 @@ fn on_package_api(root: &str) -> bool { || root.starts_with("game_") || root.starts_with("pkg_") || upper_camel + || (debug_only_file && PACKAGE_API_DEBUG_ROOTS.contains(&root)) } #[derive(Clone, Copy)] @@ -1646,16 +1659,17 @@ fn record_rooted_paths( prefix: &str, stripped: &str, exempt_lines: &[usize], + debug_only_file: bool, found: &mut Vec<(usize, usize, String)>, ) { for (root_index, root, written) in roots_after_separator(tokens, separator, prefix) { let normalized_root = root.strip_prefix("r#").unwrap_or(&root); - if normalized_root == "self" || on_package_api(normalized_root) { + if normalized_root == "self" || on_package_api(normalized_root, debug_only_file) { continue; } let offset = tokens[root_index].start; let line = stripped[..offset].matches('\n').count() + 1; - if !exempt_lines.contains(&line) { + if !exempt_lines.contains(&line) || PACKAGE_API_DEBUG_ROOTS.contains(&normalized_root) { found.push((offset, line, written)); } } @@ -1671,6 +1685,7 @@ fn record_rooted_paths( fn out_of_surface_paths(source: &str, file_depth: usize) -> Vec<(usize, String)> { let stripped_source = strip_source(source); let stripped = stripped_source.code; + let debug_only_file = debug_only_file(source); let tokens = source_tokens(&stripped); let pairs = brace_pairs(&tokens); let modules = inline_modules(&tokens, &pairs); @@ -1692,6 +1707,7 @@ fn out_of_surface_paths(source: &str, file_depth: usize) -> Vec<(usize, String)> "$crate", &stripped, &stripped_source.package_api_exempt_lines, + debug_only_file, &mut found, ); index += 3; @@ -1709,6 +1725,7 @@ fn out_of_surface_paths(source: &str, file_depth: usize) -> Vec<(usize, String)> "crate", &stripped, &stripped_source.package_api_exempt_lines, + debug_only_file, &mut found, ); } else if tokens[index].text == "super" && (index == 0 || tokens[index - 1].text != "::") { @@ -1738,6 +1755,7 @@ fn out_of_surface_paths(source: &str, file_depth: usize) -> Vec<(usize, String)> &prefix, &stripped, &stripped_source.package_api_exempt_lines, + debug_only_file, &mut found, ); } @@ -1756,6 +1774,18 @@ fn out_of_surface_paths(source: &str, file_depth: usize) -> Vec<(usize, String)> /// What the build says when a Package names a core path outside the surface. fn out_of_surface_message(package: &str, file: &Path, line: usize, path: &str) -> String { + let root = path + .split("::") + .find(|segment| !matches!(*segment, "crate" | "$crate" | "super" | "self")) + .map(|segment| segment.strip_prefix("r#").unwrap_or(segment)); + if root.is_some_and(|root| PACKAGE_API_DEBUG_ROOTS.contains(&root)) { + return format!( + "build.rs: Package `{package}` names `{path}` at {}:{line}, a Package API root that \ + exists only with `debug_reducers` (docs/package-api.md, version 1). Name it only from \ + a file whose first non-blank line is `{DEBUG_REDUCERS_FILE_CFG}`.", + file.display() + ); + } format!( "build.rs: Package `{package}` names `{path}` at {}:{line}, which is outside the Package \ API surface (docs/package-api.md, version 1). Use a path under a documented root, or, if \ @@ -1926,6 +1956,49 @@ mod package_api_lint_tests { assert_eq!(reported(source), vec!["3:crate::auth::Account::default"]); } + #[test] + fn a_debug_root_is_refused_outside_a_debug_only_file() { + // A build without `debug_reducers` compiles this file, and an item gate does not change + // what the lint sees. + let source = "#[cfg(feature = \"debug_reducers\")]\nfn f() {\n crate::package_fixture::apply_damage();\n}\n"; + assert_eq!( + reported(source), + vec!["3:crate::package_fixture::apply_damage"] + ); + } + + #[test] + fn a_debug_root_is_on_the_surface_in_a_debug_only_file() { + let source = "\n#![cfg(feature = \"debug_reducers\")]\nuse crate::package_fixture::{apply_damage, top_threat_target};\nfn f() {\n super::package_fixture::client_cast();\n}\n"; + assert!(reported(source).is_empty(), "{:?}", reported(source)); + } + + #[test] + fn an_exemption_cannot_clear_a_debug_root() { + let source = + "crate::package_fixture::apply_damage(); // package-api: exempt fixture damage\n"; + assert_eq!( + reported(source), + vec!["1:crate::package_fixture::apply_damage"] + ); + } + + #[test] + fn a_debug_root_failure_names_the_file_gate() { + let message = out_of_surface_message( + "dungeons", + Path::new("packages/dungeons/src/verify.rs"), + 3, + "crate::package_fixture::apply_damage", + ); + assert!( + message.contains("packages/dungeons/src/verify.rs:3"), + "{message}" + ); + assert!(message.contains(DEBUG_REDUCERS_FILE_CFG), "{message}"); + assert!(!message.contains(PACKAGE_API_EXEMPT), "{message}"); + } + #[test] fn an_exemption_without_a_reason_does_not_clear() { let source = "crate::auth::Account; // package-api: exempt\n"; diff --git a/module/src/lib.rs b/module/src/lib.rs index 5e0cc961e..526c8d9b6 100644 --- a/module/src/lib.rs +++ b/module/src/lib.rs @@ -372,6 +372,10 @@ mod package_account; /// their own ensure/init path. Not re-exported below, for the same reason `import_meta` is not: /// nothing outside this module reads its table. mod package_config; +/// The debug-only Package API root for Package fixtures. Not re-exported below: Packages name it by +/// path, which is what the Package API lint checks. +#[cfg(feature = "debug_reducers")] +mod package_fixture; /// Package Deltas applied to the spell tables as the last stage of their base import, and the /// per-Package provenance that records what landed. Not re-exported below, for the same reason /// `import_meta` is not: nothing outside this module reads its table. diff --git a/module/src/package_fixture.rs b/module/src/package_fixture.rs new file mode 100644 index 000000000..ef40a0b1c --- /dev/null +++ b/module/src/package_fixture.rs @@ -0,0 +1,92 @@ +#![cfg_attr(not(has_packages), allow(dead_code, unused_imports))] + +//! Setup and observation steps for a Package's own debug fixtures. The root exists only with +//! `debug_reducers`, so a release build has none of it, and the Package API lint accepts it only in +//! a Package file gated on that feature. + +use spacetimedb::{ReducerContext, ScheduleAt, Table, TimeDuration}; + +use crate::creatures::{game_creature_move_schedule, GLOBAL_TICK_INSTANCE}; +use crate::import_meta::game_import_meta; + +/// The highest-threat living source on the creature's map and instance. Ties go to the lower guid. +pub use crate::threat::top_threat_target; + +/// Damage a live, living target through the real Core damage pipeline as a main-hand hit from +/// `attacker_guid` (0 means anonymous). Damage is capped one below the target's health, so the +/// call never kills. +pub fn apply_damage( + ctx: &ReducerContext, + target_guid: u64, + amount: u32, + attacker_guid: u64, +) -> Result<(), String> { + crate::debug::debug_apply_damage(ctx, target_guid, amount, attacker_guid) +} + +/// Remove a live Character from the world as a logout does: `on_logout` fires, the Character keeps +/// its position and progress, and its combat, duel, trade and corpse end. The Character row stays. +pub fn remove_live_character(ctx: &ReducerContext, character_guid: u64) -> Result<(), String> { + let entity = crate::helpers::live_entity(ctx, character_guid)?; + if !entity.is_player() { + return Err(format!("live entity {character_guid} is not a Character")); + } + crate::world::remove_live_character(ctx, entity); + Ok(()) +} + +/// Refuse when the Shard holds imported content. The temporary weather seed a fresh Module stamps +/// is Core's own data, so it does not count. +pub fn require_no_imported_content(ctx: &ReducerContext) -> Result<(), String> { + match ctx + .db + .game_import_meta() + .iter() + .find(|row| row.family != crate::weather::WEATHER_SEED_FAMILY) + { + Some(import) => Err(format!( + "fixture refuses imported content ({})", + import.family + )), + None => Ok(()), + } +} + +/// Cast through the Gates a client cast passes, as the Session Actor `caster_guid`. +pub fn client_cast( + ctx: &ReducerContext, + caster_guid: u64, + spell_id: u32, + target_guid: u64, +) -> Result<(), String> { + crate::gw::gw_cast_at( + ctx, + crate::SessionActor { + guid: caster_guid, + ownership: None, + }, + spell_id, + target_guid, + ) +} + +/// Declare that the next creature movement tick fires once, `delay` from now. Refuses unless the +/// catch-all row is the only movement schedule, which holds on a fresh Shard. The row then fires +/// once, not on an interval. +pub fn declare_next_movement_tick(ctx: &ReducerContext, delay: TimeDuration) -> Result<(), String> { + let schedules = ctx.db.game_creature_move_schedule(); + let mut rows = schedules.iter().take(2); + let (Some(mut tick), None) = (rows.next(), rows.next()) else { + return Err("fixture requires exactly one creature movement tick".to_string()); + }; + if tick.instance_id != GLOBAL_TICK_INSTANCE { + return Err("fixture requires the catch-all creature movement tick".to_string()); + } + let at = ctx + .timestamp + .checked_add(delay) + .ok_or("fixture movement tick timestamp exhausted")?; + tick.scheduled_at = ScheduleAt::Time(at); + schedules.scheduled_id().update(tick); + Ok(()) +} diff --git a/module/src/weather.rs b/module/src/weather.rs index 5951884b0..19da234da 100644 --- a/module/src/weather.rs +++ b/module/src/weather.rs @@ -504,7 +504,7 @@ pub fn gw_force_zone_weather( /// The provenance family [`seed_weather_weights`] stamps. Deliberately distinct from the family the /// world-data import will use, so the two never claim the same rows: when the import lands it fills /// `game_weather` under its own family and this seed goes away in the same change. -const WEATHER_SEED_FAMILY: &str = "weather_seed"; +pub(crate) const WEATHER_SEED_FAMILY: &str = "weather_seed"; /// One zone's four seasons of `(rain, snow, storm)` percentages, spring first. type SeasonalPercents = [(u8, u8, u8); 4];