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,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<String, BookmarkDefinition>,
|
||||
}
|
||||
|
||||
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<Item = &BookmarkDefinition> {
|
||||
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<BookmarkWire> = 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<String>,
|
||||
pub starting_location_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 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::<SelectedBookmark>()
|
||||
.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<BookmarkRegistry>,
|
||||
mut buffer: ResMut<SnapshotBuffer>,
|
||||
culture: Option<Res<crate::knowledge::CultureResolverResource>>,
|
||||
) {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// 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,
|
||||
}
|
||||
Reference in New Issue
Block a user