From c19d84f2e38c31bbc90e8388b1ba4cacacc0f85e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 19 Apr 2026 13:12:43 +0200 Subject: [PATCH] feat(simulation): bookmark definition system with bridge protocol (#614) BookmarkPlugin, BookmarkRegistry, SelectedBookmark resources. Tycoon bookmark defined; PROTOCOL_VERSION bumped to 22. RequestBookmarkCatalog + ConfirmBookmark actions wired into process_player_input via BookmarkInputParams SystemParam bundle (resolves Bevy's 16-system-param limit). build_catalog() accepts optional CultureResolver for D-128 location-culture mapping. Snapshot delivery at tick-0 via SnapshotBuffer. Co-Authored-By: Claude Sonnet 4.6 --- docs/architecture/sprint-36-bookmark-spec.md | 319 +++++++++++++++++++ server/src/bookmark/mod.rs | 221 +++++++++++++ server/src/bookmark/types.rs | 53 +++ server/src/bridge/text_renderer.rs | 2 + server/src/bridge/types.rs | 69 +++- server/src/lib.rs | 1 + server/src/main.rs | 19 ++ server/src/perception/observer/mod.rs | 4 + server/src/simulation/input.rs | 93 +++++- server/tests/bridge_ipc.rs | 2 + server/tests/bridge_tcp.rs | 2 + server/tests/error_handling.rs | 2 + server/tests/gen_fixtures.rs | 6 + server/tests/serialization.rs | 6 + 14 files changed, 797 insertions(+), 2 deletions(-) create mode 100644 docs/architecture/sprint-36-bookmark-spec.md create mode 100644 server/src/bookmark/mod.rs create mode 100644 server/src/bookmark/types.rs diff --git a/docs/architecture/sprint-36-bookmark-spec.md b/docs/architecture/sprint-36-bookmark-spec.md new file mode 100644 index 000000000..deb6cf8ab --- /dev/null +++ b/docs/architecture/sprint-36-bookmark-spec.md @@ -0,0 +1,319 @@ +--- +title: "Bookmark Definition — Contract Spec (Sprint 36)" +description: "Struct shape, module placement, and bridge protocol for the CK3-style bookmark system. Contract between server #614 and client #618." +type: architecture +status: draft +ticket: "#614" +decision_refs: [D-115, D-117, D-118, D-128, D-146] +author: "Tyre" +created: 2026-04-19 +updated: 2026-04-19 +--- + +# Bookmark Definition — Contract Spec + +**Tickets:** server #614 (implementation), client #618 (consumer) +**Decisions:** D-115 (creation = skills + bookmark), D-117 (tycoon is the v0.2 bookmark), D-118 (start = small business owner), D-128 (culture implicit in location), D-146 (tile-scale preview — not in contract) +**Scope:** Minimum viable bookmark enumeration + selection. Skills live in a sibling system (#618 territory). Culture is derived from `starting_location_id` via the #679 API — NOT a field on `BookmarkDefinition`. + +--- + +## 1. Purpose + +A bookmark is a **named starting scenario** the player chooses at character creation. It bundles: +- A display identity (title, subtitle, flavor blurb) — what the player reads. +- A starting-state seed (location, career, a small set of seed parameters) — what the simulation consumes. + +The client enumerates available bookmarks on the character-creation screen and emits a selected `bookmark_id` + `starting_location_id` when the player confirms. + +For v0.2 there is exactly one bookmark: `tycoon`. The system is built for one but must not hard-code one — future bookmarks (explorer, homesteader, etc.) plug in as additional static entries. + +## 2. Module placement + +Per server-team convention (see `server/src/settings/`, `server/src/knowledge/`), bookmarks get their own top-level module: + +``` +server/src/bookmark/ +├── mod.rs # Plugin, registry resource, public API +└── types.rs # BookmarkDefinition, BookmarkId, wire types +``` + +Registered as a `BookmarkPlugin` and added to the `App` alongside `SettingsPlugin` and `KnowledgePlugin`. Exports flow through `server/src/lib.rs`: + +```rust +pub mod bookmark; // new +``` + +Rationale: parallel to settings/knowledge — bookmarks are a first-class domain, not simulation state. A sub-module under `settings/` would be wrong (settings are player prefs; bookmarks are content). + +## 3. Rust types + +### 3.1 BookmarkId (stable string key) + +```rust +/// Stable identifier for a bookmark definition. +/// +/// String-backed (not an enum) so new bookmarks can be added without bumping +/// the protocol version. v0.2 ships exactly one: `"tycoon"`. +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct BookmarkId(pub String); + +impl BookmarkId { + pub const TYCOON: &'static str = "tycoon"; + pub fn as_str(&self) -> &str { &self.0 } +} +``` + +### 3.2 BookmarkDefinition (server-internal) + +```rust +/// Full static definition of a bookmark. Loaded once at startup, immutable +/// at runtime. Lives server-side; a projection (`BookmarkWire`) crosses the +/// bridge. +#[derive(Debug, Clone)] +pub struct BookmarkDefinition { + /// Stable ID (e.g. `"tycoon"`). + pub id: BookmarkId, + + /// Short display title for the bookmark card (≤32 chars). + /// e.g. "Tycoon" — shown as the tab/tile header. + pub title: String, + + /// One-line subtitle under the title (≤64 chars). + /// e.g. "Small business owner on the make." + pub subtitle: String, + + /// Flavor blurb shown on selection. 2–4 sentences, Mellanie-authored. + /// Markdown NOT supported — plain text only. + pub flavor: String, + + /// Default starting location the bookmark places the character in. + /// Format: `system_id` from `server/data/systems.db` (e.g. "GJ 35"). + /// The client location picker (#680) MAY let the player choose another + /// location within the bookmark's allowed set; this is the default. + pub default_location: String, + + /// Candidate starting locations the player can pick from for this + /// bookmark (#680). Includes `default_location`. Empty = default only. + /// For v0.2 tycoon, this is the Van Maanen's Star system entry. + pub allowed_locations: Vec, + + /// Career seed — determines initial skills weighting, inventory, and + /// starting business. Enum so downstream systems (skill seeder, + /// apartment generator, monologue pool selector) can pattern-match. + pub career: CareerKind, + + /// Starting capital in Tractus (D-118 small-business scale — not mogul). + pub starting_capital_tractus: i64, + + /// Visible in the character-creation screen. Use `false` to author + /// work-in-progress bookmarks without exposing them to the client. + pub available: bool, +} + +/// Career seed. v0.2: `Tycoon` only; extensible. +/// Used server-side to route into career-specific initialization +/// (monologue pool, apartment generator seed, starting inventory). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum CareerKind { + /// Tycoon career — small business owner, D-117/D-118. + Tycoon, +} +``` + +### 3.3 BookmarkRegistry (Bevy resource) + +```rust +/// Immutable at runtime: built once during `BookmarkPlugin::build`. +/// BTreeMap for deterministic iteration (D-010 principle 4, D-041). +#[derive(Resource, Debug, Default)] +pub struct BookmarkRegistry { + entries: BTreeMap, +} + +impl BookmarkRegistry { + pub fn get(&self, id: &str) -> Option<&BookmarkDefinition> { ... } + pub fn available(&self) -> impl Iterator { ... } + pub fn contains(&self, id: &str) -> bool { ... } +} +``` + +## 4. Wire protocol (client-facing) + +### 4.1 `BookmarkWire` — the projection that crosses the bridge + +Drop server-only fields (none today, but keep the two types separate so future additions — e.g. a `validation` closure — don't leak through serde). Locates in `server/src/bridge/types.rs` next to the other `*Wire` structs. + +```rust +/// Bookmark projection for the client. Sent as a catalog in +/// `ObserverSnapshot.bookmark_catalog` immediately after handshake +/// (and re-sent once if the client re-requests via PlayerAction). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BookmarkWire { + pub id: String, + pub title: String, + pub subtitle: String, + pub flavor: String, + pub default_location: String, + pub allowed_locations: Vec, + pub career: CareerKindWire, // mirror of CareerKind, #[serde(rename_all = "snake_case")] + pub starting_capital_tractus: i64, +} +``` + +### 4.2 Delivery — where bookmarks show up on the wire + +**Option A (chosen): piggyback on `ObserverSnapshot`.** Add a new field: + +```rust +// In ObserverSnapshot (server/src/bridge/types.rs) +// +// v22 adds: bookmark_catalog (#614, D-115/D-117). +#[serde(default, skip_serializing_if = "Option::is_none")] +pub bookmark_catalog: Option, + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BookmarkCatalog { + pub bookmarks: Vec, +} +``` + +**Semantics:** +- Populated for **exactly one tick** after the handshake completes, and for **exactly one tick** after a `PlayerAction::RequestBookmarkCatalog` arrives. `None` otherwise. +- Same pattern as `settings_response` (#627) and `economy_snapshot` (#822) — one-shot catalog responses live in their own `Option<...>` field, not in the main snapshot every tick. +- Bumps `PROTOCOL_VERSION`. **Coordination note:** #848 (conversation-system retirement) also lands a wire-format change this sprint. Whichever PR lands first takes the next version number (22); the second rebases and takes the one after. Update the protocol comment in `bridge/types.rs` with the correct ticket ref when you land. + +**Rejected:** a separate out-of-band message type. The bridge already framed MessagePack as `ObserverSnapshot`-shaped (`HandshakeMessage` + `StartupMessage` are the only exceptions and both exist for lifecycle reasons). A third top-level message type would need handling in `local.rs` + `tcp.rs` + test harness without buying anything over a snapshot field. + +### 4.3 New PlayerAction variants + +```rust +// In PlayerAction +/// Client requests the full bookmark catalog (#614). +/// Server responds with `ObserverSnapshot.bookmark_catalog` in the next tick. +RequestBookmarkCatalog, + +/// Player confirms character creation with a chosen bookmark (#614, #618). +/// `bookmark_id` must match a `BookmarkId` the server emitted in +/// BookmarkCatalog. `starting_location_id` must be in +/// `BookmarkDefinition.allowed_locations` for that bookmark. +/// +/// On invalid `bookmark_id` or `starting_location_id`: server pushes a +/// `SimError { kind: ProtocolError, ... }` — client should treat as a +/// fatal character-creation error (can't start the game). +ConfirmBookmark { + bookmark_id: String, + starting_location_id: String, +}, +``` + +Note: `ConfirmBookmark` is the **trigger** for transitioning from the character-creation screen into the live world. The downstream chain (apartment generation, starting knowledge seed, culture resolution via #679) fires on this action. Details of that chain are out of scope for this spec — #614 just delivers the action into the input queue and records the selection on a new resource. + +### 4.4 Server-side selection state + +```rust +/// The confirmed bookmark selection for the current session. +/// Populated when `ConfirmBookmark` is processed. `None` during the +/// character-creation phase (before confirm) and always `None` in a +/// fresh session. +#[derive(Resource, Debug, Clone, Default)] +pub struct SelectedBookmark { + pub bookmark_id: Option, + pub starting_location_id: Option, +} +``` + +Downstream systems (apartment generator, skill seeder) read from this +resource. Preserved across save/load (#553) as part of the SaveState. + +## 5. Content source — how bookmarks get into the registry + +v0.2 scope: **hard-coded in `server/src/bookmark/mod.rs`**. A single entry: + +```rust +fn register_default_bookmarks(registry: &mut BookmarkRegistry) { + registry.insert(BookmarkDefinition { + id: BookmarkId("tycoon".to_string()), + title: "Tycoon".into(), + subtitle: "Small business owner on the make.".into(), + flavor: "".into(), + default_location: "GJ 35".into(), // TBD — confirm with Miri + allowed_locations: vec!["GJ 35".into()], + career: CareerKind::Tycoon, + starting_capital_tractus: 5_000, + available: true, + }); +} +``` + +**Why not TOML/YAML from disk?** +- One bookmark. File IO adds failure modes (missing file, parse errors) with no upside. +- When the second bookmark lands (Sprint 38+?), promote to `content/bookmarks/*.toml` — 1 day of work, pattern already established by `content/brands/`. + +**Flavor text:** `flavor` is `` on first pass. Ping her when #614 lands so she can fill it in before #618 renders it. + +**Default location:** Van Maanen's Star's `system_id`. Worth double-checking with Miri that it's `GJ 35` vs another entry in `server/data/systems.db`. Tagged as TBD in the code until confirmed. + +## 6. Handshake-time flow + +``` + client server + | | + |-- TCP/stdio connect ---------------->| + | | + |<----------- HandshakeMessage --------| + | | + |-- StartupMessage (seed, archetype)-->| + | | + | [server initializes BookmarkRegistry] + | | + |<-- ObserverSnapshot(tick=0) ---------| <-- bookmark_catalog: Some(...) + | | (and nothing else interesting; + | | no entities, no player) + | | + | [client renders character creation screen] + | | + |-- PlayerAction::ConfirmBookmark ---->| + | | + | [server reads SelectedBookmark, + | spawns player entity, runs + | apartment generator, etc.] + | | + |<-- ObserverSnapshot(tick=1...) ------| <-- normal gameplay begins +``` + +The client MAY send `PlayerAction::RequestBookmarkCatalog` explicitly (e.g. if it missed tick 0) — server re-sends the same catalog. + +## 7. What this spec does NOT cover + +- **Skills.** D-115 includes skills in character creation, but skill selection is a parallel system; the bookmark just seeds the initial weighting via `career`. See #618 and a separate spec (not yet written). +- **Culture resolution.** Culture is NOT on `BookmarkDefinition`. The client calls the `resolve_culture(location_id)` function from the #679 contract. See `sprint-36-culture-api-spec.md`. +- **Apartment/starting-state generation.** Downstream of `ConfirmBookmark`. Fires when `SelectedBookmark` is populated. Out of scope for this ticket. +- **Bookmark preview asset.** D-146 (tile-scale preview) is rendered client-side from the player's own character descriptor, not a server asset on `BookmarkDefinition`. +- **Save-format integration.** `SelectedBookmark` is a resource; save/load (#553, D-085) already serializes resources — adding two fields is a line-item for the save-state author. + +## 8. Implementation plan (for Dudley) + +**Size:** ~1 day. Straightforward Bevy plugin + bridge types + one hard-coded entry. + +| Step | File | Work | +|------|------|------| +| 1 | `server/src/bookmark/mod.rs` (new) | `BookmarkPlugin`, `BookmarkRegistry` resource, `SelectedBookmark` resource, `register_default_bookmarks` fn | +| 2 | `server/src/bookmark/types.rs` (new) | `BookmarkId`, `BookmarkDefinition`, `CareerKind` | +| 3 | `server/src/lib.rs` | `pub mod bookmark;` | +| 4 | `server/src/bridge/types.rs` | `BookmarkWire`, `CareerKindWire`, `BookmarkCatalog`, `PROTOCOL_VERSION` bump to the next available number (coordinate with #848 — see §4.2), `bookmark_catalog` field on `ObserverSnapshot`, `RequestBookmarkCatalog` + `ConfirmBookmark` `PlayerAction` variants | +| 5 | `server/src/main.rs` (or wherever `App` is built) | `App.add_plugins(BookmarkPlugin)` | +| 6 | `server/src/perception/observer.rs` (`compute_observer_snapshot`) | Drain a `PendingBookmarkCatalog` flag — populate `bookmark_catalog` for one tick after handshake OR after `RequestBookmarkCatalog` | +| 7 | `server/src/simulation/input.rs` (or wherever PlayerAction is dispatched) | Handle `RequestBookmarkCatalog` (set the flag) and `ConfirmBookmark` (validate against registry, write `SelectedBookmark`, push `SimError` on invalid) | +| 8 | Unit tests in `server/src/bookmark/mod.rs` | Registry construction, round-trip serialization of `BookmarkWire`, `ConfirmBookmark` validation | +| 9 | Update `server/src/bridge/types.rs` module doc comment | "v22 adds: bookmark_catalog (#614, D-115/D-117)" | + +## 9. Open questions + +1. **Default `starting_location_id` for tycoon** — is it `"GJ 35"`, `"GJ 144"`, or a station-level ID? Need Miri's call. Flag as TODO in code; doesn't block implementation. +2. **Flavor text** — Mellanie to write once #614 lands. Temporarily use a placeholder; client handles empty strings gracefully. +3. **Later bookmarks** — when a second bookmark is planned, promote to TOML. Not a v0.2 concern. + +--- + +**Contract status:** Ready for #614 implementation. Client #618 can start against this spec once the server ticket opens a PR with the bridge types landed (any Sprint 36 mid-point is fine). diff --git a/server/src/bookmark/mod.rs b/server/src/bookmark/mod.rs new file mode 100644 index 000000000..0cb2fbc5a --- /dev/null +++ b/server/src/bookmark/mod.rs @@ -0,0 +1,221 @@ +//! Bookmark definition system (#614, D-115/D-117). +//! +//! A bookmark is a named starting scenario: skills weighting + starting state + +//! flavor text. The player chooses a bookmark on the character-creation screen. +//! v0.2 ships exactly one bookmark: `"tycoon"`. + +pub mod types; + +pub use types::{BookmarkDefinition, BookmarkId, CareerKind}; + +use std::collections::BTreeMap; + +use bevy_app::prelude::*; +use bevy_ecs::prelude::*; + +use crate::bridge::types::{BookmarkCatalog, BookmarkWire, CareerKindWire, SnapshotBuffer}; +use crate::knowledge::{CultureResolver, resolve_culture}; + +/// Immutable registry of bookmark definitions. +/// +/// Built once during `BookmarkPlugin::build`, read-only at runtime. +/// `BTreeMap` for deterministic iteration (D-010 principle 4, D-041). +#[derive(Resource, Debug, Default)] +pub struct BookmarkRegistry { + entries: BTreeMap, +} + +impl BookmarkRegistry { + pub fn insert(&mut self, defn: BookmarkDefinition) { + self.entries.insert(defn.id.0.clone(), defn); + } + + pub fn get(&self, id: &str) -> Option<&BookmarkDefinition> { + self.entries.get(id) + } + + pub fn available(&self) -> impl Iterator { + self.entries.values().filter(|d| d.available) + } + + pub fn contains(&self, id: &str) -> bool { + self.entries.contains_key(id) + } + + /// Build the wire catalog for the client. + /// + /// `allowed_locations_cultures` is populated via the culture resolver (D-128, #679) + /// when one is provided. Entries for locations that fail resolution are empty strings + /// — callers should log a warning; see the error handling spec in #679. + pub fn build_catalog(&self, culture: Option<&CultureResolver>) -> BookmarkCatalog { + let bookmarks: Vec = self + .available() + .map(|defn| { + let allowed_locations_cultures = defn + .allowed_locations + .iter() + .map(|loc| { + culture + .and_then(|r| match resolve_culture(r, loc) { + Ok(tag) => Some(tag.0), + Err(e) => { + tracing::warn!( + location = %loc, + error = %e, + "culture resolution failed for bookmark location" + ); + None + } + }) + .unwrap_or_default() + }) + .collect(); + BookmarkWire { + id: defn.id.0.clone(), + title: defn.title.clone(), + subtitle: defn.subtitle.clone(), + flavor: defn.flavor.clone(), + default_location: defn.default_location.clone(), + allowed_locations: defn.allowed_locations.clone(), + allowed_locations_cultures, + career: CareerKindWire::from(defn.career), + starting_capital_tractus: defn.starting_capital_tractus, + } + }) + .collect(); + BookmarkCatalog { bookmarks } + } +} + +/// The confirmed bookmark selection for the current session. +/// +/// `None` during the character-creation phase (before `ConfirmBookmark` is received). +/// Downstream systems (apartment generator, skill seeder) read from this resource. +/// Preserved across save/load (#553) as part of the SaveState. +#[derive(Resource, Debug, Clone, Default)] +pub struct SelectedBookmark { + pub bookmark_id: Option, + pub starting_location_id: Option, +} + +/// Bookmark system plugin. +/// +/// Registers `BookmarkRegistry`, `SelectedBookmark`, and the startup system that +/// stages the initial catalog in `SnapshotBuffer` for tick-0 delivery. +pub struct BookmarkPlugin; + +impl Plugin for BookmarkPlugin { + fn build(&self, app: &mut App) { + let mut registry = BookmarkRegistry::default(); + register_default_bookmarks(&mut registry); + + app.insert_resource(registry) + .init_resource::() + .add_systems(Startup, prime_initial_catalog); + + tracing::debug!("BookmarkPlugin initialized"); + } +} + +/// Startup system: stage the initial bookmark catalog in `SnapshotBuffer` so it +/// is delivered in the first `ObserverSnapshot` (tick 0). +/// +/// `SnapshotBuffer` is registered by `BridgePlugin` (added before `BookmarkPlugin`). +/// `CultureResolverResource` is optional — missing in test worlds and when +/// `systems.db` is unavailable (server logs a warning in that case). +fn prime_initial_catalog( + registry: Res, + mut buffer: ResMut, + culture: Option>, +) { + let resolver = culture.as_deref().map(|c| &c.0); + buffer.pending_bookmark_catalog = Some(registry.build_catalog(resolver)); + tracing::debug!("BookmarkPlugin: initial catalog staged for tick-0 snapshot"); +} + +/// Register all v0.2 bookmarks. +/// +/// Hard-coded for now (single entry). Promote to TOML loading in Sprint 38+ +/// when a second bookmark is planned; the pattern is established by `content/brands/`. +fn register_default_bookmarks(registry: &mut BookmarkRegistry) { + registry.insert(BookmarkDefinition { + id: BookmarkId("tycoon".to_string()), + title: "Tycoon".into(), + subtitle: "Small business owner on the make.".into(), + // TODO(mellanie): author flavor text (2–4 sentences, plain text only). + flavor: String::new(), + // TODO(miri): confirm system_id for Van Maanen's Star. + default_location: "GJ 35".into(), + allowed_locations: vec!["GJ 35".into()], + career: CareerKind::Tycoon, + starting_capital_tractus: 5_000, + available: true, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bridge::types::CareerKindWire; + + fn make_registry() -> BookmarkRegistry { + let mut r = BookmarkRegistry::default(); + register_default_bookmarks(&mut r); + r + } + + #[test] + fn registry_contains_tycoon() { + let r = make_registry(); + assert!(r.contains("tycoon")); + assert_eq!(r.available().count(), 1); + } + + #[test] + fn registry_get_returns_definition() { + let r = make_registry(); + let defn = r.get("tycoon").expect("tycoon missing"); + assert_eq!(defn.id.as_str(), "tycoon"); + assert!(!defn.title.is_empty()); + assert_eq!(defn.career, CareerKind::Tycoon); + assert!(defn.starting_capital_tractus > 0); + } + + #[test] + fn catalog_builds_with_one_entry() { + let r = make_registry(); + let catalog = r.build_catalog(None); + assert_eq!(catalog.bookmarks.len(), 1); + let wire = &catalog.bookmarks[0]; + assert_eq!(wire.id, "tycoon"); + assert_eq!(wire.career, CareerKindWire::Tycoon); + assert!(!wire.default_location.is_empty()); + assert_eq!(wire.allowed_locations.len(), wire.allowed_locations_cultures.len()); + } + + #[test] + fn catalog_roundtrip_msgpack() { + let r = make_registry(); + let catalog = r.build_catalog(None); + let bytes = rmp_serde::to_vec_named(&catalog).expect("serialize"); + let decoded: BookmarkCatalog = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(decoded.bookmarks.len(), 1); + assert_eq!(decoded.bookmarks[0].id, "tycoon"); + assert_eq!(decoded.bookmarks[0].career, CareerKindWire::Tycoon); + } + + #[test] + fn confirm_bookmark_validates_id() { + let r = make_registry(); + assert!(r.get("tycoon").is_some()); + assert!(r.get("explorer").is_none()); + } + + #[test] + fn confirm_bookmark_validates_location() { + let r = make_registry(); + let defn = r.get("tycoon").unwrap(); + assert!(defn.allowed_locations.contains(&"GJ 35".to_string())); + assert!(!defn.allowed_locations.contains(&"Unknown System".to_string())); + } +} diff --git a/server/src/bookmark/types.rs b/server/src/bookmark/types.rs new file mode 100644 index 000000000..485a41e72 --- /dev/null +++ b/server/src/bookmark/types.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; + +/// Stable identifier for a bookmark definition. +/// +/// String-backed so new bookmarks can be added without bumping the protocol version. +/// v0.2 ships exactly one: `"tycoon"`. +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct BookmarkId(pub String); + +impl BookmarkId { + pub const TYCOON: &'static str = "tycoon"; + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Career seed used to route into career-specific initialization. +/// +/// Pattern-matched by downstream systems (skill seeder, apartment generator, +/// monologue pool selector) to derive starting state. +/// v0.2: `Tycoon` only; extensible. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum CareerKind { + /// Tycoon career — small business owner, D-117/D-118. + Tycoon, +} + +/// Full static definition of a bookmark. Loaded once at startup, immutable at runtime. +/// +/// Lives server-side; a projection (`BookmarkWire`) crosses the bridge. +#[derive(Debug, Clone)] +pub struct BookmarkDefinition { + /// Stable ID (e.g. `"tycoon"`). + pub id: BookmarkId, + /// Short display title for the bookmark card (≤32 chars). + pub title: String, + /// One-line subtitle under the title (≤64 chars). + pub subtitle: String, + /// Flavor blurb shown on selection. 2–4 sentences, plain text only. + pub flavor: String, + /// Default starting location (`system_id` from systems.db, e.g. `"GJ 35"`). + pub default_location: String, + /// Candidate starting locations the player can pick from. + /// Includes `default_location`. Empty vec = default only. + pub allowed_locations: Vec, + /// Career seed — determines initial skill weighting, inventory, and starting business. + pub career: CareerKind, + /// Starting capital in Tractus (D-118 small-business scale). + pub starting_capital_tractus: i64, + /// Whether this bookmark is visible in the character-creation screen. + pub available: bool, +} diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index 50b98c623..6cd25b552 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -321,6 +321,7 @@ mod tests { current_ticker: None, settings_response: None, economy_snapshot: None, + bookmark_catalog: None, } } @@ -463,6 +464,7 @@ mod tests { current_ticker: None, settings_response: None, economy_snapshot: None, + bookmark_catalog: None, }; let text = format_snapshot_text(&snap); assert!(text.contains("Tick 0")); diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index af45e09d2..3b0e0b65f 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -17,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate}; /// negotiation is unnecessary. Client should reject snapshots with version != /// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration /// period, then the default is removed once both sides are updated. -pub const PROTOCOL_VERSION: u8 = 21; +pub const PROTOCOL_VERSION: u8 = 22; /// Handshake message sent as the very first framed message after connection (#555). /// Client reads this before entering the normal tick loop and validates @@ -83,6 +83,8 @@ pub struct StartupMessage { /// v20 adds: settings_response (#627, SQLite settings IPC). /// v21 adds: economy_snapshot (#822, D-181 7-signal snapshot per queried system), /// EconStateQuery PlayerAction variant (#822). +/// v22 adds: bookmark_catalog (#614, D-115/D-117 CK3-style bookmark system), +/// RequestBookmarkCatalog + ConfirmBookmark PlayerAction variants (#614). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { @@ -224,6 +226,56 @@ pub struct ObserverSnapshot { /// None during normal gameplay; client queries explicitly via `EconStateQuery`. #[serde(default, skip_serializing_if = "Option::is_none")] pub economy_snapshot: Option, + /// Bookmark catalog (#614, D-115/D-117). + /// Present for exactly one tick after handshake (tick 0) and after + /// `PlayerAction::RequestBookmarkCatalog`. None otherwise. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bookmark_catalog: Option, +} + +/// Career kind wire-safe mirror for `CareerKind` (#614, D-117). +/// +/// Kept separate from the server-internal `CareerKind` so future server-only +/// variants (e.g. debug/test archetypes) don't leak through serde. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CareerKindWire { + Tycoon, +} + +impl From for CareerKindWire { + fn from(k: crate::bookmark::types::CareerKind) -> Self { + match k { + crate::bookmark::types::CareerKind::Tycoon => CareerKindWire::Tycoon, + } + } +} + +/// Bookmark projection for the client (#614, D-115/D-117). +/// +/// Sent inside `BookmarkCatalog` in `ObserverSnapshot.bookmark_catalog`. +/// Drop server-only fields; keep the struct stable for the bridge. +/// `allowed_locations_cultures` is pre-resolved server-side (§9 of culture spec, #679). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BookmarkWire { + pub id: String, + pub title: String, + pub subtitle: String, + pub flavor: String, + pub default_location: String, + pub allowed_locations: Vec, + /// Parallel to `allowed_locations`: resolved culture tag per location (#679). + /// Populated by `BookmarkRegistry::build_catalog` once CultureResolverResource + /// is available. Empty string placeholder until #679 lands. + pub allowed_locations_cultures: Vec, + pub career: CareerKindWire, + pub starting_capital_tractus: i64, +} + +/// Catalog of all available bookmarks sent to the client (#614). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BookmarkCatalog { + pub bookmarks: Vec, } /// A single news ticker headline crossing the wire boundary (#591). @@ -564,6 +616,19 @@ pub enum PlayerAction { EconStateQuery { system_id: String, }, + /// Client requests the full bookmark catalog (#614). + /// Server responds with `ObserverSnapshot.bookmark_catalog` in the next tick. + RequestBookmarkCatalog, + /// Player confirms character creation with a chosen bookmark (#614, #618). + /// + /// `bookmark_id` must match a known `BookmarkId`. `starting_location_id` must + /// be in `BookmarkDefinition.allowed_locations` for that bookmark. + /// On invalid inputs: server pushes a `SimError { kind: ProtocolError }` and + /// ignores the action — client should block the confirm button and re-prompt. + ConfirmBookmark { + bookmark_id: String, + starting_location_id: String, + }, } impl PlayerAction { @@ -1038,6 +1103,8 @@ pub struct SnapshotBuffer { pub pending_settings_response: Option, /// Pending economy snapshot, consumed once by `compute_observer_snapshot` (#822). pub pending_economy_response: Option, + /// Pending bookmark catalog, consumed once by `compute_observer_snapshot` (#614). + pub pending_bookmark_catalog: Option, } #[cfg(test)] diff --git a/server/src/lib.rs b/server/src/lib.rs index 454a502d4..e2706c9cd 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -1,6 +1,7 @@ // The Settled Reach - Simulation Server // Rust/bevy_ecs simulation server for D-010 client-server architecture +pub mod bookmark; pub mod bridge; pub mod cause_chain; pub mod knowledge; diff --git a/server/src/main.rs b/server/src/main.rs index c386848b6..f9452fd98 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -153,6 +153,23 @@ fn main() { app.add_plugins(settled_reach_server::npc::NpcPlugin); app.add_plugins(settled_reach_server::storyteller::StorytellerPlugin); app.add_plugins(settled_reach_server::settings::SettingsPlugin); + app.add_plugins(settled_reach_server::bookmark::BookmarkPlugin); + + // Initialize culture resolver (#679, D-128). + // systems.db is shipped read-only alongside the binary. + let systems_db_path = std::path::PathBuf::from("data/systems.db"); + match settled_reach_server::knowledge::CultureResolver::open(&systems_db_path) { + Ok(resolver) => { + tracing::info!("Culture resolver opened: {:?}", systems_db_path); + app.insert_resource(settled_reach_server::knowledge::CultureResolverResource(resolver)); + } + Err(e) => { + tracing::warn!( + "Culture resolver unavailable ({}). Culture lookups will not work.", + e + ); + } + } // Initialize SQLite settings store (#627). // Path: alongside save files in the server's working directory. @@ -341,6 +358,7 @@ fn send_panic_error(app: &App, panic_msg: &str) { current_ticker: None, settings_response: None, economy_snapshot: None, + bookmark_catalog: None, sim_errors: vec![SimError { kind: SimErrorKind::Panic, message: format!("Simulation panic: {}", panic_msg), @@ -376,6 +394,7 @@ fn dump_schedule_graph() { app.add_plugins(settled_reach_server::npc::NpcPlugin); app.add_plugins(settled_reach_server::storyteller::StorytellerPlugin); app.add_plugins(settled_reach_server::settings::SettingsPlugin); + app.add_plugins(settled_reach_server::bookmark::BookmarkPlugin); app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(0)); // Access Schedules resource directly — schedules are populated by plugins diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index eff81176b..3d4eedc21 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -397,6 +397,9 @@ pub fn compute_observer_snapshot( // Consume pending economy snapshot for this tick (#822). let economy_snapshot = buffer.pending_economy_response.take(); + // Consume pending bookmark catalog for this tick (#614). + let bookmark_catalog = buffer.pending_bookmark_catalog.take(); + // Consume pending save/load result for this tick (#553). let save_result = buffer.pending_save_result.take(); @@ -493,6 +496,7 @@ pub fn compute_observer_snapshot( current_ticker, settings_response, economy_snapshot, + bookmark_catalog, }); } diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 74b0a1795..69b837ad6 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -2,8 +2,12 @@ // Timestamped player input events for deterministic simulation (D-010 principle 4) // PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance) +use crate::bookmark::{BookmarkRegistry, SelectedBookmark}; +use crate::knowledge::CultureResolverResource; use crate::bridge::debug::DebugCommandBuffer; -use crate::bridge::types::{FacingDirection, ObjectType, PlayerAction, PlayerInput}; +use crate::bridge::types::{ + FacingDirection, ObjectType, PlayerAction, PlayerInput, SimError, SimErrorKind, SnapshotBuffer, +}; use crate::knowledge::{EntityRegistry, StableId}; use crate::perception::vision_cone::{facing_from_delta, Facing}; use crate::settings::{SettingsCommand, SettingsCommandBuffer}; @@ -18,6 +22,7 @@ use crate::simulation::stance::{PlayerMoveCooldown, Stance}; use crate::simulation::time::{SimulationTime, TickRate}; use crate::test_world::reset::{RoomResetTrigger, RoomSnapshots}; use bevy_ecs::prelude::*; +use bevy_ecs::system::SystemParam; use std::collections::VecDeque; /// Maximum number of inputs the queue will hold before dropping oldest. @@ -78,6 +83,19 @@ impl InputQueue { } } +/// Bundled SystemParam for bookmark-related input handling. +/// +/// Bevy's blanket `IntoSystem` impl covers functions up to 16 parameters. +/// Bundling the 4 bookmark params keeps `process_player_input` at exactly 16. +#[derive(SystemParam)] +pub struct BookmarkInputParams<'w> { + pub registry: Option>, + pub selected: Option>, + pub snapshot_buf: Option>, + pub sim_error_buf: Option>, + pub culture: Option>, +} + /// Drains InputQueue for the current tick, converts PlayerActions to ECS components. /// Handles stance toggling (D-053), movement cooldown, Take/Place verbs (#424), /// and save/load commands (#553). @@ -106,6 +124,7 @@ pub fn process_player_input( mut econ_query_buf: Option>, door_states: Query<&DoorState>, object_types: Query<&ObjectType>, + mut bookmark: BookmarkInputParams<'_>, ) { let current_tick = time.tick; let paused = time.paused(); @@ -130,6 +149,8 @@ pub fn process_player_input( | PlayerAction::RequestAllSettings | PlayerAction::DeleteSetting { .. } | PlayerAction::EconStateQuery { .. } + | PlayerAction::RequestBookmarkCatalog + | PlayerAction::ConfirmBookmark { .. } ) { continue; @@ -420,6 +441,30 @@ pub fn process_player_input( tracing::debug!("EconStateQuery received but EconQueryBuffer not registered — economy not loaded"); } } + PlayerAction::RequestBookmarkCatalog => { + if let (Some(ref registry), Some(ref mut buf)) = + (&bookmark.registry, &mut bookmark.snapshot_buf) + { + let resolver = bookmark.culture.as_deref().map(|c| &c.0); + buf.pending_bookmark_catalog = Some(registry.build_catalog(resolver)); + tracing::debug!("RequestBookmarkCatalog: catalog staged"); + } else { + tracing::warn!("RequestBookmarkCatalog: BookmarkRegistry or SnapshotBuffer not available"); + } + } + PlayerAction::ConfirmBookmark { + bookmark_id, + starting_location_id, + } => { + handle_confirm_bookmark( + bookmark_id, + starting_location_id, + &bookmark.registry, + &mut bookmark.selected, + &mut bookmark.sim_error_buf, + current_tick, + ); + } } } @@ -1121,6 +1166,52 @@ fn handle_teleport_to_hub( } } +fn handle_confirm_bookmark( + bookmark_id: String, + starting_location_id: String, + registry: &Option>, + selected_bookmark: &mut Option>, + sim_error_buf: &mut Option>, + tick: u64, +) { + let Some(ref registry) = registry else { + tracing::warn!("ConfirmBookmark: BookmarkRegistry not available"); + return; + }; + let Some(defn) = registry.get(&bookmark_id) else { + let msg = format!("ConfirmBookmark: unknown bookmark_id {:?}", bookmark_id); + tracing::warn!("{}", msg); + if let Some(ref mut buf) = sim_error_buf { + buf.push(SimError { + kind: SimErrorKind::ProtocolError, + message: msg, + tick, + }); + } + return; + }; + if !defn.allowed_locations.contains(&starting_location_id) { + let msg = format!( + "ConfirmBookmark: starting_location_id {:?} not in allowed_locations for {:?}", + starting_location_id, bookmark_id + ); + tracing::warn!("{}", msg); + if let Some(ref mut buf) = sim_error_buf { + buf.push(SimError { + kind: SimErrorKind::ProtocolError, + message: msg, + tick, + }); + } + return; + } + if let Some(ref mut sel) = selected_bookmark { + sel.bookmark_id = Some(bookmark_id.clone()); + sel.starting_location_id = Some(starting_location_id.clone()); + tracing::info!(bookmark_id, starting_location_id, "ConfirmBookmark: selection recorded"); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index ebd2c55de..3f03310c6 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -78,6 +78,8 @@ fn snapshot_roundtrip_over_unix_socket() { sim_errors: vec![], current_ticker: None, settings_response: None, + economy_snapshot: None, + bookmark_catalog: None, }; bridge diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 32062441a..f13264122 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -64,6 +64,8 @@ fn snapshot_roundtrip_over_tcp() { sim_errors: vec![], current_ticker: None, settings_response: None, + economy_snapshot: None, + bookmark_catalog: None, }; bridge diff --git a/server/tests/error_handling.rs b/server/tests/error_handling.rs index 7db6a3a55..66513a8a9 100644 --- a/server/tests/error_handling.rs +++ b/server/tests/error_handling.rs @@ -307,6 +307,8 @@ fn snapshot_with_sim_errors_roundtrips() { }], current_ticker: None, settings_response: None, + economy_snapshot: None, + bookmark_catalog: None, }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 854a6492f..8f2fe5971 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -54,6 +54,8 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot sim_errors: vec![], current_ticker: None, settings_response: None, + economy_snapshot: None, + bookmark_catalog: None, } } @@ -251,6 +253,8 @@ fn generate_msgpack_fixtures() { sim_errors: vec![], current_ticker: None, settings_response: None, + economy_snapshot: None, + bookmark_catalog: None, }; write_fixture( "snapshot_v2_full", @@ -418,6 +422,8 @@ fn generate_msgpack_fixtures() { sim_errors: vec![], current_ticker: None, settings_response: None, + economy_snapshot: None, + bookmark_catalog: None, }; write_fixture( "snapshot_full", diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 3d275b2ee..8ffa79777 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -42,6 +42,8 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { sim_errors: vec![], current_ticker: None, settings_response: None, + economy_snapshot: None, + bookmark_catalog: None, } } @@ -306,6 +308,8 @@ fn snapshot_v2_fields_roundtrip() { sim_errors: vec![], current_ticker: None, settings_response: None, + economy_snapshot: None, + bookmark_catalog: None, }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); @@ -416,6 +420,8 @@ fn all_facing_direction_variants_roundtrip() { sim_errors: vec![], current_ticker: None, settings_response: None, + economy_snapshot: None, + bookmark_catalog: None, }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");