1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
//! **The game's answer to a reload**, written where the editor can read it.
//!
//! A game that reloads its grimoire project when the editor writes it either takes the new map or
//! refuses it (a descriptor id that does not resolve, a plan that cannot be built). Without a trace
//! the editor, and an agent driving it, keeps editing a map the game stopped following. The game
//! writes a [`Verdict`] to `<grimoire_dir>/.live/game.ron`; the dot-directory is skipped by
//! [`super::FileWatch`], so this write never triggers a reload.
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::ron_surgery::save_atomic;
/// The schema this build reads and writes. Version 2 adds [`Verdict::seq`] and [`Verdict::kinds`]; a
/// version 1 file reads with both empty.
pub const VERDICT_VERSION: u32 = 2;
/// **What became of one file's change.**
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Outcome {
/// The file parsed, validated, and what was made from it is in use.
Applied,
/// The file was refused and the previous state stays; [`KindVerdict::reason`] says why.
Refused,
/// The file reads and is valid, but the program holds this kind from when it started and cannot take
/// a change without a restart (a [`crate::reload::ReloadClass::Restart`] kind). What it holds stays,
/// and [`KindVerdict::reason`] says so.
NeedsRestart,
}
impl Outcome {
/// The word `grimoire_state` and the journal use.
pub fn word(self) -> &'static str {
match self {
Outcome::Applied => "applied",
Outcome::Refused => "refused",
Outcome::NeedsRestart => "needs_restart",
}
}
}
/// **One file's verdict**: its kind (an id from [`crate::reload::KINDS`], or `project` for a refusal
/// about how files fit together that belongs to no single one), its path under the project directory,
/// and what became of it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KindVerdict {
pub kind: String,
/// Relative to the project directory (`assets/grimoire`), `/`-separated; `.` for a `project` one.
pub file: String,
pub outcome: Outcome,
/// The field the file is wrong at (`version`, `placements[3].yaw`), when one can be named.
#[serde(default)]
pub field: Option<String>,
/// Why it was refused, with the position in the file; empty when applied.
#[serde(default)]
pub reason: String,
}
impl KindVerdict {
pub fn applied(kind: impl Into<String>, file: impl Into<String>) -> KindVerdict {
KindVerdict { kind: kind.into(), file: file.into(), outcome: Outcome::Applied, field: None, reason: String::new() }
}
pub fn refused(
kind: impl Into<String>,
file: impl Into<String>,
field: Option<String>,
reason: impl Into<String>,
) -> KindVerdict {
KindVerdict { kind: kind.into(), file: file.into(), outcome: Outcome::Refused, field, reason: reason.into() }
}
/// **A change the program cannot take without a restart**: the file is valid, what the program
/// holds stays, and `reason` says what to do.
pub fn needs_restart(kind: impl Into<String>, file: impl Into<String>, reason: impl Into<String>) -> KindVerdict {
KindVerdict { kind: kind.into(), file: file.into(), outcome: Outcome::NeedsRestart, field: None, reason: reason.into() }
}
/// `maps/yard.map.ron (placements[3].yaw): 1:47: Expected float`, for the journal.
pub fn sentence(&self) -> String {
match &self.field {
Some(field) => format!("{} ({field}): {}", self.file, self.reason),
None => format!("{}: {}", self.file, self.reason),
}
}
}
/// **What the game said about its latest reload.**
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Verdict {
pub version: u32,
/// Milliseconds since the Unix epoch when the game decided.
pub at_unix_ms: u64,
/// True when the reload landed.
pub ok: bool,
/// What the game said: the diff on success, the refusal's reason otherwise.
pub message: String,
/// Which decision this is, counted by the game from its start: one edit is one number, however
/// many frames pass before the next. `0` in a version 1 file.
#[serde(default)]
pub seq: u64,
/// Every file the game reads, with what became of it in this decision. Empty in a version 1 file.
#[serde(default)]
pub kinds: Vec<KindVerdict>,
}
impl Verdict {
/// A verdict stamped now, with no per-file detail.
pub fn now(ok: bool, message: impl Into<String>) -> Verdict {
let at_unix_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64);
Verdict { version: VERDICT_VERSION, at_unix_ms, ok, message: message.into(), seq: 0, kinds: Vec::new() }
}
/// A verdict stamped now whose `ok` is that every file was applied: a refused file and one that
/// needs a restart are both an edit that did not land.
pub fn with_kinds(seq: u64, message: impl Into<String>, kinds: Vec<KindVerdict>) -> Verdict {
let ok = kinds.iter().all(|k| k.outcome == Outcome::Applied);
Verdict { seq, kinds, ..Verdict::now(ok, message) }
}
/// `<grimoire_dir>/.live/game.ron`.
pub fn path(grimoire_dir: &Path) -> PathBuf {
grimoire_dir.join(".live").join("game.ron")
}
/// Writes the verdict atomically, creating `.live/`.
pub fn write(&self, grimoire_dir: &Path) -> Result<(), String> {
let text = ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
.map_err(|e| format!("live verdict: {e}"))?;
save_atomic(&Self::path(grimoire_dir), &text)
}
/// An absent file is `Ok(None)`; a file that does not parse, or comes from a newer version,
/// is an `Err` naming its path.
pub fn read(grimoire_dir: &Path) -> Result<Option<Verdict>, String> {
let path = Self::path(grimoire_dir);
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("{}: {e}", path.display())),
};
let v: Verdict =
ron::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
if v.version > VERDICT_VERSION {
return Err(format!(
"{}: version {} but this build reads {VERDICT_VERSION} - refusing a verdict \
written by a newer tool",
path.display(),
v.version
));
}
Ok(Some(v))
}
}