use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::project::ProjectLayout;
fn sessions_dir(layout: &ProjectLayout) -> PathBuf {
layout.root.join(".inkhaven").join("worldbuilder-sessions")
}
pub(crate) fn session_slug(name: &str) -> String {
let s: String = name
.trim()
.chars()
.map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
.collect();
let mut out = String::with_capacity(s.len());
let mut prev_dash = false;
for c in s.chars() {
if c == '-' {
if !prev_dash {
out.push('-');
}
prev_dash = true;
} else {
out.push(c);
prev_dash = false;
}
}
let out = out.trim_matches('-').to_string();
if out.is_empty() { "default".to_string() } else { out }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub(crate) struct SessionTurn {
pub seq: u64,
pub at: String,
pub user: String,
#[serde(default)]
pub assistant_summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hjson_delta: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mapspec_delta: Option<String>,
#[serde(default)]
pub plausibility_before: Option<u8>,
#[serde(default)]
pub plausibility_after: Option<u8>,
#[serde(default)]
pub map_renders: Vec<String>,
#[serde(default)]
pub facts_inserted: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct WorldbuilderSession {
pub name: String,
pub slug: String,
pub created: String,
#[serde(default)]
pub world_name: String,
#[serde(default)]
pub turns: Vec<SessionTurn>,
#[serde(default)]
pub pending_ops: Vec<super::commands::Op>,
#[serde(default = "default_left_split")]
pub left_split: u8,
#[serde(default = "default_split_ratio")]
pub split_ratio: u8,
}
fn default_left_split() -> u8 {
5
}
fn default_split_ratio() -> u8 {
4
}
impl WorldbuilderSession {
fn path(layout: &ProjectLayout, slug: &str) -> PathBuf {
sessions_dir(layout).join(format!("{slug}.json"))
}
fn load(layout: &ProjectLayout, slug: &str) -> Option<WorldbuilderSession> {
let raw = std::fs::read_to_string(WorldbuilderSession::path(layout, slug)).ok()?;
serde_json::from_str(&raw).ok()
}
pub(crate) fn open_or_create(
layout: &ProjectLayout,
display_name: &str,
now: String,
) -> Result<WorldbuilderSession> {
let slug = session_slug(display_name);
if let Some(mut s) = WorldbuilderSession::load(layout, &slug) {
s.left_split = s.left_split.clamp(2, 8);
s.split_ratio = s.split_ratio.clamp(2, 8);
return Ok(s);
}
let s = WorldbuilderSession {
name: display_name.trim().to_string(),
slug,
created: now,
world_name: String::new(),
turns: Vec::new(),
pending_ops: Vec::new(),
left_split: default_left_split(),
split_ratio: default_split_ratio(),
};
s.save(layout)?;
Ok(s)
}
pub(crate) fn list(layout: &ProjectLayout) -> Vec<String> {
let mut out = Vec::new();
if let Ok(entries) = std::fs::read_dir(sessions_dir(layout)) {
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) == Some("json") {
if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
out.push(stem.to_string());
}
}
}
}
out.sort();
out
}
pub(crate) fn save(&self, layout: &ProjectLayout) -> Result<()> {
let dir = sessions_dir(layout);
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let json = serde_json::to_string_pretty(self).context("serialise worldbuilder session")?;
crate::io_atomic::write(&WorldbuilderSession::path(layout, &self.slug), json.as_bytes())
.context("write worldbuilder session")?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_is_stable_and_filesystem_safe() {
assert_eq!(session_slug("Initial Build!"), "initial-build");
assert_eq!(session_slug(" Aldoria — v2 "), "aldoria-v2");
assert_eq!(session_slug(""), "default");
assert_eq!(session_slug("///"), "default");
}
#[test]
fn session_round_trips_through_disk() {
let dir = tempfile::tempdir().unwrap();
let layout = ProjectLayout::new(dir.path());
let mut s = WorldbuilderSession::open_or_create(&layout, "session-01", "now".into()).unwrap();
s.world_name = "Aldoria".into();
s.left_split = 6;
s.split_ratio = 3;
s.turns.push(SessionTurn {
seq: 1,
at: "t".into(),
user: "/star K".into(),
assistant_summary: "Set star to K-dwarf".into(),
plausibility_before: Some(100),
plausibility_after: Some(100),
..Default::default()
});
s.save(&layout).unwrap();
let reopened = WorldbuilderSession::open_or_create(&layout, "session-01", "ignored".into()).unwrap();
assert_eq!(reopened.world_name, "Aldoria");
assert_eq!(reopened.left_split, 6);
assert_eq!(reopened.split_ratio, 3);
assert_eq!(reopened.turns.len(), 1);
assert_eq!(reopened.turns[0].user, "/star K");
assert_eq!(reopened.created, "now"); assert!(WorldbuilderSession::list(&layout).contains(&"session-01".to_string()));
}
#[test]
fn pending_ops_survive_a_reopen() {
let dir = tempfile::tempdir().unwrap();
let layout = ProjectLayout::new(dir.path());
let mut s = WorldbuilderSession::open_or_create(&layout, "s", "now".into()).unwrap();
s.pending_ops = vec![
super::super::commands::Op::Set {
path: vec!["astronomy".into(), "star_class".into()],
value: serde_json::json!("K"),
},
super::super::commands::Op::Push {
path: vec!["nations".into()],
value: serde_json::json!({ "name": "Velmari" }),
},
];
s.save(&layout).unwrap();
let reopened = WorldbuilderSession::open_or_create(&layout, "s", "x".into()).unwrap();
assert_eq!(reopened.pending_ops.len(), 2);
assert_eq!(reopened.pending_ops, s.pending_ops);
}
#[test]
fn out_of_range_sizing_is_clamped_on_load() {
let dir = tempfile::tempdir().unwrap();
let layout = ProjectLayout::new(dir.path());
let mut s = WorldbuilderSession::open_or_create(&layout, "s", "now".into()).unwrap();
s.left_split = 99;
s.split_ratio = 0;
s.save(&layout).unwrap();
let reopened = WorldbuilderSession::open_or_create(&layout, "s", "x".into()).unwrap();
assert_eq!(reopened.left_split, 8);
assert_eq!(reopened.split_ratio, 2);
}
}