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

328 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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. 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)
```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<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.
```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<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:
```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<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
```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` 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:
```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: "<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).