diff --git a/README.md b/README.md index 2969938..c90a0c3 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,11 @@ From the Core checkout root: ```bash ./lyracore doctor ./lyracore dev up -./lyracore account create TEST +./lyracore account create ALICE +./lyracore import +./lyracore dev check ./lyracore dev status +lyracore dev check ``` The global `lyracore` launcher supplied by Core's installer also works from a checkout subdirectory. @@ -47,8 +50,9 @@ lyracore preflight lyracore publish [DATABASE ...] [--skip-preflight] lyracore dev up [--single] [--lan ] lyracore dev status +lyracore dev check lyracore dev logs [spacetime|gateway] -lyracore dev smoke +lyracore dev smoke ACCOUNT CHARACTER [--password-stdin] lyracore dev down [--forget] lyracore account create USER [--password-stdin] lyracore character gm NAME true|false @@ -62,6 +66,7 @@ lyracore config lyracore config set client-data PATH lyracore import [--client-data PATH] [--accept] [--profile-shard PROFILE=SHARD ...] lyracore import world [--client-data PATH] [--accept] [--profile-shard PROFILE=SHARD ...] +lyracore import check [--profile-shard PROFILE=SHARD ...] lyracore import vmaps [--client-data PATH] [--profile-shard PROFILE=SHARD ...] lyracore client sync lyracore client pack --out DIR [--zip] @@ -108,8 +113,13 @@ explicit `--client-data PATH` first, then the saved path, then asks for one. With the realm running, `import` fetches the pinned cMaNGOS `classic-db` world data and combines it with your own 1.12.1 client's archives. It imports the supported Alliance early-game areas and -Deadmines, including world content, DBCs, terrain, and navigation. It replaces the seeded content -in the affected Import Families. You do not need a running MaNGOS installation. +Deadmines, including world content, DBCs, terrain, and navigation. Playable worlds require +imported content. A fresh publish contains configuration and runtime schedules only. It creates +no Accounts, Characters, or game catalogue. You do not need a running MaNGOS installation. + +Server-issued Operator credentials reach importer subprocesses through a temporary SpacetimeDB +CLI config with owner-only permissions. The CLI removes that config when the import ends. The +global SpacetimeDB login stays intact. The command asks for consent on every run. For a scripted run: @@ -136,7 +146,9 @@ The import checks each destination against its World Import Profile. World profi skinning content. The `instances` profile allows no skinnable creatures, as in Deadmines, but requires loot for every referenced skinning table. -`lyracore import vmaps` imports exact model and WMO collision geometry into populated World Shards. +`lyracore import world` automatically imports required WMO geometry for Deeprun Tram and Deadmines +before completing content. `lyracore import vmaps` refreshes model and WMO collision geometry in +populated World Shards. It accepts the same client path and profile assignments, skips the Instance Pool, and does not enable exact collision checks. It reads your own archives without fetching game data, so it has no `--accept` option. @@ -215,8 +227,8 @@ created by this command before it can be replaced. ## Grant Character GM access ```bash -lyracore character gm Tester true -lyracore character gm Tester false +lyracore character gm Ginger true +lyracore character gm Ginger false ``` The command searches the fixture's World Shards for the named Character and uses the coordinator @@ -466,35 +478,30 @@ server to the internet" should not be one mistyped character away from "let my f A running gateway cannot be rebound: switching modes is refused with the `dev down` to run first, rather than reporting "already up" for a realm that is not listening where you asked. -### `dev smoke` - -Runs the pinned wire harness's generic login smoke — logon, world handshake, character enumerate, -enter world — against the running fixture. - -The harness is a separate, server-agnostic repository consumed as the RELEASE pinned in the -checkout's `.wire-harness-rev` (` `), and this CLI owns the consume path: - -* the **tag** is what is cloned — a release, never a branch, and there is deliberately no way to say - `main`; -* the **sha** is what the checkout is then verified against, because a tag is a mutable ref and - "pinned to a tag someone moved" is not pinned. A mismatch is reported as a supply-chain event, not - a stale cache; -* the clone lands in the git-ignored `.lyracore/wire-harness//`. The - [wire-harness repository](https://github.com/LyraCoreProject/wire-harness) is public and fetched - over HTTPS, so no GitHub account or SSH key is needed; -* `LYRACORE_WIRE_HARNESS_DIR=/path/to/wire-harness` overrides all of that with a local working tree. - It is validated, and announced on stderr every time — a stale local checkout silently substituted - for the pin is a measurement nobody can reproduce. +### `dev check` and `dev smoke` -The seam is resolved **inside that pinned checkout**, not from an `adapters/` directory in the -server repo. The wire client is built from the harness's own manifest. - -It signs in as the fixture account, so provision that first: +`lyracore dev check` asks the Module to check imported content on each World Shard and the +Instance Pool. It checks completed imports and actual content dependencies. It reports all +missing destinations and changes no content. `dev status` reports processes and published +Shards; `preflight` checks the build offline. ```bash -printf 'test123' | lyracore account create TEST --password-stdin +lyracore account create ALICE +lyracore import +lyracore dev check +lyracore dev smoke ALICE Ginger ``` +Smoke reads your Account password from a hidden terminal prompt, or from stdin with +`--password-stdin`. It checks content before requesting the password. It builds the Headless +Client from the release pinned in Core's `.wire-harness-rev`, verifies its commit, and runs +login, Character enumeration and world entry. The client creates the named Human Warrior if +it does not exist. That Character uses imported starting content. + +The release cache is `.lyracore/wire-harness//`. Set `LYRACORE_WIRE_HARNESS_DIR` to use a +local checkout; the CLI reports that override on every run. Smoke uses the local Realm's +recorded client address, including LAN mode. + ### Not the production topology The sharded fixture is not the production recipe in the server repo's `docs/danger-zones.md` §3 — @@ -578,7 +585,7 @@ stdin and never becomes a command-line argument, so `ps` shows only the username zeroized buffer and is absent from rendered commands, logs, error messages, and `state.json`. ```bash -printf 'hunter2' | lyracore account create TEST --password-stdin +printf 'hunter2' | lyracore account create ALICE --password-stdin ``` On a sharded realm the account is written **twice** — once on the world shard, whose account id owns diff --git a/docs/commands.md b/docs/commands.md index 882bfe6..dbc7057 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -51,8 +51,9 @@ lyracore preflight lyracore publish [DATABASE ...] [--skip-preflight] lyracore dev up [--lan ] lyracore dev status +lyracore dev check lyracore dev logs [spacetime|gateway] -lyracore dev smoke +lyracore dev smoke ACCOUNT CHARACTER [--password-stdin] lyracore dev down [--forget] lyracore account create USER [--password-stdin] lyracore account alpha-test-tools enrollment REALM_CORE [true|false] @@ -87,11 +88,13 @@ lyracore update | `dev up` | start (or reuse) the loopback fixture — four databases: two world shards, the instance pool, realm-core (`--single` for one) | | `dev status` | process identity, endpoint, and whether the database is actually published | | `dev logs` | tail the components this CLI started | -| `dev smoke` | the pinned wire harness's generic login smoke against the running fixture | +| `dev smoke` | check content, then log in with your chosen Account and Character through the pinned Headless Client | +| `dev check` | check imported content on each local World Shard and Instance Pool | +| `import check` | check imported content using explicit World Import Profile assignments | | `dev down` | stop only the processes this CLI started, and only if the PID is still ours | | `account create` | provision an account's SRP6 credentials without a password in `argv` | | `account alpha-test-tools` | read or set automatic enrollment, or grant or revoke one Account | -| `import` | replace the seed fixture with the real world — consent notice, then the ETL on every database the fixture populates | +| `import` | import playable content into each selected World Shard and Instance Pool after consent | | `config` | show, or set, the client-data path `import` and `doctor` remember | | `client sync` | pack `patch-3.MPQ` and every enabled Package's addons, then install them into the configured client | | `client pack` | build the Client Artifact a player installs: package-authored content only, into a directory of your choosing | @@ -144,17 +147,20 @@ directory, and build `lyracore-importer`. Each content destination then has thre run its importer modes, re-arm its schedules and gather pools, and run profile-aware Verification. That is six displayed stages under `--single` and twelve on the sharded fixture. +Server-issued Operator credentials reach importer subprocesses through a temporary SpacetimeDB +CLI config with owner-only permissions. The CLI removes that config when the import ends. The +global SpacetimeDB login stays intact. + The sharded destination plan is fixed: | Destination | Profile | Spatial result | | --- | --- | --- | -| `lyracore` | `alliance-eastern` | Human corridor, Dun Morogh and Loch Modan on map 0 | +| `lyracore` | `alliance-eastern` | Human corridor, Dun Morogh and Loch Modan on map 0, plus Deeprun Tram map 369 | | `lyracore-kalimdor` | `alliance-kalimdor` | Teldrassil and Darkshore on map 1 | -| `lyracore-instances` | `instances` | whole map 36, with no open-world terrain or navigation pass | +| `lyracore-instances` | `instances` | whole map 36 with required WMO geometry, no open-world terrain or navigation pass | -The single topology uses `alliance-single`, the union of those bounded continent slices and map 36. -Every importer child names its destination and loopback SpacetimeDB endpoint. The curated -`import-class-spells.sh` pass receives both through explicit environment variables. The full +The single topology uses `alliance-single`, the union of those bounded continent slices and maps 36 and 369. +Every importer child names its destination and loopback SpacetimeDB endpoint. The full Spell.dbc catalogue already supplies caster spells, so Verification derives every referenced caster spell from `game_creature_cast` and `game_creature_spell` instead of maintaining an independent allowlist. @@ -163,7 +169,9 @@ If a mode fails, later modes and later destinations do not start. The error name profile and mode. Fix the cause and rerun the full command. Import families use clear-and-reload, so the rerun repairs a partial family; the complete multi-destination operation is not atomic. -After `import world`, run `./lyracore import vmaps` when exact model/WMO collision data is needed. +`import world` automatically imports canonical Vmap geometry for profiles that own Deeprun Tram +or Deadmines before recording content completion. Run `./lyracore import vmaps` to refresh +World Shard collision data afterward. It follows the World Shard profiles and skips the Instance Pool. Importing vmaps does not enable exact rays. Enabling them is a separate Operator decision after `docs/verification/vmap-rollout.md` Verification. @@ -179,8 +187,8 @@ trigger center for diagnostics; the center need not have a floor when the suppor entered the trigger volume. This establishes static geometry suitability, not actual Character movement. The private fixture must exercise the ordinary Core route before the attended client check. Selected WMO groups that -reference active doodads are also refused until their nested transforms are supported. `./lyracore -import vmaps` does not send this slice to the Instance Pool yet. Map 36 terrain and Navigation +reference active doodads are also refused until their nested transforms are supported. `import world` applies this slice to the Instance Pool; the separate `import vmaps` refresh +command still visits World Shards only. Map 36 terrain and Navigation Coverage remain unavailable until archive-derived evidence supports a representation that preserves its floors. @@ -969,11 +977,27 @@ The credential reaches a child as an environment variable, never argv, and the n `Authorization` header, never a URL — so it is absent from rendered commands, logs, errors and `state.json`. Its only on-disk copy is the `0600` file above. -The realm it brings up is **playable without a client-data import**: the module's `init` reducer -seeds the realm row, the Human-Warrior start position, the graveyards, the `TEST` account and its -character, and a small demo population. `./lyracore import` (which needs a world-database dump it -pulls for you and client data you already own) is what turns that into the Alliance Human, -Dwarf/Gnome and Night Elf early-game corridors, and it is not a prerequisite for logging in. +A fresh Module creates configuration and runtime schedules. `dev up` claims the Operator on +each Shard, repairs schedules, provisions the Realm advertisement on the default World Shard, +and starts the Gateway. It creates no Accounts, Characters, or playable content. + +Provision your Account, import your world, and check content before play: + +```bash +./lyracore account create ALICE +./lyracore import +./lyracore dev check +``` + +`dev check` asks each World Shard and Instance Pool to check its completed import and actual +content dependencies. It reports missing content with the Module's diagnostic. Realm-core +needs no spatial content. The command changes no content and is separate from offline +`preflight` and the process status in `dev status`. + +Use `./lyracore import check --profile-shard PROFILE=SHARD ...` to check the same explicit +assignments you supplied to `import world`. The import clears prior completion before each +destination's first mode. It completes that destination only after all modes, schedule repair, +profile floors, and dependency checks pass. A failed import stays unavailable for play. ### `--lan ` — let another machine on your network in @@ -983,47 +1007,35 @@ Dwarf/Gnome and Night Elf early-game corridors, and it is not a prerequisite for This binds the two **client-facing** listeners — logon 3724 and world 8085 — to that address, and makes the realm list advertise it (`LYRACORE_REALM_ADDRESS`, read by `gateway/src/config.rs`). Both -halves matter: the seeded `game_realm` row says `127.0.0.1:8085`, so a client that logged in over -the LAN would otherwise be told to open its world connection to *its own* loopback — a realm that -authenticates and then goes nowhere. +The CLI provisions `game_realm` with the address of the client listener. **SpacetimeDB does not move.** It stays on `127.0.0.1:3000` in every mode. The address must be private (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`); a public address or `0.0.0.0` is a usage error, not a wildcard bind. A running gateway cannot be rebound — switching modes is refused, with the `dev down` to run first, rather than reported as "already up". -This is a LAN convenience for a contributor fixture, not a deployment mode: no rate limiting, no -TLS, 2004-era password hashing, and a `TEST` account whose password is in this document. +LAN mode supports local development. It uses the Accounts and passwords you provision. ### `dev smoke` ```bash -printf 'test123' | ./lyracore account create TEST --password-stdin # once -./lyracore dev smoke +./lyracore dev check +./lyracore dev smoke ALICE Ginger ``` -Runs the pinned wire harness's generic login smoke — logon → world handshake → character enumerate -→ enter world — against the running fixture. - -**Where the harness comes from:** the CLI resolves it itself, from the release pinned in -`.wire-harness-rev` — the same semantics the maintainers' internal `wire-harness.sh` script -implements, and the same cache directory. It clones the pinned **tag** (a branch is refused as a -pin) into the git-ignored `.lyracore/wire-harness//`, then **verifies HEAD is the recorded -sha**; a tag that has been re-pointed is reported as a supply-chain event, not a stale cache. It -then builds `vanilla-wire` from the harness's own manifest and runs the harness's adapter seam out -of that checkout. +Smoke checks the local World's content before it builds or runs the pinned Headless Client. +It reads the Account password from a hidden terminal prompt. Use `--password-stdin` to supply +one line through stdin. The password stays out of command arguments, logs and runtime state. -That is the one behavioural change here: `dev smoke` used to hand off to an internal -`adapters/lyracore/run-suite.sh` wrapper, and now it does not touch it at all. That wrapper still -exists, in the maintainers' own working tree, for running the **full** suite by hand; it is only -`dev smoke` that no longer needs it. +The CLI clones the release tag from `.wire-harness-rev` into `.lyracore/wire-harness//` +and verifies its commit. It builds `vanilla-wire` from that checkout, then runs login, +Character enumeration and world entry with your chosen Account and Character. If the named +Character is missing, the client creates a Human Warrior using imported starting content. +It performs no fixture restoration or automatic Account provisioning. -`LYRACORE_WIRE_HARNESS_DIR=/path/to/wire-harness` still overrides the pin with a local working tree. -The CLI validates it and announces it on stderr every single time, because a stale local checkout -silently substituted for the pin is a measurement nobody can reproduce. - -It refuses to run against a stack that is not up, and in `--lan` mode it connects to the LAN -address rather than loopback. +`LYRACORE_WIRE_HARNESS_DIR=/path/to/wire-harness` selects a local checkout. The CLI reports the +override each time. Smoke requires running local processes and uses the recorded client +address, including LAN mode. ### Sharded out of the box, on purpose @@ -1048,7 +1060,7 @@ itself: | Database | Role | | --- | --- | -| `lyracore` | the default world shard — Eastern Kingdoms, where `init` seeds the fixture content (Northshire Valley) and a new character spawns | +| `lyracore` | default World Shard for imported Eastern Kingdoms maps and the Realm advertisement | | `lyracore-kalimdor` | world shard for map 1, reached via a `LYRACORE_SHARD_MAP` rule | | `lyracore-instances` | the instance pool — every dungeon run (map 36, the Deadmines), reached by a second rule of the same shape | | `lyracore-realm` | realm-core — accounts and sessions, the character→shard index, load samples | @@ -1065,9 +1077,8 @@ Eastern Kingdoms stays whole on the default database. Three of those four names are production's own, and deliberately: what keeps a fixture off a production node is the node it is published to (`-s local`, loopback:3000), never the name. -The fixture does not provide the wider world. **Elwynn beyond Northshire and all of the Kalimdor -World Shard are empty** until you run `./lyracore import` with a cmangos dump and your own client -MPQs. The import assigns `alliance-eastern` to `lyracore`, `alliance-kalimdor` to +All World Shards and the Instance Pool require `./lyracore import` with public world data and +your own client MPQs before play. The import assigns `alliance-eastern` to `lyracore`, `alliance-kalimdor` to `lyracore-kalimdor`, and the instance-only `instances` profile to `lyracore-instances`. The single topology receives their union in `lyracore`. Each destination is verified independently after its clear-and-reload stages, so rerun the command after fixing a failure. @@ -1185,7 +1196,7 @@ stdin and never becomes a command-line argument, so `ps` shows only the username zeroized buffer, and is absent from rendered commands, logs, error messages, and `state.json`. ```bash -printf 'hunter2' | ./lyracore account create TEST --password-stdin +printf 'hunter2' | ./lyracore account create ALICE --password-stdin ``` ## `service reconcile` — make a host match the tracked unit @@ -1258,3 +1269,5 @@ not just a process restart. `doctor` exits nonzero only for launch-blocking failures. A busy port is a warning, not a failure — it is usually your own running stack. + +A separate `import vmaps` clears content readiness on each changed Shard. Complete a full world import with the same profile assignments, then run `import check` before playing. diff --git a/src/bin/main.rs b/src/bin/main.rs index d3490c7..8e84bde 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -71,9 +71,20 @@ fn run(args: &[String]) -> Result { let dev = cmd::dev::DevManager::new(ProjectLayout::discover()?)?; dev.status(&runner, &inspector).map(|_| EXIT_OK) } - Command::DevSmoke => { + Command::DevCheck => { let dev = cmd::dev::DevManager::new(ProjectLayout::discover()?)?; - dev.smoke(&runner, &inspector).map(|_| EXIT_OK) + dev.check(&runner, &http).map(|_| EXIT_OK) + } + Command::DevSmoke { + account, + character, + source, + } => { + let dev = cmd::dev::DevManager::new(ProjectLayout::discover()?)?; + dev.smoke(&runner, &inspector, &http, &account, &character, || { + cmd::account::read_password(source) + }) + .map(|_| EXIT_OK) } Command::DevLogs(component) => { let dev = cmd::dev::DevManager::new(ProjectLayout::discover()?)?; @@ -83,9 +94,14 @@ fn run(args: &[String]) -> Result { let mut dev = cmd::dev::DevManager::new(ProjectLayout::discover()?)?; dev.down(&runner, &inspector, forget).map(|_| EXIT_OK) } + Command::ImportCheck { profile_shards } => { + cmd::import::run_check(&ProjectLayout::discover()?, &runner, &http, &profile_shards) + .map(|_| EXIT_OK) + } Command::ImportWorld(options) => cmd::import::run_world( &ProjectLayout::discover()?, &runner, + &http, &cmd::import::TtyPrompt::import_world(), &options, ) diff --git a/src/cmd/account.rs b/src/cmd/account.rs index 9a1fc78..afde502 100644 --- a/src/cmd/account.rs +++ b/src/cmd/account.rs @@ -60,11 +60,7 @@ pub fn create( // account is written somewhere the logon path never looks. let topology = RuntimeState::load(&project.state_file())?.topology(); - let password = match source { - PasswordSource::Stdin => read_line_secret(&mut std::io::stdin().lock())?, - PasswordSource::Tty => read_password_from_tty()?, - }; - validate(&password)?; + let password = read_password(source)?; runner.run_with_secret_stdin( &provision_command(project, user, credential.token(), topology), @@ -105,7 +101,16 @@ fn provision_command( ) } -fn validate(password: &[u8]) -> Result<()> { +pub fn read_password(source: PasswordSource) -> Result>> { + let password = match source { + PasswordSource::Stdin => read_line_secret(&mut std::io::stdin().lock())?, + PasswordSource::Tty => read_password_from_tty()?, + }; + validate(&password)?; + Ok(password) +} + +pub(crate) fn validate(password: &[u8]) -> Result<()> { if password.is_empty() { return Err(Error::Usage( "the password was empty — nothing was provisioned".to_string(), @@ -161,7 +166,7 @@ fn read_password_from_tty() -> Result>> { ) })?; - eprint!("Password for the new account: "); + eprint!("Account password: "); std::io::stderr().flush()?; let echo_was_off = set_tty_echo(false).is_ok(); diff --git a/src/cmd/dev.rs b/src/cmd/dev.rs index 73b0949..f922e7e 100644 --- a/src/cmd/dev.rs +++ b/src/cmd/dev.rs @@ -1,6 +1,6 @@ //! The local realm lifecycle: `dev up | status | logs | smoke | down`. //! -//! Scope is the seeded loopback fixture, which since #11 is **sharded by default**: four databases +//! Scope is the local Realm, which since #11 is **sharded by default**: four databases //! — the default world shard, the map-1 shard, the instance pool (#108) and realm-core //! (`Topology::Sharded`), one per production tier in core `docs/architecture.md` §3.1. `dev up //! --single` is the pre-#11 one-database fixture, unchanged down to the hard unset of every @@ -21,8 +21,9 @@ //! database rather than the default one. use crate::cmd::{ + account, gateway_log::{connected_shards, CONNECTED_MARKER}, - preflight, publish, + import, preflight, publish, }; use crate::harness::{self, Harness}; use crate::http::HttpClient; @@ -198,12 +199,26 @@ impl DevManager { &ProjectLayout::stdb_uri(), )?; self.claim_operator(runner, http, &credential)?; + for shard in self.topology.databases() { + call_as_operator( + &self.project, + runner, + http, + &credential, + shard, + "debug_repair_after_publish", + &[], + ) + .map_err(|error| operator_call_failure(&self.project, shard, error))?; + } + self.provision_realm(runner, http, &credential)?; self.ensure_gateway(runner, inspector, &gateway, &credential)?; // Before the verification, so a realm that came up wrong is still one `dev down` can stop. self.state.save(&self.project.state_file())?; self.verify_topology()?; - println!("✓ dev stack is up."); + println!("✓ dev stack is up for Account provisioning and world import."); + println!(" Before play, run `lyracore import`, then `lyracore dev check`."); if let ClientBind::Lan(ip) = &self.bind { println!( " LAN mode: clients on this network use realmlist {ip}. SpacetimeDB stays on \ @@ -396,6 +411,36 @@ impl DevManager { Ok(()) } + fn provision_realm( + &self, + runner: &dyn ProcessRunner, + http: &dyn HttpClient, + credential: &Credential, + ) -> Result<()> { + let shard = ProjectLayout::DATABASE; + call_as_operator( + &self.project, + runner, + http, + credential, + shard, + "provision_realm", + &[ + serde_json::json!(1), + serde_json::json!("LyraCore"), + serde_json::json!(ProjectLayout::realm_address(&self.bind)), + serde_json::json!(0), + serde_json::json!(1), + ], + ) + .map_err(|error| operator_call_failure(&self.project, shard, error))?; + Ok(()) + } + + pub fn check(&self, runner: &dyn ProcessRunner, http: &dyn HttpClient) -> Result<()> { + import::run_check(&self.project, runner, http, &[]) + } + // ---- the realised topology ---- /// Read the topology the gateway ACTUALLY built back out of its own log. @@ -728,19 +773,16 @@ impl DevManager { // ---- smoke ---- - /// Hand off to the pinned wire harness's generic login smoke (#246): logon → world handshake → - /// character enumerate → enter world, against the running fixture. - /// - /// The harness is a separate, server-agnostic repository consumed as the RELEASE pinned in - /// `.wire-harness-rev`, and everything below the seam — the fixtures, the scenarios, the - /// assertions — belongs to it. What changed with the CLI absorbing the lifecycle scripts is - /// only WHERE the seam is resolved from: the pinned harness checkout in `.lyracore/`, not a - /// `adapters/` directory in the server repo. The server repo's copy was the last thing making - /// `dev smoke` depend on a directory the public mirror does not carry. + /// Check imported content, then run login and world entry through the pinned Headless Client. + /// Request the Account password only after the local processes, content and client build pass. pub fn smoke( &self, runner: &dyn ProcessRunner, inspector: &dyn ProcessInspector, + http: &dyn HttpClient, + account_name: &str, + character: &str, + password: impl FnOnce() -> Result>>, ) -> Result<()> { for component in Component::ALL { match self.status_for(component, inspector) { @@ -761,6 +803,8 @@ impl DevManager { } } + self.check(runner, http)?; + // Resolved AFTER the stack check, so a `dev smoke` against a stopped stack does not clone // a harness release to tell you to run `dev up`. let harness = harness::resolve( @@ -772,16 +816,21 @@ impl DevManager { println!("· building the wire client from the pinned harness..."); runner.run_and_wait(&client_build_command(&harness))?; - println!("· running the pinned wire harness's login smoke..."); - runner - .run_streaming(&smoke_command(&self.project, &harness, &self.bind)) - .map_err(|e| { + println!("· running the pinned Headless Client's login smoke..."); + let password = password()?; + account::validate(&password)?; + let output = runner + .run_with_secret_stdin( + &smoke_command(&harness, &self.bind, account_name, character), + &password, + ) + .map_err(|error| { Error::Process(format!( - "{e}\n The smoke test signs in as the fixture account. If it failed to log in, \ - provision it first:\n printf 'test123' | ./lyracore account create TEST \ - --password-stdin" - )) + "{error}\n Check the Account credentials and imported start content. \ + Provision the Account with `lyracore account create {account_name}`." + )) })?; + print!("{output}"); println!("✓ smoke passed."); Ok(()) } @@ -916,11 +965,7 @@ fn gateway_command( .env("LYRACORE_SPACETIMEDB_URL", ProjectLayout::stdb_uri()) .env("LYRACORE_LOGON_BIND", ProjectLayout::logon_bind(bind)) .env("LYRACORE_WORLD_BIND", ProjectLayout::world_bind(bind)) - // The realm list is answered from the seeded `game_realm` row, which says 127.0.0.1 — a - // client that reached the logon tier over the LAN would be sent to its OWN loopback for - // the world tier. This override is what makes `--lan` a working realm rather than a - // working handshake. It is set in loopback mode too, so the advertised address is a - // property of how the CLI launched the gateway and not of a row someone may have edited. + // The advertised address follows the client listener in loopback and LAN modes. .env("LYRACORE_REALM_ADDRESS", ProjectLayout::realm_address(bind)) .env("LYRACORE_AOI", "1") .env("MALLOC_ARENA_MAX", "2") @@ -949,6 +994,34 @@ pub(crate) fn reducer_url(database: &str, reducer: &str) -> String { ) } +pub(crate) fn call_as_operator( + project: &ProjectLayout, + runner: &dyn ProcessRunner, + http: &dyn HttpClient, + credential: &Credential, + shard: &str, + reducer: &str, + args: &[serde_json::Value], +) -> Result { + if credential.is_server_issued() { + let body = + serde_json::to_string(args).map_err(|error| Error::Process(error.to_string()))?; + return http.post_json( + &reducer_url(shard, reducer), + Some(credential.token()), + &body, + ); + } + let mut command = import::call_command(project, shard, reducer); + for value in args { + command = command.arg(match value { + serde_json::Value::String(text) => text.clone(), + value => value.to_string(), + }); + } + runner.run_and_wait(&command) +} + /// What to do about a failed call to an operator-gated reducer, keeping the node's own message. /// /// The two refusals worth distinguishing are the ones a contributor cannot diagnose from the node's @@ -1012,35 +1085,23 @@ fn client_build_command(harness: &Harness) -> CommandSpec { .arg(ProjectLayout::HARNESS_CLIENT_BIN) } -/// `dev smoke` — the pinned harness's own seam, told which database and gateway this fixture is -/// and (in LAN mode) which host to connect to. Everything below it belongs to the harness. -/// -/// A release that carries its own suite entrypoint is driven through that; otherwise the generic -/// login smoke is driven straight through the adapter seam, which is precisely what the server -/// repo's `run-suite.sh --smoke` did with the flags it was given. -fn smoke_command(project: &ProjectLayout, harness: &Harness, bind: &ClientBind) -> CommandSpec { - let base = match harness.suite_script() { - Some(suite) => CommandSpec::new("bash") - .arg(suite.to_string_lossy().to_string()) - .arg("--smoke") - .arg("--database") - .arg(ProjectLayout::DATABASE) - .arg("--gateway") - .arg(project.gateway_bin().to_string_lossy().to_string()), - None => CommandSpec::new("bash") - .arg(harness.smoke_seam().to_string_lossy().to_string()) - .arg(ProjectLayout::SMOKE_ACCOUNT) - .arg(ProjectLayout::SMOKE_CHARACTER), - }; - base.env( - "WIRE_BIN", - harness.client_bin().to_string_lossy().to_string(), - ) - .env("WIRE_HOST", bind.host()) - // The harness resolves TWO roots and this is the one it cannot guess from its own - // location: it now lives under `.lyracore/`, not inside the checkout it is testing. - .env("LYRACORE_DIR", project.root.to_string_lossy().to_string()) - .env("DB", ProjectLayout::DATABASE) +/// The Headless Client takes the Account password through stdin. Suite scripts can restore +/// fixture content, so local smoke uses the client directly with explicit Account credentials. +fn smoke_command( + harness: &Harness, + bind: &ClientBind, + account_name: &str, + character: &str, +) -> CommandSpec { + CommandSpec::new(harness.client_bin().to_string_lossy().to_string()) + .arg("smoke") + .arg("--account") + .arg(account_name) + .arg("--character") + .arg(character) + .arg("--password-stdin") + .arg("--host") + .arg(bind.host()) } fn classify( @@ -1275,8 +1336,7 @@ mod tests { gateway.env_value("LYRACORE_WORLD_BIND"), Some("192.168.1.50:8085") ); - // The realm list must send the client to an address it can reach — the seeded - // `game_realm` row says 127.0.0.1, which is the client's OWN machine over the LAN. + // The Realm advertises the address where its client listener runs. assert_eq!( gateway.env_value("LYRACORE_REALM_ADDRESS"), Some("192.168.1.50:8085") @@ -1296,7 +1356,11 @@ mod tests { vec!["spacetime start --listen-addr 127.0.0.1:3000"] ); assert!( - !stack.rendered().iter().any(|r| r.contains("192.168.1.50")), + !stack + .rendered() + .iter() + .filter(|r| !r.contains("provision_realm")) + .any(|r| r.contains("192.168.1.50")), "no LAN address may reach the database tier: {:?}", stack.rendered() ); @@ -1465,9 +1529,7 @@ mod tests { } #[test] - fn smoke_runs_the_seam_out_of_the_pinned_harness_checkout_not_the_server_repo() { - // #246 + the mirror: `adapters/` is not a directory the published repository carries, so - // resolving the seam relative to the checkout would make `dev smoke` unrunnable there. + fn smoke_runs_the_pinned_headless_client_with_explicit_credentials() { let tmp = TempDir::new().unwrap(); let mut dev = DevManager::new(project_with_harness(&tmp)).unwrap(); let stack = harness_stack(); @@ -1480,13 +1542,24 @@ mod tests { ) .unwrap(); - dev.smoke(&stack.runner(), &stack.inspector()).unwrap(); + dev.smoke( + &stack.runner(), + &stack.inspector(), + &FakeHttp::new(), + "ALICE", + "Ginger", + || Ok(zeroize::Zeroizing::new(b"test-secret".to_vec())), + ) + .unwrap(); let smoke: Vec = stack .calls() .into_iter() .filter_map(|call| match call { - Call::Stream(spec) if spec.render().contains("adapters/lyracore") => Some(spec), + Call::SecretStdin { spec, secret } => { + assert_eq!(secret, b"test-secret"); + Some(spec) + } _ => None, }) .collect(); @@ -1497,19 +1570,14 @@ mod tests { "the seam must come from the pinned cache: {rendered}" ); assert!( - rendered.contains(ProjectLayout::HARNESS_SMOKE_SEAM), + rendered.contains(ProjectLayout::HARNESS_CLIENT_BIN), "{rendered}" ); assert!( !rendered.starts_with(&tmp.path().join("adapters").display().to_string()), "nothing may be resolved out of the server repo's adapters/: {rendered}" ); - assert_eq!(smoke[0].env_value("WIRE_HOST"), Some("127.0.0.1")); - assert_eq!( - smoke[0].env_value("LYRACORE_DIR"), - Some(dev.project.root.to_string_lossy().to_string()).as_deref(), - "the harness resolves two roots; this is the one it cannot guess" - ); + assert!(rendered.contains("--host 127.0.0.1"), "{rendered}"); // The client is built from the HARNESS's manifest, never this workspace's. let built = stack .rendered() @@ -1523,15 +1591,11 @@ mod tests { } #[test] - fn a_release_that_carries_its_own_suite_entrypoint_is_preferred() { + fn smoke_never_runs_a_suite_that_can_restore_fixture_content() { let tmp = TempDir::new().unwrap(); let project = project_with_harness(&tmp); let cached = project.harness_cache().join(HARNESS_SHA); - std::fs::write( - cached.join(ProjectLayout::HARNESS_SUITE_SCRIPT), - "#!/bin/sh\n", - ) - .unwrap(); + std::fs::write(cached.join("adapters/lyracore/run-suite.sh"), "#!/bin/sh\n").unwrap(); let mut dev = DevManager::new(project).unwrap(); let stack = harness_stack(); dev.up( @@ -1543,12 +1607,22 @@ mod tests { ) .unwrap(); - dev.smoke(&stack.runner(), &stack.inspector()).unwrap(); + dev.smoke( + &stack.runner(), + &stack.inspector(), + &FakeHttp::new(), + "ALICE", + "Ginger", + || Ok(zeroize::Zeroizing::new(b"test-secret".to_vec())), + ) + .unwrap(); let harness = resolved_harness(&dev.project, &stack); - let rendered = smoke_command(&dev.project, &harness, &dev.bind).render(); - assert!(rendered.contains("run-suite.sh"), "{rendered}"); - assert!(rendered.contains("--smoke"), "{rendered}"); - assert!(rendered.contains("--database lyracore"), "{rendered}"); + let rendered = smoke_command(&harness, &dev.bind, "ALICE", "Ginger").render(); + assert!(!rendered.contains("run-suite.sh"), "{rendered}"); + assert!( + rendered.contains("smoke --account ALICE --character Ginger --password-stdin"), + "{rendered}" + ); } #[test] @@ -1565,12 +1639,19 @@ mod tests { ) .unwrap(); - dev.smoke(&stack.runner(), &stack.inspector()).unwrap(); + dev.smoke( + &stack.runner(), + &stack.inspector(), + &FakeHttp::new(), + "ALICE", + "Ginger", + || Ok(zeroize::Zeroizing::new(b"test-secret".to_vec())), + ) + .unwrap(); let harness = resolved_harness(&dev.project, &stack); - assert_eq!( - smoke_command(&dev.project, &harness, &dev.bind).env_value("WIRE_HOST"), - Some("192.168.1.50") - ); + assert!(smoke_command(&harness, &dev.bind, "ALICE", "Ginger") + .render() + .contains("--host 192.168.1.50")); } #[test] @@ -1579,7 +1660,16 @@ mod tests { let dev = DevManager::new(project_with_harness(&tmp)).unwrap(); let stack = harness_stack(); - let error = dev.smoke(&stack.runner(), &stack.inspector()).unwrap_err(); + let error = dev + .smoke( + &stack.runner(), + &stack.inspector(), + &FakeHttp::new(), + "ALICE", + "Ginger", + || Ok(zeroize::Zeroizing::new(b"test-secret".to_vec())), + ) + .unwrap_err(); assert!( error.to_string().contains("lyracore dev up"), "must name the fix: {error}" @@ -1604,7 +1694,16 @@ mod tests { Topology::Single, ) .unwrap(); - let error = dev.smoke(&stack.runner(), &stack.inspector()).unwrap_err(); + let error = dev + .smoke( + &stack.runner(), + &stack.inspector(), + &FakeHttp::new(), + "ALICE", + "Ginger", + || Ok(zeroize::Zeroizing::new(b"test-secret".to_vec())), + ) + .unwrap_err(); assert!( error.to_string().contains(ProjectLayout::WIRE_HARNESS_PIN), "{error}" @@ -1612,7 +1711,7 @@ mod tests { } #[test] - fn a_failing_smoke_points_at_the_fixture_account() { + fn a_failing_smoke_names_the_selected_account() { let tmp = TempDir::new().unwrap(); let mut dev = DevManager::new(project_with_harness(&tmp)).unwrap(); let stack = harness_stack(); @@ -1627,17 +1726,85 @@ mod tests { let failing = harness_stack() .with_process(4001, "x") - .fail_on(ProjectLayout::HARNESS_SMOKE_SEAM, "logon failed"); + .fail_on("smoke --account", "logon failed"); // Reuse the running stack's view of the world, but a runner that fails the harness. let error = dev - .smoke(&failing.runner(), &stack.inspector()) + .smoke( + &failing.runner(), + &stack.inspector(), + &FakeHttp::new(), + "ALICE", + "Ginger", + || Ok(zeroize::Zeroizing::new(b"test-secret".to_vec())), + ) .unwrap_err(); assert!( - error.to_string().contains("account create TEST"), - "the most common smoke failure is an unprovisioned fixture account: {error}" + error.to_string().contains("account create ALICE"), + "the diagnostic must name the selected Account: {error}" ); } + #[test] + fn smoke_stops_before_password_or_client_build_when_content_is_missing() { + let tmp = TempDir::new().unwrap(); + let mut dev = DevManager::new(project_with_harness(&tmp)).unwrap(); + let stack = harness_stack(); + dev.up( + &stack.runner(), + &stack.inspector(), + &FakeHttp::new(), + ClientBind::Loopback, + Topology::Single, + ) + .unwrap(); + let missing = harness_stack().fail_on( + "check_world_content", + "missing imported Hearthstone item 6948", + ); + let error = dev + .smoke( + &missing.runner(), + &stack.inspector(), + &FakeHttp::new(), + "ALICE", + "Ginger", + || panic!("password must not be requested"), + ) + .unwrap_err(); + assert!(error.to_string().contains("Hearthstone item 6948")); + assert!(!missing + .rendered() + .iter() + .any(|command| command.contains("cargo build"))); + } + + #[test] + fn up_provisions_the_advertisement_as_the_claimed_operator() { + let tmp = TempDir::new().unwrap(); + let mut dev = DevManager::new(project(&tmp)).unwrap(); + let stack = FakeStack::new().fail_on("login show", "no login"); + let http = FakeHttp::new(); + dev.up( + &stack.runner(), + &stack.inspector(), + &http, + lan(), + Topology::Sharded, + ) + .unwrap(); + let calls = calls_to(&http, "provision_realm"); + assert_eq!(calls.len(), 1); + assert!(calls[0].url.contains("/lyracore/call/")); + assert_eq!(calls[0].bearer.as_deref(), Some(MINTED_TOKEN)); + assert_eq!(calls[0].body, r#"[1,"LyraCore","192.168.1.50:8085",0,1]"#); + assert_eq!(calls_to(&http, "debug_repair_after_publish").len(), 4); + assert!(!stack + .rendered() + .iter() + .any(|command| command.contains("provision_account") + || command.contains("restore_taxi_fixture"))); + } + // ---- database health ---- #[test] diff --git a/src/cmd/import.rs b/src/cmd/import.rs index d13494c..a398891 100644 --- a/src/cmd/import.rs +++ b/src/cmd/import.rs @@ -29,8 +29,7 @@ //! from the network — so it carries no consent gate. //! //! What still shells to bash, on purpose: `pull-classic-db.sh` (the pinned, checksum-verified dump -//! fetch — its lockfile discipline lives with the lock it reads) and `import-class-spells.sh` (the -//! curated overlay whose spell-id lists are core-repo data). `import-manifest.sh` is sourced, not +//! fetch, with its lockfile discipline). `import-manifest.sh` is sourced, not //! run: it is a data file of floors the core repo tunes per dump, read fresh on every import so a //! retuned floor never needs a CLI release. //! @@ -38,6 +37,8 @@ //! floors per content destination: six on `dev up --single`, twelve on the sharded fixture. See //! [`import_destinations`]. +use crate::cmd::dev::call_as_operator; +use crate::http::HttpClient; use crate::proc::{CommandSpec, ProcessRunner}; use crate::project::{ProjectLayout, Topology}; use crate::state::RuntimeState; @@ -117,7 +118,8 @@ impl TtyPrompt { pub const fn packages_apply() -> Self { Self { - no_terminal_remedy: "pass --yes to confirm the application in advance, or --check to see \ + no_terminal_remedy: + "pass --yes to confirm the application in advance, or --check to see \ the plan without writing", } } @@ -269,6 +271,15 @@ impl WorldProfile { matches!(self, Self::AllianceSingle | Self::Instances) } + fn map_ids(self) -> &'static [u32] { + match self { + Self::AllianceEastern | Self::StartingEastern => &[0, 369], + Self::AllianceKalimdor | Self::StartingKalimdor => &[1], + Self::AllianceSingle => &[0, 1, 36, 369], + Self::Instances => &[36], + } + } + fn open_world_maps(self) -> &'static [i64] { match self { Self::AllianceEastern | Self::StartingEastern => &[0], @@ -292,6 +303,7 @@ struct ImportDestination { pub fn run_world( project: &ProjectLayout, runner: &dyn ProcessRunner, + http: &dyn HttpClient, prompt: &dyn Prompt, options: &ImportOptions, ) -> Result<()> { @@ -312,7 +324,6 @@ pub fn run_world( for script in [ project.pull_classic_db_script(), - project.import_class_spells_script(), project.import_manifest_script(), ] { if !script.exists() { @@ -336,6 +347,9 @@ pub fn run_world( ProjectLayout::IMPORT_MANIFEST_SCRIPT ); + let credential = crate::token::resolve_existing(runner, &project.token_file())?; + let owner_config = temporary_owner_config(&credential)?; + // Three fixed stages, then the modes + the re-arm + the floors per destination. let total = 3 + 3 * destinations.len() as u8; @@ -404,6 +418,17 @@ pub fn run_world( "instance creatures, loot, gameobjects, spells" }; + call_as_operator( + project, + runner, + http, + &credential, + database, + "begin_world_import", + &[], + ) + .map_err(|error| content_failure(destination, error))?; + // ---- the importer's modes, in the bash flow's order ---------------------------------- stage( n, @@ -420,6 +445,7 @@ pub fn run_world( for (what, advice, command) in world_etl_commands(project, &client_data, destination) { println!(); println!(" -> {what}"); + let command = with_owner_config(command, owner_config.as_ref()); runner.run_streaming(&command).map_err(|e| { stage_failure(&format!("{what} for {database} ({profile})"), advice, e) })?; @@ -433,19 +459,19 @@ pub fn run_world( stage( n + 1, total, - &format!("re-arming {database} (creature tick, fixtures, gather pools)"), + &format!("re-arming {database} (runtime schedules and gather pools)"), ); for reducer in ["debug_repair_after_publish", "arm_all_pools"] { - runner - .run_and_wait(&call_command(project, database, reducer)) - .map_err(|e| { + call_as_operator(project, runner, http, &credential, database, reducer, &[]).map_err( + |e| { stage_failure( &format!("{reducer} for {database} ({profile})"), "Both reducers are operator-gated — `lyracore dev up` publishes the \ module and claims the operator; is the stack up (`lyracore dev status`)?", e, ) - })?; + }, + )?; } // ---- the FLOOR_* assertions ------------------------------------------------------------ @@ -454,23 +480,125 @@ pub fn run_world( total, &format!("asserting the import floors on {database} (the FLOOR_* manifest)"), ); - assert_floors(project, runner, database, destination.profile, &floors).map_err( - |error| { - Error::Process(format!( - "asserting import floors for {database} ({profile}) failed: {error}" - )) - }, - )?; + assert_floors( + project, + runner, + database, + destination.profile, + &floors, + owner_config.as_ref().map(|file| file.path()), + ) + .map_err(|error| { + Error::Process(format!( + "asserting import floors for {database} ({profile}) failed: {error}" + )) + })?; + let args = [serde_json::json!(destination.profile.map_ids())]; + call_as_operator( + project, + runner, + http, + &credential, + database, + "complete_world_import", + &args, + ) + .map_err(|error| content_failure(destination, error))?; + call_as_operator( + project, + runner, + http, + &credential, + database, + "check_world_content", + &args, + ) + .map_err(|error| content_failure(destination, error))?; } println!(); println!( - "import complete. The realm now holds the imported world rather than the seed fixture." + "import complete. Every selected World Shard and Instance Pool passed the content checks." ); println!("Nothing produced here is redistributable — see the notice this command opened with."); Ok(()) } +const SPACETIME_CONFIG_VAR: &str = "LYRACORE_SPACETIME_CONFIG_PATH"; + +fn temporary_owner_config( + credential: &crate::token::Credential, +) -> Result> { + if !credential.is_server_issued() { + return Ok(None); + } + let mut file = tempfile::NamedTempFile::new()?; + let token = serde_json::to_string(credential.token()) + .map_err(|error| Error::Process(error.to_string()))?; + writeln!(file, "spacetimedb_token = {token}")?; + file.flush()?; + Ok(Some(file)) +} + +fn with_owner_config( + command: CommandSpec, + config: Option<&tempfile::NamedTempFile>, +) -> CommandSpec { + match config { + Some(file) => command.env( + SPACETIME_CONFIG_VAR, + file.path().to_string_lossy().to_string(), + ), + None => command.env_remove(SPACETIME_CONFIG_VAR), + } +} + +pub fn run_check( + project: &ProjectLayout, + runner: &dyn ProcessRunner, + http: &dyn HttpClient, + profile_shards: &[String], +) -> Result<()> { + let destinations = import_destinations( + RuntimeState::load(&project.state_file())?.topology(), + profile_shards, + )?; + let credential = crate::token::resolve_existing(runner, &project.token_file())?; + let mut failures = Vec::new(); + for destination in destinations { + match call_as_operator( + project, + runner, + http, + &credential, + &destination.shard, + "check_world_content", + &[serde_json::json!(destination.profile.map_ids())], + ) { + Ok(_) => println!( + " {} ({}) content ready", + destination.shard, + destination.profile.name() + ), + Err(error) => failures.push(content_failure(&destination, error).to_string()), + } + } + if !failures.is_empty() { + return Err(Error::Process(failures.join("\n"))); + } + Ok(()) +} + +fn content_failure(destination: &ImportDestination, error: Error) -> Error { + Error::Process(format!( + "{} ({}) content is not ready for play: {error}\n \ + Run `lyracore import world` with the same World Import Profile assignments, \ + then `lyracore import check`.", + destination.shard, + destination.profile.name(), + )) +} + /// Content destinations, in import order. The importer owns every profile's spatial facts; the CLI /// owns only the Realm topology assignment. fn import_destinations( @@ -617,6 +745,8 @@ pub fn run_vmaps( "per-cell collision triangles into this checkout's local database(s). Nothing is fetched" ); println!("from the network, and nothing read here leaves your machine."); + println!("This partial import clears content readiness on each changed Shard."); + println!("Run a full world import afterward to check dependencies and reopen world entry."); println!(); let data = resolve_client_data(project, prompt, options.client_data.as_deref())?; @@ -637,6 +767,14 @@ pub fn run_vmaps( RuntimeState::load(&project.state_file())?.topology(), &options.profile_shards, )?; + let owner_config = if project.token_file().is_file() { + temporary_owner_config(&crate::token::resolve_existing( + runner, + &project.token_file(), + )?)? + } else { + None + }; let world_shards: Vec = destinations .into_iter() .filter(|destination| destination.profile.has_bounded_slices()) @@ -647,7 +785,10 @@ pub fn run_vmaps( println!(); println!("==> {shard}: vmap extract + import ({profile})"); runner - .run_streaming(&vmap_command(project, destination, &data)) + .run_streaming(&with_owner_config( + vmap_command(project, destination, &data), + owner_config.as_ref(), + )) .map_err(|e| { stage_failure( &format!("importing vmaps for {shard} ({profile})"), @@ -669,9 +810,9 @@ pub fn run_vmaps( destination.shard ); } - println!( - "— flipping it is a runtime decision, not part of this import, so it is not done here." - ); + println!("Enabling collision is an explicit runtime choice."); + println!("World entry remains closed until a full world import completes with the same profile assignments."); + println!("Run `lyracore import world`, then `lyracore import check`."); Ok(()) } @@ -864,17 +1005,14 @@ pub(crate) fn importer_command(project: &ProjectLayout, database: &str) -> Comma .arg(database) .arg("--server") .arg(ProjectLayout::stdb_uri()) + .env("LYRACORE_DEFER_CONTENT_READY", "1") .cwd(project.root.clone()) } /// The world ETL: the importer's modes, in `import-world.sh`'s order, each with the advice its /// failure needs. One list so the ordering is a fact in one place — the ordering IS the port. /// -/// Two deliberate differences from the bash flow: nothing is piped through a reporting `grep` -/// (the child's full output streams, and its exit status is checked mode by mode — the bash ran -/// every mode even after one failed and let the assertions catch the fallout), and the curated -/// class-spell overlay therefore runs exactly ONCE (the old façade ran it twice because the bash -/// swallowed its exit status inside that grep). +/// Stream each mode and stop on failure before completing the destination import. fn world_etl_commands( project: &ProjectLayout, client_data: &Path, @@ -927,11 +1065,19 @@ fn world_etl_commands( ]); } + if profile.includes_eastern_corridors() || profile.includes_instances() { + commands.push(( + "WMO map geometry (Deeprun Tram and Deadmines where owned)", + "Reads wmo.MPQ and model.MPQ. The profile must include collision geometry for every \ + WMO map; a failure leaves the destination unavailable for play.", + vmap_command(project, destination, client_data), + )); + } + commands.extend([ ( "character-creation + faction DBC tables", - "Reads dbc.MPQ. Without this pass non-Warrior classes inherit the Warrior loadout — \ - it must not be skipped.", + "Reads dbc.MPQ. Missing starting outfits block Character creation.", importer_command(project, database) .arg("--dbc") .arg(&data) @@ -939,8 +1085,7 @@ fn world_etl_commands( ), ( "real talent trees (TalentTab.dbc + Talent.dbc)", - "Reads dbc.MPQ. Without this the demo talent ids do not match what the 5875 client \ - sends, and talent clicks silently no-op.", + "Reads dbc.MPQ. Referenced talent spells must exist in the imported catalogue.", importer_command(project, database) .arg("--dbc") .arg(&data) @@ -957,30 +1102,10 @@ fn world_etl_commands( .arg("--spells") .arg("--apply"), ), - ( - "curated class spells (all nine classes)", - "This reads Spell.dbc out of the client archives and writes trainer offerings. A \ - \"requested N ids but matched M\" warning names ids your client build does not have.", - class_spells_command(project, client_data, database), - ), ]); commands } -/// The class-spell import takes the client path as its argument, the destination from `DB`, and the -/// node from `SPACETIME_SERVER`. Pass all three explicitly so no Shard or node comes from ambient -/// shell state. -fn class_spells_command( - project: &ProjectLayout, - client_data: &Path, - database: &str, -) -> CommandSpec { - from_root(project, project.import_class_spells_script()) - .arg(client_data.to_string_lossy().to_string()) - .env("DB", database) - .env("SPACETIME_SERVER", ProjectLayout::stdb_uri()) -} - /// `spacetime call`, pinned to the loopback node — bare `spacetime call` inherits the CLI's /// AMBIENT default server, which on a fresh machine is maincloud, not the node `dev up` started /// (the #440 class of failure the bash flow's `SPACETIME_SERVER` chokepoint exists for). @@ -996,7 +1121,22 @@ pub(crate) fn call_command(project: &ProjectLayout, database: &str, reducer: &st /// `spacetime sql`, pinned to the loopback node for the same #440 reason as [`call_command`]. pub(crate) fn sql_command(project: &ProjectLayout, database: &str, query: &str) -> CommandSpec { - CommandSpec::new("spacetime") + sql_command_with_config(project, database, query, None) +} + +fn sql_command_with_config( + project: &ProjectLayout, + database: &str, + query: &str, + config: Option<&Path>, +) -> CommandSpec { + let mut command = CommandSpec::new("spacetime"); + if let Some(path) = config { + command = command + .arg("--config-path") + .arg(path.to_string_lossy().to_string()); + } + command .arg("sql") .arg("--server") .arg(ProjectLayout::stdb_uri()) @@ -1273,6 +1413,7 @@ struct Assertions<'a> { runner: &'a dyn ProcessRunner, database: &'a str, floors: &'a Floors, + owner_config: Option<&'a Path>, failed: usize, } @@ -1282,7 +1423,12 @@ impl Assertions<'_> { /// FAILs — so the first failure aborts the whole pass instead of reading as a count of 0. fn sql(&self, query: &str) -> Result { self.runner - .run_and_wait(&sql_command(self.project, self.database, query)) + .run_and_wait(&sql_command_with_config( + self.project, + self.database, + query, + self.owner_config, + )) .map_err(|e| { Error::Process(format!( "a verification query against '{db}' failed — a failed query is NOT zero \ @@ -1661,12 +1807,14 @@ fn assert_floors( database: &str, profile: WorldProfile, floors: &Floors, + owner_config: Option<&Path>, ) -> Result<()> { let mut a = Assertions { project, runner, database, floors, + owner_config, failed: 0, }; println!(" assertions ({} → {database}):", profile.name()); @@ -2087,6 +2235,7 @@ fn assert_floors( #[cfg(test)] pub(crate) mod tests { use super::*; + use crate::http::fake::FakeHttp; use crate::proc::fake::{Call, FakeStack}; use std::cell::RefCell; use tempfile::TempDir; @@ -2128,11 +2277,7 @@ pub(crate) mod tests { std::fs::write(tmp.path().join("Cargo.toml"), "[workspace]\n").unwrap(); let dir = tmp.path().join(ProjectLayout::IMPORTER_SCRIPTS_DIR); std::fs::create_dir_all(&dir).unwrap(); - for name in [ - "pull-classic-db.sh", - "import-class-spells.sh", - "import-manifest.sh", - ] { + for name in ["pull-classic-db.sh", "import-manifest.sh"] { std::fs::write(dir.join(name), "#!/usr/bin/env bash\n").unwrap(); } let import_dir = tmp.path().join(".import"); @@ -2198,6 +2343,232 @@ pub(crate) mod tests { .unwrap_or_else(|| panic!("{needle} was never run: {calls:?}")) } + #[test] + fn check_reads_world_shards_and_instance_pool_without_changing_content() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + let stack = healthy(); + run_check(&project, &stack.runner(), &FakeHttp::new(), &[]).unwrap(); + let calls: Vec = rendered(&stack) + .into_iter() + .filter(|command| command.contains("check_world_content")) + .collect(); + let prefix = format!("spacetime call --server {}", ProjectLayout::stdb_uri()); + assert_eq!( + calls, + vec![ + format!("{prefix} lyracore check_world_content [0,369]"), + format!("{prefix} lyracore-kalimdor check_world_content [1]"), + format!("{prefix} lyracore-instances check_world_content [36]") + ] + ); + assert_eq!(stack.calls().len(), 4); + } + + #[test] + fn check_reports_every_missing_destination_with_module_diagnostics() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + let stack = healthy().fail_on("check_world_content", "no completed world import for map"); + let error = run_check(&project, &stack.runner(), &FakeHttp::new(), &[]) + .unwrap_err() + .to_string(); + for name in [ + "alliance-eastern", + "alliance-kalimdor", + "instances", + "no completed world import", + "lyracore import world", + ] { + assert!(error.contains(name), "{error}"); + } + assert_eq!( + rendered(&stack) + .iter() + .filter(|command| command.contains("check_world_content")) + .count(), + 3 + ); + } + + #[test] + fn single_check_includes_continents_and_instances() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + set_topology(&project, Topology::Single); + let stack = healthy(); + run_check(&project, &stack.runner(), &FakeHttp::new(), &[]).unwrap(); + assert!(rendered(&stack) + .iter() + .any(|command| command.ends_with("check_world_content [0,1,36,369]"))); + } + + #[test] + fn check_uses_persisted_operator_and_explicit_destinations() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + std::fs::create_dir_all(project.token_file().parent().unwrap()).unwrap(); + std::fs::write(project.token_file(), crate::proc::fake::FAKE_TOKEN).unwrap(); + let stack = healthy(); + let http = FakeHttp::new(); + run_check( + &project, + &stack.runner(), + &http, + &[ + "starting-eastern=custom-east".into(), + "starting-kalimdor=custom-west".into(), + "instances=custom-pool".into(), + ], + ) + .unwrap(); + assert!(stack.calls().is_empty()); + let requests = http.requests(); + assert_eq!(requests.len(), 3); + for (request, shard, body) in [ + (requests[0].clone(), "custom-east", "[[0,369]]"), + (requests[1].clone(), "custom-west", "[[1]]"), + (requests[2].clone(), "custom-pool", "[[36]]"), + ] { + assert!(request.url.contains(shard)); + assert_eq!(request.body, body); + assert_eq!( + request.bearer.as_deref(), + Some(crate::proc::fake::FAKE_TOKEN) + ); + } + } + + #[test] + fn failed_import_never_completes_the_destination() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + let data = client_data(&tmp); + let stack = healthy().fail_on("--spells --apply", "Spell catalogue parse failed"); + run_world( + &project, + &stack.runner(), + &FakeHttp::new(), + &ScriptedPrompt::new(&[]), + &accepted(&data), + ) + .unwrap_err(); + let calls = rendered(&stack); + assert!(calls + .iter() + .any(|command| command.contains("begin_world_import"))); + assert!(!calls + .iter() + .any(|command| command.contains("complete_world_import"))); + } + + #[test] + fn import_completes_each_profile_only_after_its_checks() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + let data = client_data(&tmp); + let stack = healthy(); + run_world( + &project, + &stack.runner(), + &FakeHttp::new(), + &ScriptedPrompt::new(&[]), + &accepted(&data), + ) + .unwrap(); + let calls = rendered(&stack); + for (shard, maps) in [ + ("lyracore", "[0,369]"), + ("lyracore-kalimdor", "[1]"), + ("lyracore-instances", "[36]"), + ] { + let begin = pos(&calls, &format!("{shard} begin_world_import")); + let first = pos(&calls, &format!("--db {shard} --server")); + let complete = pos(&calls, &format!("{shard} complete_world_import {maps}")); + let check = pos(&calls, &format!("{shard} check_world_content {maps}")); + assert!(begin < first && first < complete && complete < check); + let last_query = calls + .iter() + .rposition(|command| command.contains(&format!("{shard} SELECT"))) + .unwrap(); + assert!(last_query < complete); + } + for call in stack.calls() { + if let Call::Stream(command) = call { + if command.render().contains("--db") { + assert_eq!(command.env_value("LYRACORE_DEFER_CONTENT_READY"), Some("1")); + } + } + } + } + + #[test] + fn minted_operator_imports_use_a_private_temporary_cli_config() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + let data = client_data(&tmp); + std::fs::create_dir_all(project.token_file().parent().unwrap()).unwrap(); + std::fs::write(project.token_file(), crate::proc::fake::FAKE_TOKEN).unwrap(); + let stack = healthy().with_stdout("sql --server", SQL_ROWS); + run_world( + &project, + &stack.runner(), + &FakeHttp::new(), + &ScriptedPrompt::new(&[]), + &accepted(&data), + ) + .unwrap(); + let mut paths = BTreeSet::new(); + for call in stack.calls() { + let (Call::Stream(command) | Call::Wait(command)) = call else { + continue; + }; + assert!(!command.render().contains(crate::proc::fake::FAKE_TOKEN)); + if command.render().contains("--db") { + paths.insert( + command + .env_value(SPACETIME_CONFIG_VAR) + .expect("importer needs the claimed identity") + .to_string(), + ); + } + if command.program() == "spacetime" { + assert_eq!(command.args()[0], "--config-path"); + paths.insert(command.args()[1].clone()); + } + } + assert_eq!(paths.len(), 1); + assert!(!Path::new(paths.iter().next().unwrap()).exists()); + } + + #[cfg(unix)] + #[test] + fn temporary_cli_config_contains_the_operator_token_and_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + std::fs::create_dir_all(project.token_file().parent().unwrap()).unwrap(); + std::fs::write(project.token_file(), crate::proc::fake::FAKE_TOKEN).unwrap(); + let credential = + crate::token::resolve_existing(&healthy().runner(), &project.token_file()).unwrap(); + let config = temporary_owner_config(&credential).unwrap().unwrap(); + assert_eq!( + std::fs::metadata(config.path()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert_eq!( + std::fs::read_to_string(config.path()).unwrap(), + format!( + "spacetimedb_token = \"{}\"\n", + crate::proc::fake::FAKE_TOKEN + ) + ); + } + // ---- consent ---- #[test] @@ -2227,7 +2598,14 @@ pub(crate) mod tests { client_data: Some(data.to_string_lossy().to_string()), profile_shards: vec![], }; - let error = run_world(&project, &stack.runner(), &prompt, &options).unwrap_err(); + let error = run_world( + &project, + &stack.runner(), + &FakeHttp::new(), + &prompt, + &options, + ) + .unwrap_err(); assert_eq!(error.exit_code(), crate::error::EXIT_USAGE, "{answer:?}"); assert!( stack.calls().is_empty(), @@ -2250,7 +2628,14 @@ pub(crate) mod tests { client_data: Some(data.to_string_lossy().to_string()), profile_shards: vec![], }; - run_world(&project, &stack.runner(), &prompt, &options).unwrap(); + run_world( + &project, + &stack.runner(), + &FakeHttp::new(), + &prompt, + &options, + ) + .unwrap(); assert!( rendered(&stack).iter().any(|c| c.contains("--dump")), "{answer:?} should have run the ETL" @@ -2267,7 +2652,14 @@ pub(crate) mod tests { // No answers at all: this prompt errors if it is asked anything. let prompt = ScriptedPrompt::new(&[]); - run_world(&project, &stack.runner(), &prompt, &accepted(&data)).unwrap(); + run_world( + &project, + &stack.runner(), + &FakeHttp::new(), + &prompt, + &accepted(&data), + ) + .unwrap(); assert!(prompt.asked().is_empty(), "--accept must not prompt"); } @@ -2302,21 +2694,6 @@ pub(crate) mod tests { .collect() } - /// Every `(script name, DB=)` pair the run handed out, in order. - fn imported_databases(stack: &FakeStack) -> Vec<(String, String)> { - stack - .calls() - .into_iter() - .filter_map(|call| match call { - Call::Stream(spec) | Call::Wait(spec) => { - let script = spec.args().first()?.rsplit('/').next()?.to_string(); - Some((script, spec.env_value("DB")?.to_string())) - } - _ => None, - }) - .collect() - } - #[test] fn topology_assigns_each_content_destination_its_canonical_profile_once() { for (topology, want) in [ @@ -2346,6 +2723,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -2357,14 +2735,9 @@ pub(crate) mod tests { .iter() .filter(|command| command.contains(" --dump ")) .all(|command| !command.contains("--include-creatures"))); - let expected_shards: Vec = - want.iter().map(|(shard, _)| shard.clone()).collect(); - let spells: Vec = imported_databases(&stack) - .into_iter() - .filter(|(script, _)| script == "import-class-spells.sh") - .map(|(_, db)| db) - .collect(); - assert_eq!(spells, expected_shards, "{topology:?}"); + assert!(!rendered(&stack) + .iter() + .any(|command| command.contains("import-class-spells.sh"))); } } @@ -2387,6 +2760,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &options, ) @@ -2438,6 +2812,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &options, ) @@ -2497,6 +2872,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -2545,15 +2921,69 @@ pub(crate) mod tests { })); assert!(!rendered.iter().any(|command| command.contains("--terrain"))); assert!(!rendered.iter().any(|command| command.contains("--nav"))); - for mode in ["--talents", "--spells"] { + for mode in ["--vmap", "--talents", "--spells"] { assert!(rendered.iter().any(|command| command.contains(mode))); } - let spells = pool + assert!(!rendered .iter() - .find(|(what, _, _)| what.contains("class spells")) - .map(|(_, _, c)| c) - .expect("the pool's mode list lost the class-spell overlay"); - assert_eq!(spells.env_value("DB"), Some(ProjectLayout::INSTANCE_POOL)); + .any(|command| command.contains("import-class-spells.sh"))); + } + + #[test] + fn required_wmo_geometry_runs_only_for_profiles_that_own_wmo_maps() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + let data = client_data(&tmp); + for (profile, expected) in [ + (WorldProfile::AllianceEastern, true), + (WorldProfile::StartingEastern, true), + (WorldProfile::AllianceKalimdor, false), + (WorldProfile::StartingKalimdor, false), + (WorldProfile::AllianceSingle, true), + (WorldProfile::Instances, true), + ] { + let commands = world_etl_commands( + &project, + &data, + &ImportDestination { + shard: "world".into(), + profile, + }, + ); + assert_eq!( + commands + .iter() + .any(|(_, _, command)| command.args().iter().any(|arg| arg == "--vmap")), + expected, + "{}", + profile.name() + ); + } + } + + #[test] + fn failed_required_wmo_geometry_leaves_content_unavailable() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + let data = client_data(&tmp); + let stack = healthy().fail_on("--vmap", "missing Tram WMO"); + let error = run_world( + &project, + &stack.runner(), + &FakeHttp::new(), + &ScriptedPrompt::new(&[]), + &accepted(&data), + ) + .unwrap_err() + .to_string(); + assert!(error.contains("missing Tram WMO"), "{error}"); + let calls = rendered(&stack); + assert!(calls + .iter() + .any(|command| command.contains("begin_world_import"))); + assert!(!calls + .iter() + .any(|command| command.contains("complete_world_import"))); } #[test] @@ -2583,9 +3013,15 @@ pub(crate) mod tests { client_data: Some(data.to_string_lossy().to_string()), profile_shards: vec![], }; - let error = run_world(&project, &stack.runner(), &prompt, &options) - .unwrap_err() - .to_string(); + let error = run_world( + &project, + &stack.runner(), + &FakeHttp::new(), + &prompt, + &options, + ) + .unwrap_err() + .to_string(); assert!(error.contains("Nothing was fetched"), "{error}"); } @@ -2601,7 +3037,14 @@ pub(crate) mod tests { client_data: Some(data.to_string_lossy().to_string()), profile_shards: vec![], }; - assert!(run_world(&project, &stack.runner(), &prompt, &options).is_err()); + assert!(run_world( + &project, + &stack.runner(), + &FakeHttp::new(), + &prompt, + &options + ) + .is_err()); assert!(stack.calls().is_empty()); } @@ -2619,6 +3062,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -2635,6 +3079,7 @@ pub(crate) mod tests { ), format!("{importer} --db {db} --server {server} --terrain {data} --world-profile alliance-single --apply"), format!("{importer} --db {db} --server {server} --nav {data} --world-profile alliance-single --apply"), + format!("{importer} --db {db} --server {server} --vmap {data} --world-profile alliance-single --apply"), format!("{importer} --db {db} --server {server} --dbc {data} --apply"), format!("{importer} --db {db} --server {server} --dbc {data} --talents --apply"), format!("{importer} --db {db} --server {server} --dbc {data} --spells --apply"), @@ -2656,6 +3101,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -2670,8 +3116,9 @@ pub(crate) mod tests { "--dump", "--terrain", "--nav", + "--vmap", "--talents", - "import-class-spells.sh", + "--spells --apply", "debug_repair_after_publish", "arm_all_pools", "spacetime sql", @@ -2684,12 +3131,10 @@ pub(crate) mod tests { pair[1] ); } - // The curated overlay follows the full Spell.dbc catalogue and adds trainer bindings. - assert!(pos(&calls, "--spells --apply") < pos(&calls, "import-class-spells.sh")); // The long children stream; the bookkeeping ones are captured. for call in stack.calls() { match call { - Call::Stream(spec) | Call::Wait(spec) => { + Call::Stream(spec) | Call::Wait(spec) if !spec.render().contains("login show") => { assert_eq!( spec.cwd_value(), Some(project.root.as_path()), @@ -2697,6 +3142,7 @@ pub(crate) mod tests { spec.render() ); } + Call::Wait(spec) if spec.render().contains("login show") => {} other => panic!("unexpected call kind: {other:?}"), } } @@ -2716,6 +3162,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -2752,6 +3199,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -2776,6 +3224,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -2789,13 +3238,7 @@ pub(crate) mod tests { other => panic!("unexpected call kind: {other:?}"), }) .collect(); - for (needle, shipped) in [ - ("pull-classic-db.sh", ProjectLayout::PULL_CLASSIC_DB_SCRIPT), - ( - "import-class-spells.sh", - ProjectLayout::IMPORT_CLASS_SPELLS_SCRIPT, - ), - ] { + for (needle, shipped) in [("pull-classic-db.sh", ProjectLayout::PULL_CLASSIC_DB_SCRIPT)] { let spec = specs .iter() .find(|s| s.render().contains(needle)) @@ -2828,6 +3271,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -2850,22 +3294,13 @@ pub(crate) mod tests { } // Every spacetime query/call is pinned to the loopback node (#440) and the fixture // database. - if render.starts_with("spacetime") { + if render.starts_with("spacetime") && !render.contains("login show") { assert!( render.contains(&format!("--server {}", ProjectLayout::stdb_uri())), "{render}" ); assert!(render.contains(ProjectLayout::DATABASE), "{render}"); } - // The class-spell overlay takes the client path as its argument and the database from - // env — both explicit, because its DB default is how spells once landed elsewhere. - if render.contains("import-class-spells.sh") { - assert_eq!( - spec.args().last().map(String::as_str), - Some(canonical.as_str()) - ); - assert_eq!(spec.env_value("DB"), Some(ProjectLayout::DATABASE)); - } } } @@ -2880,6 +3315,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -2901,11 +3337,7 @@ pub(crate) mod tests { assert!(shards.contains(&spec.args()[1].as_str()), "{render}"); assert_eq!(&spec.args()[2..4], &["--server", server.as_str()]); } - if render.contains("import-class-spells.sh") { - assert!(shards.contains(&spec.env_value("DB").unwrap_or(""))); - assert_eq!(spec.env_value("SPACETIME_SERVER"), Some(server.as_str())); - } - if render.starts_with("spacetime") { + if render.starts_with("spacetime") && !render.contains("login show") { assert!(render.contains(&format!("--server {server}")), "{render}"); assert!( shards.contains(&spec.args().get(3).map(String::as_str).unwrap_or_default()), @@ -2960,6 +3392,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -2983,6 +3416,7 @@ pub(crate) mod tests { let error = run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -3009,6 +3443,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &prompt, &ImportOptions::default(), ) @@ -3034,6 +3469,7 @@ pub(crate) mod tests { let error = run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &options, ) @@ -3105,6 +3541,7 @@ pub(crate) mod tests { let error = run_world( &project, &stack.runner(), + &FakeHttp::new(), &prompt, &ImportOptions::default(), ) @@ -3131,6 +3568,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&flagged), ) @@ -3160,6 +3598,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &prompt, &ImportOptions::default(), ) @@ -3184,6 +3623,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &prompt, &ImportOptions::default(), ) @@ -3213,6 +3653,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &prompt, &ImportOptions::default(), ) @@ -3251,9 +3692,9 @@ pub(crate) mod tests { ), ("--terrain", "self-check bail", "terrain", "--nav"), ( - "import-class-spells.sh", - "requested 31 ids but matched 30", - "class spells", + "--spells --apply", + "client spell parse failed", + "full Spell.dbc", "debug_repair_after_publish", ), ] { @@ -3261,6 +3702,7 @@ pub(crate) mod tests { let error = run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -3291,6 +3733,7 @@ pub(crate) mod tests { let error = run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -3321,6 +3764,7 @@ pub(crate) mod tests { let error = run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -3332,11 +3776,7 @@ pub(crate) mod tests { #[test] fn a_checkout_without_the_import_tooling_says_so_before_consent_is_spent() { - for missing in [ - "pull-classic-db.sh", - "import-class-spells.sh", - "import-manifest.sh", - ] { + for missing in ["pull-classic-db.sh", "import-manifest.sh"] { let tmp = TempDir::new().unwrap(); let project = checkout(&tmp); std::fs::remove_file( @@ -3352,6 +3792,7 @@ pub(crate) mod tests { let error = run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -3378,6 +3819,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -3404,6 +3846,7 @@ pub(crate) mod tests { let error = run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -3437,6 +3880,7 @@ pub(crate) mod tests { let error = run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -3465,6 +3909,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -3485,6 +3930,7 @@ pub(crate) mod tests { let error = run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -3512,6 +3958,7 @@ pub(crate) mod tests { let error = run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -3536,6 +3983,7 @@ pub(crate) mod tests { let error = run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -3563,6 +4011,7 @@ pub(crate) mod tests { let error = run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) @@ -3585,6 +4034,7 @@ pub(crate) mod tests { run_world( &project, &stack.runner(), + &FakeHttp::new(), &ScriptedPrompt::new(&[]), &accepted(&data), ) diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 97bf300..960a4ce 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -22,21 +22,20 @@ use import::{ImportOptions, VmapOptions}; /// Everything else — the commands you only need once you're developing LyraCore itself — lives in /// [`USAGE_ALL`], reached with `lyracore help --all`. pub const USAGE: &str = "\ -lyracore — run a local LyraCore vanilla WoW server +lyracore: run a local LyraCore vanilla WoW Realm USAGE: - lyracore doctor check whether your machine has what `dev up` needs - lyracore dev up start the local server (or reconnect to one already running) + lyracore doctor check the prerequisites for `dev up` + lyracore dev up publish and start the local Realm for setup lyracore account create USER - create a login account so you can connect a 1.12.1 client - lyracore dev status show whether the server is running - lyracore dev down stop the server - lyracore import replace the placeholder starter data with the real game - world — every Alliance early-game corridor — from public - game data and your own 1.12.1 client + provision an Account with your chosen password + lyracore import import world content from public game data and + your own 1.12.1 client before play + lyracore dev check check imported content on every playable Shard + lyracore dev status report the running processes and published Shards + lyracore dev down stop the local Realm -Run `lyracore help --all` to see every command, including the ones for working on -LyraCore itself."; +Run `lyracore help --all` to see every command."; /// The full command surface, including the contributor-facing corners `USAGE` leaves out. pub const USAGE_ALL: &str = "\ @@ -65,12 +64,15 @@ USAGE: lyracore dev status report each component's state, and each database's own published/connected verdict lyracore dev logs [spacetime|gateway] show a component's log file - lyracore dev smoke run the pinned wire harness's login smoke + lyracore dev check check imported content on World Shards and the + Instance Pool, without changing their content + lyracore dev smoke ACCOUNT CHARACTER [--password-stdin] + check content, then run the pinned Headless Client + with your Account password lyracore dev down [--forget] stop the processes this CLI started lyracore account create USER [--password-stdin] provision an account's SRP6 credentials - lyracore import [--client-data PATH] build the REAL world in place of the seed - fixture: every Alliance early-game corridor + lyracore import [--client-data PATH] import the playable world: Alliance early-game corridors in Eastern Kingdoms and Kalimdor, plus the instance maps. Pulls cmangos' classic-db, reads your own 1.12.1 client's Data/ archives, @@ -86,6 +88,9 @@ USAGE: lyracore import world --accept the same command by its full name (`import` is its alias), with the consent answered in advance (scripted runs) + lyracore import check [--profile-shard PROFILE=SHARD ...] + check imported content at the same destinations + selected by `import world` lyracore import vmaps [--client-data PATH] exact model/WMO collision triangles for each populated World Shard, including Kalimdor, read from your own client's archives — nothing @@ -212,7 +217,15 @@ pub enum Command { }, DevStatus, DevLogs(Option), - DevSmoke, + DevCheck, + DevSmoke { + account: String, + character: String, + source: PasswordSource, + }, + ImportCheck { + profile_shards: Vec, + }, DevDown { forget: bool, }, @@ -319,7 +332,14 @@ impl Command { ["dev", "up", rest @ ..] => parse_dev_up(rest), ["dev", "status"] => Ok(Command::DevStatus), - ["dev", "smoke"] => Ok(Command::DevSmoke), + ["dev", "check"] => Ok(Command::DevCheck), + ["dev", "smoke", account, character] if !account.trim().is_empty() && !character.trim().is_empty() && !account.starts_with('-') && !character.starts_with('-') => Ok(Command::DevSmoke { + account: (*account).to_string(), character: (*character).to_string(), source: PasswordSource::Tty, + }), + ["dev", "smoke", account, character, "--password-stdin"] if !account.trim().is_empty() && !character.trim().is_empty() && !account.starts_with('-') && !character.starts_with('-') => Ok(Command::DevSmoke { + account: (*account).to_string(), character: (*character).to_string(), source: PasswordSource::Stdin, + }), + ["dev", "smoke", ..] => Err(Error::Usage("`dev smoke` needs ACCOUNT CHARACTER [--password-stdin]".to_string())), ["dev", "down"] => Ok(Command::DevDown { forget: false }), ["dev", "down", "--forget"] => Ok(Command::DevDown { forget: true }), ["dev", "logs"] => Ok(Command::DevLogs(None)), @@ -331,10 +351,10 @@ impl Command { )) }), ["dev"] => Err(Error::Usage( - "`dev` needs a subcommand: up, status, logs, smoke, down".to_string(), + "`dev` needs a subcommand: up, status, check, logs, smoke, down".to_string(), )), ["dev", other, ..] => Err(Error::Usage(format!( - "unknown `dev` subcommand '{other}' — expected up, status, logs, smoke, or down" + "unknown `dev` subcommand '{other}' — expected up, status, check, logs, smoke, or down" ))), ["account", "create", user] => Ok(Command::AccountCreate { @@ -356,6 +376,7 @@ impl Command { // `import` is an ALIAS of `import world` rather than a separate arm: this parser is // literal slice-matching, so the alias is one line, the two spellings cannot drift, // and the command bare `import` has meant since it existed keeps meaning that. + ["import", "check", rest @ ..] => parse_import_check(rest), ["import", "world", rest @ ..] => parse_import_world(rest), ["import", "vmaps", rest @ ..] => parse_import_vmaps(rest), // ...and `import` still takes no positional arguments. A bare path is the shape @@ -879,6 +900,24 @@ fn parse_packages_name_and_yes(verb: &str, args: &[&str]) -> Result<(String, boo }) } +fn parse_import_check(args: &[&str]) -> Result { + let mut profile_shards = Vec::new(); + let mut rest = args; + while let ["--profile-shard", assignment, tail @ ..] = rest { + if assignment.starts_with('-') { + break; + } + profile_shards.push((*assignment).to_string()); + rest = tail; + } + if !rest.is_empty() { + return Err(Error::Usage( + "`import check` accepts only --profile-shard PROFILE=SHARD".to_string(), + )); + } + Ok(Command::ImportCheck { profile_shards }) +} + /// `dev up [--single] [--lan ]`, in either order. /// /// The two options are orthogonal — `--single` chooses how many databases, `--lan` chooses who can @@ -1030,6 +1069,34 @@ mod tests { Command::parse(&args) } + #[test] + fn check_and_smoke_require_explicit_valid_arguments() { + for command in [ + "dev check extra", + "dev smoke", + "dev smoke ALICE", + "dev smoke --bad Ginger", + "import check --accept", + "import check --profile-shard", + ] { + assert!(parse(command).is_err(), "{command}"); + } + assert_eq!( + parse("import check --profile-shard instances=pool").unwrap(), + Command::ImportCheck { + profile_shards: vec!["instances=pool".into()] + } + ); + assert_eq!( + parse("dev smoke ALICE Ginger --password-stdin").unwrap(), + Command::DevSmoke { + account: "ALICE".into(), + character: "Ginger".into(), + source: PasswordSource::Stdin, + } + ); + } + #[test] fn the_documented_surface_parses() { assert_eq!(parse("doctor").unwrap(), Command::Doctor); @@ -1041,7 +1108,15 @@ mod tests { } ); assert_eq!(parse("dev status").unwrap(), Command::DevStatus); - assert_eq!(parse("dev smoke").unwrap(), Command::DevSmoke); + assert_eq!(parse("dev check").unwrap(), Command::DevCheck); + assert_eq!( + parse("dev smoke ALICE Ginger").unwrap(), + Command::DevSmoke { + account: "ALICE".into(), + character: "Ginger".into(), + source: PasswordSource::Tty, + } + ); assert_eq!(parse("dev logs").unwrap(), Command::DevLogs(None)); assert_eq!( parse("dev down").unwrap(), @@ -1349,10 +1424,7 @@ mod tests { assert!(USAGE_ALL.contains("lyracore import vmaps"), "{USAGE_ALL}"); assert!(USAGE_ALL.contains("--client-data"), "{USAGE_ALL}"); assert!(USAGE_ALL.contains("--accept"), "{USAGE_ALL}"); - assert!( - USAGE.contains("every Alliance early-game corridor"), - "{USAGE}" - ); + assert!(USAGE.contains("before play"), "{USAGE}"); assert!( USAGE_ALL.contains("Eastern Kingdoms and Kalimdor"), "{USAGE_ALL}" diff --git a/src/harness.rs b/src/harness.rs index e278711..5845318 100644 --- a/src/harness.rs +++ b/src/harness.rs @@ -57,14 +57,6 @@ impl Harness { self.dir.join(ProjectLayout::HARNESS_SMOKE_SEAM) } - /// A full-suite entrypoint, if the release carries one. Historically this script lived in the - /// SERVER repo (`adapters/lyracore/run-suite.sh`); a release that has absorbed it is preferred - /// over driving the seam directly, because then the harness owns its own orchestration. - pub fn suite_script(&self) -> Option { - let path = self.dir.join(ProjectLayout::HARNESS_SUITE_SCRIPT); - path.is_file().then_some(path) - } - pub fn manifest(&self) -> PathBuf { self.dir.join("Cargo.toml") } diff --git a/src/project.rs b/src/project.rs index 6eaaac3..a2c508b 100644 --- a/src/project.rs +++ b/src/project.rs @@ -332,7 +332,6 @@ impl ProjectLayout { /// provision, and this CLI deliberately does not address it. pub const IMPORTER_SCRIPTS_DIR: &'static str = "importer/scripts"; pub const PULL_CLASSIC_DB_SCRIPT: &'static str = "importer/scripts/pull-classic-db.sh"; - pub const IMPORT_CLASS_SPELLS_SCRIPT: &'static str = "importer/scripts/import-class-spells.sh"; /// The FLOOR_* count minimums the post-import assertions compare against. A DATA file the /// core repo tunes per dump — the CLI sources it fresh on every run rather than baking the /// numbers in, so retuning a floor never needs a CLI release. @@ -395,11 +394,8 @@ impl ProjectLayout { /// Paths INSIDE a harness checkout. `dev smoke` drives the generic login smoke through the /// harness's own adapter seam; a release that also carries a suite entrypoint is preferred. pub const HARNESS_SMOKE_SEAM: &'static str = "adapters/lyracore/wire.sh"; - pub const HARNESS_SUITE_SCRIPT: &'static str = "adapters/lyracore/run-suite.sh"; pub const HARNESS_CLIENT_BIN: &'static str = "vanilla-wire"; /// The fixture account and character the login smoke signs in as. - pub const SMOKE_ACCOUNT: &'static str = "TEST"; - pub const SMOKE_CHARACTER: &'static str = "Ginger"; // The database is loopback in every mode. Only the two client-facing ports follow the // `ClientBind` chosen by `dev up [--lan IP]`. @@ -578,10 +574,6 @@ impl ProjectLayout { self.root.join(Self::PULL_CLASSIC_DB_SCRIPT) } - pub fn import_class_spells_script(&self) -> PathBuf { - self.root.join(Self::IMPORT_CLASS_SPELLS_SCRIPT) - } - pub fn import_manifest_script(&self) -> PathBuf { self.root.join(Self::IMPORT_MANIFEST_SCRIPT) }