Files
settled-reach/docs/architecture/sprint-36-bookmark-spec.md
T
jpmschweitzerandClaude Opus 4.6 9f755f2a62 feat(simulation): persist SelectedBookmark across save/load (#863, #862)
Adds Serialize/Deserialize to SelectedBookmark and wires it into
SaveStateV1 so a loaded game remembers which bookmark and starting
location the player picked. Replaces the TODO at bookmark/mod.rs:95
(originally deferred to Sprint 37 alongside #614).

Also refactors BookmarkPlugin to accept an injected BookmarkRegistry
via BookmarkPlugin::new(registry) (#862). The Default constructor
still wires the canonical tycoon registry — injection is for tests
and future TOML loading. Flagged in PR #132 review as a follow-up.

Updates bookmark spec §4.4 to remove the v0.2-deferred scope note.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:54:21 +02:00

16 KiB
Raw Blame History

title, description, type, status, ticket, decision_refs, author, created, updated
title description type status ticket decision_refs author created updated
Bookmark Definition — Contract Spec (Sprint 36) Struct shape, module placement, and bridge protocol for the CK3-style bookmark system. Contract between server #614 and client #618. architecture draft #614
D-115
D-117
D-118
D-128
D-146
Tyre 2026-04-19 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:

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)

/// 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)

/// 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. 24 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<String>,

    /// 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)

/// 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<String, BookmarkDefinition>,
}

impl BookmarkRegistry {
    pub fn get(&self, id: &str) -> Option<&BookmarkDefinition> { ... }
    pub fn available(&self) -> impl Iterator<Item = &BookmarkDefinition> { ... }
    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.

/// 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<String>,
    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:

// 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<BookmarkCatalog>,

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BookmarkCatalog {
    pub bookmarks: Vec<BookmarkWire>,
}

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

// 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

/// The confirmed bookmark selection for the current session.
/// Populated when `ConfirmBookmark` is processed. `None` fields during the
/// character-creation phase (before confirm) and in a fresh session.
///
/// Serialized into `SaveStateV1.selected_bookmark` (#863) so that a loaded
/// game remembers which bookmark and starting location were chosen.
#[derive(Resource, Debug, Clone, Default, Serialize, Deserialize)]
pub struct SelectedBookmark {
    pub bookmark_id: Option<String>,
    pub starting_location_id: Option<String>,
}

Downstream systems (apartment generator, skill seeder) read from this resource.

Save/load scope (Sprint 37, #863): SelectedBookmark is persisted into SaveStateV1.selected_bookmark. After load_from_file completes, the resource reflects the bookmark confirmed at session-start. Saves created before Sprint 37 will deserialize the field as SelectedBookmark::default() (both fields None) via #[serde(default)] on the SaveStateV1 field.

5. Content source — how bookmarks get into the registry

v0.2 scope: hard-coded in server/src/bookmark/mod.rs. A single entry:

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: "<Mellanie to author — 24 sentences>".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 <Mellanie to author> 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).