载入中...
搜索中...
未找到
TacticsPersistence.cpp
浏览该文件的文档.
2
3#include <algorithm>
4#include <charconv>
5#include <map>
6#include <limits>
7#include <set>
8#include <utility>
9
10namespace eve::tactics {
11namespace {
12
13constexpr std::string_view kType = "tactics.battle";
14
15LogicalId schema() {
16 const auto value = LogicalId::parse("tactics:battle");
17 if (!value) std::terminate();
18 return *value;
19}
20
29Result<std::string> migrateLegacyPolicyId(int value) {
30 if (value == 0) return Result<std::string>::success(std::string(kSideAlternatingPolicyId));
31 if (value == 1) return Result<std::string>::success(std::string(kInitiativePolicyId));
33 DiagnosticCode::UnknownVersion, "snapshot contains an unknown battle enum", "payload.policy"));
34}
35
36Value cellValue(Cell cell) {
37 return Value(Value::Object{{"layer", Value(cell.layer)}, {"x", Value(cell.x)}, {"y", Value(cell.y)}});
38}
39
40Value subjectValue(SubjectRef subject) { return Value(subject.isValid() ? subject.format() : std::string{}); }
41
42Result<const Value::Object*> object(const Value& value, std::string path) {
43 const auto* result = value.getIf<Value::Object>();
44 if (!result)
48}
49
50Result<const Value*> field(const Value::Object& value, std::string_view name, std::string path) {
51 const auto found = value.find(std::string(name));
52 if (found == value.end())
54 Diagnostic::error(DiagnosticCode::ParseError, "missing required field", path + "." + std::string(name)));
55 return Result<const Value*>::success(&found->second);
56}
57
58Result<std::int64_t> integer(const Value& value, std::string path) {
59 const auto* result = value.getIf<std::int64_t>();
60 if (!result)
63 return Result<std::int64_t>::success(*result);
64}
65
66Result<std::uint64_t> decimal(const Value& value, std::string path) {
67 const auto* text = value.getIf<std::string>();
68 if (!text)
70 Diagnostic::error(DiagnosticCode::ParseError, "expected decimal string", path));
71 std::uint64_t result = 0;
72 const auto [end, error] = std::from_chars(text->data(), text->data() + text->size(), result);
73 if (error != std::errc{} || end != text->data() + text->size())
75 Diagnostic::error(DiagnosticCode::ParseError, "invalid decimal string", path));
76 return Result<std::uint64_t>::success(result);
77}
78
79Result<std::int64_t> signedDecimal(const Value& value, std::string path) {
80 const auto* text = value.getIf<std::string>();
81 if (!text)
83 Diagnostic::error(DiagnosticCode::ParseError, "expected signed decimal string", path));
84 std::int64_t result = 0;
85 const auto [end, error] = std::from_chars(text->data(), text->data() + text->size(), result);
86 if (error != std::errc{} || end != text->data() + text->size())
88 Diagnostic::error(DiagnosticCode::ParseError, "invalid signed decimal string", path));
89 return Result<std::int64_t>::success(result);
90}
91
92Result<SubjectRef> subject(const Value& value, std::string path, bool allowNil = false) {
93 const auto* text = value.getIf<std::string>();
94 if (!text)
96 Diagnostic::error(DiagnosticCode::ParseError, "expected subject UUID", path));
97 if (allowNil && text->empty()) return Result<SubjectRef>::success(SubjectRef::nil());
98 const auto id = PersistentId::parse(*text);
99 if (!id || id->isNil())
101 Diagnostic::error(DiagnosticCode::ParseError, "invalid subject UUID", path));
103}
104
105Result<LogicalId> logicalId(const Value& value, std::string path) {
106 const auto* text = value.getIf<std::string>();
107 if (!text)
109 Diagnostic::error(DiagnosticCode::ParseError, "expected logical ID", path));
110 const auto result = LogicalId::parse(*text);
111 if (!result)
113 Diagnostic::error(DiagnosticCode::ParseError, "invalid logical ID", path));
114 return Result<LogicalId>::success(*result);
115}
116
117Result<Cell> parseCell(const Value& value, std::string path) {
118 auto candidate = object(value, path);
119 if (!candidate) return Result<Cell>::failure(candidate.status());
120 if (candidate.value()->size() != 3)
122 Diagnostic::error(DiagnosticCode::ParseError, "cell has unknown or missing fields", path));
123 Cell result;
124 for (const auto& [name, target] : {std::pair{"x", &result.x}, {"y", &result.y}, {"layer", &result.layer}}) {
125 auto member = field(*candidate.value(), name, path);
126 if (!member) return Result<Cell>::failure(member.status());
127 auto parsed = integer(*member.value(), path + "." + name);
128 if (!parsed) return Result<Cell>::failure(parsed.status());
129 *target = static_cast<int>(parsed.value());
130 if (static_cast<std::int64_t>(*target) != parsed.value())
132 Diagnostic::error(DiagnosticCode::ParseError, "cell coordinate is out of range", path + "." + name));
133 }
134 return Result<Cell>::success(result);
135}
136
137template <class T>
138T* resolve(const ecs::EntityHandle& handle) noexcept {
139 return dynamic_cast<T*>(ecs::try_get(handle));
140}
141
142struct UnitCandidate {
143 TacticalUnit* unit = nullptr;
144 ecs::EntityHandle side{};
145 LogicalId definition;
146 Cell cell;
147 int facing = 0;
148 bool placed = false;
149 TacticalUnit::TurnResources turn;
150};
151
152struct Candidate {
153 BoardState board;
154 std::uint64_t seed = 0;
159 std::uint64_t round = 0;
160 std::size_t cursor = 0;
161 std::optional<ecs::EntityHandle> activeUnit;
162 std::optional<ecs::EntityHandle> activeSide;
163 std::vector<UnitCandidate> units;
170 std::vector<ecs::EntityHandle> schedule;
171 std::vector<ecs::EntityHandle> sides;
172 Battle::Events events;
173 Battle::Reactions reactions;
174 Battle::Commands commands;
175 Battle::Objectives objectives;
176 Battle::Random random;
177};
178
179Value eventValue(const BattleEvent& event) {
180 return Value(Value::Object{{"causationCommand", Value(std::to_string(event.causationCommand))},
181 {"correlationCommand", Value(std::to_string(event.correlationCommand))},
182 {"from", Value(static_cast<int>(event.from))},
183 {"sequence", Value(std::to_string(event.sequence))},
184 {"subject", subjectValue(event.subject)},
185 {"tick", Value(std::to_string(event.tick.value()))},
186 {"to", Value(static_cast<int>(event.to))},
187 {"type", Value(event.type)}});
188}
189
190Value candidateValue(const ReactionCandidate& candidate) {
191 return Value(Value::Object{{"action", Value(candidate.action.format())},
192 {"initiative", Value(candidate.initiative)},
193 {"priority", Value(candidate.priority)},
194 {"reactor", subjectValue(candidate.reactor)}});
195}
196
197Value windowValue(const ReactionWindow& window) {
198 Value::Array candidates;
199 for (const auto& candidate : window.candidates) candidates.push_back(candidateValue(candidate));
200 return Value(Value::Object{{"candidates", Value(std::move(candidates))},
201 {"depth", Value(std::to_string(window.depth))},
202 {"triggerSequence", Value(std::to_string(window.triggerSequence))}});
203}
204
205Value commandValue(const BattleCommand& command) {
206 Value::Array candidates;
207 for (const auto& candidate : command.candidates) candidates.push_back(candidateValue(candidate));
208 return Value(Value::Object{
209 {"action", Value(command.action.isValid() ? command.action.format() : std::string{})},
210 {"actor", subjectValue(command.actor)},
211 {"candidates", Value(std::move(candidates))},
212 {"cell", cellValue(command.cell)},
213 {"deltaNanoseconds", Value(std::to_string(command.step.delta.nanoseconds()))},
214 {"expectedRevision", Value(std::to_string(command.expectedRevision.value()))},
215 {"facing", Value(command.facing)},
216 {"kind", Value(static_cast<int>(command.kind))},
217 {"payload", Value(command.payload)},
218 {"policy", Value(command.policyId)},
219 {"resultingRevision", Value(std::to_string(command.resultingRevision.value()))},
220 {"sequence", Value(std::to_string(command.sequence))},
221 {"targetUnit", subjectValue(command.targetUnit)},
222 {"tick", Value(std::to_string(command.step.tick.value()))},
223 {"triggerSequence", Value(std::to_string(command.triggerSequence))},
224 });
225}
226
227Value objectiveValue(const ObjectiveState& objective) {
229 for (const Cell cell : objective.spec.requiredCells) cells.push_back(cellValue(cell));
230 return Value(Value::Object{
231 {"beneficiarySide", subjectValue(objective.spec.beneficiarySide)},
232 {"completedRevision", Value(std::to_string(objective.completedRevision.value()))},
233 {"endsBattle", Value(objective.spec.endsBattle)},
234 {"id", Value(objective.spec.id.format())},
235 {"kind", Value(static_cast<int>(objective.spec.kind))},
236 {"requiredCells", Value(std::move(cells))},
237 {"requiredRound", Value(std::to_string(objective.spec.requiredRound))},
238 {"status", Value(static_cast<int>(objective.status))},
239 {"targetSide", subjectValue(objective.spec.targetSide)},
240 });
241}
242
243Value randomValue(const Battle::Random& random) {
244 Value::Object streams;
245 for (const auto& [name, state] : random.streams)
246 streams.emplace(name, Value(Value::Object{{"rollIndex", Value(std::to_string(state.rollIndex))},
247 {"state", Value(std::to_string(state.state))}}));
248 return Value(std::move(streams));
249}
250
251Result<Value> unitValue(TacticalUnit& unit) {
252 const auto turn = unit.turn();
253 auto* side = resolve<TacticalSide>(unit.membership()->side);
254 if (!side)
256 Diagnostic::error(DiagnosticCode::StaleHandle, "unit belongs to a stale tactical side", {}));
258 {"acted", Value(turn->acted)},
259 {"actionPoints", Value(turn->actionPoints)},
260 {"alive", Value(turn->alive)},
261 {"cell", cellValue(unit.position()->cell)},
262 {"charge", Value(turn->charge)},
263 {"definition", Value(unit.identity()->definition.isValid() ? unit.identity()->definition.format()
264 : std::string{})},
265 {"facing", Value(unit.position()->facing)},
266 {"initiative", Value(turn->initiative)},
267 {"movePoints", Value(turn->movePoints)},
268 {"placed", Value(unit.position()->placed)},
269 {"reactionPoints", Value(turn->reactionPoints)},
270 {"roundActionPoints", Value(turn->roundActionPoints)},
271 {"roundMovePoints", Value(turn->roundMovePoints)},
272 {"roundReactionPoints", Value(turn->roundReactionPoints)},
273 {"side", subjectValue(side->identity()->subject)},
274 {"subject", subjectValue(unit.identity()->subject)},
275 }));
276}
277
278Result<int> intField(const Value::Object& value, std::string_view name, const std::string& path) {
279 auto member = field(value, name, path);
280 if (!member) return Result<int>::failure(member.status());
281 auto parsed = integer(*member.value(), path + "." + std::string(name));
282 if (!parsed) return Result<int>::failure(parsed.status());
283 const int result = static_cast<int>(parsed.value());
284 if (static_cast<std::int64_t>(result) != parsed.value())
286 Diagnostic::error(DiagnosticCode::ParseError, "integer is out of range", path + "." + std::string(name)));
287 return Result<int>::success(result);
288}
289
290Result<Battle::Events> parseEvents(const Value& value) {
291 auto root = object(value, "payload.events");
292 if (!root || root.value()->size() != 2)
294 Diagnostic::error(DiagnosticCode::ParseError, "invalid events object", "payload.events"));
295 auto nextMember = field(*root.value(), "nextSequence", "payload.events");
296 auto valuesMember = field(*root.value(), "values", "payload.events");
297 if (!everyResultValid(nextMember, valuesMember))
299 Diagnostic::error(DiagnosticCode::ParseError, "incomplete events object", "payload.events"));
300 auto next = decimal(*nextMember.value(), "payload.events.nextSequence");
301 const auto* values = valuesMember.value()->getIf<Value::Array>();
302 if (!next || !values || next.value() == 0)
304 Diagnostic::error(DiagnosticCode::ParseError, "invalid event sequence", "payload.events"));
305
306 Battle::Events result;
307 result.nextSequence = next.value();
308 std::uint64_t previous = 0;
309 for (std::size_t i = 0; i < values->size(); ++i) {
310 const std::string path = "payload.events.values[" + std::to_string(i) + "]";
311 auto record = object((*values)[i], path);
312 if (!record || record.value()->size() != 8)
314 Diagnostic::error(DiagnosticCode::ParseError, "invalid event record", path));
315 auto sequenceMember = field(*record.value(), "sequence", path);
316 auto tickMember = field(*record.value(), "tick", path);
317 auto subjectMember = field(*record.value(), "subject", path);
318 auto typeMember = field(*record.value(), "type", path);
319 auto causationMember = field(*record.value(), "causationCommand", path);
320 auto correlationMember = field(*record.value(), "correlationCommand", path);
321 auto from = intField(*record.value(), "from", path);
322 auto to = intField(*record.value(), "to", path);
323 if (!everyResultValid(sequenceMember, tickMember, subjectMember, typeMember, causationMember,
324 correlationMember, from, to) ||
325 from.value() < 0 || from.value() > static_cast<int>(BattlePhase::BattleEnd) || to.value() < 0 ||
326 to.value() > static_cast<int>(BattlePhase::BattleEnd))
328 Diagnostic::error(DiagnosticCode::ParseError, "invalid event fields", path));
329 auto sequence = decimal(*sequenceMember.value(), path + ".sequence");
330 auto tick = decimal(*tickMember.value(), path + ".tick");
331 auto subjectRef = subject(*subjectMember.value(), path + ".subject", true);
332 auto causation = decimal(*causationMember.value(), path + ".causationCommand");
333 auto correlation = decimal(*correlationMember.value(), path + ".correlationCommand");
334 const auto* type = typeMember.value()->getIf<std::string>();
335 if (!everyResultValid(sequence, tick, subjectRef, causation, correlation) || !type || type->empty() ||
336 causation.value() == 0 || correlation.value() == 0 || sequence.value() <= previous ||
337 sequence.value() >= result.nextSequence)
339 Diagnostic::error(DiagnosticCode::InvariantViolation, "invalid event ordering", path));
341 result.values.push_back({sequence.value(), causation.value(), correlation.value(),
342 static_cast<BattlePhase>(from.value()), static_cast<BattlePhase>(to.value()),
343 SimulationTick(tick.value()), *type, subjectRef.value()});
344 }
345 return Result<Battle::Events>::success(std::move(result));
346}
347
348Result<Battle::Reactions> parseReactions(const Value& value, BattlePhase phase) {
349 auto root = object(value, "payload.reactions");
350 if (!root || root.value()->size() != 3)
352 Diagnostic::error(DiagnosticCode::ParseError, "invalid reactions object", "payload.reactions"));
353 auto maxDepthMember = field(*root.value(), "maxDepth", "payload.reactions");
354 auto seenMember = field(*root.value(), "seen", "payload.reactions");
355 auto stackMember = field(*root.value(), "stack", "payload.reactions");
356 if (!everyResultValid(maxDepthMember, seenMember, stackMember))
358 Diagnostic::error(DiagnosticCode::ParseError, "incomplete reactions object", "payload.reactions"));
359 auto maxDepth = decimal(*maxDepthMember.value(), "payload.reactions.maxDepth");
360 const auto* seen = seenMember.value()->getIf<Value::Array>();
361 const auto* stack = stackMember.value()->getIf<Value::Array>();
362 if (!maxDepth || maxDepth.value() == 0 || maxDepth.value() > static_cast<std::uint64_t>(SIZE_MAX) || !seen ||
363 !stack || stack->size() > maxDepth.value())
365 Diagnostic::error(DiagnosticCode::ParseError, "invalid reaction limits", "payload.reactions"));
366
367 Battle::Reactions result;
368 result.maxDepth = static_cast<std::size_t>(maxDepth.value());
369 std::set<std::string> uniqueSeen;
370 for (std::size_t i = 0; i < seen->size(); ++i) {
371 const auto* key = (*seen)[i].getIf<std::string>();
372 if (!key || key->empty() || !uniqueSeen.insert(*key).second)
374 Diagnostic::error(DiagnosticCode::InvariantViolation, "invalid reaction cycle guard",
375 "payload.reactions.seen[" + std::to_string(i) + "]"));
376 result.seen.push_back(*key);
377 }
378 for (std::size_t i = 0; i < stack->size(); ++i) {
379 const std::string path = "payload.reactions.stack[" + std::to_string(i) + "]";
380 auto window = object((*stack)[i], path);
381 if (!window || window.value()->size() != 3)
383 Diagnostic::error(DiagnosticCode::ParseError, "invalid reaction window", path));
384 auto triggerMember = field(*window.value(), "triggerSequence", path);
385 auto depthMember = field(*window.value(), "depth", path);
386 auto candidatesMember = field(*window.value(), "candidates", path);
387 if (!everyResultValid(triggerMember, depthMember, candidatesMember))
389 Diagnostic::error(DiagnosticCode::ParseError, "incomplete reaction window", path));
390 auto trigger = decimal(*triggerMember.value(), path + ".triggerSequence");
391 auto depth = decimal(*depthMember.value(), path + ".depth");
392 const auto* candidates = candidatesMember.value()->getIf<Value::Array>();
393 if (!everyResultValid(trigger, depth) || trigger.value() == 0 || depth.value() != i + 1 || !candidates ||
394 candidates->empty())
396 Diagnostic::error(DiagnosticCode::InvariantViolation, "invalid reaction window state", path));
397 ReactionWindow restored{trigger.value(), static_cast<std::size_t>(depth.value()), {}};
398 std::set<std::string> uniqueCandidates;
399 for (std::size_t j = 0; j < candidates->size(); ++j) {
400 const std::string candidatePath = path + ".candidates[" + std::to_string(j) + "]";
401 auto candidate = object((*candidates)[j], candidatePath);
402 if (!candidate || candidate.value()->size() != 4)
404 Diagnostic::error(DiagnosticCode::ParseError, "invalid reaction candidate", candidatePath));
405 auto reactorMember = field(*candidate.value(), "reactor", candidatePath);
406 auto actionMember = field(*candidate.value(), "action", candidatePath);
407 auto priority = intField(*candidate.value(), "priority", candidatePath);
408 auto initiative = intField(*candidate.value(), "initiative", candidatePath);
409 if (!everyResultValid(reactorMember, actionMember, priority, initiative))
411 Diagnostic::error(DiagnosticCode::ParseError, "incomplete reaction candidate", candidatePath));
412 auto reactor = subject(*reactorMember.value(), candidatePath + ".reactor");
413 auto action = logicalId(*actionMember.value(), candidatePath + ".action");
414 if (!everyResultValid(reactor, action))
416 Diagnostic::error(DiagnosticCode::ParseError, "invalid reaction identity", candidatePath));
417 const std::string key = reactor.value().format() + ":" + action.value().format();
418 if (!uniqueCandidates.insert(key).second)
420 Diagnostic::error(DiagnosticCode::Conflict, "duplicate reaction candidate", candidatePath));
421 restored.candidates.push_back(
422 {reactor.value(), action.value(), priority.value(), initiative.value()});
423 }
424 result.stack.push_back(std::move(restored));
425 }
426 if ((phase == BattlePhase::Reaction) != !result.stack.empty())
428 DiagnosticCode::InvariantViolation, "reaction phase and stack disagree", "payload.reactions.stack"));
429 return Result<Battle::Reactions>::success(std::move(result));
430}
431
442enum class CommandLogBoundary {
443 EndsAtRevision,
444 StartsAtRevision,
445};
446
454struct CommandFieldPolicy {
455 bool policyIdStrings = false;
456 bool allowUseAbility = false;
457 bool abilityTargetFields = false;
458 CommandLogBoundary boundary = CommandLogBoundary::EndsAtRevision;
459};
460
461Result<Battle::Commands> parseCommands(const Value& value, Revision snapshotRevision, CommandFieldPolicy policy) {
462 auto root = object(value, "payload.commands");
463 if (!root || root.value()->size() != 2)
465 Diagnostic::error(DiagnosticCode::ParseError, "invalid commands object", "payload.commands"));
466 auto nextMember = field(*root.value(), "nextSequence", "payload.commands");
467 auto valuesMember = field(*root.value(), "values", "payload.commands");
468 if (!everyResultValid(nextMember, valuesMember))
470 Diagnostic::error(DiagnosticCode::ParseError, "incomplete commands object", "payload.commands"));
471 auto next = decimal(*nextMember.value(), "payload.commands.nextSequence");
472 const auto* values = valuesMember.value()->getIf<Value::Array>();
473 if (!next || next.value() == 0 || !values)
475 Diagnostic::error(DiagnosticCode::ParseError, "invalid command sequence", "payload.commands"));
476
477 Battle::Commands result;
478 result.nextSequence = next.value();
479 std::uint64_t previousSequence = 0;
480 Revision previousRevision;
481 for (std::size_t i = 0; i < values->size(); ++i) {
482 const std::string path = "payload.commands.values[" + std::to_string(i) + "]";
483 auto record = object((*values)[i], path);
484 // Version 6 added the targeted unit and the opaque effect payload.
485 const std::size_t expectedCommandFields = policy.abilityTargetFields ? 15u : 13u;
486 if (!record || record.value()->size() != expectedCommandFields)
488 Diagnostic::error(DiagnosticCode::ParseError, "invalid command record", path));
489 auto sequenceMember = field(*record.value(), "sequence", path);
490 auto expectedMember = field(*record.value(), "expectedRevision", path);
491 auto resultingMember = field(*record.value(), "resultingRevision", path);
492 auto tickMember = field(*record.value(), "tick", path);
493 auto deltaMember = field(*record.value(), "deltaNanoseconds", path);
494 auto triggerMember = field(*record.value(), "triggerSequence", path);
495 auto actorMember = field(*record.value(), "actor", path);
496 auto actionMember = field(*record.value(), "action", path);
497 auto cellMember = field(*record.value(), "cell", path);
498 auto candidatesMember = field(*record.value(), "candidates", path);
499 auto kind = intField(*record.value(), "kind", path);
500 auto facing = intField(*record.value(), "facing", path);
501 auto policyMember = field(*record.value(), "policy", path);
502 auto targetMember = policy.abilityTargetFields ? field(*record.value(), "targetUnit", path)
504 auto payloadMember = policy.abilityTargetFields ? field(*record.value(), "payload", path)
506 const int highestKnownKind = policy.allowUseAbility ? static_cast<int>(BattleCommandKind::UseAbility)
507 : static_cast<int>(BattleCommandKind::RollRandom);
508 // Every Result is observed before the decision, so a record with several
509 // missing fields reports one structured failure instead of destroying
510 // unobserved Results.
511 if (!everyResultValid(sequenceMember, expectedMember, resultingMember, tickMember, deltaMember,
512 triggerMember, actorMember, actionMember, cellMember, candidatesMember, kind,
513 facing, policyMember, targetMember, payloadMember) ||
514 kind.value() < 0 || kind.value() > highestKnownKind)
516 Diagnostic::error(DiagnosticCode::ParseError, "invalid command fields", path));
517 // Version 3 stores the stable id string; versions 1-2 stored a numeric enum.
518 std::string policyId;
519 if (policy.policyIdStrings) {
520 if (!policyMember.value()->isString() || policyMember.value()->asString().empty())
522 Diagnostic::error(DiagnosticCode::ParseError, "invalid command policy", path + ".policy"));
523 policyId = policyMember.value()->asString();
524 } else {
525 auto legacy = intField(*record.value(), "policy", path);
526 if (!legacy)
528 Diagnostic::error(DiagnosticCode::ParseError, "invalid command policy", path));
529 auto migrated = migrateLegacyPolicyId(legacy.value());
530 if (!migrated) return Result<Battle::Commands>::failure(migrated.status());
531 policyId = std::move(migrated).takeValue();
532 }
533 auto sequence = decimal(*sequenceMember.value(), path + ".sequence");
534 auto expected = decimal(*expectedMember.value(), path + ".expectedRevision");
535 auto resulting = decimal(*resultingMember.value(), path + ".resultingRevision");
536 auto tick = decimal(*tickMember.value(), path + ".tick");
537 auto delta = signedDecimal(*deltaMember.value(), path + ".deltaNanoseconds");
538 auto trigger = decimal(*triggerMember.value(), path + ".triggerSequence");
539 auto actor = subject(*actorMember.value(), path + ".actor", true);
540 auto cell = parseCell(*cellMember.value(), path + ".cell");
541 const auto* actionText = actionMember.value()->getIf<std::string>();
542 const auto* candidates = candidatesMember.value()->getIf<Value::Array>();
543 if (!everyResultValid(sequence, expected, resulting, tick, delta, trigger, actor, cell) || !actionText ||
544 !candidates || sequence.value() <= previousSequence ||
545 sequence.value() >= result.nextSequence || expected.value() == std::numeric_limits<std::uint64_t>::max() ||
546 resulting.value() != expected.value() + 1 ||
547 (i > 0 && expected.value() != previousRevision.value()))
549 Diagnostic::error(DiagnosticCode::InvariantViolation, "invalid command ordering", path));
550 BattleCommand command;
551 command.sequence = sequence.value();
552 command.kind = static_cast<BattleCommandKind>(kind.value());
553 command.expectedRevision = Revision(expected.value());
554 command.resultingRevision = Revision(resulting.value());
555 command.step = {SimulationTick(tick.value()), Duration::fromNanoseconds(delta.value())};
556 command.actor = actor.value();
557 command.cell = cell.value();
558 command.facing = facing.value();
559 command.policyId = policyId;
560 command.triggerSequence = trigger.value();
561 if (policy.abilityTargetFields) {
562 auto targetUnit = subject(*targetMember.value(), path + ".targetUnit", true);
563 if (!targetUnit) return Result<Battle::Commands>::failure(targetUnit.status());
564 command.targetUnit = targetUnit.value();
565 const auto* payload = payloadMember.value()->getIf<std::string>();
566 if (payload == nullptr)
568 Diagnostic::error(DiagnosticCode::ParseError, "invalid command payload", path + ".payload"));
569 // The bound is re-checked on restore: a payload written by a buggy or foreign
570 // writer must not become unbounded persisted state.
571 if (payload->size() > kMaxAbilityPayloadBytes)
573 DiagnosticCode::ParseError, "command payload exceeds the persisted size bound", path + ".payload"));
574 command.payload = *payload;
575 }
576 if (!actionText->empty()) {
577 const auto parsedAction = LogicalId::parse(*actionText);
578 if (!parsedAction)
580 Diagnostic::error(DiagnosticCode::ParseError, "invalid command action", path + ".action"));
581 command.action = *parsedAction;
582 }
583 for (std::size_t j = 0; j < candidates->size(); ++j) {
584 const std::string candidatePath = path + ".candidates[" + std::to_string(j) + "]";
585 auto candidate = object((*candidates)[j], candidatePath);
586 if (!candidate || candidate.value()->size() != 4)
588 Diagnostic::error(DiagnosticCode::ParseError, "invalid command candidate", candidatePath));
589 auto reactorMember = field(*candidate.value(), "reactor", candidatePath);
590 auto candidateActionMember = field(*candidate.value(), "action", candidatePath);
591 auto priority = intField(*candidate.value(), "priority", candidatePath);
592 auto initiative = intField(*candidate.value(), "initiative", candidatePath);
593 if (!everyResultValid(reactorMember, candidateActionMember, priority, initiative))
595 Diagnostic::error(DiagnosticCode::ParseError, "incomplete command candidate", candidatePath));
596 auto reactor = subject(*reactorMember.value(), candidatePath + ".reactor");
597 auto candidateAction = logicalId(*candidateActionMember.value(), candidatePath + ".action");
598 if (!everyResultValid(reactor, candidateAction))
600 Diagnostic::error(DiagnosticCode::ParseError, "invalid command candidate identity", candidatePath));
601 command.candidates.push_back(
602 {reactor.value(), candidateAction.value(), priority.value(), initiative.value()});
603 }
604 const auto invalidCommand = [&]() {
605 switch (command.kind) {
606 case BattleCommandKind::Start: return false;
607 case BattleCommandKind::Advance: return command.step.tick.isZero() || command.step.delta.nanoseconds() < 0;
612 case BattleCommandKind::DefeatUnit: return !command.actor.isValid();
613 case BattleCommandKind::Finish: return false;
615 return command.triggerSequence == 0 || command.candidates.empty();
617 return command.triggerSequence == 0 || !command.actor.isValid() || !command.action.isValid();
618 case BattleCommandKind::DeclineReaction: return command.triggerSequence == 0;
619 case BattleCommandKind::RollRandom: return !command.action.isValid();
621 return command.triggerSequence != 0 || !command.actor.isValid() || !command.action.isValid();
622 }
623 return true;
624 };
625 if (invalidCommand())
627 DiagnosticCode::InvariantViolation, "command fields disagree with command kind", path));
628 previousSequence = sequence.value();
629 previousRevision = command.resultingRevision;
630 result.values.push_back(std::move(command));
631 }
632 if (policy.boundary == CommandLogBoundary::EndsAtRevision && !result.values.empty() &&
633 result.values.back().resultingRevision != snapshotRevision)
635 DiagnosticCode::Conflict, "command log revision differs from snapshot revision", "payload.commands"));
636 // A replay log is applied onto the state it starts from, so it is anchored at its first
637 // command instead: re-applying a log captured elsewhere would run commands against state
638 // they were never accepted against.
639 if (policy.boundary == CommandLogBoundary::StartsAtRevision && !result.values.empty() &&
640 result.values.front().expectedRevision != snapshotRevision)
642 DiagnosticCode::Conflict, "replay log does not start at the target revision", "payload.commands"));
643 return Result<Battle::Commands>::success(std::move(result));
644}
645
646Result<Battle::Objectives> parseObjectives(Battle& battle, const BoardState& board, const Value& value,
647 Revision snapshotRevision) {
648 const auto* values = value.getIf<Value::Array>();
649 if (!values)
651 Diagnostic::error(DiagnosticCode::ParseError, "objectives must be an array", "payload.objectives"));
652 std::set<std::string> sideSubjects;
653 for (const auto& handle : battle.turn()->sides) {
654 auto* side = resolve<TacticalSide>(handle);
655 if (!side)
657 Diagnostic::error(DiagnosticCode::StaleHandle, "target battle contains a stale side", {}));
658 sideSubjects.insert(side->identity()->subject.format());
659 }
660 Battle::Objectives result;
661 std::set<std::string> ids;
662 for (std::size_t i = 0; i < values->size(); ++i) {
663 const std::string path = "payload.objectives[" + std::to_string(i) + "]";
664 auto record = object((*values)[i], path);
665 if (!record || record.value()->size() != 9)
667 Diagnostic::error(DiagnosticCode::ParseError, "invalid objective record", path));
668 auto idMember = field(*record.value(), "id", path);
669 auto beneficiaryMember = field(*record.value(), "beneficiarySide", path);
670 auto targetMember = field(*record.value(), "targetSide", path);
671 auto roundMember = field(*record.value(), "requiredRound", path);
672 auto cellsMember = field(*record.value(), "requiredCells", path);
673 auto endsMember = field(*record.value(), "endsBattle", path);
674 auto completedMember = field(*record.value(), "completedRevision", path);
675 auto kind = intField(*record.value(), "kind", path);
676 auto status = intField(*record.value(), "status", path);
677 if (!everyResultValid(idMember, beneficiaryMember, targetMember, roundMember, cellsMember, endsMember,
678 completedMember, kind, status) ||
679 kind.value() < 0 || kind.value() > static_cast<int>(ObjectiveKind::OccupyCells) ||
680 status.value() < 0 || status.value() > static_cast<int>(ObjectiveStatus::Completed))
682 Diagnostic::error(DiagnosticCode::ParseError, "invalid objective fields", path));
683 auto id = logicalId(*idMember.value(), path + ".id");
684 auto beneficiary = subject(*beneficiaryMember.value(), path + ".beneficiarySide");
685 auto target = subject(*targetMember.value(), path + ".targetSide", true);
686 auto round = decimal(*roundMember.value(), path + ".requiredRound");
687 auto completed = decimal(*completedMember.value(), path + ".completedRevision");
688 const auto* cells = cellsMember.value()->getIf<Value::Array>();
689 const auto* endsBattle = endsMember.value()->getIf<bool>();
690 if (!everyResultValid(id, beneficiary, target, round, completed) || !cells || !endsBattle ||
691 !ids.insert(id.value().format()).second || !sideSubjects.contains(beneficiary.value().format()) ||
692 (target.value().isValid() && !sideSubjects.contains(target.value().format())) ||
693 completed.value() > snapshotRevision.value())
695 Diagnostic::error(DiagnosticCode::InvariantViolation, "invalid objective state", path));
696 ObjectiveState objective;
697 objective.spec.id = id.value();
698 objective.spec.kind = static_cast<ObjectiveKind>(kind.value());
699 objective.spec.beneficiarySide = beneficiary.value();
700 objective.spec.targetSide = target.value();
701 objective.spec.requiredRound = round.value();
702 objective.spec.endsBattle = *endsBattle;
703 objective.status = static_cast<ObjectiveStatus>(status.value());
704 objective.completedRevision = Revision(completed.value());
705 for (std::size_t j = 0; j < cells->size(); ++j) {
706 auto cell = parseCell((*cells)[j], path + ".requiredCells[" + std::to_string(j) + "]");
707 if (!cell || !board.contains(cell.value()))
709 Diagnostic::error(DiagnosticCode::InvariantViolation, "invalid objective cell", path));
710 objective.spec.requiredCells.push_back(cell.value());
711 }
712 if ((objective.status == ObjectiveStatus::Pending && !objective.completedRevision.isZero()) ||
713 (objective.status == ObjectiveStatus::Completed && objective.completedRevision.isZero()) ||
714 (objective.spec.kind == ObjectiveKind::EliminateSide && !objective.spec.targetSide.isValid()) ||
715 (objective.spec.kind == ObjectiveKind::SurviveRounds && objective.spec.requiredRound == 0) ||
716 (objective.spec.kind == ObjectiveKind::OccupyCells && objective.spec.requiredCells.empty()))
718 Diagnostic::error(DiagnosticCode::InvariantViolation, "inconsistent objective state", path));
719 std::sort(objective.spec.requiredCells.begin(), objective.spec.requiredCells.end());
720 if (std::adjacent_find(objective.spec.requiredCells.begin(), objective.spec.requiredCells.end()) !=
721 objective.spec.requiredCells.end())
723 Diagnostic::error(DiagnosticCode::Conflict, "duplicate objective cell", path));
724 result.values.push_back(std::move(objective));
725 }
726 return Result<Battle::Objectives>::success(std::move(result));
727}
728
729Result<Battle::Random> parseRandom(const Value& value) {
730 const auto* streams = value.getIf<Value::Object>();
731 if (!streams)
733 Diagnostic::error(DiagnosticCode::ParseError, "random streams must be an object", "payload.random"));
734 Battle::Random result;
735 for (const auto& [name, stateValue] : *streams) {
738 DiagnosticCode::ParseError, "invalid random stream logical ID", "payload.random." + name));
739 auto state = object(stateValue, "payload.random." + name);
740 if (!state || state.value()->size() != 2)
742 Diagnostic::error(DiagnosticCode::ParseError, "invalid random stream state", "payload.random." + name));
743 auto stateMember = field(*state.value(), "state", "payload.random." + name);
744 auto indexMember = field(*state.value(), "rollIndex", "payload.random." + name);
745 if (!everyResultValid(stateMember, indexMember))
747 DiagnosticCode::ParseError, "incomplete random stream state", "payload.random." + name));
748 auto parsedState = decimal(*stateMember.value(), "payload.random." + name + ".state");
749 auto parsedIndex = decimal(*indexMember.value(), "payload.random." + name + ".rollIndex");
750 if (!everyResultValid(parsedState, parsedIndex) || parsedIndex.value() == 0)
752 DiagnosticCode::InvariantViolation, "invalid random stream state", "payload.random." + name));
753 result.streams.emplace(name, Battle::RandomStreamState{parsedState.value(), parsedIndex.value()});
754 }
755 return Result<Battle::Random>::success(std::move(result));
756}
757
758Result<Candidate> parseCandidate(Battle& battle, const Value& payload, Revision snapshotRevision,
759 SchemaVersion sourceVersion) {
760 auto root = object(payload, "payload");
761 if (!root) return Result<Candidate>::failure(root.status());
762 static const std::set<std::string> fields = {"activeUnit", "board", "commands", "cursor", "events",
763 "objectives", "phase", "policy", "random", "reactions", "round",
764 "seed", "sides", "status", "units"};
765 // Version 5 added the round's activation queue, because charge-time scheduling
766 // makes it shorter than the roster and impossible to recompute after a restore.
767 const bool hasSchedule = sourceVersion >= SchemaVersion(5);
768 std::set<std::string> expectedFields = fields;
769 if (hasSchedule) expectedFields.insert("schedule");
770 for (const auto& [name, unused] : *root.value())
771 if (!expectedFields.contains(name))
772 return Result<Candidate>::failure(
773 Diagnostic::error(DiagnosticCode::ParseError, "unknown payload field", name));
774 if (root.value()->size() != expectedFields.size())
776 Diagnostic::error(DiagnosticCode::ParseError, "snapshot payload is incomplete", "payload"));
777
778 Candidate result;
779 auto status = intField(*root.value(), "status", "payload");
780 auto phase = intField(*root.value(), "phase", "payload");
783 if (status.value() < 0 || status.value() > static_cast<int>(BattleStatus::Ended) || phase.value() < 0 ||
784 phase.value() > static_cast<int>(BattlePhase::BattleEnd))
786 Diagnostic::error(DiagnosticCode::UnknownVersion, "snapshot contains an unknown battle enum", {}));
787 result.status = static_cast<BattleStatus>(status.value());
788 result.phase = static_cast<BattlePhase>(phase.value());
789 // Version 3 carries the stable policy id; versions 1-2 carried a numeric enum.
790 auto policyMember = field(*root.value(), "policy", "payload");
791 if (!policyMember)
793 if (sourceVersion >= SchemaVersion(3)) {
794 if (!policyMember.value()->isString() || policyMember.value()->asString().empty())
796 Diagnostic::error(DiagnosticCode::ParseError, "invalid battle policy", "payload.policy"));
797 result.policyId = policyMember.value()->asString();
798 } else {
799 auto legacy = intField(*root.value(), "policy", "payload");
800 if (!legacy)
802 auto migrated = migrateLegacyPolicyId(legacy.value());
803 if (!migrated) return Result<Candidate>::failure(migrated.status());
804 result.policyId = std::move(migrated).takeValue();
805 }
806 auto roundValue = field(*root.value(), "round", "payload");
807 auto cursorValue = field(*root.value(), "cursor", "payload");
808 if (!everyResultValid(roundValue, cursorValue))
810 auto round = decimal(*roundValue.value(), "payload.round");
811 auto cursor = decimal(*cursorValue.value(), "payload.cursor");
812 if (!everyResultValid(round, cursor) || cursor.value() > static_cast<std::uint64_t>(SIZE_MAX))
813 return Result<Candidate>::failure(Diagnostic::error(DiagnosticCode::ParseError, "invalid turn counters", {}));
814 result.round = round.value();
815 result.cursor = static_cast<std::size_t>(cursor.value());
816 auto seedMember = field(*root.value(), "seed", "payload");
817 if (!seedMember) return Result<Candidate>::failure(seedMember.status());
818 auto seed = decimal(*seedMember.value(), "payload.seed");
819 if (!seed) return Result<Candidate>::failure(seed.status());
820 result.seed = seed.value();
821
822 auto boardMember = field(*root.value(), "board", "payload");
823 if (!boardMember) return Result<Candidate>::failure(boardMember.status());
824 auto boardObject = object(*boardMember.value(), "payload.board");
825 // Version 1 boards carry topology + cells only; every later version also
826 // carries the directed edge set.
827 const bool hasEdges = sourceVersion != SchemaVersion(1);
828 const std::size_t expectedBoardFields = hasEdges ? 3u : 2u;
829 if (!boardObject || boardObject.value()->size() != expectedBoardFields)
831 Diagnostic::error(DiagnosticCode::ParseError, "invalid board object", "payload.board"));
832 auto topology = intField(*boardObject.value(), "topology", "payload.board");
833 auto cellsMember = field(*boardObject.value(), "cells", "payload.board");
834 if (!everyResultValid(topology, cellsMember) || topology.value() < 0 ||
835 topology.value() > (sourceVersion >= SchemaVersion(7) ? 3 : 2))
836 return Result<Candidate>::failure(
837 Diagnostic::error(DiagnosticCode::ParseError, "invalid board topology", "payload.board.topology"));
838 const auto* cells = cellsMember.value()->getIf<Value::Array>();
839 if (!cells)
841 Diagnostic::error(DiagnosticCode::ParseError, "board cells must be an array", "payload.board.cells"));
842 result.board.setTopology(static_cast<BoardTopology>(topology.value()));
843 for (std::size_t i = 0; i < cells->size(); ++i) {
844 const std::string path = "payload.board.cells[" + std::to_string(i) + "]";
845 auto record = object((*cells)[i], path);
846 if (!record || record.value()->size() != 5)
848 Diagnostic::error(DiagnosticCode::ParseError, "invalid board cell record", path));
849 auto cellMember = field(*record.value(), "cell", path);
850 auto moveCost = intField(*record.value(), "moveCost", path);
851 auto height = intField(*record.value(), "height", path);
852 auto passableMember = field(*record.value(), "passable", path);
853 auto tagsMember = field(*record.value(), "tags", path);
854 if (!everyResultValid(cellMember, moveCost, height, passableMember, tagsMember))
856 Diagnostic::error(DiagnosticCode::ParseError, "incomplete board cell record", path));
857 auto cell = parseCell(*cellMember.value(), path + ".cell");
858 const auto* passable = passableMember.value()->getIf<bool>();
859 const auto* tags = tagsMember.value()->getIf<Value::Array>();
860 if (!cell || !passable || !tags)
862 Diagnostic::error(DiagnosticCode::ParseError, "invalid board cell", path));
863 CellState state{moveCost.value(), height.value(), *passable, {}};
864 for (const auto& tag : *tags) {
865 const auto* text = tag.getIf<std::string>();
866 if (!text)
868 Diagnostic::error(DiagnosticCode::ParseError, "cell tag must be a string", path + ".tags"));
869 state.tags.push_back(*text);
870 }
871 auto added = result.board.addCell(cell.value(), std::move(state));
872 if (!added) return Result<Candidate>::failure(added.status());
873 }
874
875 // Edges are parsed after every cell so the board can validate adjacency itself
876 // instead of the parser re-deriving the topology rules.
877 if (hasEdges) {
878 auto edgesMember = field(*boardObject.value(), "edges", "payload.board");
879 const auto* edges = edgesMember ? edgesMember.value()->getIf<Value::Array>() : nullptr;
880 if (edges == nullptr)
882 Diagnostic::error(DiagnosticCode::ParseError, "board edges must be an array", "payload.board.edges"));
883 for (std::size_t i = 0; i < edges->size(); ++i) {
884 const std::string path = "payload.board.edges[" + std::to_string(i) + "]";
885 auto record = object((*edges)[i], path);
886 if (!record || record.value()->size() != 5)
888 Diagnostic::error(DiagnosticCode::ParseError, "invalid board edge record", path));
889 auto fromMember = field(*record.value(), "from", path);
890 auto toMember = field(*record.value(), "to", path);
891 auto passableMember = field(*record.value(), "passable", path);
892 auto extraCost = intField(*record.value(), "extraCost", path);
893 auto tagsMember = field(*record.value(), "tags", path);
894 if (!everyResultValid(fromMember, toMember, passableMember, extraCost, tagsMember))
896 Diagnostic::error(DiagnosticCode::ParseError, "incomplete board edge record", path));
897 auto from = parseCell(*fromMember.value(), path + ".from");
898 auto to = parseCell(*toMember.value(), path + ".to");
899 const auto* passable = passableMember.value()->getIf<bool>();
900 const auto* tags = tagsMember.value()->getIf<Value::Array>();
901 if (!everyResultValid(from, to) || !passable || !tags)
903 Diagnostic::error(DiagnosticCode::ParseError, "invalid board edge", path));
904 EdgeState state;
905 state.passable = *passable;
906 state.extraCost = extraCost.value();
907 for (const auto& tag : *tags) {
908 const auto* text = tag.getIf<std::string>();
909 if (!text)
911 Diagnostic::error(DiagnosticCode::ParseError, "edge tag must be a string", path + ".tags"));
912 state.tags.push_back(*text);
913 }
914 auto added = result.board.addEdge(from.value(), to.value(), std::move(state));
915 if (!added) return Result<Candidate>::failure(added.status());
916 }
917 }
918
919 std::map<std::string, TacticalSide*> currentSides;
920 for (const auto& handle : battle.turn()->sides) {
921 auto* side = resolve<TacticalSide>(handle);
922 if (!side)
924 Diagnostic::error(DiagnosticCode::StaleHandle, "target battle contains a stale side", {}));
925 currentSides.emplace(side->identity()->subject.format(), side);
926 }
927 auto sidesMember = field(*root.value(), "sides", "payload");
928 if (!sidesMember) return Result<Candidate>::failure(sidesMember.status());
929 const auto* sides = sidesMember.value()->getIf<Value::Array>();
930 if (!sides || sides->size() != currentSides.size())
932 DiagnosticCode::Conflict, "snapshot side set differs from target battle", "payload.sides"));
933 std::set<std::string> restoredSides;
934 for (std::size_t i = 0; i < sides->size(); ++i) {
935 auto side = subject((*sides)[i], "payload.sides[" + std::to_string(i) + "]");
936 if (!side) return Result<Candidate>::failure(side.status());
937 const auto found = currentSides.find(side.value().format());
938 if (found == currentSides.end() || !restoredSides.insert(found->first).second)
940 "snapshot side cannot resolve uniquely",
941 "payload.sides[" + std::to_string(i) + "]"));
942 result.sides.push_back(found->second->identity()->self);
943 }
944
945 std::map<std::string, TacticalUnit*> currentUnits;
946 for (const auto& handle : battle.turn()->units) {
947 auto* unit = resolve<TacticalUnit>(handle);
948 if (!unit)
950 Diagnostic::error(DiagnosticCode::StaleHandle, "target battle contains a stale unit", {}));
951 currentUnits.emplace(unit->identity()->subject.format(), unit);
952 }
953 auto unitsMember = field(*root.value(), "units", "payload");
954 if (!unitsMember) return Result<Candidate>::failure(unitsMember.status());
955 const auto* units = unitsMember.value()->getIf<Value::Array>();
956 if (!units || units->size() != currentUnits.size())
958 DiagnosticCode::Conflict, "snapshot unit set differs from target battle", "payload.units"));
959 std::set<std::string> restoredSubjects;
960 for (std::size_t i = 0; i < units->size(); ++i) {
961 const std::string path = "payload.units[" + std::to_string(i) + "]";
962 auto unitObject = object((*units)[i], path);
963 // Version 5 added the persisted scheduling charge.
964 const std::size_t expectedUnitFields = hasSchedule ? 16u : 15u;
965 if (!unitObject || unitObject.value()->size() != expectedUnitFields)
967 Diagnostic::error(DiagnosticCode::ParseError, "invalid unit record", path));
968 auto subjectMember = field(*unitObject.value(), "subject", path);
969 auto cellMember = field(*unitObject.value(), "cell", path);
970 auto definitionMember = field(*unitObject.value(), "definition", path);
971 auto sideMember = field(*unitObject.value(), "side", path);
972 if (!everyResultValid(subjectMember, cellMember, definitionMember, sideMember))
974 Diagnostic::error(DiagnosticCode::ParseError, "incomplete unit record", path));
975 auto subjectRef = subject(*subjectMember.value(), path + ".subject");
976 auto cell = parseCell(*cellMember.value(), path + ".cell");
977 auto facing = intField(*unitObject.value(), "facing", path);
978 const auto* definitionText = definitionMember.value()->getIf<std::string>();
979 auto sideRef = subject(*sideMember.value(), path + ".side");
980 if (!everyResultValid(subjectRef, cell, facing, sideRef) || !definitionText)
982 Diagnostic::error(DiagnosticCode::ParseError, "invalid unit identity or cell", path));
983 const auto found = currentUnits.find(subjectRef.value().format());
984 if (found == currentUnits.end() || !restoredSubjects.insert(found->first).second)
986 DiagnosticCode::Conflict, "snapshot unit cannot resolve uniquely", path + ".subject"));
987 const auto restoredSide = currentSides.find(sideRef.value().format());
988 if (restoredSide == currentSides.end())
990 Diagnostic::error(DiagnosticCode::Conflict, "snapshot unit side is absent", path + ".side"));
991 LogicalId definition;
992 if (!definitionText->empty()) {
993 const auto parsedDefinition = LogicalId::parse(*definitionText);
994 if (!parsedDefinition)
996 Diagnostic::error(DiagnosticCode::ParseError, "invalid unit definition", path + ".definition"));
997 definition = *parsedDefinition;
998 }
999 TacticalUnit::TurnResources turn;
1000 for (const auto& [name, target] :
1001 {std::pair{"actionPoints", &turn.actionPoints}, {"movePoints", &turn.movePoints},
1002 {"reactionPoints", &turn.reactionPoints}, {"roundActionPoints", &turn.roundActionPoints},
1003 {"roundMovePoints", &turn.roundMovePoints},
1004 {"roundReactionPoints", &turn.roundReactionPoints}, {"initiative", &turn.initiative}}) {
1005 auto parsed = intField(*unitObject.value(), name, path);
1006 if (!parsed) return Result<Candidate>::failure(parsed.status());
1007 *target = parsed.value();
1008 }
1009 if (hasSchedule) {
1010 auto charge = intField(*unitObject.value(), "charge", path);
1011 if (!charge) return Result<Candidate>::failure(charge.status());
1012 // Charge is a non-negative accumulator: a negative value could never have
1013 // been produced by the round machine, so it is a corrupted payload.
1014 if (charge.value() < 0)
1016 Diagnostic::error(DiagnosticCode::InvariantViolation, "unit charge is negative", path + ".charge"));
1017 turn.charge = charge.value();
1018 }
1019 auto aliveMember = field(*unitObject.value(), "alive", path);
1020 auto actedMember = field(*unitObject.value(), "acted", path);
1021 auto placedMember = field(*unitObject.value(), "placed", path);
1022 const bool* alive = aliveMember ? aliveMember.value()->getIf<bool>() : nullptr;
1023 const bool* acted = actedMember ? actedMember.value()->getIf<bool>() : nullptr;
1024 const bool* placed = placedMember ? placedMember.value()->getIf<bool>() : nullptr;
1025 if (!alive || !acted || !placed)
1027 Diagnostic::error(DiagnosticCode::ParseError, "invalid unit flags", path));
1028 turn.alive = *alive;
1029 turn.acted = *acted;
1030 if (*alive != *placed)
1032 DiagnosticCode::InvariantViolation, "v1 tactics units must be placed exactly while alive", path));
1033 if (*placed) {
1034 auto placement = result.board.place(subjectRef.value(), cell.value());
1035 if (!placement) return Result<Candidate>::failure(placement.status());
1036 }
1037 const int facingCount = result.board.topology() == BoardTopology::Square4 ? 4
1038 : result.board.topology() == BoardTopology::Square8 ? 8
1039 : 6;
1040 if (facing.value() < 0 || facing.value() >= facingCount ||
1041 (result.board.topology() == BoardTopology::ExplicitGraph && facing.value() != 0))
1043 Diagnostic::error(DiagnosticCode::InvariantViolation, "unit facing is invalid", path + ".facing"));
1044 result.units.push_back(
1045 {found->second, restoredSide->second->identity()->self, definition, cell.value(), facing.value(),
1046 *placed, turn});
1047 }
1048 auto activeMember = field(*root.value(), "activeUnit", "payload");
1049 if (!activeMember) return Result<Candidate>::failure(activeMember.status());
1050 auto active = subject(*activeMember.value(), "payload.activeUnit", true);
1051 if (!active) return Result<Candidate>::failure(active.status());
1052 if (active.value().isValid()) {
1053 const auto found = currentUnits.find(active.value().format());
1054 if (found == currentUnits.end())
1055 return Result<Candidate>::failure(Diagnostic::error(DiagnosticCode::Conflict, "active unit is absent", {}));
1056 result.activeUnit = found->second->identity()->self;
1057 result.activeSide = found->second->membership()->side;
1058 }
1059 if (result.cursor >= result.units.size() && !result.units.empty())
1061 Diagnostic::error(DiagnosticCode::InvariantViolation, "turn cursor is outside unit schedule", {}));
1062 // The activation queue. Version 5 stores it explicitly; versions 1-4 used the
1063 // unit roster itself as the queue, so the migration keeps that roster order and
1064 // the persisted cursor keeps indexing exactly what it used to.
1065 if (hasSchedule) {
1066 auto scheduleMember = field(*root.value(), "schedule", "payload");
1067 if (!scheduleMember) return Result<Candidate>::failure(scheduleMember.status());
1068 const auto* values = scheduleMember.value()->getIf<Value::Array>();
1069 if (values == nullptr)
1071 Diagnostic::error(DiagnosticCode::ParseError, "round schedule must be an array", "payload.schedule"));
1072 std::set<std::string> scheduled;
1073 for (std::size_t i = 0; i < values->size(); ++i) {
1074 const std::string path = "payload.schedule[" + std::to_string(i) + "]";
1075 auto entry = subject((*values)[i], path);
1076 if (!entry) return Result<Candidate>::failure(entry.status());
1077 const auto found = currentUnits.find(entry.value().format());
1078 if (found == currentUnits.end())
1080 DiagnosticCode::Conflict, "scheduled unit is absent from the target battle", path));
1081 if (!scheduled.insert(found->first).second)
1083 Diagnostic::error(DiagnosticCode::Conflict, "round schedule repeats a unit", path));
1084 result.schedule.push_back(found->second->identity()->self);
1085 }
1086 const bool activationPhase = result.phase == BattlePhase::TurnStart || result.phase == BattlePhase::Acting ||
1087 result.phase == BattlePhase::TurnEnd;
1088 if (activationPhase && result.cursor >= result.schedule.size())
1090 DiagnosticCode::InvariantViolation, "turn cursor is outside the round schedule", "payload.cursor"));
1091 } else {
1092 for (const auto& unit : result.units) result.schedule.push_back(unit.unit->identity()->self);
1093 }
1094 auto eventsMember = field(*root.value(), "events", "payload");
1095 auto reactionsMember = field(*root.value(), "reactions", "payload");
1096 auto commandsMember = field(*root.value(), "commands", "payload");
1097 auto objectivesMember = field(*root.value(), "objectives", "payload");
1098 auto randomMember = field(*root.value(), "random", "payload");
1099 if (!everyResultValid(eventsMember, reactionsMember, commandsMember, objectivesMember, randomMember))
1101 Diagnostic::error(DiagnosticCode::ParseError, "snapshot is missing deterministic streams", {}));
1102 auto events = parseEvents(*eventsMember.value());
1103 auto reactions = parseReactions(*reactionsMember.value(), result.phase);
1104 CommandFieldPolicy commandFields;
1105 commandFields.policyIdStrings = sourceVersion >= SchemaVersion(3);
1106 commandFields.allowUseAbility = sourceVersion >= SchemaVersion(4);
1107 commandFields.abilityTargetFields = sourceVersion >= SchemaVersion(6);
1108 auto commands = parseCommands(*commandsMember.value(), snapshotRevision, commandFields);
1109 auto objectives = parseObjectives(battle, result.board, *objectivesMember.value(), snapshotRevision);
1110 auto random = parseRandom(*randomMember.value());
1111 // Observe every parsed stream *before* returning the first failure. A
1112 // short-circuiting guard chain would leave the later Results unobserved, and an
1113 // unobserved Result asserts when destroyed; two such destructors running during
1114 // the same unwinding would terminate the process instead of reporting failure.
1115 const bool eventsOk = events.ok();
1116 const bool reactionsOk = reactions.ok();
1117 const bool commandsOk = commands.ok();
1118 const bool objectivesOk = objectives.ok();
1119 const bool randomOk = random.ok();
1120 if (!eventsOk) return Result<Candidate>::failure(events.status());
1121 if (!reactionsOk) return Result<Candidate>::failure(reactions.status());
1122 if (!commandsOk) return Result<Candidate>::failure(commands.status());
1123 if (!objectivesOk) return Result<Candidate>::failure(objectives.status());
1124 if (!randomOk) return Result<Candidate>::failure(random.status());
1125 result.events = std::move(events).takeValue();
1126 result.reactions = std::move(reactions).takeValue();
1127 result.commands = std::move(commands).takeValue();
1128 result.objectives = std::move(objectives).takeValue();
1129 result.random = std::move(random).takeValue();
1130 std::set<std::uint64_t> commandSequences;
1131 std::map<std::string, std::uint64_t> randomCommandCounts;
1132 for (const auto& command : result.commands.values) {
1133 commandSequences.insert(command.sequence);
1134 if (command.kind == BattleCommandKind::RollRandom) ++randomCommandCounts[command.action.format()];
1135 }
1136 for (const auto& [stream, state] : result.random.streams)
1137 if (randomCommandCounts[stream] != state.rollIndex)
1138 return Result<Candidate>::failure(Diagnostic::error(
1139 DiagnosticCode::InvariantViolation, "random stream roll index differs from accepted command log",
1140 "payload.random." + stream));
1141 for (const auto& event : result.events.values) {
1142 if (!commandSequences.contains(event.causationCommand) ||
1143 !commandSequences.contains(event.correlationCommand))
1146 "event causation/correlation references an absent command", "payload.events"));
1147 }
1148 std::set<std::uint64_t> eventSequences;
1149 for (const auto& event : result.events.values) eventSequences.insert(event.sequence);
1150 for (const auto& window : result.reactions.stack) {
1151 if (!eventSequences.contains(window.triggerSequence))
1153 "reaction window references an absent trigger event",
1154 "payload.reactions.stack"));
1155 for (const auto& reactionCandidate : window.candidates)
1156 if (!currentUnits.contains(reactionCandidate.reactor.format()))
1157 return Result<Candidate>::failure(
1158 Diagnostic::error(DiagnosticCode::Conflict, "reaction candidate is absent from the target battle",
1159 "payload.reactions.stack"));
1160 }
1161 auto valid = result.board.validateInvariants();
1162 if (!valid) return Result<Candidate>::failure(valid.status());
1163 return Result<Candidate>::success(std::move(result));
1164}
1165
1166} // namespace
1167
1170 for (const auto& record : battle.board()->value.records()) {
1171 Value::Array tags;
1172 for (const auto& tag : record.state.tags) tags.emplace_back(tag);
1173 cells.emplace_back(Value::Object{{"cell", cellValue(record.cell)},
1174 {"height", Value(record.state.height)},
1175 {"moveCost", Value(record.state.moveCost)},
1176 {"passable", Value(record.state.passable)},
1177 {"tags", Value(std::move(tags))}});
1178 }
1180 for (const auto& record : battle.board()->value.edgeRecords()) {
1181 Value::Array tags;
1182 for (const auto& tag : record.state.tags) tags.emplace_back(tag);
1183 edges.emplace_back(Value::Object{{"extraCost", Value(record.state.extraCost)},
1184 {"from", cellValue(record.from)},
1185 {"passable", Value(record.state.passable)},
1186 {"tags", Value(std::move(tags))},
1187 {"to", cellValue(record.to)}});
1188 }
1190 for (const auto& handle : battle.turn()->units) {
1191 auto* unit = resolve<TacticalUnit>(handle);
1192 if (!unit)
1194 Diagnostic::error(DiagnosticCode::StaleHandle, "battle contains a stale unit", {}));
1195 auto encoded = unitValue(*unit);
1196 if (!encoded) return Result<SnapshotEnvelope>::failure(encoded.status());
1197 units.push_back(std::move(encoded).takeValue());
1198 }
1200 for (const auto& handle : battle.turn()->sides) {
1201 auto* side = resolve<TacticalSide>(handle);
1202 if (!side)
1204 Diagnostic::error(DiagnosticCode::StaleHandle, "battle contains a stale side", {}));
1205 sides.push_back(subjectValue(side->identity()->subject));
1206 }
1208 if (battle.turn()->activeUnit) {
1209 auto* unit = resolve<TacticalUnit>(*battle.turn()->activeUnit);
1210 if (!unit)
1212 Diagnostic::error(DiagnosticCode::StaleHandle, "battle active unit is stale", {}));
1213 active = unit->identity()->subject;
1214 }
1216 for (const auto& event : battle.events()->values) events.push_back(eventValue(event));
1217 Value::Array reactionStack;
1218 for (const auto& window : battle.reactions()->stack) reactionStack.push_back(windowValue(window));
1219 Value::Array reactionSeen;
1220 for (const auto& key : battle.reactions()->seen) reactionSeen.emplace_back(key);
1222 for (const auto& command : battle.commands()->values) commands.push_back(commandValue(command));
1224 for (const auto& objective : battle.objectives()->values) objectives.push_back(objectiveValue(objective));
1225 // The round's activation queue cannot be recomputed after a restore: the charge
1226 // that produced it has already been spent, so a suspended round has to carry it.
1228 for (const auto& handle : battle.turn()->schedule) {
1229 auto* unit = resolve<TacticalUnit>(handle);
1230 if (!unit)
1232 Diagnostic::error(DiagnosticCode::StaleHandle, "battle schedule is stale", {}));
1233 schedule.push_back(subjectValue(unit->identity()->subject));
1234 }
1236 {"activeUnit", subjectValue(active)},
1237 {"board", Value(Value::Object{{"cells", Value(std::move(cells))},
1238 {"edges", Value(std::move(edges))},
1239 {"topology", Value(static_cast<int>(battle.board()->value.topology()))}})},
1240 {"cursor", Value(std::to_string(battle.turn()->cursor))},
1241 {"commands", Value(Value::Object{{"nextSequence", Value(std::to_string(battle.commands()->nextSequence))},
1242 {"values", Value(std::move(commands))}})},
1243 {"events", Value(Value::Object{{"nextSequence", Value(std::to_string(battle.events()->nextSequence))},
1244 {"values", Value(std::move(events))}})},
1245 {"objectives", Value(std::move(objectives))},
1246 {"phase", Value(static_cast<int>(battle.turn()->phase))},
1247 {"policy", Value(battle.turn()->policyId)},
1248 {"random", randomValue(*battle.random())},
1249 {"reactions", Value(Value::Object{
1250 {"maxDepth", Value(std::to_string(battle.reactions()->maxDepth))},
1251 {"seen", Value(std::move(reactionSeen))},
1252 {"stack", Value(std::move(reactionStack))}})},
1253 {"round", Value(std::to_string(battle.turn()->round))},
1254 {"schedule", Value(std::move(schedule))},
1255 {"seed", Value(std::to_string(battle.identity()->seed))},
1256 {"sides", Value(std::move(sides))},
1257 {"status", Value(static_cast<int>(battle.turn()->status))},
1258 {"units", Value(std::move(units))},
1259 });
1260 const auto version = battle.board()->value.topology() == BoardTopology::ExplicitGraph ? 7u : 6u;
1261 return makeSnapshotEnvelope(std::string(kType), schema(), SchemaVersion(version),
1262 battle.identity()->subject.persistentId(), battle.turn()->revision, battle.turn()->tick,
1263 std::move(payload), hashProvider);
1264}
1265
1268 for (const BattleCommand& command : battle.commands()->values) {
1269 if (command.resultingRevision > fromRevision) values.push_back(commandValue(command));
1270 }
1271 // The sequence counter is part of the value: replay refuses a log whose next sequence does
1272 // not match the target battle, and that check is only meaningful if the counter travels
1273 // with the commands.
1275 {"nextSequence", Value(std::to_string(battle.commands()->nextSequence))},
1276 {"values", Value(std::move(values))},
1277 }));
1278}
1279
1281 CommandFieldPolicy policy;
1282 policy.policyIdStrings = true;
1283 policy.allowUseAbility = true;
1284 policy.abilityTargetFields = true;
1285 // A replay log is anchored at the revision it will be replayed onto, not at its own end.
1286 policy.boundary = CommandLogBoundary::StartsAtRevision;
1287 return parseCommands(value, snapshotRevision, policy);
1288}
1289
1291 const SnapshotHashProvider& hashProvider) {
1292 if (source.type != kType || source.schema != schema())
1293 return Result<void>::failure(
1294 Diagnostic::error(DiagnosticCode::InvalidArgument, "snapshot does not belong to tactics battle", {}));
1295 if (source.instanceId != battle.identity()->subject.persistentId())
1296 return Result<void>::failure(
1297 Diagnostic::error(DiagnosticCode::Conflict, "snapshot battle identity differs from target", {}));
1298 auto verified = verifySnapshotEnvelope(source, hashProvider);
1299 if (!verified) return Result<void>::failure(verified.status());
1300 // V1 predates board edges; V2 predates stable policy ids; V3 predates the
1301 // UseAbility command kind; V4 predates persisted scheduling charge and the
1302 // explicit round schedule; V5 predates the ability target unit and payload. Each is
1303 // migrated by the documented default for the field that did not exist yet, so this
1304 // list only ever grows.
1305 if (source.schemaVersion != SchemaVersion(1) && source.schemaVersion != SchemaVersion(2) &&
1306 source.schemaVersion != SchemaVersion(3) && source.schemaVersion != SchemaVersion(4) &&
1307 source.schemaVersion != SchemaVersion(5) && source.schemaVersion != SchemaVersion(6) &&
1308 source.schemaVersion != SchemaVersion(7))
1309 return Result<void>::failure(
1310 Diagnostic::error(DiagnosticCode::UnknownVersion, "unsupported tactics battle snapshot version", {}));
1311 auto metadata = validateSnapshotPayloadMetadata(source.payload, source.revision, source.tick);
1312 if (!metadata) return Result<void>::failure(metadata.status());
1313 auto candidate = parseCandidate(battle, source.payload, source.revision, source.schemaVersion);
1314 if (!candidate) return Result<void>::failure(candidate.status());
1315
1316 Candidate restored = std::move(candidate).takeValue();
1317 battle.board()->value = std::move(restored.board);
1318 battle.identity()->seed = restored.seed;
1319 for (auto& unit : restored.units) {
1320 unit.unit->identity()->definition = unit.definition;
1321 unit.unit->membership()->side = unit.side;
1322 *unit.unit->turn() = unit.turn;
1323 unit.unit->position()->cell = unit.cell;
1324 unit.unit->position()->facing = unit.facing;
1325 unit.unit->position()->placed = unit.placed;
1326 }
1327 auto turn = battle.turn();
1328 turn->status = restored.status;
1329 turn->phase = restored.phase;
1330 turn->policyId = std::move(restored.policyId);
1331 turn->tick = source.tick;
1332 turn->revision = source.revision;
1333 turn->round = restored.round;
1334 turn->cursor = restored.cursor;
1335 turn->activeUnit = restored.activeUnit;
1336 turn->activeSide = restored.activeSide;
1337 turn->sides = std::move(restored.sides);
1338 turn->schedule = std::move(restored.schedule);
1339 *battle.events() = std::move(restored.events);
1340 *battle.reactions() = std::move(restored.reactions);
1341 *battle.commands() = std::move(restored.commands);
1342 *battle.objectives() = std::move(restored.objectives);
1343 *battle.random() = std::move(restored.random);
1345}
1346
1347} // namespace eve::tactics
LogicalId target
double value
Value::Object payload
bool & active
int root
Definition AnimSmr.cpp:119
int subject
Definition AnimSmr.cpp:163
std::string from
int priority
float phase
Definition CaveMesh.cpp:58
std::map< std::string, Var > values
std::uint32_t alive
std::uint32_t key
wgpu::PopErrorScopeStatus status
glm::uvec4 ids
std::int32_t second
HexCoordinates to
Cell the unit walks towards on this segment.
Definition HexUnits.cpp:64
std::uint32_t height
bool scheduled
std::string text
TokenKind kind
std::string name
bool valid
ModuleFacing facing
const std::string * tag
graphics::Canvas * previous
std::unique_ptr< gpgpu::Sequence > sequence
Definition OnnxGpgpu.cpp:43
std::string error
Definition Package.cpp:60
Topology topology
std::array< PixelCell, kPixelChunkSize *kPixelChunkSize > cells
std::string action
Definition PlayHost.cpp:117
std::string path
Definition PlayHost.cpp:110
std::vector< std::string > fields
Definition PlayHost.cpp:111
std::string id
Definition PlayHost.cpp:108
std::uint32_t seed
Definition PointSet.cpp:807
PrimitiveHandle handle
bool found
std::string string
Cell cell
std::vector< ecs::EntityHandle > schedule
The round's activation queue, resolved against the target battle.
std::optional< ecs::EntityHandle > activeSide
TacticalUnit * unit
bool placed
Battle::Events events
Battle::Reactions reactions
std::vector< UnitCandidate > units
CommandLogBoundary boundary
Anchor rule, see above.
TacticalUnit::TurnResources turn
SimulationTick tick
std::size_t cursor
Battle::Random random
std::string policyId
bool allowUseAbility
v4+: the UseAbility command kind exists.
Battle::Objectives objectives
bool abilityTargetFields
v6+: commands carry targetUnit and payload.
std::uint64_t round
bool policyIdStrings
v3+: stable policy id string instead of the legacy numeric enum.
ecs::EntityHandle side
std::optional< ecs::EntityHandle > activeUnit
BoardState board
Versioned battle snapshots with transactional restore.
Json object
int sides
Definition TreeMesh.cpp:322
float(ui::Theme::* member)[4]
const UnitySourceAsset & source
std::uint32_t depth
std::vector< int > edges
std::vector< VegetationPresetCommand > commands
static Diagnostic error(DiagnosticCode code, std::string message, std::string path={}, DiagnosticDetails details={}, std::string source={})
Construct an error diagnostic with the standard error severity.
Definition Diagnostic.h:125
static constexpr Duration fromNanoseconds(std::int64_t nanoseconds) noexcept
Construct an exact duration from nanoseconds.
Definition Time.h:55
static std::optional< LogicalId > parse(std::string_view text)
Parses a scoped logical name.
Definition Identity.cpp:36
Move-only operation result carrying either a value or Status.
Definition Result.h:155
static Result success(T value)
Construct a successful result owning value.
Definition Result.h:164
const T & value() const &
Borrow the value from a const lvalue after checking success.
Definition Result.h:308
static Result failure(Status status)
Construct a failed result from a structured status.
Definition Result.h:175
static Status success(StatusCode code=StatusCode::Ok)
Construct a successful status with an explicit non-error outcome.
Definition Status.h:81
Strong, domain-neutral reference to a runtime subject.
Definition SubjectRef.h:26
static SubjectRef fromPersistentId(PersistentId id) noexcept
Wrap a persistent identity without changing its bytes.
Definition SubjectRef.h:32
static SubjectRef nil() noexcept
Return the invalid nil reference.
Definition SubjectRef.h:35
The canonical owning dynamic value used by data-facing protocols.
Definition Value.h:31
std::map< std::string, Value > Object
Definition Value.h:34
std::vector< Value > Array
Definition Value.h:33
static std::optional< Id128 > parse(std::string_view text) noexcept
Parses canonical UUID text.
Definition Identity.h:151
constexpr std::uint64_t value() const noexcept
Returns the underlying value at an explicit protocol boundary.
Battle short-root and authoritative owner of board and turn state.
bool contains(Cell cell) const noexcept
Return whether the logical cell exists.
static Result< Battle::Commands > parseCommandLog(const Value &value, Revision snapshotRevision)
Parse a command log written by commandLogValue.
static Result< void > restore(Battle &battle, const SnapshotEnvelope &snapshot, const SnapshotHashProvider &hashProvider)
Restore a verified compatible snapshot without partial mutation on failure.
static Result< SnapshotEnvelope > snapshot(Battle &battle, const SnapshotHashProvider &hashProvider)
Capture a version-one, integrity-sealed battle snapshot.
static Result< Value > commandLogValue(Battle &battle, Revision fromRevision)
Serialize the accepted command log after fromRevision in its persisted shape.
std::variant< std::monostate, std::int64_t, double, std::string, bool > Value
Definition Database.h:26
eve::Diagnostic Diagnostic
eve::Result< T > Result
@ Cell
A cell was removed; the out-parameter holds it.
std::int32_t moveCost(const HexMap &map, HexCoordinates from, HexCoordinates to, HexDirection direction, const HexOccupancyQuery &occupied)
Cost of moving between two adjacent cells.
constexpr HexDirection next(HexDirection d) noexcept
The next direction clockwise (NW wraps to NE).
Definition HexMetrics.h:76
const EditorValue * field(const EditorValue &value, const char *name)
constexpr std::string_view kInitiativePolicyId
Stable id of the built-in per-unit initiative turn policy.
ObjectiveStatus
Objective lifecycle owned by its battle.
constexpr std::string_view kSideAlternatingPolicyId
Stable id of the built-in side-alternating turn policy.
BattleStatus
Tactical battle lifecycle.
BattleCommandKind
Accepted deterministic command kinds recorded for replay.
@ UseAbility
Declare a unit ability activation against a target cell.
ObjectiveKind
Built-in deterministic objective rules.
BattlePhase
Explicit, externally observable battle phase.
constexpr std::size_t kMaxAbilityPayloadBytes
Upper bound on the opaque ability payload a declaration may carry.
BoardTopology
Supported deterministic board-neighbour topologies.
int64_t integer(const RuntimeTensor &v, size_t i=0)
Integer.
WidgetDesc window(std::string title, std::vector< WidgetDesc > children, std::string id)
Top-level window widget with a title bar.
Definition Widget.cpp:292
DiagnosticCode
Stable machine-readable diagnostic codes.
Definition Diagnostic.h:47
Result< void > validateSnapshotPayloadMetadata(const Value &payload, Revision revision, SimulationTick tick)
Validate optional payload copies of envelope revision and tick.
Definition Snapshot.cpp:80
detail::StrongUint64< detail::SchemaVersionTag > SchemaVersion
Persistent data-format version; not a runtime replacement generation.
bool everyResultValid(const Results &... results) noexcept
True only when every argument Result succeeded, observing all of them.
Definition Result.h:586
detail::StrongUint64< detail::SimulationTickTag > SimulationTick
Deterministic simulation time step; it is not wall-clock time.
Definition Time.h:31
Result< SnapshotEnvelope > makeSnapshotEnvelope(std::string type, LogicalId schema, SchemaVersion schemaVersion, PersistentId instanceId, Revision revision, SimulationTick tick, Value payload, const SnapshotHashProvider &hashProvider)
Construct and seal a snapshot envelope.
Definition Snapshot.cpp:103
detail::StrongUint64< detail::RevisionTag > Revision
Monotonic content/state revision used for optimistic-concurrency checks.
Definition Revision.h:13
Result< void > verifySnapshotEnvelope(const SnapshotEnvelope &snapshot, const SnapshotHashProvider &hashProvider)
Verify an envelope's content hash without modifying it.
Definition Snapshot.cpp:63
std::function< Result< ContentId >(std::string_view canonicalInput)> SnapshotHashProvider
Injected content-digest implementation used by snapshots.
Definition Snapshot.h:36
Stable outer format shared by persistence and cross-process snapshots.
Definition Snapshot.h:46
One accepted replay command using only persistent identities and values.
int charge
Accumulated scheduling charge for charge-time policies.