yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
story_event_graph.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <cctype>
5#include <fstream>
6#include <limits>
7#include <queue>
8#include <sstream>
9#include <string_view>
10
11#include "absl/status/status.h"
12#include "absl/strings/str_format.h"
13#include "nlohmann/json.hpp"
14#include "util/macro.h"
15
16namespace yaze::core {
17
18using json = nlohmann::json;
19
20// ─── JSON Loading ────────────────────────────────────────────────
21
22static std::vector<StoryFlag> ParseFlags(const json& arr) {
23 std::vector<StoryFlag> flags;
24 if (!arr.is_array()) return flags;
25 for (const auto& item : arr) {
26 StoryFlag flag;
27 flag.name = item.value("name", "");
28 flag.value = item.value("value", "");
29 flag.reg = item.value("register", "");
30 flag.bit = item.value("bit", -1);
31 flag.operation = item.value("operation", "");
32 flags.push_back(flag);
33 }
34 return flags;
35}
36
37static std::vector<StoryLocation> ParseLocations(const json& arr) {
38 std::vector<StoryLocation> locations;
39 if (!arr.is_array()) return locations;
40 for (const auto& item : arr) {
41 StoryLocation loc;
42 loc.name = item.value("name", "");
43 loc.entrance_id = item.value("entrance_id", "");
44 loc.overworld_id = item.value("overworld_id", "");
45 loc.special_world_id = item.value("special_world_id", "");
46 loc.room_id = item.value("room_id", "");
47 locations.push_back(loc);
48 }
49 return locations;
50}
51
52static std::vector<std::string> ParseStringArray(const json& arr) {
53 std::vector<std::string> result;
54 if (!arr.is_array()) return result;
55 for (const auto& item : arr) {
56 if (item.is_string()) {
57 result.push_back(item.get<std::string>());
58 }
59 }
60 return result;
61}
62
63static int ParseIntFlexible(const json& obj, const char* key, int def) {
64 if (!obj.is_object() || !obj.contains(key)) {
65 return def;
66 }
67 const auto& v = obj.at(key);
68 if (v.is_number_integer()) {
69 return v.get<int>();
70 }
71 if (v.is_number_unsigned()) {
72 const auto u = v.get<unsigned int>();
73 return (u > static_cast<unsigned int>(std::numeric_limits<int>::max()))
74 ? def
75 : static_cast<int>(u);
76 }
77 if (v.is_string()) {
78 try {
79 size_t idx = 0;
80 const std::string s = v.get<std::string>();
81 const int parsed = std::stoi(s, &idx, 0);
82 if (idx == s.size()) {
83 return parsed;
84 }
85 } catch (...) {
86 }
87 }
88 return def;
89}
90
91static std::vector<std::string> ParseScriptArray(const json& arr) {
92 std::vector<std::string> result;
93 if (!arr.is_array()) return result;
94
95 for (const auto& item : arr) {
96 if (item.is_string()) {
97 result.push_back(item.get<std::string>());
98 continue;
99 }
100 if (!item.is_object()) {
101 continue;
102 }
103
104 // Allow the generator to embed a fully composed reference.
105 if (item.contains("ref") && item["ref"].is_string()) {
106 result.push_back(item["ref"].get<std::string>());
107 continue;
108 }
109 // Prefer stable script identifiers when present.
110 if (item.contains("script_id") && item["script_id"].is_string()) {
111 result.push_back(item["script_id"].get<std::string>());
112 continue;
113 }
114 if (item.contains("id") && item["id"].is_string()) {
115 result.push_back(item["id"].get<std::string>());
116 continue;
117 }
118
119 const std::string file = item.value("file", "");
120 const std::string symbol = item.value("symbol", "");
121 if (!symbol.empty()) {
122 if (!file.empty()) {
123 result.push_back(file + ":" + symbol);
124 } else {
125 result.push_back(symbol);
126 }
127 continue;
128 }
129
130 // Legacy / fallback: file + line number (fragile, but supported).
131 const int line = ParseIntFlexible(item, "line", -1);
132 if (line > 0 && !file.empty()) {
133 result.push_back(file + ":" + std::to_string(line));
134 }
135 }
136
137 return result;
138}
139
140static uint32_t ParseUintFlexible(const json& obj, const char* key,
141 uint32_t def) {
142 if (!obj.is_object() || !obj.contains(key)) {
143 return def;
144 }
145 const auto& v = obj.at(key);
146 if (v.is_number_unsigned()) {
147 return v.get<uint32_t>();
148 }
149 if (v.is_number_integer()) {
150 const int64_t i = v.get<int64_t>();
151 return (i < 0) ? def : static_cast<uint32_t>(i);
152 }
153 if (v.is_string()) {
154 try {
155 size_t idx = 0;
156 const std::string s = v.get<std::string>();
157 const unsigned long parsed = std::stoul(s, &idx, 0);
158 if (idx == s.size()) {
159 return static_cast<uint32_t>(parsed);
160 }
161 } catch (...) {
162 }
163 }
164 return def;
165}
166
167static std::vector<StoryPredicate> ParsePredicates(const json& arr) {
168 std::vector<StoryPredicate> preds;
169 if (!arr.is_array()) return preds;
170 for (const auto& item : arr) {
171 if (!item.is_object()) continue;
172 StoryPredicate p;
173 p.reg = item.value("register", "");
174 if (p.reg.empty()) {
175 p.reg = item.value("reg", "");
176 }
177 p.op = item.value("op", "");
178 p.value = ParseIntFlexible(item, "value", 0);
179 p.bit = ParseIntFlexible(item, "bit", -1);
180
181 // For mask operations, accept either explicit "mask" or reuse "value".
182 p.mask = ParseUintFlexible(item, "mask", 0);
183 if (p.mask == 0 && item.contains("value")) {
184 p.mask = static_cast<uint32_t>(ParseUintFlexible(item, "value", 0));
185 }
186
187 preds.push_back(std::move(p));
188 }
189 return preds;
190}
191
192absl::Status StoryEventGraph::LoadFromJson(const std::string& path) {
193 std::ifstream file(path);
194 if (!file.is_open()) {
195 return absl::NotFoundError("Cannot open story events file: " + path);
196 }
197 std::stringstream buffer;
198 buffer << file.rdbuf();
199 return LoadFromString(buffer.str());
200}
201
202absl::Status StoryEventGraph::LoadFromString(const std::string& json_content) {
203 json root;
204 try {
205 root = json::parse(json_content);
206 } catch (const json::parse_error& e) {
207 return absl::InvalidArgumentError(
208 std::string("JSON parse error: ") + e.what());
209 }
210
211 nodes_.clear();
212 edges_.clear();
213 node_index_.clear();
214 std::unordered_map<std::string, std::string> id_aliases;
215
216 auto add_alias = [&](const std::string& alias, const std::string& canonical,
217 const char* field_name) -> absl::Status {
218 if (alias.empty()) {
219 return absl::OkStatus();
220 }
221 auto [it, inserted] = id_aliases.emplace(alias, canonical);
222 if (!inserted && it->second != canonical) {
223 return absl::InvalidArgumentError(absl::StrFormat(
224 "Conflicting story event %s alias '%s' maps to both '%s' and '%s'",
225 field_name, alias, it->second, canonical));
226 }
227 return absl::OkStatus();
228 };
229
230 // Parse events
231 if (root.contains("events") && root["events"].is_array()) {
232 size_t event_index = 0;
233 for (const auto& item : root["events"]) {
234 StoryEventNode node;
235 const std::string legacy_id = item.value("id", "");
236 const std::string stable_id = item.value("stable_id", "");
237 const std::string key_id = item.value("key", "");
238 node.id = !stable_id.empty()
239 ? stable_id
240 : (!legacy_id.empty() ? legacy_id : key_id);
241 if (node.id.empty()) {
242 return absl::InvalidArgumentError(absl::StrFormat(
243 "Story event at index %zu is missing required id (set stable_id or id)",
244 event_index));
245 }
246 if (node_index_.contains(node.id)) {
247 return absl::InvalidArgumentError(
248 absl::StrFormat("Duplicate story event id: %s", node.id));
249 }
250 node.name = item.value("name", "");
251 node.flags = ParseFlags(item.value("flags", json::array()));
252 node.locations = ParseLocations(item.value("locations", json::array()));
253 node.scripts = ParseScriptArray(item.value("scripts", json::array()));
254 node.text_ids = ParseStringArray(item.value("text_ids", json::array()));
255 node.completed_when =
256 ParsePredicates(item.value("completed_when", json::array()));
257 node.dependencies =
258 ParseStringArray(item.value("dependencies", json::array()));
259 node.unlocks = ParseStringArray(item.value("unlocks", json::array()));
260 node.evidence = item.value("evidence", "");
261 node.last_verified = item.value("last_verified", "");
262 node.notes = item.value("notes", "");
263
264 RETURN_IF_ERROR(add_alias(node.id, node.id, "canonical"));
265 RETURN_IF_ERROR(add_alias(legacy_id, node.id, "id"));
266 RETURN_IF_ERROR(add_alias(stable_id, node.id, "stable_id"));
267 RETURN_IF_ERROR(add_alias(key_id, node.id, "key"));
268
269 node_index_[node.id] = nodes_.size();
270 nodes_.push_back(std::move(node));
271 ++event_index;
272 }
273 }
274
275 // Parse edges
276 if (root.contains("edges") && root["edges"].is_array()) {
277 for (const auto& item : root["edges"]) {
278 StoryEdge edge;
279 edge.from = item.value("from", "");
280 edge.to = item.value("to", "");
281 edge.type = item.value("type", "dependency");
282 edges_.push_back(std::move(edge));
283 }
284 }
285
286 auto canonicalize_id = [&](const std::string& raw) -> std::string {
287 auto it = id_aliases.find(raw);
288 return it != id_aliases.end() ? it->second : raw;
289 };
290
291 // Normalize all references to canonical IDs.
292 for (auto& node : nodes_) {
293 for (auto& dep : node.dependencies) {
294 dep = canonicalize_id(dep);
295 }
296 for (auto& unlock : node.unlocks) {
297 unlock = canonicalize_id(unlock);
298 }
299 }
300 for (auto& edge : edges_) {
301 edge.from = canonicalize_id(edge.from);
302 edge.to = canonicalize_id(edge.to);
303 }
304
305 // Validate references so malformed graphs fail closed.
306 for (const auto& node : nodes_) {
307 for (const auto& dep : node.dependencies) {
308 if (!dep.empty() && !node_index_.contains(dep)) {
309 return absl::InvalidArgumentError(absl::StrFormat(
310 "Story event '%s' has unknown dependency '%s'", node.id, dep));
311 }
312 }
313 for (const auto& unlock : node.unlocks) {
314 if (!unlock.empty() && !node_index_.contains(unlock)) {
315 return absl::InvalidArgumentError(absl::StrFormat(
316 "Story event '%s' has unknown unlock '%s'", node.id, unlock));
317 }
318 }
319 }
320 for (const auto& edge : edges_) {
321 if (!edge.from.empty() && !node_index_.contains(edge.from)) {
322 return absl::InvalidArgumentError(
323 absl::StrFormat("Story edge has unknown from node '%s'", edge.from));
324 }
325 if (!edge.to.empty() && !node_index_.contains(edge.to)) {
326 return absl::InvalidArgumentError(
327 absl::StrFormat("Story edge has unknown to node '%s'", edge.to));
328 }
329 }
330
331 return absl::OkStatus();
332}
333
334// ─── Layout ──────────────────────────────────────────────────────
335
337 if (nodes_.empty()) return;
338
339 // Topological sort to determine layers (BFS-based Kahn's algorithm)
340 std::unordered_map<std::string, int> in_degree;
341 std::unordered_map<std::string, std::vector<std::string>> adj;
342
343 for (const auto& node : nodes_) {
344 in_degree[node.id] = 0;
345 }
346
347 for (const auto& edge : edges_) {
348 adj[edge.from].push_back(edge.to);
349 in_degree[edge.to]++;
350 }
351
352 // BFS to assign layers
353 std::queue<std::string> queue;
354 std::unordered_map<std::string, int> layer;
355
356 for (const auto& [id, deg] : in_degree) {
357 if (deg == 0) {
358 queue.push(id);
359 layer[id] = 0;
360 }
361 }
362
363 int max_layer = 0;
364 while (!queue.empty()) {
365 std::string current = queue.front();
366 queue.pop();
367
368 for (const auto& next : adj[current]) {
369 int new_layer = layer[current] + 1;
370 if (layer.find(next) == layer.end() || new_layer > layer[next]) {
371 layer[next] = new_layer;
372 }
373 in_degree[next]--;
374 if (in_degree[next] == 0) {
375 queue.push(next);
376 max_layer = std::max(max_layer, layer[next]);
377 }
378 }
379 }
380
381 // Group nodes by layer
382 std::vector<std::vector<size_t>> layers(max_layer + 1);
383 for (size_t i = 0; i < nodes_.size(); ++i) {
384 int l = layer.count(nodes_[i].id) ? layer[nodes_[i].id] : 0;
385 layers[l].push_back(i);
386 }
387
388 // Assign positions: X by layer, Y by position within layer
389 constexpr float kLayerSpacing = 200.0f;
390 constexpr float kNodeSpacing = 120.0f;
391
392 for (int l = 0; l <= max_layer; ++l) {
393 const size_t count = layers[l].size();
394 if (count == 0) continue;
395
396 float total_height = static_cast<float>(count - 1) * kNodeSpacing;
397 float start_y = -total_height / 2.0f;
398
399 for (size_t j = 0; j < count; ++j) {
400 nodes_[layers[l][j]].pos_x = static_cast<float>(l) * kLayerSpacing;
401 nodes_[layers[l][j]].pos_y =
402 start_y + static_cast<float>(j) * kNodeSpacing;
403 }
404 }
405}
406
407// ─── Status ──────────────────────────────────────────────────────
408
409namespace {
410
411std::string NormalizeRegKey(std::string_view input) {
412 std::string out;
413 out.reserve(input.size());
414 for (unsigned char ch : input) {
415 if (std::isalnum(ch)) {
416 out.push_back(static_cast<char>(std::toupper(ch)));
417 }
418 }
419 return out;
420}
421
422std::optional<uint32_t> GetRegisterValue(const OracleProgressionState& state,
423 std::string_view reg) {
424 const std::string key = NormalizeRegKey(reg);
425 if (key == "CRYSTALS" || key == "CRYSTAL" || key == "CRYSTALBITFIELD") {
426 return state.crystal_bitfield;
427 }
428 if (key == "GAMESTATE" || key == "GAME") {
429 return state.game_state;
430 }
431 if (key == "OOSPROG") {
432 return state.oosprog;
433 }
434 if (key == "OOSPROG2") {
435 return state.oosprog2;
436 }
437 if (key == "SIDEQUEST" || key == "SIDEQUESTS") {
438 return state.side_quest;
439 }
440 if (key == "PENDANTS" || key == "PENDANT") {
441 return state.pendants;
442 }
443 return std::nullopt;
444}
445
447 const OracleProgressionState& state) {
448 const auto reg_val_opt = GetRegisterValue(state, p.reg);
449 if (!reg_val_opt.has_value()) {
450 return false;
451 }
452 const uint32_t reg_val = *reg_val_opt;
453
454 const std::string op = NormalizeRegKey(p.op);
455 if (op == "BITSET") {
456 if (p.bit < 0 || p.bit >= 32) return false;
457 return (reg_val & (1u << static_cast<uint32_t>(p.bit))) != 0;
458 }
459 if (op == "BITCLEAR") {
460 if (p.bit < 0 || p.bit >= 32) return false;
461 return (reg_val & (1u << static_cast<uint32_t>(p.bit))) == 0;
462 }
463 if (op == "MASKANY") {
464 return p.mask != 0 && (reg_val & p.mask) != 0;
465 }
466 if (op == "MASKALL") {
467 return p.mask != 0 && (reg_val & p.mask) == p.mask;
468 }
469
470 // Integer comparisons.
471 const int lhs = static_cast<int>(reg_val);
472 const int rhs = p.value;
473 if (p.op == "==" || op == "EQ") return lhs == rhs;
474 if (p.op == "!=" || op == "NE") return lhs != rhs;
475 if (p.op == ">=" || op == "GE") return lhs >= rhs;
476 if (p.op == "<=" || op == "LE") return lhs <= rhs;
477 if (p.op == ">" || op == "GT") return lhs > rhs;
478 if (p.op == "<" || op == "LT") return lhs < rhs;
479
480 return false;
481}
482
483} // namespace
484
485void StoryEventGraph::UpdateStatus(uint8_t crystal_bitfield,
486 uint8_t game_state) {
488 state.crystal_bitfield = crystal_bitfield;
489 state.game_state = game_state;
490 UpdateStatus(state);
491}
492
494 auto completed = GetCompletedNodes(state);
495 std::unordered_map<std::string, bool> completed_set;
496 for (const auto& id : completed) {
497 completed_set[id] = true;
498 }
499
500 for (auto& node : nodes_) {
501 if (completed_set.count(node.id)) {
502 node.status = StoryNodeStatus::kCompleted;
503 continue;
504 }
505
506 // Check if all dependencies are completed
507 bool all_deps_met = true;
508 for (const auto& dep : node.dependencies) {
509 if (!completed_set.count(dep)) {
510 all_deps_met = false;
511 break;
512 }
513 }
514
515 node.status =
517 }
518}
519
520std::vector<std::string> StoryEventGraph::GetCompletedNodes(
521 uint8_t crystal_bitfield, uint8_t game_state) const {
523 state.crystal_bitfield = crystal_bitfield;
524 state.game_state = game_state;
525 return GetCompletedNodes(state);
526}
527
528std::vector<std::string> StoryEventGraph::GetCompletedNodes(
529 const OracleProgressionState& state) const {
530 std::vector<std::string> completed;
531
532 for (const auto& node : nodes_) {
533 // Primary: explicit completion predicates (if present).
534 if (!node.completed_when.empty()) {
535 bool ok = true;
536 for (const auto& p : node.completed_when) {
537 if (!EvaluatePredicate(p, state)) {
538 ok = false;
539 break;
540 }
541 }
542 if (ok) {
543 completed.push_back(node.id);
544 }
545 continue;
546 }
547
548 // Fallback: heuristics (legacy behavior, keeps older story_events.json usable).
549 bool is_completed = false;
550
551 for (const auto& flag : node.flags) {
552 // GameState checks
553 if (flag.name == "IntroState" || flag.name == "GameState") {
554 int required = 0;
555 try {
556 required = std::stoi(flag.value);
557 } catch (...) {
558 required = 1; // Default: any progress means started
559 }
560 if (state.game_state >= required) {
561 is_completed = true;
562 }
563 }
564
565 // Crystal/dungeon completion checks
566 if (flag.name.find("Crystal") != std::string::npos ||
567 flag.name.find("Fortress") != std::string::npos) {
568 // Any crystal means some dungeon progress
569 if (state.crystal_bitfield != 0) {
570 is_completed = true;
571 }
572 }
573 }
574
575 // Heuristic anchors for early graph usefulness (until predicates are filled in).
576 if (node.id == "EV-001" && state.game_state >= 1) {
577 is_completed = true;
578 }
579 if ((node.id == "EV-002" || node.id == "EV-003") && state.game_state >= 1) {
580 is_completed = true;
581 }
582 if (node.id == "EV-005" && state.game_state >= 2) {
583 is_completed = true;
584 }
585 if (node.id == "EV-008" && state.game_state >= 3) {
586 is_completed = true;
587 }
588
589 if (is_completed) {
590 completed.push_back(node.id);
591 }
592 }
593
594 return completed;
595}
596
597const StoryEventNode* StoryEventGraph::GetNode(const std::string& id) const {
598 auto it = node_index_.find(id);
599 if (it != node_index_.end() && it->second < nodes_.size()) {
600 return &nodes_[it->second];
601 }
602 return nullptr;
603}
604
605} // namespace yaze::core
void AutoLayout()
Compute layout positions using topological sort + layered positioning.
std::vector< StoryEventNode > nodes_
std::vector< StoryEdge > edges_
const StoryEventNode * GetNode(const std::string &id) const
void UpdateStatus(uint8_t crystal_bitfield, uint8_t game_state)
Update node completion status based on SRAM state.
std::vector< std::string > GetCompletedNodes(uint8_t crystal_bitfield, uint8_t game_state) const
Get IDs of events that are completed based on SRAM state.
absl::Status LoadFromJson(const std::string &path)
Load the graph from a JSON file.
std::unordered_map< std::string, size_t > node_index_
absl::Status LoadFromString(const std::string &json_content)
Load the graph from a JSON string.
bool EvaluatePredicate(const StoryPredicate &p, const OracleProgressionState &state)
std::optional< uint32_t > GetRegisterValue(const OracleProgressionState &state, std::string_view reg)
nlohmann::json json
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
Oracle of Secrets game progression state parsed from SRAM.
A directed edge in the story event graph.
A node in the Oracle story event graph.
std::vector< StoryLocation > locations
std::vector< std::string > unlocks
std::vector< std::string > dependencies
std::vector< std::string > scripts
std::vector< std::string > text_ids
std::vector< StoryFlag > flags
std::vector< StoryPredicate > completed_when
A flag set or cleared by a story event.
A predicate for determining event completion from SRAM state.