From 34f860b8666fc711dc56c3fcbd491203af295546 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Wed, 7 Oct 2026 13:51:17 +0200 Subject: [PATCH] feat(package-api): create Package-owned Accounts and session-less Characters A Package created its own Characters by writing a game_account row and calling Core's create_character from the auth root, which the Package API does not list. package_account::create_package_character creates a Character with no Session on an Account the Package owns. It shares the name, race and class Refusals of client creation through check_new_character. When every owned Account is full, it creates an Account without credentials and records the owner in the new private game_package_account table. A Core table keeps that record when the Package is disabled, and a new table publishes over a populated Realm as an additive migration. The installed-Package raw Character lookup budget drops from 4 to 2. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 + CONTEXT.md | 6 + docs/package-api.md | 23 +- docs/schema.md | 4 +- .../debug_create_package_character_reducer.rs | 93 +++++++ .../bindings/game_package_account_table.rs | 228 ++++++++++++++++++ gateway/src/stdb/bindings/mod.rs | 48 ++++ .../src/stdb/bindings/package_account_type.rs | 54 +++++ module/build.rs | 1 + module/src/auth.rs | 125 +++++++--- module/src/debug/auth.rs | 14 ++ module/src/lib.rs | 3 + module/src/package_account.rs | 75 ++++++ module/src/tripwires.rs | 6 +- module/tests/package_account.rs | 122 ++++++++++ 15 files changed, 767 insertions(+), 39 deletions(-) create mode 100644 gateway/src/stdb/bindings/debug_create_package_character_reducer.rs create mode 100644 gateway/src/stdb/bindings/game_package_account_table.rs create mode 100644 gateway/src/stdb/bindings/package_account_type.rs create mode 100644 module/src/package_account.rs create mode 100644 module/tests/package_account.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa4d45d0..ef1144d7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ 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-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 + Package owns that Account, and the record stays when the Package is disabled. - **Meeting stones.** Using a dungeon's meeting stone puts a Character, or the Party it leads, in one realm-wide queue on Realm-core. Five Seekers for one dungeon and team form a Party, and a queued Party fills its open tank, healer and damage roles from Seekers on any World Shard. diff --git a/CONTEXT.md b/CONTEXT.md index 9fa2bea16..3f2a25a81 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -134,6 +134,12 @@ _Avoid_: reject, deny, error (for gameplay refusals) **Account**: A login. Owns characters. +**Package-owned Account**: +An Account a Package created for its session-less Characters through the Package API. It has no +credentials, so no login reaches it. `game_package_account` records the owning Package in Core, so +the record outlives the Package when the Package is disabled. +_Avoid_: bot account, account block + **Alpha Test Tools**: Account-owned authority for a limited set of alpha testing dot-commands. The Gateway reads its current value from Realm-core for every command and conveys it to the Home Shard. The Module applies diff --git a/docs/package-api.md b/docs/package-api.md index fb8cf9fdc..d1ed19c26 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -85,6 +85,21 @@ The Gateway obtains acknowledged admission from the owning World Shard before it Realm-core. Admission committed before a concurrent controller change can finish afterwards; this ordering does not promise an atomic operation across Shards. +### Package-owned Accounts + +`package_account::create_package_character(ctx, package_name, name, race, class) -> Result` +creates a Character with no Session and returns its guid. It applies the Refusals a client-created +Character meets: `NAME_IN_USE` for a taken name, `INVALID_RACE_CLASS` for a pair the imported +CharBaseInfo does not list, and the guid range Refusal. These Refusals write nothing. + +The Character goes on the first Account that `package_name` owns with room for another Character. +When every owned Account is full, Core creates a new Account without credentials, so no login can +reach it. `game_package_account` records `package_name` as its owner. That record is a Core table, +so it stays when the Package is disabled. Pass the Package's own name. + +The Character starts at level 1 at its race and class start position, with zero gender and +appearance bytes. The Package places it, levels it and builds its live entity. + ### Encounter kernel `crate::encounter` holds the encounter state machine and the choreography verbs a Package drives it @@ -204,9 +219,9 @@ contract. ``` actor chat combat creatures encounter faction gameobject -group helpers hooks items loot nav package_config -quest script_binding spell stats terrain transfer -world xp +group helpers hooks items loot nav package_account +package_config quest script_binding spell stats +terrain transfer world xp ``` Plus, at the crate root: any `game_*` name (a table accessor or registration marker), any @@ -248,7 +263,7 @@ inert. A Package that genuinely needs a path off the surface writes the reason on the line that names it: ```rust -crate::auth::create_character(ctx, ..) // package-api: exempt a bot Character is created without a Session +crate::realm_core::record_shard(ctx, ..) // package-api: exempt fixture models a completed Realm locator crossing ``` The marker clears that line and no other. There is no global toggle, and the reason is required — a diff --git a/docs/schema.md b/docs/schema.md index f1169fd6d..51af49010 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -61,7 +61,7 @@ rather than trust the numbers below, which drift on every schema change. The pat undercounts. The second command lists the tables per file, which is what the rows below add up: ```bash -grep -rEn '^#\[(spacetimedb::)?table\(' module/src --include='*.rs' | wc -l # 285 on 2026-10-03 +grep -rEn '^#\[(spacetimedb::)?table\(' module/src --include='*.rs' | wc -l # 287 on 2026-10-07 grep -rEc '^#\[(spacetimedb::)?table\(' module/src --include='*.rs' | grep -v ':0$' # tables per file ``` @@ -89,7 +89,7 @@ grep -rEc '^#\[(spacetimedb::)?table\(' module/src --include='*.rs' | grep -v ': | Gateway leases | 3 | 0 | `gw.rs` | | Taxi | 5 | 1 | `taxi.rs` | | Weather | 3 | 1 | `weather.rs` | -| Packages | 3 | 1 | `package_config.rs`, `package_import.rs`, `script_binding.rs` | +| Packages | 4 | 1 | `package_account.rs`, `package_config.rs`, `package_import.rs`, `script_binding.rs` | | Event GC | 1 | 0 | `gc.rs` | | Config / static data / diagnostics | 22 | 21 | `config.rs`, `gm.rs`, `faction.rs`, `skilldata.rs`, `stats.rs`, `import_meta.rs`, `debug/*` | diff --git a/gateway/src/stdb/bindings/debug_create_package_character_reducer.rs b/gateway/src/stdb/bindings/debug_create_package_character_reducer.rs new file mode 100644 index 000000000..99539f342 --- /dev/null +++ b/gateway/src/stdb/bindings/debug_create_package_character_reducer.rs @@ -0,0 +1,93 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct DebugCreatePackageCharacterArgs { + pub package_name: String, + pub name: String, + pub race: u8, + pub class: u8, +} + +impl From for super::Reducer { + fn from(args: DebugCreatePackageCharacterArgs) -> Self { + Self::DebugCreatePackageCharacter { + package_name: args.package_name, + name: args.name, + race: args.race, + class: args.class, + } + } +} + +impl __sdk::InModule for DebugCreatePackageCharacterArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `debug_create_package_character`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait debug_create_package_character { + /// Request that the remote module invoke the reducer `debug_create_package_character` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`debug_create_package_character:debug_create_package_character_then`] to run a callback after the reducer completes. + fn debug_create_package_character( + &self, + package_name: String, + name: String, + race: u8, + class: u8, + ) -> __sdk::Result<()> { + self.debug_create_package_character_then(package_name, name, race, class, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `debug_create_package_character` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn debug_create_package_character_then( + &self, + package_name: String, + name: String, + race: u8, + class: u8, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl debug_create_package_character for super::RemoteReducers { + fn debug_create_package_character_then( + &self, + package_name: String, + name: String, + race: u8, + class: u8, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp.invoke_reducer_with_callback( + DebugCreatePackageCharacterArgs { + package_name, + name, + race, + class, + }, + callback, + ) + } +} diff --git a/gateway/src/stdb/bindings/game_package_account_table.rs b/gateway/src/stdb/bindings/game_package_account_table.rs new file mode 100644 index 000000000..ad08c3d5e --- /dev/null +++ b/gateway/src/stdb/bindings/game_package_account_table.rs @@ -0,0 +1,228 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::package_account_type::PackageAccount; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `game_package_account`. +/// +/// Obtain a handle from the [`GamePackageAccountTableAccess::game_package_account`] method on [`super::RemoteTables`], +/// like `ctx.db.game_package_account()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_package_account().on_insert(...)`. +pub struct GamePackageAccountTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `game_package_account`. +pub struct GamePackageAccountTableAccessor; + +impl __sdk::TableAccessor for GamePackageAccountTableAccessor { + type Row = PackageAccount; + type Handle<'db> = GamePackageAccountTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.game_package_account() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `game_package_account`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait GamePackageAccountTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`GamePackageAccountTableHandle`], which mediates access to the table `game_package_account`. + fn game_package_account(&self) -> GamePackageAccountTableHandle<'_>; +} + +impl GamePackageAccountTableAccess for super::RemoteTables { + fn game_package_account(&self) -> GamePackageAccountTableHandle<'_> { + GamePackageAccountTableHandle { + imp: self.imp.get_table::("game_package_account"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct GamePackageAccountInsertCallbackId(__sdk::CallbackId); +pub struct GamePackageAccountDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for GamePackageAccountTableHandle<'ctx> { + type Row = PackageAccount; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for GamePackageAccountTableHandle<'ctx> { + type Row = PackageAccount; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = GamePackageAccountInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GamePackageAccountInsertCallbackId { + GamePackageAccountInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GamePackageAccountInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = GamePackageAccountDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GamePackageAccountDeleteCallbackId { + GamePackageAccountDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GamePackageAccountDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for GamePackageAccountTableHandle<'ctx> { + type InsertCallbackId = GamePackageAccountInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GamePackageAccountInsertCallbackId { + GamePackageAccountInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GamePackageAccountInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for GamePackageAccountTableHandle<'ctx> { + type DeleteCallbackId = GamePackageAccountDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GamePackageAccountDeleteCallbackId { + GamePackageAccountDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GamePackageAccountDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct GamePackageAccountUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for GamePackageAccountTableHandle<'ctx> { + type UpdateCallbackId = GamePackageAccountUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GamePackageAccountUpdateCallbackId { + GamePackageAccountUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GamePackageAccountUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for GamePackageAccountTableHandle<'ctx> { + type UpdateCallbackId = GamePackageAccountUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GamePackageAccountUpdateCallbackId { + GamePackageAccountUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GamePackageAccountUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `account_id` unique index on the table `game_package_account`, +/// which allows point queries on the field of the same name +/// via the [`GamePackageAccountAccountIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_package_account().account_id().find(...)`. +pub struct GamePackageAccountAccountIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> GamePackageAccountTableHandle<'ctx> { + /// Get a handle on the `account_id` unique index on the table `game_package_account`. + pub fn account_id(&self) -> GamePackageAccountAccountIdUnique<'ctx> { + GamePackageAccountAccountIdUnique { + imp: self.imp.get_unique_constraint::("account_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> GamePackageAccountAccountIdUnique<'ctx> { + /// Find the subscribed row whose `account_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &u64) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("game_package_account"); + _table.add_unique_constraint::("account_id", |row| &row.account_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `PackageAccount`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait game_package_accountQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `PackageAccount`. + fn game_package_account(&self) -> __sdk::__query_builder::Table; +} + +impl game_package_accountQueryTableAccess for __sdk::QueryTableAccessor { + fn game_package_account(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("game_package_account") + } +} diff --git a/gateway/src/stdb/bindings/mod.rs b/gateway/src/stdb/bindings/mod.rs index e82bf4457..80ec966bc 100644 --- a/gateway/src/stdb/bindings/mod.rs +++ b/gateway/src/stdb/bindings/mod.rs @@ -169,6 +169,7 @@ pub mod debug_clear_creatures_reducer; pub mod debug_compute_spell_reducer; pub mod debug_compute_swing_reducer; pub mod debug_create_fixture_instance_reducer; +pub mod debug_create_package_character_reducer; pub mod debug_creature_path_reducer; pub mod debug_delete_character_reducer; pub mod debug_deliver_mail_fixture_reducer; @@ -577,6 +578,7 @@ pub mod game_object_trap_type; pub mod game_object_type; pub mod game_object_unlocked_type; pub mod game_operator_table; +pub mod game_package_account_table; pub mod game_package_config_table; pub mod game_package_import_table; pub mod game_party_command_dispatch_lane_table; @@ -908,6 +910,7 @@ pub mod npc_vendor_type; pub mod on_disconnect_reducer; pub mod operator_type; pub mod out_of_combat_sight_condition_type; +pub mod package_account_type; pub mod package_config_type; pub mod package_import_type; pub mod parsed_client_command_type; @@ -1339,6 +1342,7 @@ pub use debug_clear_creatures_reducer::debug_clear_creatures; pub use debug_compute_spell_reducer::debug_compute_spell; pub use debug_compute_swing_reducer::debug_compute_swing; pub use debug_create_fixture_instance_reducer::debug_create_fixture_instance; +pub use debug_create_package_character_reducer::debug_create_package_character; pub use debug_creature_path_reducer::debug_creature_path; pub use debug_delete_character_reducer::debug_delete_character; pub use debug_deliver_mail_fixture_reducer::debug_deliver_mail_fixture; @@ -1747,6 +1751,7 @@ pub use game_object_trap_type::GameObjectTrap; pub use game_object_type::GameObject; pub use game_object_unlocked_type::GameObjectUnlocked; pub use game_operator_table::*; +pub use game_package_account_table::*; pub use game_package_config_table::*; pub use game_package_import_table::*; pub use game_party_command_dispatch_lane_table::*; @@ -2078,6 +2083,7 @@ pub use npc_vendor_type::NpcVendor; pub use on_disconnect_reducer::on_disconnect; pub use operator_type::Operator; pub use out_of_combat_sight_condition_type::OutOfCombatSightCondition; +pub use package_account_type::PackageAccount; pub use package_config_type::PackageConfig; pub use package_import_type::PackageImport; pub use parsed_client_command_type::ParsedClientCommand; @@ -2642,6 +2648,12 @@ pub enum Reducer { DebugCreateFixtureInstance { character_guid: u64, }, + DebugCreatePackageCharacter { + package_name: String, + name: String, + race: u8, + class: u8, + }, DebugCreaturePath { guid: u64, x: f32, @@ -4446,6 +4458,7 @@ impl __sdk::Reducer for Reducer { Reducer::DebugComputeSpell { .. } => "debug_compute_spell", Reducer::DebugComputeSwing { .. } => "debug_compute_swing", Reducer::DebugCreateFixtureInstance { .. } => "debug_create_fixture_instance", + Reducer::DebugCreatePackageCharacter { .. } => "debug_create_package_character", Reducer::DebugCreaturePath { .. } => "debug_creature_path", Reducer::DebugDeleteCharacter { .. } => "debug_delete_character", Reducer::DebugDeliverMailFixture { .. } => "debug_deliver_mail_fixture", @@ -5410,6 +5423,17 @@ Reducer::DebugCheckRestAt{ character_guid, } => __sats::bsatn::to_vec(&debug_create_fixture_instance_reducer::DebugCreateFixtureInstanceArgs { character_guid: character_guid.clone(), +}), + Reducer::DebugCreatePackageCharacter{ + package_name, + name, + race, + class, +} => __sats::bsatn::to_vec(&debug_create_package_character_reducer::DebugCreatePackageCharacterArgs { + package_name: package_name.clone(), + name: name.clone(), + race: race.clone(), + class: class.clone(), }), Reducer::DebugCreaturePath{ guid, @@ -8718,6 +8742,7 @@ pub struct DbUpdate { game_npc_text_slot: __sdk::TableUpdate, game_npc_vendor: __sdk::TableUpdate, game_operator: __sdk::TableUpdate, + game_package_account: __sdk::TableUpdate, game_package_config: __sdk::TableUpdate, game_package_import: __sdk::TableUpdate, game_party_command_dispatch_lane: __sdk::TableUpdate, @@ -9456,6 +9481,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "game_operator" => db_update .game_operator .append(game_operator_table::parse_table_update(table_update)?), + "game_package_account" => db_update.game_package_account.append( + game_package_account_table::parse_table_update(table_update)?, + ), "game_package_config" => db_update .game_package_config .append(game_package_config_table::parse_table_update(table_update)?), @@ -10649,6 +10677,12 @@ impl __sdk::DbUpdate for DbUpdate { diff.game_operator = cache .apply_diff_to_table::("game_operator", &self.game_operator) .with_updates_by_pk(|row| &row.id); + diff.game_package_account = cache + .apply_diff_to_table::( + "game_package_account", + &self.game_package_account, + ) + .with_updates_by_pk(|row| &row.account_id); diff.game_package_config = cache .apply_diff_to_table::("game_package_config", &self.game_package_config) .with_updates_by_pk(|row| &row.id); @@ -11643,6 +11677,9 @@ impl __sdk::DbUpdate for DbUpdate { "game_operator" => db_update .game_operator .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "game_package_account" => db_update + .game_package_account + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "game_package_config" => db_update .game_package_config .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -12514,6 +12551,9 @@ impl __sdk::DbUpdate for DbUpdate { "game_operator" => db_update .game_operator .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "game_package_account" => db_update + .game_package_account + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "game_package_config" => db_update .game_package_config .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -13008,6 +13048,7 @@ pub struct AppliedDiff<'r> { game_npc_text_slot: __sdk::TableAppliedDiff<'r, NpcTextSlot>, game_npc_vendor: __sdk::TableAppliedDiff<'r, NpcVendor>, game_operator: __sdk::TableAppliedDiff<'r, Operator>, + game_package_account: __sdk::TableAppliedDiff<'r, PackageAccount>, game_package_config: __sdk::TableAppliedDiff<'r, PackageConfig>, game_package_import: __sdk::TableAppliedDiff<'r, PackageImport>, game_party_command_dispatch_lane: __sdk::TableAppliedDiff<'r, PartyCommandDispatchLane>, @@ -14027,6 +14068,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.game_operator, event, ); + callbacks.invoke_table_row_callbacks::( + "game_package_account", + &self.game_package_account, + event, + ); callbacks.invoke_table_row_callbacks::( "game_package_config", &self.game_package_config, @@ -15333,6 +15379,7 @@ impl __sdk::SpacetimeModule for RemoteModule { game_npc_text_slot_table::register_table(client_cache); game_npc_vendor_table::register_table(client_cache); game_operator_table::register_table(client_cache); + game_package_account_table::register_table(client_cache); game_package_config_table::register_table(client_cache); game_package_import_table::register_table(client_cache); game_party_command_dispatch_lane_table::register_table(client_cache); @@ -15621,6 +15668,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "game_npc_text_slot", "game_npc_vendor", "game_operator", + "game_package_account", "game_package_config", "game_package_import", "game_party_command_dispatch_lane", diff --git a/gateway/src/stdb/bindings/package_account_type.rs b/gateway/src/stdb/bindings/package_account_type.rs new file mode 100644 index 000000000..a1ff709b9 --- /dev/null +++ b/gateway/src/stdb/bindings/package_account_type.rs @@ -0,0 +1,54 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PackageAccount { + pub account_id: u64, + pub package_name: String, +} + +impl __sdk::InModule for PackageAccount { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PackageAccount`. +/// +/// Provides typed access to columns for query building. +pub struct PackageAccountCols { + pub account_id: __sdk::__query_builder::Col, + pub package_name: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for PackageAccount { + type Cols = PackageAccountCols; + fn cols(table_name: &'static str) -> Self::Cols { + PackageAccountCols { + account_id: __sdk::__query_builder::Col::new(table_name, "account_id"), + package_name: __sdk::__query_builder::Col::new(table_name, "package_name"), + } + } +} + +/// Indexed column accessor struct for the table `PackageAccount`. +/// +/// Provides typed access to indexed columns for query building. +pub struct PackageAccountIxCols { + pub account_id: __sdk::__query_builder::IxCol, + pub package_name: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for PackageAccount { + type IxCols = PackageAccountIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + PackageAccountIxCols { + account_id: __sdk::__query_builder::IxCol::new(table_name, "account_id"), + package_name: __sdk::__query_builder::IxCol::new(table_name, "package_name"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for PackageAccount {} diff --git a/module/build.rs b/module/build.rs index 59ce91abf..78145a908 100644 --- a/module/build.rs +++ b/module/build.rs @@ -275,6 +275,7 @@ const PACKAGE_API_ROOTS: &[&str] = &[ "items", "loot", "nav", + "package_account", "package_config", "quest", "script_binding", diff --git a/module/src/auth.rs b/module/src/auth.rs index 7e0fa707d..5f83bf1ab 100644 --- a/module/src/auth.rs +++ b/module/src/auth.rs @@ -860,24 +860,27 @@ mod guid_allocator_tests { } /// …and the WIRING (playbook §8 — the repo's dominant test failure is pinning the helper and - /// not the call site). `create_character` must consult the rule BEFORE it writes anything; + /// not the call site). Character creation must consult the rule BEFORE it writes anything; /// deleting that one line brings back exactly the silent minting this rule exists to end, with every /// other test in this file still green. #[test] - fn create_character_refuses_before_it_writes_anything() { - // The trailing newline matters: this test module sits ABOVE the reducer in the file, so a - // bare `"pub fn create_character("` needle finds THIS line's own string literal first and - // scans the test instead of the reducer. The real signature breaks its line after the `(`. - let body = crate::test_scan::code_of(include_str!("auth.rs"), "pub fn create_character(\n"); + fn character_creation_refuses_before_it_writes_anything() { + // The trailing newline matters: this test module sits ABOVE the functions in the file, so a + // bare needle finds THIS test's own string literal first and scans the test instead. + let src = include_str!("auth.rs"); + let check = crate::test_scan::code_of(src, "pub(crate) fn check_new_character(\n"); assert!( - body.contains("require_guid_range(ctx)?"), - "`create_character` no longer checks this database's guid range, so a shard that was \ - never assigned one mints from zero into another shard's guids — silently. Body \ - was:\n{body}" + check + .trim_start_matches(|c: char| c == '{' || c.is_whitespace()) + .starts_with("require_guid_range(ctx)?"), + "`check_new_character` no longer checks this database's guid range first, so a shard \ + that was never assigned one mints from zero into another shard's guids — silently. \ + Body was:\n{check}" ); + let body = crate::test_scan::code_of(src, "pub(crate) fn insert_new_character(\n"); let check_at = body - .find("require_guid_range(ctx)?") - .expect("checked above"); + .find("check_new_character(ctx") + .expect("insert_new_character must run check_new_character"); let mint_at = body.find("next_character_guid(ctx)").unwrap_or(usize::MAX); assert!( check_at < mint_at, @@ -1037,6 +1040,21 @@ mod guid_allocator_tests { // Character creation (CMSG_CHAR_CREATE) // =========================================================================================== +/// Most Characters one Account holds. The 5875 client cannot render a longer character list: it +/// rejects SMSG_CHAR_ENUM with "Error retrieving character list". +pub(crate) const CHARACTERS_PER_ACCOUNT: usize = 10; + +/// The look bytes a client picks at character creation. +#[derive(Clone, Copy, Default)] +pub(crate) struct Appearance { + pub gender: u8, + pub skin: u8, + pub face: u8, + pub hair_style: u8, + pub hair_color: u8, + pub facial_hair: u8, +} + /// Create a new character for `account_id` (called by the trusted gateway on `CMSG_CHAR_CREATE`, /// like `provision_account`/`establish_session`; restrict to the gateway identity in production). /// Returns a distinguished `Err("NAME_IN_USE")` for a taken name (the gateway maps it to @@ -1059,22 +1077,39 @@ pub fn create_character( facial_hair: u8, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; + let appearance = Appearance { + gender, + skin, + face, + hair_style, + hair_color, + facial_hair, + }; + insert_new_character(ctx, account_id, name, race, class, appearance).map(|_| ()) +} + +/// The Refusals a new Character meets before anything is written. Client and Package creation +/// both pass through here. +pub(crate) fn check_new_character( + ctx: &ReducerContext, + name: &str, + race: u8, + class: u8, +) -> Result<(), String> { // Minting is licensed by a range realm-core assigned, and an unlicensed database refuses - // rather than minting from zero into another shard's guids. FIRST — before any write, and - // before the cheaper validations, so the refusal is unambiguous in the log. + // rather than minting from zero into another shard's guids. FIRST — before the cheaper + // validations, so the refusal is unambiguous in the log. require_guid_range(ctx)?; - let chars = ctx.db.game_character(); // `name` is unique; check first so we can return a clean, distinguished result. - if chars.name().find(&name).is_some() { + if ctx + .db + .game_character() + .name() + .find(name.to_string()) + .is_some() + { return Err("NAME_IN_USE".to_string()); } - // Per-realm character cap (vanilla: 10). The 5875 client can't render a char list with >10 entries - // (it rejects SMSG_CHAR_ENUM → "Error retrieving character list"), so refuse the 11th here with a - // distinguished error the gateway maps to CHAR_CREATE_SERVER_LIMIT ("cannot create any more"). - // Routed through the `by_account` index — this used to be a full-table scan. - if chars.by_account().filter(&account_id).count() >= 10 { - return Err("SERVER_LIMIT".to_string()); - } // Reject an illegal (race, class) (e.g. Human Shaman) — server-side defense-in-depth from the // imported CharBaseInfo.dbc (the client already gates the UI). Skipped when the table is empty // (unloaded), so creation still works pre-import. [importer P1] @@ -1087,6 +1122,36 @@ pub fn create_character( { return Err("INVALID_RACE_CLASS".to_string()); } + Ok(()) +} + +/// Whether `account_id` holds fewer than [`CHARACTERS_PER_ACCOUNT`] Characters on this database. +pub(crate) fn account_has_room(ctx: &ReducerContext, account_id: u64) -> bool { + ctx.db + .game_character() + .by_account() + .filter(&account_id) + .count() + < CHARACTERS_PER_ACCOUNT +} + +/// Create a Character for `account_id` and return its guid. Applies every +/// [`check_new_character`] Refusal, then the per-Account cap. +pub(crate) fn insert_new_character( + ctx: &ReducerContext, + account_id: u64, + name: String, + race: u8, + class: u8, + appearance: Appearance, +) -> Result { + check_new_character(ctx, &name, race, class)?; + // The distinguished error the gateway maps to CHAR_CREATE_SERVER_LIMIT ("cannot create any + // more"). + if !account_has_room(ctx, account_id) { + return Err("SERVER_LIMIT".to_string()); + } + let chars = ctx.db.game_character(); let account = ctx .db .game_account() @@ -1121,12 +1186,12 @@ pub fn create_character( name: name.clone(), race, class, - gender, - skin, - face, - hair_style, - hair_color, - facial_hair, + gender: appearance.gender, + skin: appearance.skin, + face: appearance.face, + hair_style: appearance.hair_style, + hair_color: appearance.hair_color, + facial_hair: appearance.facial_hair, level: 1, xp: 0, next_level_xp: crate::xp::xp_to_next_level(1), @@ -1190,7 +1255,7 @@ pub fn create_character( // ZERO/stale owner stamp is corrected by player_login's restamp_owned_data sweep BEFORE the // first player-scoped (RLS) read of these rows can happen. crate::items::grant_starter_item(ctx, next_guid, owner)?; - Ok(()) + Ok(next_guid) } /// Delete `character_guid` for `account_id` — the player `CMSG_CHAR_DELETE` path. Operator-gated (the diff --git a/module/src/debug/auth.rs b/module/src/debug/auth.rs index 12976ab53..ec4f29b75 100644 --- a/module/src/debug/auth.rs +++ b/module/src/debug/auth.rs @@ -15,3 +15,17 @@ pub fn debug_expire_session(ctx: &ReducerContext, account_id: u64) -> Result<(), sessions.account_id().update(session); Ok(()) } + +/// Drive `package_account::create_package_character` for `package_name` without a Package present. +#[reducer] +pub fn debug_create_package_character( + ctx: &ReducerContext, + package_name: String, + name: String, + race: u8, + class: u8, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + crate::package_account::create_package_character(ctx, &package_name, &name, race, class) + .map(|_| ()) +} diff --git a/module/src/lib.rs b/module/src/lib.rs index 128884dce..5e0cc961e 100644 --- a/module/src/lib.rs +++ b/module/src/lib.rs @@ -364,6 +364,9 @@ mod motion; /// idempotent dismount every removal path converges on. mod mount; pub mod nav; +/// Package-owned Accounts: the operation a Package creates its session-less Characters through, and +/// the record of which Package owns each Account. +mod package_account; /// The Operator key-value config surface any installed Package reads: `game_package_config`, /// `set_package_config`, and the `ensure_package_config_default` seeding helper Packages call from /// their own ensure/init path. Not re-exported below, for the same reason `import_meta` is not: diff --git a/module/src/package_account.rs b/module/src/package_account.rs new file mode 100644 index 000000000..cf6fc173d --- /dev/null +++ b/module/src/package_account.rs @@ -0,0 +1,75 @@ +//! Package-owned Accounts: the Accounts a Package creates for its own session-less Characters, and +//! the record of which Package made each one. + +use spacetimedb::{table, ReducerContext, Table}; + +use crate::auth::{game_account, Account, Appearance}; + +/// The Package that created an Account. A Core table, so it outlives the Package's own tables when +/// the Package is disabled. Private. [session] +#[table(accessor = game_package_account)] +pub struct PackageAccount { + #[primary_key] + pub account_id: u64, + #[index(btree)] + pub package_name: String, +} + +/// Create a Character with no Session on an Account `package_name` owns, and return its guid. The +/// Character meets the same name, race and class Refusals as a client-created one, and those +/// Refusals write nothing. The Character goes on the first owned Account with room; when every one is full, +/// a new Account without credentials is created and recorded as owned by `package_name`. +#[cfg_attr(not(has_packages), allow(dead_code))] // package-only caller — see `build.rs` +pub(crate) fn create_package_character( + ctx: &ReducerContext, + package_name: &str, + name: &str, + race: u8, + class: u8, +) -> Result { + crate::auth::check_new_character(ctx, name, race, class)?; + let account_id = owned_account_with_room(ctx, package_name)?; + crate::auth::insert_new_character( + ctx, + account_id, + name.to_string(), + race, + class, + Appearance::default(), + ) +} + +fn owned_account_with_room(ctx: &ReducerContext, package_name: &str) -> Result { + let owned: Vec = ctx + .db + .game_package_account() + .package_name() + .filter(package_name) + .map(|row| row.account_id) + .collect(); + if let Some(&id) = owned + .iter() + .find(|&&id| crate::auth::account_has_room(ctx, id)) + { + return Ok(id); + } + // `#` keeps the name apart from every login name, which are uppercased alphanumerics. + let account = ctx + .db + .game_account() + .try_insert(Account { + id: 0, + username: format!("{package_name}#{}", owned.len()), + salt: Vec::new(), + verifier: Vec::new(), + identity: None, + banned: false, + alpha_test_tools: false, + }) + .map_err(|error| format!("Package Account for {package_name} not created: {error}"))?; + ctx.db.game_package_account().insert(PackageAccount { + account_id: account.id, + package_name: package_name.to_string(), + }); + Ok(account.id) +} diff --git a/module/src/tripwires.rs b/module/src/tripwires.rs index efc0a2edf..26d59cdba 100644 --- a/module/src/tripwires.rs +++ b/module/src/tripwires.rs @@ -1187,7 +1187,7 @@ mod character_fence_tripwire { // OPEN, not decided — spec puts group MEMBERSHIP state on realm-core, settled. ("module/src/group.rs", 3, "OPEN: group_accept/group_uninvite/group_leave have a THIRD party mutate game_group_member; if #22 lands membership on realm-core these stop being a world shard's concern entirely. Guessing a verdict here is the mistake #30 exists to correct. `push_event`'s own `other_guid` lookup dropped from this count: it could never resolve INVITE's or DECLINE's name on Realm-core (no `game_character` rows there), so the gateway now resolves it from the World Shard caches at render time instead. The join core's two member-identity reads now go through `character_by_guid`"), // REGENERATE at the destination — connection-derived state, never carried in the blob. - ("module/src/auth.rs", 4, "REGENERATE: create_character's two name checks (NAME_IN_USE, pre-insert and the race-losing retry) predate any character; its guid-allocator seed scan (`legacy_guid_seed_now`, first-ever touch only) needs the whole table; delete_character keeps a raw find so NO_SUCH_CHAR/NOT_OWNER/CHAR_IN_TRANSIT stay three answers, with the fence on the next line. establish_session's owner_identity rebind and create_character's per-account cap check now route through the `by_account` index instead of a full scan (issue #390), so they no longer count here"), + ("module/src/auth.rs", 4, "REGENERATE: character creation's two name checks (NAME_IN_USE in `check_new_character` and `insert_new_character`'s race-losing retry) predate any character; its guid-allocator seed scan (`legacy_guid_seed_now`, first-ever touch only) needs the whole table; delete_character keeps a raw find so NO_SUCH_CHAR/NOT_OWNER/CHAR_IN_TRANSIT stay three answers, with the fence on the next line. establish_session's owner_identity rebind and `account_has_room`'s per-account cap check now route through the `by_account` index instead of a full scan (issue #390), so they no longer count here"), // READS, not writes: name/class/race/identity lookups that mutate nothing on the character. ("module/src/items/ops.rs", 2, "race/class reads for the starter loadout and the mana-class gate — no write to the character"), ("module/src/spell/cast/targeting.rs", 1, "caster NAME for the resurrect prompt — no write"), @@ -1208,8 +1208,8 @@ mod character_fence_tripwire { /// installed Package, so there is no single file to measure it against. const PACKAGE_FILE_BUDGET: &[(&str, usize, &str)] = &[( "src/mod.rs", - 4, - "roster bookkeeping over rows this same reducer just created — free-name probe, post-create fetch, post-update re-read; +1 for a per-account character COUNT, which reaches no character (it decides whether an account still has room under its character cap) — an in-transit character counting or not toward that cap is harmless either way", + 2, + "roster bookkeeping over rows this same reducer just created: the free-name probe and the post-create fetch", )]; /// If `rel` sits under `packages//`, its path relative to that Package's own root — diff --git a/module/tests/package_account.rs b/module/tests/package_account.rs new file mode 100644 index 000000000..3e394f71e --- /dev/null +++ b/module/tests/package_account.rs @@ -0,0 +1,122 @@ +//! `package_account::create_package_character` against a real Shard, through +//! `debug_create_package_character`. + +mod support; + +use support::Standalone; + +const PACKAGE: &str = "example"; + +fn arg(value: &str) -> String { + serde_json::to_string(value).expect("a string encodes as JSON") +} + +fn stage(name: &str) -> Standalone { + let mut shard = Standalone::start(name); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("install_guid_range", &["0"]); + shard +} + +fn create(shard: &Standalone, name: &str, race: &str, class: &str) -> std::process::Output { + shard.call( + "debug_create_package_character", + &[&arg(PACKAGE), &arg(name), race, class], + ) +} + +fn assert_refusal(shard: &Standalone, name: &str, race: &str, class: &str, tag: &str) { + let output = create(shard, name, race, class); + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(!output.status.success(), "{name} was created: {text}"); + assert!(text.contains(tag), "{name}: expected {tag}, got {text}"); +} + +fn package_accounts(shard: &Standalone) -> Vec { + shard + .query_rows(&format!( + "SELECT account_id FROM game_package_account WHERE package_name = '{PACKAGE}'" + )) + .into_iter() + .map(|row| row["account_id"].clone()) + .collect() +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_package_character_lands_on_an_account_that_records_its_package() { + let shard = stage("package-account-owner"); + for index in 0..11 { + let output = create(&shard, &format!("Bot{}", (b'a' + index) as char), "1", "1"); + assert!( + output.status.success(), + "creation {index} failed: {output:?}" + ); + } + + let owned = package_accounts(&shard); + assert_eq!( + owned.len(), + 2, + "the eleventh Character needs a second Account: {owned:?}" + ); + for account_id in &owned { + let account = shard.query_rows(&format!( + "SELECT username, verifier FROM game_account WHERE id = {account_id}" + )); + assert_eq!(account.len(), 1, "owned Account {account_id} is missing"); + assert!( + account[0]["username"].starts_with("example#"), + "unexpected username: {account:?}" + ); + assert_eq!( + account[0]["verifier"], "0x", + "a Package Account has no credentials" + ); + } + let first = shard.query_rows(&format!( + "SELECT guid FROM game_character WHERE account_id = {}", + owned + .iter() + .min_by_key(|id| id.parse::().unwrap()) + .unwrap() + )); + assert_eq!( + first.len(), + 10, + "the first Account fills before another is made" + ); + let session = shard.query_rows("SELECT account_id FROM game_session"); + assert!(session.is_empty(), "creation opened a Session: {session:?}"); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_package_character_meets_the_client_name_race_and_class_refusals() { + let shard = stage("package-account-refusals"); + shard.assert_call( + "create_character", + &["1", &arg("Taken"), "1", "1", "0", "0", "0", "0", "0", "0"], + ); + // Human Warrior is the one legal pair, so Human Shaman is illegal. + shard + .assert_sql("INSERT INTO game_char_base_info (race_class, race, class) VALUES (257, 1, 1)"); + + assert_refusal(&shard, "Taken", "1", "1", "NAME_IN_USE"); + assert_refusal(&shard, "Shaman", "1", "7", "INVALID_RACE_CLASS"); + assert!( + package_accounts(&shard).is_empty(), + "a Refusal created a Package Account" + ); + + let output = create(&shard, "Warrior", "1", "1"); + assert!( + output.status.success(), + "a legal pair was refused: {output:?}" + ); +}