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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<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` 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<String>,
|
||||
pub starting_location_id: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
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: "<Mellanie to author — 2–4 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).
|
||||
Reference in New Issue
Block a user