ego_view() decided whether a game overrode the default party robot by stat'ing sprites/ego.png on disk. A wasm build has no disk — the game is baked into the binary with include_dir — so the check always failed and every web player was a party robot regardless of what the game shipped. Read the already-loaded sprite table instead, which both loaders populate by file stem, so native and web now agree and native does one less read. The transcript was drawn one draw_text per line with no wrapping, so any line wider than the window ran off the right edge and was simply lost. Room descriptions here are whole paragraphs, so this ate the end of most of them. Greedy word-wrap on measured width, and the visible row count is derived from the panel geometry rather than hardcoded at 5. Panel grows 150 -> 186 so a wrapped paragraph fits without pushing its own opening line off the top. Also: Game > Manual opened monsterrobot.games/engine/manual.html, which has not served the manual since that domain moved hosts — it silently returns the arcade index (200, so nothing looked wrong). Point native at the repo's own web/manual.html, which always matches the build. Verified: all 16 policesquadquest win paths still replay to max score, and both fixes confirmed rendering in the native --shot and on the live deploy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
624 lines
22 KiB
Rust
624 lines
22 KiB
Rust
//! The world data model — rooms, objects, dialogue, strokes — and [`World`],
|
|
//! the single source of truth for "what game is loaded and which room are we
|
|
//! in". Everything here is serde: a game is a folder of JSON + PNGs, and an
|
|
//! LLM expansion is just JSON that deserializes.
|
|
|
|
use crate::assets;
|
|
use crate::framebuffer::{FrameBuffer, CTRL_BLOCK, PIC_H, PIC_W};
|
|
use crate::parser::VerbDef;
|
|
use crate::picture::{self, PriFill};
|
|
use crate::view::{self, Cel};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
|
|
pub enum Meaning {
|
|
None,
|
|
Floor,
|
|
Wall,
|
|
Water,
|
|
Trigger,
|
|
}
|
|
|
|
impl Meaning {
|
|
pub fn pri(self) -> PriFill {
|
|
match self {
|
|
Meaning::None => PriFill::Keep,
|
|
Meaning::Floor => PriFill::Band,
|
|
Meaning::Wall => PriFill::Set(CTRL_BLOCK),
|
|
Meaning::Water => PriFill::Set(1),
|
|
Meaning::Trigger => PriFill::Set(2),
|
|
}
|
|
}
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Meaning::None => "none",
|
|
Meaning::Floor => "floor",
|
|
Meaning::Wall => "wall",
|
|
Meaning::Water => "water",
|
|
Meaning::Trigger => "trigger",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One editable paint operation. A room's look + walkability is the replayable
|
|
/// list of these — the modern echo of AGI's PICTURE opcode streams.
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
pub enum Stroke {
|
|
Brush { pts: Vec<(i32, i32)>, color: u8, meaning: Meaning, size: i32 },
|
|
Line { pts: Vec<(i32, i32)>, color: u8 },
|
|
Rect { x: i32, y: i32, w: i32, h: i32, color: u8, meaning: Meaning },
|
|
Ellipse { x: i32, y: i32, w: i32, h: i32, color: u8, meaning: Meaning },
|
|
Fill { x: i32, y: i32, color: u8, meaning: Meaning },
|
|
Image { name: String, x: i32, y: i32, meaning: Meaning },
|
|
}
|
|
|
|
#[derive(Clone, Default, Serialize, Deserialize)]
|
|
pub struct DlgChoice {
|
|
pub text: String,
|
|
pub goto: i32, // next node index, or -1 to end the conversation
|
|
/// Only offered while this flag is true (empty = always offered).
|
|
#[serde(default)]
|
|
pub requires_flag: String,
|
|
/// Picking this choice sets this flag true (empty = none).
|
|
#[serde(default)]
|
|
pub sets_flag: String,
|
|
/// Points awarded when this choice flips its `sets_flag` false → true
|
|
/// (the flag latch is what makes the award once-only).
|
|
#[serde(default)]
|
|
pub points: u32,
|
|
/// Picking this choice kills the player, Sierra style. The `goto` node's
|
|
/// `says` is printed as the death text, then the day restarts.
|
|
#[serde(default)]
|
|
pub kills: bool,
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
pub struct DlgNode {
|
|
pub says: String,
|
|
#[serde(default)]
|
|
pub choices: Vec<DlgChoice>,
|
|
}
|
|
|
|
/// A live object: a sprite the player can look at / take / use / talk to. Its
|
|
/// base point (x, y) is its feet, like the ego, so it depth-sorts the same way.
|
|
#[derive(Clone, Default, Serialize, Deserialize)]
|
|
pub struct ObjDef {
|
|
pub name: String,
|
|
pub sprite: String,
|
|
pub x: i32,
|
|
pub y: i32,
|
|
#[serde(default)]
|
|
pub look: String,
|
|
#[serde(default)]
|
|
pub takeable: bool,
|
|
#[serde(default)]
|
|
pub synonyms: String, // space-separated alternate names
|
|
#[serde(default)]
|
|
pub use_text: String, // shown on "use"/"open"
|
|
#[serde(default)]
|
|
pub needs: String, // item required to use it (empty = no lock)
|
|
#[serde(default)]
|
|
pub wins: bool, // using it (successfully) wins the game
|
|
/// Using it also requires this flag to be true (empty = no gate).
|
|
#[serde(default)]
|
|
pub requires_flag: String,
|
|
/// Using it (successfully) sets this flag true (empty = none).
|
|
#[serde(default)]
|
|
pub sets_flag: String,
|
|
#[serde(default)]
|
|
pub dialogue: Vec<DlgNode>, // node 0 = entry; empty = no conversation
|
|
/// Points for taking it (takeable) or first successful use (otherwise).
|
|
/// Awarded once per room+name.
|
|
#[serde(default)]
|
|
pub points: u32,
|
|
/// A successful use removes the `needs` item from inventory.
|
|
#[serde(default)]
|
|
pub consumes: bool,
|
|
/// A successful use kills the player (use_text is the death text).
|
|
#[serde(default)]
|
|
pub kills: bool,
|
|
/// Object exists only while this flag is true (empty = always).
|
|
#[serde(default)]
|
|
pub visible_flag: String,
|
|
/// Object disappears once this flag is true (empty = never).
|
|
#[serde(default)]
|
|
pub hidden_by_flag: String,
|
|
}
|
|
|
|
/// Is this object currently part of the room, given taken-list and flags?
|
|
pub fn obj_visible(
|
|
o: &ObjDef,
|
|
room: u32,
|
|
taken: &[(u32, String)],
|
|
flags: &std::collections::HashMap<String, bool>,
|
|
) -> bool {
|
|
let on = |name: &str| flags.get(name).copied().unwrap_or(false);
|
|
!taken.iter().any(|(r, n)| *r == room && *n == o.name)
|
|
&& (o.visible_flag.is_empty() || on(&o.visible_flag))
|
|
&& (o.hidden_by_flag.is_empty() || !on(&o.hidden_by_flag))
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
pub struct RoomDoc {
|
|
pub strokes: Vec<Stroke>,
|
|
pub spawn: (i32, i32),
|
|
#[serde(default)]
|
|
pub exits: [Option<u32>; 4], // N, E, S, W → target room index
|
|
#[serde(default)]
|
|
pub objects: Vec<ObjDef>,
|
|
#[serde(default)]
|
|
pub music: u8, // 0 = silence, 1.. = ambient mood
|
|
/// Display name, shown on entry and in `look` ("" = just the number).
|
|
#[serde(default)]
|
|
pub name: String,
|
|
/// Prose printed when the ego enters the room ("" = the old stock line).
|
|
#[serde(default)]
|
|
pub enter_text: String,
|
|
/// Per-direction flag required before that exit works ("" = open). N,E,S,W.
|
|
#[serde(default = "empty4")]
|
|
pub exit_flags: [String; 4],
|
|
/// What to say when the matching exit refuses ("" = a stock line). N,E,S,W.
|
|
#[serde(default = "empty4")]
|
|
pub exit_blocked: [String; 4],
|
|
}
|
|
|
|
fn empty4() -> [String; 4] {
|
|
[String::new(), String::new(), String::new(), String::new()]
|
|
}
|
|
|
|
impl Default for RoomDoc {
|
|
fn default() -> Self {
|
|
RoomDoc {
|
|
strokes: Vec::new(),
|
|
spawn: (80, 150),
|
|
exits: [None; 4],
|
|
objects: Vec::new(),
|
|
music: 0,
|
|
name: String::new(),
|
|
enter_text: String::new(),
|
|
exit_flags: empty4(),
|
|
exit_blocked: empty4(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `game.json` — what makes a folder a game. Every field defaults, so a bare
|
|
/// folder of rooms is already a valid game (that's how legacy projects load).
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
pub struct GameManifest {
|
|
#[serde(default = "default_name")]
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub start_room: u32,
|
|
#[serde(default = "default_intro")]
|
|
pub intro_text: String,
|
|
/// Extra verb synonyms, merged over the built-in table by canonical name.
|
|
#[serde(default)]
|
|
pub verbs: Vec<VerbDef>,
|
|
/// Initial flag values for a new game.
|
|
#[serde(default)]
|
|
pub flags: HashMap<String, bool>,
|
|
/// Total points on offer; 0 = scoring off (no HUD line, no score events).
|
|
#[serde(default)]
|
|
pub max_score: u32,
|
|
/// The banner shown when the player dies and the day restarts.
|
|
#[serde(default = "default_death")]
|
|
pub death_text: String,
|
|
}
|
|
|
|
fn default_name() -> String {
|
|
"untitled".into()
|
|
}
|
|
|
|
fn default_death() -> String {
|
|
"\u{2020} You have died. The day restarts. \u{2020}".into()
|
|
}
|
|
fn default_intro() -> String {
|
|
"You blink awake. Type 'help'.".into()
|
|
}
|
|
|
|
impl Default for GameManifest {
|
|
fn default() -> Self {
|
|
GameManifest {
|
|
name: default_name(),
|
|
start_room: 0,
|
|
intro_text: default_intro(),
|
|
verbs: Vec::new(),
|
|
flags: HashMap::new(),
|
|
max_score: 0,
|
|
death_text: default_death(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- stroke application ------------------------------------------------
|
|
|
|
/// Paint a filled disc (brush / eraser).
|
|
pub fn disc(fb: &mut FrameBuffer, cx: i32, cy: i32, size: i32, color: u8, pri: PriFill) {
|
|
for dy in -size..=size {
|
|
for dx in -size..=size {
|
|
if dx * dx + dy * dy <= size * size {
|
|
picture::px(fb, cx + dx, cy + dy, Some(color), pri);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stamp a cel centered on (cx, cy); transparent pixels are skipped. `pri`
|
|
/// writes walkability under the painted pixels.
|
|
pub fn stamp(fb: &mut FrameBuffer, cel: &Cel, cx: i32, cy: i32, pri: PriFill) {
|
|
let ox = cx - cel.w as i32 / 2;
|
|
let oy = cy - cel.h as i32 / 2;
|
|
for y in 0..cel.h {
|
|
for x in 0..cel.w {
|
|
let c = cel.at(x, y);
|
|
if c == view::TRANSPARENT {
|
|
continue;
|
|
}
|
|
picture::px(fb, ox + x as i32, oy + y as i32, Some(c), pri);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn apply_stroke(fb: &mut FrameBuffer, s: &Stroke, sprites: &[(String, Cel)]) {
|
|
match s {
|
|
Stroke::Brush { pts, color, meaning, size } => {
|
|
for &(x, y) in pts {
|
|
disc(fb, x, y, *size, *color, meaning.pri());
|
|
}
|
|
}
|
|
Stroke::Line { pts, color } => {
|
|
for w in pts.windows(2) {
|
|
picture::line_into(fb, w[0], w[1], *color, PriFill::Keep);
|
|
}
|
|
}
|
|
Stroke::Rect { x, y, w, h, color, meaning } => {
|
|
picture::rect_into(fb, *x, *y, *w, *h, Some(*color), meaning.pri());
|
|
}
|
|
Stroke::Ellipse { x, y, w, h, color, meaning } => {
|
|
picture::ellipse_into(fb, *x, *y, *w, *h, Some(*color), meaning.pri());
|
|
}
|
|
Stroke::Fill { x, y, color, meaning } => {
|
|
picture::flood_fill(fb, *x, *y, Some(*color), meaning.pri());
|
|
}
|
|
Stroke::Image { name, x, y, meaning } => {
|
|
if let Some((_, cel)) = sprites.iter().find(|(n, _)| n == name) {
|
|
stamp(fb, cel, *x, *y, meaning.pri());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Find a walkable spawn near `prefer`, scanning upward from the bottom.
|
|
pub fn find_spawn(fb: &FrameBuffer, prefer: (i32, i32)) -> (i32, i32) {
|
|
if fb.priority_at(prefer.0, prefer.1) != CTRL_BLOCK {
|
|
return prefer;
|
|
}
|
|
for y in (20..164).rev() {
|
|
for x in 8..152 {
|
|
if fb.priority_at(x, y) != CTRL_BLOCK {
|
|
return (x, y);
|
|
}
|
|
}
|
|
}
|
|
prefer
|
|
}
|
|
|
|
/// A whole world in one JSON document: the manifest + every room. What cloud
|
|
/// saves store and what `to_bundle`/`apply_bundle` round-trip.
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
pub struct WorldBundle {
|
|
#[serde(default)]
|
|
pub manifest: GameManifest,
|
|
#[serde(default)]
|
|
pub rooms: HashMap<u32, RoomDoc>,
|
|
}
|
|
|
|
/// The loaded game: manifest + sprites + the current room baked into a
|
|
/// framebuffer, plus an in-memory overlay of rooms that differ from disk
|
|
/// (unsaved editor work, or lore upserted live over the bus).
|
|
pub struct World {
|
|
pub base: PathBuf,
|
|
pub manifest: GameManifest,
|
|
pub current: u32,
|
|
pub doc: RoomDoc,
|
|
pub fb: FrameBuffer,
|
|
pub sprites: Vec<(String, Cel)>,
|
|
pub overlay: HashMap<u32, RoomDoc>,
|
|
}
|
|
|
|
impl World {
|
|
/// Load a game folder: `game.json` (all-defaults if absent) + `rooms/` +
|
|
/// `sprites/`. A legacy project (bare `rooms/*.json` in the cwd) is just a
|
|
/// game folder with no manifest — one code path serves both.
|
|
pub fn load_game(dir: impl Into<PathBuf>) -> Self {
|
|
let base: PathBuf = dir.into();
|
|
let manifest: GameManifest = std::fs::read_to_string(base.join("game.json"))
|
|
.ok()
|
|
.and_then(|s| serde_json::from_str(&s).ok())
|
|
.unwrap_or_default();
|
|
let sprites = assets::load_sprite_dir(&base.join("sprites").to_string_lossy());
|
|
let mut w = World {
|
|
base,
|
|
current: manifest.start_room,
|
|
manifest,
|
|
doc: RoomDoc::default(),
|
|
fb: FrameBuffer::new(),
|
|
sprites,
|
|
overlay: HashMap::new(),
|
|
};
|
|
let start = w.current;
|
|
// Legacy fallback: a single old-style `rooms/room.json` acts as room 0.
|
|
if !w.room_path(start).exists() && start == 0 {
|
|
let legacy = w.base.join("rooms/room.json");
|
|
if let Ok(s) = std::fs::read_to_string(legacy) {
|
|
if let Ok(doc) = serde_json::from_str::<RoomDoc>(&s) {
|
|
w.overlay.insert(0, doc);
|
|
}
|
|
}
|
|
}
|
|
w.goto_room(start);
|
|
w
|
|
}
|
|
|
|
/// Build a World entirely from memory — no filesystem. This is how the
|
|
/// browser build boots: the game folder is baked into the binary and
|
|
/// handed over here as parsed rooms + quantized sprites.
|
|
pub fn from_memory(
|
|
manifest: GameManifest,
|
|
rooms: impl IntoIterator<Item = (u32, RoomDoc)>,
|
|
sprites: Vec<(String, Cel)>,
|
|
) -> Self {
|
|
let mut w = World {
|
|
base: PathBuf::from("."),
|
|
current: manifest.start_room,
|
|
manifest,
|
|
doc: RoomDoc::default(),
|
|
fb: FrameBuffer::new(),
|
|
sprites,
|
|
overlay: rooms.into_iter().collect(),
|
|
};
|
|
let start = w.current;
|
|
w.goto_room(start);
|
|
w
|
|
}
|
|
|
|
pub fn room_path(&self, n: u32) -> PathBuf {
|
|
self.base.join(format!("rooms/room{}.json", n))
|
|
}
|
|
|
|
/// Snapshot the whole world as one JSON document — manifest + every room
|
|
/// (disk, overlay, and the live current room). The single-file project
|
|
/// format used by cloud saves and shareable world files.
|
|
pub fn to_bundle(&self) -> WorldBundle {
|
|
let mut rooms: HashMap<u32, RoomDoc> = HashMap::new();
|
|
// rooms on disk
|
|
if let Ok(rd) = std::fs::read_dir(self.base.join("rooms")) {
|
|
for e in rd.flatten() {
|
|
let name = e.file_name().to_string_lossy().into_owned();
|
|
if let Some(n) = name.strip_prefix("room").and_then(|s| s.strip_suffix(".json")).and_then(|s| s.parse().ok()) {
|
|
if let Some(doc) = self.peek_room(n) {
|
|
rooms.insert(n, doc);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// overlay wins over disk; the live doc wins over everything
|
|
for (n, doc) in &self.overlay {
|
|
rooms.insert(*n, doc.clone());
|
|
}
|
|
rooms.insert(self.current, self.doc.clone());
|
|
WorldBundle { manifest: self.manifest.clone(), rooms }
|
|
}
|
|
|
|
/// Replace the loaded world with a bundle (sprites are kept — bundles
|
|
/// carry world data, not art). The caller decides what to do next
|
|
/// (usually `Command::NewGame`).
|
|
pub fn apply_bundle(&mut self, b: WorldBundle) {
|
|
self.manifest = b.manifest;
|
|
self.overlay = b.rooms.into_iter().collect();
|
|
let start = self.manifest.start_room;
|
|
self.goto_room(start);
|
|
}
|
|
|
|
/// Read a room doc without switching to it: overlay first, then disk.
|
|
pub fn peek_room(&self, n: u32) -> Option<RoomDoc> {
|
|
if let Some(doc) = self.overlay.get(&n) {
|
|
return Some(doc.clone());
|
|
}
|
|
std::fs::read_to_string(self.room_path(n)).ok().and_then(|s| serde_json::from_str(&s).ok())
|
|
}
|
|
|
|
/// Switch the working room WITHOUT saving. Overlay wins over disk; a
|
|
/// missing room is a fresh blank one.
|
|
pub fn goto_room(&mut self, n: u32) {
|
|
self.current = n;
|
|
self.doc = self.peek_room(n).unwrap_or_default();
|
|
self.bake();
|
|
}
|
|
|
|
/// Replay the current room's strokes into the framebuffer.
|
|
pub fn bake(&mut self) {
|
|
self.fb.clear();
|
|
for s in &self.doc.strokes {
|
|
apply_stroke(&mut self.fb, s, &self.sprites);
|
|
}
|
|
}
|
|
|
|
/// Stash the current room into the overlay (so leaving and returning
|
|
/// keeps unsaved changes) — used before playtesting live edits.
|
|
pub fn stash_current(&mut self) {
|
|
self.overlay.insert(self.current, self.doc.clone());
|
|
}
|
|
|
|
/// Insert/replace a room. If it's the current room the framebuffer rebakes.
|
|
/// With `persist` it is also written to disk (and dropped from the overlay).
|
|
pub fn upsert_room(&mut self, n: u32, doc: RoomDoc, persist: bool) -> std::io::Result<()> {
|
|
if persist {
|
|
self.write_room(n, &doc)?;
|
|
self.overlay.remove(&n);
|
|
} else {
|
|
self.overlay.insert(n, doc.clone());
|
|
}
|
|
if n == self.current {
|
|
self.doc = doc;
|
|
self.bake();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn write_room(&self, n: u32, doc: &RoomDoc) -> std::io::Result<()> {
|
|
let path = self.room_path(n);
|
|
if let Some(dir) = path.parent() {
|
|
std::fs::create_dir_all(dir)?;
|
|
}
|
|
let j = serde_json::to_string_pretty(doc).map_err(std::io::Error::other)?;
|
|
std::fs::write(path, j)
|
|
}
|
|
|
|
/// Save the current room to disk.
|
|
pub fn save_current(&mut self) -> std::io::Result<()> {
|
|
self.write_room(self.current, &self.doc.clone())?;
|
|
self.overlay.remove(&self.current);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn sprite_cel(&self, name: &str) -> Option<&Cel> {
|
|
self.sprites.iter().find(|(n, _)| n == name).map(|(_, c)| c)
|
|
}
|
|
|
|
pub fn reload_sprites(&mut self) {
|
|
self.sprites = assets::load_sprite_dir(&self.base.join("sprites").to_string_lossy());
|
|
self.bake();
|
|
}
|
|
|
|
/// The ego's view. A game folder overrides the default party robot by
|
|
/// dropping `sprites/ego.png` (feet on the bottom edge, like any prop);
|
|
/// `ego1.png` is an optional second cel for the walk bob.
|
|
pub fn ego_view(&self) -> view::View {
|
|
// Read the already-loaded sprite table rather than stat'ing the folder:
|
|
// a wasm build is baked into the binary and has no `sprites/` to look at,
|
|
// so the filesystem version silently fell back to the robot on the web.
|
|
match self.sprite_cel("ego") {
|
|
Some(c0) => {
|
|
let mut cels = vec![c0.clone()];
|
|
if let Some(c1) = self.sprite_cel("ego1") {
|
|
cels.push(c1.clone());
|
|
}
|
|
view::View { loops: vec![view::ViewLoop { cels }] }
|
|
}
|
|
None => view::default_robot_view(),
|
|
}
|
|
}
|
|
|
|
/// Sanity-check a room before committing it (the lore-ingestion gate):
|
|
/// exits must point at rooms that exist (on disk, in the overlay, or being
|
|
/// upserted alongside), and the room must have somewhere to stand.
|
|
pub fn validate_room(&self, n: u32, doc: &RoomDoc) -> Result<(), String> {
|
|
let mut fb = FrameBuffer::new();
|
|
for s in &doc.strokes {
|
|
apply_stroke(&mut fb, s, &self.sprites);
|
|
}
|
|
let (sx, sy) = find_spawn(&fb, doc.spawn);
|
|
if fb.priority_at(sx, sy) == CTRL_BLOCK {
|
|
return Err(format!("room {} has no walkable spawn — it's all wall", n));
|
|
}
|
|
for (d, t) in doc.exits.iter().enumerate() {
|
|
if let Some(t) = t {
|
|
let known = *t == n || self.overlay.contains_key(t) || self.room_path(*t).exists();
|
|
if !known {
|
|
return Err(format!(
|
|
"room {} exit {} points at room {}, which doesn't exist yet — upsert it first",
|
|
n,
|
|
["N", "E", "S", "W"][d],
|
|
t
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// Keep constants nearby for consumers that reason about room geometry.
|
|
pub const ROOM_W: usize = PIC_W;
|
|
pub const ROOM_H: usize = PIC_H;
|
|
|
|
// --- collections: a folder of game folders ------------------------------
|
|
|
|
/// Does this directory hold a playable game? (Manifest, a start room, or a
|
|
/// legacy single-room project all count — the same tests `load_game` uses.)
|
|
pub fn is_game_dir(dir: &std::path::Path) -> bool {
|
|
dir.join("game.json").exists()
|
|
|| dir.join("rooms/room0.json").exists()
|
|
|| dir.join("rooms/room.json").exists()
|
|
}
|
|
|
|
/// Scan a directory for game folders: every immediate subdirectory that
|
|
/// `is_game_dir` accepts, as `(path, display name)`, sorted by folder name.
|
|
/// The display name comes from the game's manifest; a folder with no
|
|
/// manifest shows under its own name.
|
|
pub fn scan_collection(dir: &std::path::Path) -> Vec<(PathBuf, String)> {
|
|
let mut out = Vec::new();
|
|
let Ok(rd) = std::fs::read_dir(dir) else { return out };
|
|
for entry in rd.flatten() {
|
|
let p = entry.path();
|
|
if p.is_dir() && is_game_dir(&p) {
|
|
let name = std::fs::read_to_string(p.join("game.json"))
|
|
.ok()
|
|
.and_then(|s| serde_json::from_str::<GameManifest>(&s).ok())
|
|
.map(|m| m.name)
|
|
.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string());
|
|
out.push((p, name));
|
|
}
|
|
}
|
|
out.sort_by(|a, b| a.0.file_name().cmp(&b.0.file_name()));
|
|
out
|
|
}
|
|
|
|
/// The collection's own title, for the picker screen: an optional
|
|
/// `collection.json` `{"name": "..."}` in the directory, else the folder
|
|
/// name with separators spaced out, uppercased.
|
|
pub fn collection_name(dir: &std::path::Path) -> String {
|
|
#[derive(Deserialize)]
|
|
struct C {
|
|
name: String,
|
|
}
|
|
if let Ok(s) = std::fs::read_to_string(dir.join("collection.json")) {
|
|
if let Ok(c) = serde_json::from_str::<C>(&s) {
|
|
return c.name;
|
|
}
|
|
}
|
|
dir.file_name()
|
|
.map(|n| n.to_string_lossy().replace(['-', '_'], " ").to_uppercase())
|
|
.unwrap_or_else(|| "MRPGI".to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod collection_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn scans_names_and_sorts_a_collection() {
|
|
let base = std::env::temp_dir().join(format!("mrpgi-coll-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
std::fs::create_dir_all(base.join("b-two/rooms")).unwrap();
|
|
std::fs::write(base.join("b-two/rooms/room0.json"), "{}").unwrap();
|
|
std::fs::create_dir_all(base.join("a-one")).unwrap();
|
|
std::fs::write(base.join("a-one/game.json"), r#"{"name":"Alpha"}"#).unwrap();
|
|
std::fs::create_dir_all(base.join("not-a-game")).unwrap();
|
|
std::fs::write(base.join("collection.json"), r#"{"name":"My Shelf"}"#).unwrap();
|
|
|
|
assert!(!is_game_dir(&base));
|
|
let got = scan_collection(&base);
|
|
assert_eq!(got.len(), 2);
|
|
assert_eq!(got[0].1, "Alpha");
|
|
assert_eq!(got[1].1, "b-two");
|
|
assert_eq!(collection_name(&base), "My Shelf");
|
|
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
}
|
|
}
|