载入中...
搜索中...
未找到
Tactics.cpp
浏览该文件的文档.
1#include "tactics/Tactics.h"
2
8#include "common/Capability.h"
9
10#include <simplesquirrel/simplesquirrel.hpp>
11
12#include <algorithm>
13#include <functional>
14#include <memory>
15#include <limits>
16#include <utility>
17
18namespace eve::tactics {
19namespace {
20
21struct ScriptTacticsBattle {
22 explicit ScriptTacticsBattle(TacticsBattleSessionRef value) : reference(value) {}
23 ~ScriptTacticsBattle() noexcept {
24 Tactics::release(reference).ignore("script tactics battle proxy destruction");
25 }
27};
28
37struct ScriptTacticsInteraction {
39 std::optional<InteractionSession> session;
40};
41
42Result<SubjectRef> bindingSubject(const std::string& text, std::string path) {
43 const auto id = PersistentId::parse(text);
44 if (!id || id->isNil())
46 Diagnostic::error(DiagnosticCode::InvalidArgument, "expected a non-nil canonical UUID", path));
48}
49
56Result<SubjectRef> bindingOptionalSubject(const std::string& text, std::string path) {
57 if (text.empty()) return Result<SubjectRef>::success(SubjectRef{});
58 return bindingSubject(text, std::move(path));
59}
60
61Result<LogicalId> bindingLogicalId(const std::string& text, std::string path) {
62 const auto id = LogicalId::parse(text);
63 if (!id)
65 "expected a namespace:name logical ID", path));
67}
68
69Result<std::vector<Cell>> bindingCellsJson(const std::string& json) {
70 auto value = Value::fromJson(json);
71 if (!value) return Result<std::vector<Cell>>::failure(value.status());
72 const auto* rows = value.value().getIf<Value::Array>();
73 if (!rows || rows->empty())
74 return Result<std::vector<Cell>>::failure(Diagnostic::error(
75 DiagnosticCode::InvalidArgument, "objective cells JSON must be a non-empty array", "cellsJson"));
76 std::vector<Cell> result;
77 result.reserve(rows->size());
78 for (std::size_t index = 0; index < rows->size(); ++index) {
79 const auto* tuple = (*rows)[index].getIf<Value::Array>();
80 if (!tuple || tuple->size() != 3 || !(*tuple)[0].isInt64() || !(*tuple)[1].isInt64() ||
81 !(*tuple)[2].isInt64())
82 return Result<std::vector<Cell>>::failure(Diagnostic::error(DiagnosticCode::InvalidArgument,
83 "objective cell must be [x,y,layer] integers",
84 "cellsJson[" + std::to_string(index) + "]"));
85 const auto convert = [&](std::size_t coordinate) -> Result<int> {
86 const auto value = (*tuple)[coordinate].asInt();
87 if (value < std::numeric_limits<int>::min() || value > std::numeric_limits<int>::max())
89 "objective cell coordinate is out of range",
90 "cellsJson[" + std::to_string(index) + "]"));
91 return Result<int>::success(static_cast<int>(value));
92 };
93 auto x = convert(0);
94 auto y = convert(1);
95 auto layer = convert(2);
96 if (!x) return Result<std::vector<Cell>>::failure(x.status());
97 if (!y) return Result<std::vector<Cell>>::failure(y.status());
98 if (!layer) return Result<std::vector<Cell>>::failure(layer.status());
99 result.push_back({x.value(), y.value(), layer.value()});
100 }
101 return Result<std::vector<Cell>>::success(std::move(result));
102}
103
111Result<std::vector<ReactionCandidate>> bindingReactionCandidatesJson(const std::string& json) {
112 auto value = Value::fromJson(json);
113 if (!value) return Result<std::vector<ReactionCandidate>>::failure(value.status());
114 const auto* rows = value.value().getIf<Value::Array>();
115 if (!rows)
116 return Result<std::vector<ReactionCandidate>>::failure(Diagnostic::error(
117 DiagnosticCode::InvalidArgument, "reaction candidates JSON must be an array", "candidatesJson"));
118 std::vector<ReactionCandidate> result;
119 result.reserve(rows->size());
120 for (std::size_t index = 0; index < rows->size(); ++index) {
121 const std::string element = "candidatesJson[" + std::to_string(index) + "]";
122 const auto* object = (*rows)[index].getIf<Value::Object>();
123 if (object == nullptr)
124 return Result<std::vector<ReactionCandidate>>::failure(
125 Diagnostic::error(DiagnosticCode::InvalidArgument, "reaction candidate must be an object", element));
126 const auto field = [&](std::string_view name) -> const Value* {
127 const auto found = object->find(std::string(name));
128 return found == object->end() ? nullptr : &found->second;
129 };
130 const Value* reactorText = field("reactor");
131 const Value* actionText = field("action");
132 if (reactorText == nullptr || !reactorText->isString() || actionText == nullptr || !actionText->isString())
133 return Result<std::vector<ReactionCandidate>>::failure(Diagnostic::error(
134 DiagnosticCode::InvalidArgument, "reaction candidate requires string reactor and action", element));
135 auto reactor = bindingSubject(reactorText->asString(), element + ".reactor");
136 if (!reactor) return Result<std::vector<ReactionCandidate>>::failure(reactor.status());
137 auto action = bindingLogicalId(actionText->asString(), element + ".action");
138 if (!action) return Result<std::vector<ReactionCandidate>>::failure(action.status());
139 const auto integerField = [&](std::string_view name) -> Result<int> {
140 const Value* raw = field(name);
141 if (raw == nullptr) return Result<int>::success(0);
142 if (!raw->isInt64())
144 "reaction candidate field must be an integer",
145 element + "." + std::string(name)));
146 const auto number = raw->asInt();
147 if (number < std::numeric_limits<int>::min() || number > std::numeric_limits<int>::max())
149 "reaction candidate field is out of range",
150 element + "." + std::string(name)));
151 return Result<int>::success(static_cast<int>(number));
152 };
153 auto priority = integerField("priority");
154 if (!priority) return Result<std::vector<ReactionCandidate>>::failure(priority.status());
155 auto initiative = integerField("initiative");
156 if (!initiative) return Result<std::vector<ReactionCandidate>>::failure(initiative.status());
157
158 ReactionCandidate candidate;
159 candidate.reactor = reactor.value();
160 candidate.action = action.value();
161 candidate.priority = priority.value();
162 candidate.initiative = initiative.value();
163 result.push_back(std::move(candidate));
164 }
165 return Result<std::vector<ReactionCandidate>>::success(std::move(result));
166}
167
168Tactics* currentModule() noexcept { return ModuleManager::getInstance<Tactics>("Tactics"); }
169
170Result<TacticsBattleSession*> bindingSession(ScriptTacticsBattle* proxy) {
171 if (!proxy)
173 Diagnostic::error(DiagnosticCode::InvalidArgument, "tactics battle proxy must not be null", "battle"));
174 auto session = Tactics::resolve(proxy->reference);
175 if (!session.isBound())
177 Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle session handle is stale", "battle"));
179}
180
181Value eventProjection(const BattleEvent& event) {
182 return Value(Value::Object{{"causationCommand", Value(std::to_string(event.causationCommand))},
183 {"correlationCommand", Value(std::to_string(event.correlationCommand))},
184 {"from", Value(std::string(phaseName(event.from)))},
185 {"sequence", Value(std::to_string(event.sequence))},
186 {"subject", Value(event.subject.isValid() ? event.subject.format() : std::string{})},
187 {"tick", Value(std::to_string(event.tick.value()))},
188 {"to", Value(std::string(phaseName(event.to)))},
189 {"type", Value(event.type)}});
190}
191
192Value bindingCellValue(Cell cell) {
193 return Value(Value::Object{{"layer", Value(cell.layer)}, {"x", Value(cell.x)}, {"y", Value(cell.y)}});
194}
195
196Value bindingCellArrayValue(const std::vector<Cell>& cells) {
197 Value::Array array;
198 array.reserve(cells.size());
199 for (const Cell cell : cells) array.push_back(bindingCellValue(cell));
200 return Value(std::move(array));
201}
202
204Value bindingInteractionIntentValue(const InteractionIntent& intent) {
205 return Value(Value::Object{{"action", Value(intent.action.isValid() ? intent.action.format() : std::string{})},
206 {"actor", Value(intent.actor.isValid() ? intent.actor.format() : std::string{})},
207 {"cell", bindingCellValue(intent.cell)},
208 {"expectedRevision", Value(std::to_string(intent.expected.value()))},
209 {"kind", Value(std::string(interactionIntentKindName(intent.kind)))},
210 {"targetUnit",
211 Value(intent.targetUnit.isValid() ? intent.targetUnit.format() : std::string{})}});
212}
213
215Value bindingMoveReceiptValue(const MoveReceipt& receipt) {
217 path.reserve(receipt.path.size());
218 for (const Cell cell : receipt.path) path.push_back(bindingCellValue(cell));
219 return Value(Value::Object{{"cost", Value(receipt.cost)},
220 {"path", Value(std::move(path))},
221 {"remainingMovePoints", Value(receipt.remainingMovePoints)}});
222}
223
224Value bindingReachabilityValue(const Reachability& reachability) {
226 cells.reserve(reachability.cells().size());
227 for (const ReachableCell& entry : reachability.cells()) {
228 cells.push_back(Value(Value::Object{{"cell", bindingCellValue(entry.cell)}, {"cost", Value(entry.cost)}}));
229 }
230 return Value(Value::Object{{"cells", Value(std::move(cells))}});
231}
232
233Value bindingTurnResourcesValue(const TacticalUnit::TurnResources& resources) {
234 return Value(Value::Object{{"actionPoints", Value(resources.actionPoints)},
235 {"acted", Value(resources.acted)},
236 {"alive", Value(resources.alive)},
237 {"charge", Value(resources.charge)},
238 {"initiative", Value(resources.initiative)},
239 {"movePoints", Value(resources.movePoints)},
240 {"reactionPoints", Value(resources.reactionPoints)},
241 {"roundActionPoints", Value(resources.roundActionPoints)},
242 {"roundMovePoints", Value(resources.roundMovePoints)},
243 {"roundReactionPoints", Value(resources.roundReactionPoints)}});
244}
245
247Value bindingEdgeValue(const EdgeState& state) {
248 Value::Array tags;
249 tags.reserve(state.tags.size());
250 for (const auto& tag : state.tags) tags.emplace_back(tag);
251 return Value(Value::Object{{"extraCost", Value(state.extraCost)},
252 {"passable", Value(state.passable)},
253 {"tags", Value(std::move(tags))}});
254}
255
257Value bindingAbilityReceiptValue(const AbilityReceipt& receipt) {
258 return Value(Value::Object{{"action", Value(receipt.action.format())},
259 {"actor", Value(receipt.actor.format())},
260 {"remainingActionPoints", Value(receipt.remainingActionPoints)},
261 {"targetCell", bindingCellValue(receipt.targetCell)},
262 {"targetUnit",
263 Value(receipt.targetUnit.isValid() ? receipt.targetUnit.format() : std::string{})}});
264}
265
267Value bindingCommandValue(const BattleCommand& command) {
268 return Value(Value::Object{
269 {"action", Value(command.action.isValid() ? command.action.format() : std::string{})},
270 {"actor", Value(command.actor.isValid() ? command.actor.format() : std::string{})},
271 {"cell", bindingCellValue(command.cell)},
272 {"facing", Value(command.facing)},
273 {"kind", Value(std::string(commandKindName(command.kind)))},
274 {"payload", Value(command.payload)},
275 {"policyId", Value(command.policyId)},
276 {"sequence", Value(std::to_string(command.sequence))},
277 {"targetUnit", Value(command.targetUnit.isValid() ? command.targetUnit.format() : std::string{})},
278 {"triggerSequence", Value(std::to_string(command.triggerSequence))}});
279}
280
289Result<EdgeState> bindingEdgeStateJson(const std::string& json) {
290 EdgeState state;
291 if (json.empty()) return Result<EdgeState>::success(state);
292 auto value = Value::fromJson(json);
293 if (!value) return Result<EdgeState>::failure(value.status());
294 const auto* object = value.value().getIf<Value::Object>();
295 if (object == nullptr)
297 Diagnostic::error(DiagnosticCode::InvalidArgument, "edge spec JSON must be an object", "edge"));
298 const auto field = [&](std::string_view name) -> const Value* {
299 const auto found = object->find(std::string(name));
300 return found == object->end() ? nullptr : &found->second;
301 };
302 if (const Value* passable = field("passable"); passable != nullptr) {
303 const auto* boolean = passable->getIf<bool>();
304 if (boolean == nullptr)
306 Diagnostic::error(DiagnosticCode::InvalidArgument, "edge passable must be a boolean", "edge.passable"));
307 state.passable = *boolean;
308 }
309 if (const Value* extra = field("extraCost"); extra != nullptr) {
310 if (!extra->isInt64())
312 "edge extraCost must be an integer", "edge.extraCost"));
313 const auto number = extra->asInt();
314 if (number < std::numeric_limits<int>::min() || number > std::numeric_limits<int>::max())
316 Diagnostic::error(DiagnosticCode::InvalidArgument, "edge extraCost is out of range", "edge.extraCost"));
317 state.extraCost = static_cast<int>(number);
318 }
319 if (const Value* tags = field("tags"); tags != nullptr) {
320 const auto* array = tags->getIf<Value::Array>();
321 if (array == nullptr)
323 Diagnostic::error(DiagnosticCode::InvalidArgument, "edge tags must be an array", "edge.tags"));
324 for (const auto& tag : *array) {
325 if (!tag.isString())
327 Diagnostic::error(DiagnosticCode::InvalidArgument, "edge tag must be a string", "edge.tags"));
328 state.tags.push_back(tag.asString());
329 }
330 }
331 return Result<EdgeState>::success(std::move(state));
332}
333
334Result<std::string> scriptAddSide(ScriptTacticsBattle* proxy, const std::string& subjectText) {
335 auto session = bindingSession(proxy);
336 if (!session) return Result<std::string>::failure(session.status());
337 auto subject = bindingSubject(subjectText, "subject");
338 if (!subject) return Result<std::string>::failure(subject.status());
339 Tactics* module = currentModule();
340 if (!module)
342 Diagnostic::error(DiagnosticCode::StaleHandle, "Tactics module is no longer loaded", "battle"));
343 auto created = module->newSide(session.value()->battle, subject.value());
344 if (!created) return Result<std::string>::failure(created.status());
345 std::move(created).takeValue();
347}
348
349Result<std::string> scriptAddUnit(ScriptTacticsBattle* proxy, const std::string& subjectText,
350 const std::string& sideText, const std::string& definitionText, int x, int y,
351 int layer, int actionPoints, int movePoints, int reactionPoints, int initiative) {
352 auto session = bindingSession(proxy);
353 if (!session) return Result<std::string>::failure(session.status());
354 auto subject = bindingSubject(subjectText, "subject");
355 auto definition = bindingLogicalId(definitionText, "definition");
356 auto sideSubject = bindingSubject(sideText, "side");
357 if (!subject) return Result<std::string>::failure(subject.status());
358 if (!definition) return Result<std::string>::failure(definition.status());
359 if (!sideSubject) return Result<std::string>::failure(sideSubject.status());
360 auto* battle = dynamic_cast<Battle*>(ecs::try_get(session.value()->battle));
361 if (!battle)
363 Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle entity is stale", "battle"));
364 std::optional<ecs::EntityHandle> side;
365 for (const auto& handle : battle->turn()->sides) {
366 auto* sideValue = dynamic_cast<TacticalSide*>(ecs::try_get(handle));
367 if (sideValue && sideValue->identity()->subject == sideSubject.value()) {
368 side = handle;
369 break;
370 }
371 }
372 if (!side)
374 Diagnostic::error(DiagnosticCode::NotFound, "tactics side is not part of this script battle", "side"));
375 Tactics* module = currentModule();
376 if (!module)
378 Diagnostic::error(DiagnosticCode::StaleHandle, "Tactics module is no longer loaded", "battle"));
379 auto created = module->newUnit(session.value()->battle, *side, subject.value(), definition.value(),
380 {x, y, layer}, {actionPoints, movePoints, reactionPoints, initiative});
381 if (!created) return Result<std::string>::failure(created.status());
382 std::move(created).takeValue();
384}
385
386template <class ResultType, class Function>
387ResultType withScriptBattle(ScriptTacticsBattle* proxy, Function&& function) {
388 auto session = bindingSession(proxy);
389 if (!session) return ResultType::failure(session.status());
390 Tactics* module = currentModule();
391 if (!module)
392 return ResultType::failure(
393 Diagnostic::error(DiagnosticCode::StaleHandle, "Tactics module is no longer loaded", "battle"));
394 return std::invoke(std::forward<Function>(function), *module, *session.value());
395}
396
397bool sameHandle(const ecs::EntityHandle& left, const ecs::EntityHandle& right) noexcept {
398 return left.table == right.table && left.type == right.type && left.id == right.id &&
399 left.generation == right.generation;
400}
401
402bool controls(const GameplaySession& session, SubjectRef subject) {
403 return session.access != GameplayAccess::PlayerEquivalent ||
404 std::find(session.controlledSubjects.begin(), session.controlledSubjects.end(), subject) !=
405 session.controlledSubjects.end();
406}
407
408LogicalId gameplayId(std::string_view value) {
409 return LogicalId::parse(value).value();
410}
411
412Value cellValue(Cell cell) {
413 return Value(Value::Object{{"layer", Value(cell.layer)}, {"x", Value(cell.x)}, {"y", Value(cell.y)}});
414}
415
416Result<int> integerParameter(const Value& parameters, std::string_view name) {
417 const auto* object = parameters.getIf<Value::Object>();
418 const auto found = object ? object->find(std::string(name)) : Value::Object::const_iterator{};
419 if (!object || found == object->end() || !found->second.isInt64())
421 "gameplay command parameter must be an integer",
422 "parameters." + std::string(name)));
423 const auto value = found->second.asInt();
424 if (value < std::numeric_limits<int>::min() || value > std::numeric_limits<int>::max())
426 "gameplay command parameter is out of range",
427 "parameters." + std::string(name)));
428 return Result<int>::success(static_cast<int>(value));
429}
430
438Result<std::string> optionalStringParameter(const Value& parameters, std::string_view name) {
439 const auto* object = parameters.getIf<Value::Object>();
440 if (object == nullptr) return Result<std::string>::success(std::string{});
441 const auto found = object->find(std::string(name));
442 if (found == object->end() || found->second.isNull()) return Result<std::string>::success(std::string{});
443 const auto* text = found->second.getIf<std::string>();
444 if (text == nullptr)
446 "gameplay command parameter must be a string",
447 "parameters." + std::string(name)));
449}
450
451template <typename T>
452std::size_t countLive(const std::vector<ecs::EntityHandle>& handles) {
453 return static_cast<std::size_t>(std::count_if(handles.begin(), handles.end(), [](const auto& handle) {
454 return dynamic_cast<T*>(ecs::try_get(handle)) != nullptr;
455 }));
456}
457
458template <typename T>
459void releaseLive(std::vector<ecs::EntityHandle>& handles) noexcept {
460 for (const auto& handle : handles) {
461 if (auto* entity = dynamic_cast<T*>(ecs::try_get(handle)); entity != nullptr) entity->release();
462 }
463 handles.clear();
464}
465
466} // namespace
467
469
471 cap::addListener<IGameplayControlProvider>(this);
472 cap::addListener<IGameplayInstanceCatalog>(this);
473 // Importing tactics is what makes grid line of sight available to the targeting pipeline. A
474 // refusal here (a foreign provider already owning the capability, say) is observed rather than
475 // thrown: the project keeps whatever it registered, and grid sight simply is not claimed.
476 registerTacticsLineOfSightProvider().ignore("grid line-of-sight spaces were not claimed");
477}
478
480 auto* value = dynamic_cast<Battle*>(ecs::try_get(battle));
481 if (!value) return;
482 auto units = value->turn()->units;
483 auto sides = value->turn()->sides;
484 releaseLive<TacticalUnit>(units);
485 releaseLive<TacticalSide>(sides);
486 value->release();
487}
488
490 cap::removeListener<IGameplayInstanceCatalog>(this);
491 cap::removeListener<IGameplayControlProvider>(this);
492 releaseLive<TacticalUnit>(units_);
493 releaseLive<TacticalSide>(sides_);
494 releaseLive<Battle>(battles_);
495}
496
497std::string_view Tactics::gameplayDomain() const noexcept { return "tactics"; }
498
499std::vector<SubjectRef> Tactics::gameplayInstances() const {
500 // One tactics instance is one battle; the identity is the battle subject.
501 std::vector<SubjectRef> result;
502 for (const auto& handle : battles_) {
503 auto* battle = dynamic_cast<Battle*>(ecs::try_get(handle));
504 if (battle != nullptr && battle->identity()->subject.isValid()) result.push_back(battle->identity()->subject);
505 }
506 std::sort(result.begin(), result.end(),
507 [](const SubjectRef& left, const SubjectRef& right) { return left.format() < right.format(); });
508 return result;
509}
510
512 SubjectRef instance) const {
513 Battle* battle = resolveBattle(instance);
514 if (!battle)
516 Diagnostic::error(DiagnosticCode::NotFound, "tactics gameplay instance was not found", "instance"));
518 const bool ownsParticipant = std::any_of(battle->turn()->units.begin(), battle->turn()->units.end(),
519 [&](const ecs::EntityHandle& handle) {
520 auto* unit = dynamic_cast<TacticalUnit*>(ecs::try_get(handle));
521 return unit && controls(session, unit->identity()->subject);
522 });
523 if (!ownsParticipant)
525 DiagnosticCode::PreconditionViolation, "session controls no participant in this tactics battle",
526 "session.controlledSubjects"));
527 }
528
530 for (const auto& handle : battle->turn()->units) {
531 auto* unit = dynamic_cast<TacticalUnit*>(ecs::try_get(handle));
532 if (!unit) continue;
533 units.emplace_back(Value::Object{
534 {"acted", Value(unit->turn()->acted)},
535 {"actionPoints", Value(unit->turn()->actionPoints)},
536 {"alive", Value(unit->turn()->alive)},
537 {"cell", cellValue(unit->position()->cell)},
538 {"controlled", Value(controls(session, unit->identity()->subject))},
539 {"facing", Value(unit->position()->facing)},
540 {"movePoints", Value(unit->turn()->movePoints)},
541 {"subject", Value(unit->identity()->subject.format())},
542 });
543 }
544 const auto active = battle->turn()->activeUnit
545 ? dynamic_cast<TacticalUnit*>(ecs::try_get(*battle->turn()->activeUnit))
546 : nullptr;
547 GameplayObservation observation;
548 observation.domain = gameplayId("gameplay:tactics");
549 observation.instance = instance;
550 observation.tick = battle->turn()->tick;
551 observation.revision = battle->turn()->revision.value();
552 observation.state = Value(Value::Object{
553 {"activeUnit", Value(active ? active->identity()->subject.format() : std::string{})},
554 {"phase", Value(std::string(phaseName(battle->turn()->phase)))},
555 {"round", Value(static_cast<std::int64_t>(battle->turn()->round))},
556 {"status", Value(std::string(statusName(battle->turn()->status)))},
557 {"units", Value(std::move(units))},
558 });
559 return Result<GameplayObservation>::success(std::move(observation));
560}
561
563 const GameplaySession& session, SubjectRef instance, SubjectRef subject) const {
564 Battle* battle = resolveBattle(instance);
565 if (!battle)
567 Diagnostic::error(DiagnosticCode::NotFound, "tactics gameplay instance was not found", "instance"));
568 if (!controls(session, subject))
570 DiagnosticCode::PreconditionViolation, "session does not control the requested subject", "subject"));
571 auto* active = battle->turn()->activeUnit
572 ? dynamic_cast<TacticalUnit*>(ecs::try_get(*battle->turn()->activeUnit))
573 : nullptr;
574 if (!active || active->identity()->subject != subject || !active->turn()->alive)
576
577 Value integerSchema(Value::Object{{"type", Value("integer")}});
578 Value stringSchema(Value::Object{{"type", Value("string")}});
579 Value moveSchema(Value::Object{{"layer", integerSchema}, {"x", integerSchema}, {"y", integerSchema}});
580 Value faceSchema(Value::Object{{"facing", integerSchema}});
581 // `targetUnit` and `payload` are optional: an ability may target a cell only, and the
582 // effect parameters are the caller's own data, carried verbatim.
583 Value abilitySchema(Value::Object{{"action", stringSchema},
584 {"layer", integerSchema},
585 {"payload", stringSchema},
586 {"targetUnit", stringSchema},
587 {"x", integerSchema},
588 {"y", integerSchema}});
589 std::vector<GameplayActionDescriptor> actions;
590 if (battle->turn()->phase == BattlePhase::Acting) {
591 actions.push_back({gameplayId("tactics:move"), std::move(moveSchema)});
592 actions.push_back({gameplayId("tactics:face"), std::move(faceSchema)});
593 actions.push_back({gameplayId("tactics:wait"), Value(Value::Object{})});
594 actions.push_back({gameplayId("tactics:end-turn"), Value(Value::Object{})});
595 actions.push_back({gameplayId("tactics:use-ability"), std::move(abilitySchema)});
596 }
597 return Result<std::vector<GameplayActionDescriptor>>::success(std::move(actions));
598}
599
601 const GameplayCommand& command) {
602 Battle* battle = resolveBattle(instance);
603 if (!battle)
605 Diagnostic::error(DiagnosticCode::NotFound, "tactics gameplay instance was not found", "instance"));
606 if (command.id.empty())
608 Diagnostic::error(DiagnosticCode::InvalidArgument, "command id must not be empty", "command.id"));
609 if (!controls(session, command.subject))
611 DiagnosticCode::PreconditionViolation, "session does not control the command subject", "command.subject"));
612 if (command.observedTick != battle->turn()->tick ||
613 command.expectedRevision != battle->turn()->revision.value())
615 DiagnosticCode::Conflict, "gameplay command was based on a stale observation", "command.expectedRevision"));
616
617 Value details(Value::Object{});
618 if (command.action == gameplayId("tactics:move")) {
619 auto x = integerParameter(command.parameters, "x");
620 auto y = integerParameter(command.parameters, "y");
621 auto layer = integerParameter(command.parameters, "layer");
622 if (!x) return Result<GameplayCommandReceipt>::failure(x.status());
623 if (!y) return Result<GameplayCommandReceipt>::failure(y.status());
625 auto moved = BattleSystem::moveUnit(*battle, command.subject, {x.value(), y.value(), layer.value()},
626 battle->turn()->tick);
627 if (!moved) return Result<GameplayCommandReceipt>::failure(moved.status());
628 const MoveReceipt receipt = std::move(moved).takeValue();
629 details = Value(Value::Object{{"cost", Value(receipt.cost)},
630 {"remainingMovePoints", Value(receipt.remainingMovePoints)}});
631 } else if (command.action == gameplayId("tactics:face")) {
632 auto facing = integerParameter(command.parameters, "facing");
634 auto faced = BattleSystem::faceUnit(*battle, command.subject, facing.value(), battle->turn()->tick);
635 if (!faced) return Result<GameplayCommandReceipt>::failure(faced.status());
636 } else if (command.action == gameplayId("tactics:wait")) {
637 auto waited = BattleSystem::waitUnit(*battle, command.subject, battle->turn()->tick);
638 if (!waited) return Result<GameplayCommandReceipt>::failure(waited.status());
639 } else if (command.action == gameplayId("tactics:end-turn")) {
640 auto ended = BattleSystem::endTurn(*battle, command.subject);
642 } else if (command.action == gameplayId("tactics:use-ability")) {
643 auto actionText = optionalStringParameter(command.parameters, "action");
644 auto targetText = optionalStringParameter(command.parameters, "targetUnit");
645 auto payload = optionalStringParameter(command.parameters, "payload");
646 if (!actionText) return Result<GameplayCommandReceipt>::failure(actionText.status());
647 if (!targetText) return Result<GameplayCommandReceipt>::failure(targetText.status());
649 auto x = integerParameter(command.parameters, "x");
650 auto y = integerParameter(command.parameters, "y");
651 auto layer = integerParameter(command.parameters, "layer");
652 if (!x) return Result<GameplayCommandReceipt>::failure(x.status());
653 if (!y) return Result<GameplayCommandReceipt>::failure(y.status());
655 auto ability = bindingLogicalId(actionText.value(), "parameters.action");
656 if (!ability) return Result<GameplayCommandReceipt>::failure(ability.status());
657 auto target = bindingOptionalSubject(targetText.value(), "parameters.targetUnit");
659 auto declared = BattleSystem::useAbility(*battle, command.subject, ability.value(),
660 {x.value(), y.value(), layer.value()}, target.value(),
661 std::move(payload).takeValue());
662 if (!declared) return Result<GameplayCommandReceipt>::failure(declared.status());
663 const AbilityReceipt receipt = std::move(declared).takeValue();
664 details = Value(Value::Object{{"action", Value(receipt.action.format())},
665 {"remainingActionPoints", Value(receipt.remainingActionPoints)},
666 {"target",
667 Value(receipt.targetUnit.isValid() ? receipt.targetUnit.format()
668 : std::string{})}});
669 } else {
671 Diagnostic::error(DiagnosticCode::Unsupported, "unsupported tactics gameplay action", "command.action"));
672 }
673
675 receipt.commandId = command.id;
676 receipt.executionId = battle->commands()->values.empty()
677 ? std::string{}
678 : "tactics:" + std::to_string(battle->commands()->values.back().sequence);
679 receipt.acceptedTick = battle->turn()->tick;
680 receipt.resultingRevision = battle->turn()->revision.value();
681 receipt.details = std::move(details);
683}
684
686 const SimulationStep& step) {
687 Battle* battle = resolveBattle(instance);
688 if (!battle)
690 Diagnostic::error(DiagnosticCode::NotFound, "tactics gameplay instance was not found", "instance"));
691 auto advanced = BattleSystem::advance(*battle, step);
692 if (!advanced) return Result<GameplayObservation>::failure(advanced.status());
693 std::move(advanced).takeValue();
694 return observeGameplay(session, instance);
695}
696
698 std::uint64_t afterSequence) const {
699 auto observed = observeGameplay(session, instance);
700 if (!observed) return Result<std::vector<GameplayEvent>>::failure(observed.status());
701 std::move(observed).takeValue();
702 Battle* battle = resolveBattle(instance);
703 std::vector<GameplayEvent> events;
704 for (const auto& source : battle->events()->values) {
705 if (source.sequence <= afterSequence) continue;
706 GameplayEvent event;
707 event.sequence = source.sequence;
708 event.tick = source.tick;
709 event.type = source.type;
710 event.subject = source.subject;
711 event.causationCommandId = source.causationCommand == 0
712 ? std::string{}
713 : "tactics:" + std::to_string(source.causationCommand);
714 event.correlationId = source.correlationCommand == 0
715 ? std::string{}
716 : "tactics:" + std::to_string(source.correlationCommand);
717 event.payload = Value(Value::Object{{"from", Value(std::string(phaseName(source.from)))},
718 {"to", Value(std::string(phaseName(source.to)))}});
719 events.push_back(std::move(event));
720 }
721 return Result<std::vector<GameplayEvent>>::success(std::move(events));
722}
723
725 if (!subject.isValid())
727 DiagnosticCode::InvalidArgument, "tactics battle requires a valid SubjectRef", "subject"));
728 Battle* battle = Battle::create();
729 battle->identity()->self = ecs::handle_of(battle);
730 battle->identity()->subject = subject;
731 battle->identity()->seed = seed;
732 (void)battle->board();
733 (void)battle->turn();
734 (void)battle->events();
735 (void)battle->reactions();
736 (void)battle->commands();
737 (void)battle->objectives();
738 (void)battle->random();
739 battles_.push_back(battle->identity()->self);
741}
742
744 Battle* battle = resolveBattle(battleHandle);
745 if (battle == nullptr)
747 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
748 if (!subject.isValid())
750 Diagnostic::error(DiagnosticCode::InvalidArgument, "tactics side requires a valid SubjectRef", "subject"));
751 TacticalSide* side = TacticalSide::create();
752 side->identity()->self = ecs::handle_of(side);
753 side->identity()->subject = subject;
754 auto registered = BattleSystem::addSide(*battle, side->identity()->self);
755 if (!registered) {
756 const Status status = registered.status();
757 side->release();
759 }
760 sides_.push_back(side->identity()->self);
762}
763
764Result<ecs::EntityHandle> Tactics::newUnit(ecs::EntityHandle battleHandle, ecs::EntityHandle sideHandle,
765 SubjectRef subject, LogicalId definition, Cell cellValue,
767 Battle* battle = resolveBattle(battleHandle);
768 if (battle == nullptr)
770 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
771 if (!owns(sides_, sideHandle))
773 DiagnosticCode::StaleHandle, "tactics side is stale or not owned by this facade", "side"));
774 if (!subject.isValid())
776 Diagnostic::error(DiagnosticCode::InvalidArgument, "tactics unit requires a valid SubjectRef", "subject"));
777 auto resourcesValid = resources.validate();
778 if (!resourcesValid) return Result<ecs::EntityHandle>::failure(resourcesValid.status());
779 TacticalUnit* unit = TacticalUnit::create();
780 unit->identity()->self = ecs::handle_of(unit);
781 unit->identity()->subject = subject;
782 unit->identity()->definition = std::move(definition);
783 (void)unit->membership();
784 (void)unit->position();
785 (void)unit->turn();
786 unit->turn()->actionPoints = resources.actionPoints;
787 unit->turn()->movePoints = resources.movePoints;
788 unit->turn()->reactionPoints = resources.reactionPoints;
789 unit->turn()->roundActionPoints = resources.actionPoints;
790 unit->turn()->roundMovePoints = resources.movePoints;
791 unit->turn()->roundReactionPoints = resources.reactionPoints;
792 unit->turn()->initiative = resources.initiative;
793 auto registered = BattleSystem::addUnit(*battle, unit->identity()->self, sideHandle, cellValue);
794 if (!registered) {
795 const Status status = registered.status();
796 unit->release();
798 }
799 units_.push_back(unit->identity()->self);
801}
802
803Result<void> Tactics::addCell(ecs::EntityHandle battleHandle, Cell cellValue, CellState state) {
804 Battle* battle = resolveBattle(battleHandle);
805 if (battle == nullptr)
806 return Result<void>::failure(Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
807 if (battle->turn()->status != BattleStatus::Setup)
808 return Result<void>::failure(Diagnostic::error(DiagnosticCode::PreconditionViolation, "tactics cells can only be added during setup", "battle.status"));
809 const auto nextRevision = battle->turn()->revision.incremented();
810 if (!nextRevision)
811 return Result<void>::failure(Diagnostic::error(DiagnosticCode::PreconditionViolation, "tactics battle revision is exhausted", "battle.revision"));
812 auto added = battle->board()->value.addCell(cellValue, std::move(state));
813 if (!added) return added;
814 battle->turn()->revision = *nextRevision;
816}
817
818Result<void> Tactics::setTopology(ecs::EntityHandle battleHandle, BoardTopology topology) {
819 Battle* battle = resolveBattle(battleHandle);
820 if (!battle)
821 return Result<void>::failure(Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
822 if (battle->turn()->status != BattleStatus::Setup)
823 return Result<void>::failure(Diagnostic::error(DiagnosticCode::PreconditionViolation, "tactics topology can only change during setup", "battle.status"));
824 if (battle->board()->value.topology() == topology)
829 Diagnostic::error(DiagnosticCode::InvalidArgument, "unknown board topology", "topology"));
830 if (!battle->board()->value.edgeRecords().empty())
832 Diagnostic::error(DiagnosticCode::Conflict, "choose topology before declaring edges", "topology"));
833 const auto revision = battle->turn()->revision.incremented();
834 if (!revision)
835 return Result<void>::failure(Diagnostic::error(DiagnosticCode::PreconditionViolation, "tactics battle revision is exhausted", "battle.revision"));
836 battle->board()->value.setTopology(topology);
837 battle->turn()->revision = *revision;
839}
840
841Result<void> Tactics::start(ecs::EntityHandle battleHandle, std::string_view policyId) {
842 Battle* battle = resolveBattle(battleHandle);
843 if (battle == nullptr)
844 return Result<void>::failure(Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
845 return BattleSystem::start(*battle, std::string(policyId));
846}
847
848Result<BattlePhase> Tactics::advance(ecs::EntityHandle battleHandle, const SimulationStep& step) {
849 Battle* battle = resolveBattle(battleHandle);
850 if (battle == nullptr)
852 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
853 return BattleSystem::advance(*battle, step);
854}
855
856Result<void> Tactics::endTurn(ecs::EntityHandle battleHandle, SubjectRef actor) {
857 Battle* battle = resolveBattle(battleHandle);
858 if (battle == nullptr)
859 return Result<void>::failure(Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
860 return BattleSystem::endTurn(*battle, actor);
861}
862
863Result<AbilityReceipt> Tactics::useAbility(ecs::EntityHandle battleHandle, SubjectRef actor, const LogicalId& action,
864 Cell targetCell, SubjectRef targetUnit, std::string payload) {
865 Battle* battle = resolveBattle(battleHandle);
866 if (battle == nullptr)
868 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
869 return BattleSystem::useAbility(*battle, actor, action, targetCell, targetUnit, std::move(payload));
870}
871
872Result<AbilityReceipt> Tactics::previewAbility(ecs::EntityHandle battleHandle, SubjectRef actor,
873 const LogicalId& action, Cell targetCell, SubjectRef targetUnit,
874 std::string_view payload) {
875 Battle* battle = resolveBattle(battleHandle);
876 if (battle == nullptr)
878 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
879 return BattleSystem::previewAbility(*battle, actor, action, targetCell, targetUnit, payload);
880}
881
882Result<MoveReceipt> Tactics::moveUnit(ecs::EntityHandle battleHandle, SubjectRef actor, Cell destination) {
883 Battle* battle = resolveBattle(battleHandle);
884 if (battle == nullptr)
886 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
887 return BattleSystem::moveUnit(*battle, actor, destination);
888}
889
890Result<void> Tactics::faceUnit(ecs::EntityHandle battleHandle, SubjectRef actor, int facing) {
891 Battle* battle = resolveBattle(battleHandle);
892 if (!battle)
893 return Result<void>::failure(Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
894 return BattleSystem::faceUnit(*battle, actor, facing);
895}
896
897Result<void> Tactics::waitUnit(ecs::EntityHandle battleHandle, SubjectRef actor) {
898 Battle* battle = resolveBattle(battleHandle);
899 if (!battle)
900 return Result<void>::failure(Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
901 return BattleSystem::waitUnit(*battle, actor);
902}
903
904Result<void> Tactics::finish(ecs::EntityHandle battleHandle) {
905 Battle* battle = resolveBattle(battleHandle);
906 if (battle == nullptr)
907 return Result<void>::failure(Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
908 return BattleSystem::finish(*battle);
909}
910
911Result<std::size_t> Tactics::openReaction(ecs::EntityHandle battleHandle, std::uint64_t triggerSequence,
912 std::vector<ReactionCandidate> candidates) {
913 Battle* battle = resolveBattle(battleHandle);
914 if (battle == nullptr)
916 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
917 return BattleSystem::openReaction(*battle, triggerSequence, std::move(candidates));
918}
919
920Result<ReactionReceipt> Tactics::acceptReaction(ecs::EntityHandle battleHandle, SubjectRef reactor,
921 const LogicalId& action) {
922 Battle* battle = resolveBattle(battleHandle);
923 if (battle == nullptr)
925 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
926 return BattleSystem::acceptReaction(*battle, reactor, action);
927}
928
929Result<void> Tactics::declineReaction(ecs::EntityHandle battleHandle) {
930 Battle* battle = resolveBattle(battleHandle);
931 if (battle == nullptr)
932 return Result<void>::failure(Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
933 return BattleSystem::declineReaction(*battle);
934}
935
936Result<void> Tactics::addObjective(ecs::EntityHandle battleHandle, ObjectiveSpec objective) {
937 Battle* battle = resolveBattle(battleHandle);
938 if (!battle)
939 return Result<void>::failure(Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
940 return BattleSystem::addObjective(*battle, std::move(objective));
941}
942
943Result<void> Tactics::defeatUnit(ecs::EntityHandle battleHandle, SubjectRef unit) {
944 Battle* battle = resolveBattle(battleHandle);
945 if (!battle)
946 return Result<void>::failure(Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
947 return BattleSystem::defeatUnit(*battle, unit);
948}
949
950Result<std::uint64_t> Tactics::roll(ecs::EntityHandle battleHandle, const LogicalId& stream) {
951 Battle* battle = resolveBattle(battleHandle);
952 if (!battle)
954 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
955 return BattleSystem::roll(*battle, stream);
956}
957
958Result<BattleStatus> Tactics::status(ecs::EntityHandle battleHandle) {
959 Battle* battle = resolveBattle(battleHandle);
960 if (battle == nullptr)
962 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
963 return Result<BattleStatus>::success(battle->turn()->status);
964}
965
966Result<BattlePhase> Tactics::phase(ecs::EntityHandle battleHandle) {
967 Battle* battle = resolveBattle(battleHandle);
968 if (battle == nullptr)
970 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
971 return Result<BattlePhase>::success(battle->turn()->phase);
972}
973
974TacticalUnit* Tactics::findUnit(SubjectRef subject) const noexcept {
975 if (!subject.isValid()) return nullptr;
976 for (const auto& handle : units_) {
977 auto* unit = dynamic_cast<TacticalUnit*>(ecs::try_get(handle));
978 if (unit != nullptr && unit->identity()->subject == subject) return unit;
979 }
980 return nullptr;
981}
982
983Result<MoveReceipt> Tactics::previewMove(ecs::EntityHandle battleHandle, SubjectRef actor, Cell destination) {
984 Battle* battle = resolveBattle(battleHandle);
985 if (battle == nullptr)
987 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
988 return BattleSystem::previewMove(*battle, actor, destination);
989}
990
991Result<void> Tactics::previewFace(ecs::EntityHandle battleHandle, SubjectRef actor, int facing) {
992 Battle* battle = resolveBattle(battleHandle);
993 if (battle == nullptr)
994 return Result<void>::failure(Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
995 return BattleSystem::previewFace(*battle, actor, facing);
996}
997
998Result<void> Tactics::previewWait(ecs::EntityHandle battleHandle, SubjectRef actor) {
999 Battle* battle = resolveBattle(battleHandle);
1000 if (battle == nullptr)
1001 return Result<void>::failure(Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
1002 return BattleSystem::previewWait(*battle, actor);
1003}
1004
1005Result<Reachability> Tactics::reachable(ecs::EntityHandle battleHandle, SubjectRef subject, int budget) {
1006 Battle* battle = resolveBattle(battleHandle);
1007 if (battle == nullptr)
1009 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
1010 return PathQuery::reachable(battle->board()->value, subject, budget);
1011}
1012
1014 CellRangeMetric metric) {
1015 Battle* battle = resolveBattle(battleHandle);
1016 if (battle == nullptr)
1018 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
1019 return PathQuery::cellsInRange(battle->board()->value, origin, minimum, maximum, metric);
1020}
1021
1022Result<void> Tactics::addEdge(ecs::EntityHandle battleHandle, Cell from, Cell to, EdgeState state) {
1023 Battle* battle = resolveBattle(battleHandle);
1024 if (battle == nullptr)
1025 return Result<void>::failure(Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
1026 if (battle->turn()->status != BattleStatus::Setup)
1027 return Result<void>::failure(Diagnostic::error(DiagnosticCode::PreconditionViolation, "tactics edges can only be added during setup", "battle.status"));
1028 const auto nextRevision = battle->turn()->revision.incremented();
1029 if (!nextRevision)
1030 return Result<void>::failure(Diagnostic::error(DiagnosticCode::PreconditionViolation, "tactics battle revision is exhausted", "battle.revision"));
1031 auto added = battle->board()->value.addEdge(from, to, std::move(state));
1032 if (!added) return added;
1033 battle->turn()->revision = *nextRevision;
1035}
1036
1037Result<EdgeState> Tactics::edge(ecs::EntityHandle battleHandle, Cell from, Cell to) {
1038 Battle* battle = resolveBattle(battleHandle);
1039 if (battle == nullptr)
1041 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
1042 return battle->board()->value.edge(from, to);
1043}
1044
1045std::optional<EdgeState> Tactics::tryEdge(ecs::EntityHandle battleHandle, Cell from, Cell to) {
1046 Battle* battle = resolveBattle(battleHandle);
1047 if (battle == nullptr) return std::nullopt;
1048 return battle->board()->value.tryEdge(from, to);
1049}
1050
1051Result<std::string> Tactics::policyId(ecs::EntityHandle battleHandle) {
1052 Battle* battle = resolveBattle(battleHandle);
1053 if (battle == nullptr)
1055 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
1056 return Result<std::string>::success(battle->turn()->policyId);
1057}
1058
1060 Battle* battle = resolveBattle(battleHandle);
1061 if (battle == nullptr)
1063 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
1064 TacticalUnit* found = findUnit(unit);
1065 if (found == nullptr)
1067 Diagnostic::error(DiagnosticCode::NotFound, "tactics unit is not owned by this facade", "unit"));
1069}
1070
1071Result<SubjectRef> Tactics::activeUnit(ecs::EntityHandle battleHandle) {
1072 Battle* battle = resolveBattle(battleHandle);
1073 if (battle == nullptr)
1075 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
1076 if (!battle->turn()->activeUnit)
1078 Diagnostic::error(DiagnosticCode::NotFound, "tactics battle has no active unit", "battle.activeUnit"));
1079 auto* unit = dynamic_cast<TacticalUnit*>(ecs::try_get(*battle->turn()->activeUnit));
1080 if (unit == nullptr)
1082 Diagnostic::error(DiagnosticCode::StaleHandle, "tactics active unit is stale", "battle.activeUnit"));
1083 return Result<SubjectRef>::success(unit->identity()->subject);
1084}
1085
1086Result<SnapshotEnvelope> Tactics::snapshot(ecs::EntityHandle battleHandle,
1087 const SnapshotHashProvider& hashProvider) {
1088 Battle* battle = resolveBattle(battleHandle);
1089 if (!battle)
1091 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
1092 return TacticsPersistence::snapshot(*battle, hashProvider);
1093}
1094
1095Result<void> Tactics::restore(ecs::EntityHandle battleHandle, const SnapshotEnvelope& snapshotValue,
1096 const SnapshotHashProvider& hashProvider) {
1097 Battle* battle = resolveBattle(battleHandle);
1098 if (!battle)
1099 return Result<void>::failure(Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
1100 return TacticsPersistence::restore(*battle, snapshotValue, hashProvider);
1101}
1102
1104 Battle* battle = resolveBattle(battleHandle);
1105 if (!battle)
1107 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
1109}
1110
1111Result<void> Tactics::replay(ecs::EntityHandle battleHandle, std::span<const BattleCommand> commands) {
1112 Battle* battle = resolveBattle(battleHandle);
1113 if (!battle)
1114 return Result<void>::failure(Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
1115 return BattleReplay::replay(*battle, commands);
1116}
1117
1118Result<std::string> Tactics::snapshotJson(ecs::EntityHandle battleHandle) {
1119 Battle* battle = resolveBattle(battleHandle);
1120 if (battle == nullptr)
1122 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
1123 // The provider is resolved per call, so a module that registers a stronger hasher later is
1124 // picked up instead of the facade caching a weaker one from an earlier call.
1126 if (!sealed) return Result<std::string>::failure(sealed.status());
1127 return serializeSnapshotEnvelope(sealed.value());
1128}
1129
1130Result<void> Tactics::restoreJson(ecs::EntityHandle battleHandle, std::string_view json) {
1131 Battle* battle = resolveBattle(battleHandle);
1132 if (battle == nullptr)
1133 return Result<void>::failure(Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
1134 // Parsing verifies the digest before anything is mutated, and TacticsPersistence::restore
1135 // validates the whole candidate state before it commits, so a bad document cannot leave a
1136 // half-restored battle behind.
1138 if (!parsed) return Result<void>::failure(parsed.status());
1139 return TacticsPersistence::restore(*battle, parsed.value(), snapshotContentHashProvider());
1140}
1141
1143 Battle* battle = resolveBattle(battleHandle);
1144 if (battle == nullptr)
1146 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
1147 auto log = TacticsPersistence::commandLogValue(*battle, revision);
1148 if (!log) return Result<std::string>::failure(log.status());
1149 return log.value().toJson();
1150}
1151
1152Result<void> Tactics::replayJson(ecs::EntityHandle battleHandle, std::string_view json) {
1153 Battle* battle = resolveBattle(battleHandle);
1154 if (battle == nullptr)
1155 return Result<void>::failure(Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
1156 auto parsedValue = Value::fromJson(json);
1157 if (!parsedValue) return Result<void>::failure(parsedValue.status());
1158 // The log must describe this battle's current revision: a log captured at a different point
1159 // holds commands that were accepted against different state.
1160 auto parsed = TacticsPersistence::parseCommandLog(parsedValue.value(), battle->turn()->revision);
1161 if (!parsed) return Result<void>::failure(parsed.status());
1162 return BattleReplay::replay(*battle, parsed.value().values);
1163}
1164
1165std::string_view Tactics::snapshotHashAlgorithm() const noexcept { return activeSnapshotHashAlgorithm(); }
1166
1168 int maximum, CellRangeMetric metric) {
1169 Battle* battle = resolveBattle(battleHandle);
1170 if (battle == nullptr)
1172 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
1173 // A registered sight policy wins, so a project board (a hex map with its own projection)
1174 // replaces the built-in rule instead of being ignored.
1175 const ILineOfSightPolicy* registered = cap::query<ILineOfSightPolicy>();
1176 const ILineOfSightPolicy& policy = registered != nullptr ? *registered : *gridLineOfSightPolicy();
1177 // Qualified: the member of the same name would otherwise hide the free function that
1178 // composes the range and sight rules.
1179 return eve::tactics::visibleCellsInRange(battle->board()->value, policy, origin, minimum, maximum, metric);
1180}
1181
1182Result<InteractionContext> Tactics::interactionContext(ecs::EntityHandle battleHandle, SubjectRef controlledUnit) {
1183 Battle* battle = resolveBattle(battleHandle);
1184 if (battle == nullptr)
1186 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
1187 if (!controlledUnit.isValid())
1189 DiagnosticCode::InvalidArgument, "interaction context requires a controlled unit", "controlledUnit"));
1190 TacticalUnit* unit = findUnit(controlledUnit);
1191 if (unit == nullptr)
1193 Diagnostic::error(DiagnosticCode::NotFound, "controlled unit is not in this battle", "controlledUnit"));
1194
1196 context.status = battle->turn()->status;
1197 context.phase = battle->turn()->phase;
1198 context.revision = battle->turn()->revision;
1199 if (battle->turn()->activeUnit.has_value()) {
1200 TacticalUnit* active = dynamic_cast<TacticalUnit*>(ecs::try_get(*battle->turn()->activeUnit));
1201 if (active != nullptr) context.activeUnit = active->identity()->subject;
1202 }
1203 context.controlledUnit = controlledUnit;
1204 context.controlledCell = unit->position()->cell;
1205 // Movement destinations come from the same query a commit uses, so a cell the UI offers is
1206 // a cell the commit accepts.
1207 auto reachable = PathQuery::reachable(battle->board()->value, controlledUnit, unit->turn()->movePoints);
1209 for (const ReachableCell& entry : reachable.value().cells()) context.reachableCells.push_back(entry.cell);
1210 for (const BoardCellRecord& record : battle->board()->value.records()) {
1211 if (!record.occupant.has_value()) continue;
1212 context.unitsByCell.emplace(record.cell, *record.occupant);
1213 }
1215}
1216
1217std::string_view Tactics::lineOfSightAlgorithm() const noexcept {
1218 const ILineOfSightPolicy* registered = cap::query<ILineOfSightPolicy>();
1219 return registered != nullptr ? registered->id() : gridLineOfSightPolicy()->id();
1220}
1221
1223 // The adapter resolves the handle itself, so a stale one is refused there rather than being
1224 // cached here as a pointer that could dangle.
1225 return tacticsLineOfSightAdapter().bindBattle(battle);
1226}
1227
1229 return tacticsLineOfSightAdapter().unbindBattle(battle);
1230}
1231
1233 std::uint64_t afterSequence,
1234 std::uint64_t transientTicks) {
1235 Battle* battle = resolveBattle(battleHandle);
1236 if (battle == nullptr)
1238 DiagnosticCode::StaleHandle, "tactics battle is stale or not owned by this facade", "battle"));
1239 // The frame is built from the battle itself: the revision anchors the projection and each
1240 // unit's resting state is where a transient intent has to return to.
1241 PresentationFrame frame;
1242 frame.revision = battle->turn()->revision;
1243 frame.transientTicks = transientTicks;
1244 for (const auto& handle : battle->turn()->units) {
1245 // Explicit dynamic_cast: the facade also has a static `resolve` for script session
1246 // references, which would hide the entity helper here.
1247 TacticalUnit* unit = dynamic_cast<TacticalUnit*>(ecs::try_get(handle));
1248 if (unit == nullptr)
1250 Diagnostic::error(DiagnosticCode::StaleHandle, "tactics battle contains a stale unit", "battle.units"));
1252 if (!unit->turn()->alive) {
1254 } else if (unit->turn()->acted) {
1255 resting = UnitVisualState::Finished;
1256 }
1257 frame.restingState.emplace(unit->identity()->subject.format(), resting);
1258 }
1259
1260 PresentationProjector projector(transientTicks);
1261 std::vector<PresentationCommand> commands;
1262 for (const BattleEvent& event : battle->events()->values) {
1263 if (event.sequence <= afterSequence) continue;
1264 // The event names the command that caused it; the command owns the geometry the event
1265 // does not carry. No command (or a command that is not the shape we expect) simply means
1266 // a geometry-less intent, never a guessed one.
1267 const BattleCommand* command = nullptr;
1268 if (event.causationCommand != 0) {
1269 for (const BattleCommand& candidate : battle->commands()->values) {
1270 if (candidate.sequence == event.causationCommand) {
1271 command = &candidate;
1272 break;
1273 }
1274 }
1275 }
1276 auto projected = projector.project(event, command, frame);
1277 if (!projected) return Result<std::vector<PresentationCommand>>::failure(projected.status());
1278 for (auto& entry : projected.value()) commands.push_back(std::move(entry));
1279 }
1280 return Result<std::vector<PresentationCommand>>::success(std::move(commands));
1281}
1282
1283std::size_t Tactics::battleCount() const noexcept { return countLive<Battle>(battles_); }
1284std::size_t Tactics::unitCount() const noexcept { return countLive<TacticalUnit>(units_); }
1285std::size_t Tactics::sideCount() const noexcept { return countLive<TacticalSide>(sides_); }
1286
1288 Tactics* module = Tactics::create();
1289 auto battle = module->newBattle(subject, seed);
1290 if (!battle) return Result<TacticsBattleSessionRef>::failure(battle.status());
1291 auto session = std::make_unique<TacticsBattleSession>();
1292 session->battle = std::move(battle).takeValue();
1293 auto inserted = module->sessions_.emplace(std::move(session));
1294 if (!inserted) return Result<TacticsBattleSessionRef>::failure(inserted.status());
1295 return inserted;
1296}
1297
1299 Tactics* module = ModuleManager::getInstance<Tactics>("Tactics");
1300 return module ? module->sessions_.resolve(reference) : script::Borrowed<TacticsBattleSession>();
1301}
1302
1304 Tactics* module = ModuleManager::getInstance<Tactics>("Tactics");
1305 if (!module)
1306 return Result<void>::failure(Diagnostic::error(DiagnosticCode::StaleHandle, "Tactics module is no longer loaded", "battle"));
1307 return module->sessions_.erase(reference);
1308}
1309
1311 if (!reference.isValid()) return false;
1312 Tactics* module = ModuleManager::getInstance<Tactics>("Tactics");
1313 return !module || module->sessions_.isStale(reference);
1314}
1315
1316Battle* Tactics::resolveBattle(ecs::EntityHandle handle) const noexcept {
1317 return owns(battles_, handle) ? dynamic_cast<Battle*>(ecs::try_get(handle)) : nullptr;
1318}
1319
1320Battle* Tactics::resolveBattle(SubjectRef subject) const noexcept {
1321 const auto found = std::find_if(battles_.begin(), battles_.end(), [&](const ecs::EntityHandle& handle) {
1322 auto* battle = dynamic_cast<Battle*>(ecs::try_get(handle));
1323 return battle && battle->identity()->subject == subject;
1324 });
1325 return found == battles_.end() ? nullptr : dynamic_cast<Battle*>(ecs::try_get(*found));
1326}
1327
1328bool Tactics::owns(const std::vector<ecs::EntityHandle>& handles, const ecs::EntityHandle& handle) const noexcept {
1329 return std::any_of(handles.begin(), handles.end(), [&](const auto& value) { return sameHandle(value, handle); }) &&
1330 ecs::try_get(handle) != nullptr;
1331}
1332
1333void Tactics::expose(ssq::Table& table) {
1334 const HSQUIRRELVM vm = table.getHandle();
1335 // The interaction session is created by `battle.newInteraction(unit)` and owned by the
1336 // script. The allocator returns null because there is nothing sensible to construct without
1337 // a battle: every method refuses on such an object instead of inventing a default context.
1338 auto interaction = table.addClass<ScriptTacticsInteraction>(
1339 "TacticsInteraction",
1340 std::function<ScriptTacticsInteraction*()>([]() -> ScriptTacticsInteraction* { return nullptr; }), false);
1341 interaction.addFunc("state", [](ScriptTacticsInteraction* value) {
1342 if (value == nullptr || !value->session.has_value()) return std::string("uninitialized");
1343 return std::string(interactionStateName(value->session->state()));
1344 });
1345 interaction.addFunc("armedAction", [](ScriptTacticsInteraction* value) {
1346 if (value == nullptr || !value->session.has_value()) return std::string{};
1347 const LogicalId& action = value->session->armedAction();
1348 return action.isValid() ? action.format() : std::string{};
1349 });
1350 interaction.addFunc("expectedRevision", [](ScriptTacticsInteraction* value) -> std::int64_t {
1351 if (value == nullptr || !value->session.has_value()) return 0;
1352 return static_cast<std::int64_t>(value->session->context().revision.value());
1353 });
1354 interaction.addFunc("reachableCells", [vm](ScriptTacticsInteraction* value) {
1355 // Projected through the common Result shape like every other query, so a script reads it
1356 // as `result.value` instead of getting a bare native array.
1357 if (value == nullptr || !value->session.has_value())
1359 Result<std::vector<Cell>>::failure(Diagnostic::error(
1361 "interaction object was not created from a battle", "interaction")),
1362 bindingCellArrayValue);
1364 Result<std::vector<Cell>>::success(value->session->context().reachableCells),
1365 bindingCellArrayValue);
1366 });
1367 // A click resolves to an intent. "Nothing to do here" is `kind == "none"`, while a machine
1368 // that cannot accept input at all is a refused Result: a UI can tell them apart.
1369 interaction.addFunc("click", [vm](ScriptTacticsInteraction* value, int x, int y, int layer) {
1370 if (value == nullptr || !value->session.has_value())
1374 "interaction object was not created from a battle", "interaction")),
1375 bindingInteractionIntentValue);
1376 return script::projectResult(vm, value->session->onCellClicked({x, y, layer}),
1377 bindingInteractionIntentValue);
1378 });
1379 interaction.addFunc("armAbility", [vm](ScriptTacticsInteraction* value, const std::string& actionText,
1380 const std::string& cellsJson) {
1381 if (value == nullptr || !value->session.has_value())
1384 "interaction object was not created from a battle", "interaction")));
1385 auto action = bindingLogicalId(actionText, "action");
1386 if (!action) return script::projectResult(vm, Result<void>::failure(action.status()));
1387 auto cells = bindingCellsJson(cellsJson);
1388 if (!cells) return script::projectResult(vm, Result<void>::failure(cells.status()));
1389 return script::projectResult(vm, value->session->armAbility(action.value(),
1390 std::move(cells).takeValue()));
1391 });
1392 interaction.addFunc("cancel", [vm](ScriptTacticsInteraction* value) {
1393 if (value == nullptr || !value->session.has_value())
1397 "interaction object was not created from a battle", "interaction")),
1398 bindingInteractionIntentValue);
1399 return script::projectResult(vm, value->session->onCancel(), bindingInteractionIntentValue);
1400 });
1401 interaction.addFunc("endTurn", [vm](ScriptTacticsInteraction* value) {
1402 if (value == nullptr || !value->session.has_value())
1406 "interaction object was not created from a battle", "interaction")),
1407 bindingInteractionIntentValue);
1408 return script::projectResult(vm, value->session->onEndTurn(), bindingInteractionIntentValue);
1409 });
1410 // After the caller commits (or drops) the intent it received, the session returns to
1411 // `await_selection`; until then a second click is refused rather than queued.
1412 interaction.addFunc("resolve", [vm](ScriptTacticsInteraction* value) {
1413 if (value == nullptr || !value->session.has_value())
1416 "interaction object was not created from a battle", "interaction")));
1417 return script::projectResult(vm, value->session->resolve());
1418 });
1419 auto battle = table.addClass<ScriptTacticsBattle>(
1420 "TacticsBattle", std::function<ScriptTacticsBattle*()>([]() -> ScriptTacticsBattle* { return nullptr; }),
1421 false);
1422 battle.addFunc("ownership", [](ScriptTacticsBattle*) { return std::string("owned"); });
1423 battle.addFunc("handle", [](ScriptTacticsBattle* value) {
1424 return value ? static_cast<std::int64_t>(value->reference.packed()) : std::int64_t{0};
1425 });
1426 battle.addFunc("isStale", [](ScriptTacticsBattle* value) {
1427 return !value || Tactics::isStale(value->reference);
1428 });
1429 battle.addFunc("release", [vm](ScriptTacticsBattle* value) {
1430 if (!value)
1431 return script::projectResult(
1432 vm, Result<void>::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, "tactics battle proxy must not be null", "battle")));
1433 return script::projectResult(vm, Tactics::release(value->reference));
1434 });
1435 battle.addFunc("addCell", [vm](ScriptTacticsBattle* value, int x, int y, int layer, int moveCost) {
1436 return script::projectResult(
1437 vm, withScriptBattle<Result<void>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1438 CellState state;
1439 state.moveCost = moveCost;
1440 return module.addCell(session.battle, {x, y, layer}, std::move(state));
1441 }));
1442 });
1443 battle.addFunc("setTopology", [vm](ScriptTacticsBattle* value, const std::string& topology) {
1444 BoardTopology parsed;
1445 if (topology == "square4")
1446 parsed = BoardTopology::Square4;
1447 else if (topology == "square8")
1448 parsed = BoardTopology::Square8;
1449 else if (topology == "hex")
1450 parsed = BoardTopology::HexAxial;
1451 else if (topology == "graph")
1453 else
1454 return script::projectResult(
1455 vm, Result<void>::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, "unknown tactics board topology", "topology")));
1456 return script::projectResult(
1457 vm, withScriptBattle<Result<void>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1458 return module.setTopology(session.battle, parsed);
1459 }));
1460 });
1461 battle.addFunc("addSide", [vm](ScriptTacticsBattle* value, const std::string& subject) {
1462 return script::projectResult(vm, scriptAddSide(value, subject),
1463 [](std::string result) { return Value(std::move(result)); });
1464 });
1465 battle.addFunc("addUnit", [vm](ScriptTacticsBattle* value, const std::string& subject,
1466 const std::string& side, const std::string& definition, int x, int y, int layer,
1467 int actionPoints, int movePoints, int reactionPoints, int initiative) {
1468 return script::projectResult(
1469 vm,
1470 scriptAddUnit(value, subject, side, definition, x, y, layer, actionPoints, movePoints, reactionPoints,
1471 initiative),
1472 [](std::string result) { return Value(std::move(result)); });
1473 });
1474 battle.addFunc("addEliminateObjective", [vm](ScriptTacticsBattle* value, const std::string& idText,
1475 const std::string& beneficiaryText,
1476 const std::string& targetText, bool endsBattle) {
1477 auto id = bindingLogicalId(idText, "id");
1478 auto beneficiary = bindingSubject(beneficiaryText, "beneficiarySide");
1479 auto target = bindingSubject(targetText, "targetSide");
1480 if (!id) return script::projectResult(vm, Result<void>::failure(id.status()));
1481 if (!beneficiary) return script::projectResult(vm, Result<void>::failure(beneficiary.status()));
1483 ObjectiveSpec objective;
1484 objective.id = id.value();
1485 objective.kind = ObjectiveKind::EliminateSide;
1486 objective.beneficiarySide = beneficiary.value();
1487 objective.targetSide = target.value();
1488 objective.endsBattle = endsBattle;
1489 return script::projectResult(
1490 vm, withScriptBattle<Result<void>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1491 return module.addObjective(session.battle, objective);
1492 }));
1493 });
1494 battle.addFunc("addSurviveObjective", [vm](ScriptTacticsBattle* value, const std::string& idText,
1495 const std::string& beneficiaryText, std::int64_t requiredRound,
1496 bool endsBattle) {
1497 auto id = bindingLogicalId(idText, "id");
1498 auto beneficiary = bindingSubject(beneficiaryText, "beneficiarySide");
1499 if (!id) return script::projectResult(vm, Result<void>::failure(id.status()));
1500 if (!beneficiary) return script::projectResult(vm, Result<void>::failure(beneficiary.status()));
1501 if (requiredRound <= 0)
1502 return script::projectResult(
1503 vm, Result<void>::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, "required round must be positive", "requiredRound")));
1504 ObjectiveSpec objective;
1505 objective.id = id.value();
1506 objective.kind = ObjectiveKind::SurviveRounds;
1507 objective.beneficiarySide = beneficiary.value();
1508 objective.requiredRound = static_cast<std::uint64_t>(requiredRound);
1509 objective.endsBattle = endsBattle;
1510 return script::projectResult(
1511 vm, withScriptBattle<Result<void>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1512 return module.addObjective(session.battle, objective);
1513 }));
1514 });
1515 battle.addFunc("addOccupyObjectiveJson", [vm](ScriptTacticsBattle* value, const std::string& idText,
1516 const std::string& beneficiaryText,
1517 const std::string& cellsJson, bool endsBattle) {
1518 auto id = bindingLogicalId(idText, "id");
1519 auto beneficiary = bindingSubject(beneficiaryText, "beneficiarySide");
1520 auto cells = bindingCellsJson(cellsJson);
1521 if (!id) return script::projectResult(vm, Result<void>::failure(id.status()));
1522 if (!beneficiary) return script::projectResult(vm, Result<void>::failure(beneficiary.status()));
1523 if (!cells) return script::projectResult(vm, Result<void>::failure(cells.status()));
1524 ObjectiveSpec objective;
1525 objective.id = id.value();
1526 objective.kind = ObjectiveKind::OccupyCells;
1527 objective.beneficiarySide = beneficiary.value();
1528 objective.requiredCells = std::move(cells).takeValue();
1529 objective.endsBattle = endsBattle;
1530 return script::projectResult(
1531 vm, withScriptBattle<Result<void>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1532 return module.addObjective(session.battle, objective);
1533 }));
1534 });
1535 battle.addFunc("start", [vm](ScriptTacticsBattle* value, const std::string& policy) {
1536 // The registry is the single authority for valid ids, so a typo is refused
1537 // with the registered set rather than a hand-maintained list of two names.
1538 auto* registry = &TurnPolicyRegistry::builtins();
1539 if (!registry->contains(policy)) {
1540 std::string registered;
1541 for (const auto& id : registry->ids()) {
1542 if (!registered.empty()) registered += ", ";
1543 registered += id;
1544 }
1545 return script::projectResult(
1546 vm, Result<void>::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, "unknown tactics turn policy '" + policy + "'; registered: " + registered, "policy")));
1547 }
1548 return script::projectResult(
1549 vm, withScriptBattle<Result<void>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1550 return module.start(session.battle, policy);
1551 }));
1552 });
1553 // Discoverability: scripts can list the ids instead of hard-coding them.
1554 battle.addFunc("policyIds", [](ScriptTacticsBattle*) {
1556 for (const auto& id : TurnPolicyRegistry::builtins().ids()) ids.emplace_back(id);
1557 return Value(std::move(ids));
1558 });
1559 battle.addFunc("advance", [vm](ScriptTacticsBattle* value, std::int64_t tick, std::int64_t deltaNanoseconds) {
1560 if (tick < 0)
1561 return script::projectResult(
1562 vm,
1564 Diagnostic::error(DiagnosticCode::InvalidArgument, "simulation tick must be non-negative", "tick")),
1565 [](BattlePhase phase) { return Value(std::string(phaseName(phase))); });
1566 return script::projectResult(
1567 vm,
1568 withScriptBattle<Result<BattlePhase>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1569 return module.advance(session.battle,
1570 {SimulationTick(static_cast<std::uint64_t>(tick)),
1571 Duration::fromNanoseconds(deltaNanoseconds)});
1572 }),
1573 [](BattlePhase phase) { return Value(std::string(phaseName(phase))); });
1574 });
1575 battle.addFunc("move", [vm](ScriptTacticsBattle* value, const std::string& actor, int x, int y, int layer) {
1576 auto subject = bindingSubject(actor, "actor");
1577 if (!subject)
1578 return script::projectResult(vm, Result<MoveReceipt>::failure(subject.status()), [](MoveReceipt receipt) {
1579 return Value(receipt.remainingMovePoints);
1580 });
1581 return script::projectResult(
1582 vm,
1583 withScriptBattle<Result<MoveReceipt>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1584 return module.moveUnit(session.battle, subject.value(), {x, y, layer});
1585 }),
1586 [](const MoveReceipt& receipt) { return bindingMoveReceiptValue(receipt); });
1587 });
1588 battle.addFunc("face", [vm](ScriptTacticsBattle* value, const std::string& actor, int facing) {
1589 auto subject = bindingSubject(actor, "actor");
1591 return script::projectResult(
1592 vm, withScriptBattle<Result<void>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1593 return module.faceUnit(session.battle, subject.value(), facing);
1594 }));
1595 });
1596 battle.addFunc("wait", [vm](ScriptTacticsBattle* value, const std::string& actor) {
1597 auto subject = bindingSubject(actor, "actor");
1599 return script::projectResult(
1600 vm, withScriptBattle<Result<void>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1601 return module.waitUnit(session.battle, subject.value());
1602 }));
1603 });
1604 // Explicitly end the active actor's turn and enter TurnEnd. Without this the
1605 // only script-visible way to finish an activation was wait(), which forces a
1606 // wait action and hides the real intent from the event stream.
1607 battle.addFunc("endTurn", [vm](ScriptTacticsBattle* value, const std::string& actor) {
1608 auto subject = bindingSubject(actor, "actor");
1610 return script::projectResult(
1611 vm, withScriptBattle<Result<void>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1612 return module.endTurn(session.battle, subject.value());
1613 }));
1614 });
1615 // Declare an ability activation. Tactics owns the action-economy cost and the
1616 // command record; resolving the effect stays with the RPG/game adapter, so a
1617 // script cannot smuggle damage through this call. `target` may be "" for a
1618 // cell-only ability, and `payload` is opaque caller-owned effect data.
1619 battle.addFunc("useAbility", [vm](ScriptTacticsBattle* value, const std::string& actor,
1620 const std::string& actionText, int x, int y, int layer,
1621 const std::string& targetText, const std::string& payload) {
1622 auto subject = bindingSubject(actor, "actor");
1624 bindingAbilityReceiptValue);
1625 auto action = bindingLogicalId(actionText, "action");
1626 if (!action) return script::projectResult(vm, Result<AbilityReceipt>::failure(action.status()),
1627 bindingAbilityReceiptValue);
1628 auto target = bindingOptionalSubject(targetText, "target");
1630 bindingAbilityReceiptValue);
1631 return script::projectResult(
1632 vm,
1633 withScriptBattle<Result<AbilityReceipt>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1634 return module.useAbility(session.battle, subject.value(), action.value(), {x, y, layer},
1635 target.value(), payload);
1636 }),
1637 bindingAbilityReceiptValue);
1638 });
1639 // The pre-check runs the exact commit validator, so a script UI can offer an
1640 // activation and then declare it without a divergent second rule set.
1641 battle.addFunc("previewAbility", [vm](ScriptTacticsBattle* value, const std::string& actor,
1642 const std::string& actionText, int x, int y, int layer,
1643 const std::string& targetText, const std::string& payload) {
1644 auto subject = bindingSubject(actor, "actor");
1646 bindingAbilityReceiptValue);
1647 auto action = bindingLogicalId(actionText, "action");
1648 if (!action) return script::projectResult(vm, Result<AbilityReceipt>::failure(action.status()),
1649 bindingAbilityReceiptValue);
1650 auto target = bindingOptionalSubject(targetText, "target");
1652 bindingAbilityReceiptValue);
1653 return script::projectResult(
1654 vm,
1655 withScriptBattle<Result<AbilityReceipt>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1656 return module.previewAbility(session.battle, subject.value(), action.value(), {x, y, layer},
1657 target.value(), payload);
1658 }),
1659 bindingAbilityReceiptValue);
1660 });
1661 // Snapshot / replay across the script boundary. The envelope and the command log travel as
1662 // JSON text because both are self-describing: a script can store the text, hand it to
1663 // another system, and read it back without out-of-band context.
1664 battle.addFunc("snapshotJson", [vm](ScriptTacticsBattle* value) {
1665 return script::projectResult(
1666 vm,
1667 withScriptBattle<Result<std::string>>(value, [](Tactics& module, TacticsBattleSession& session) {
1668 return module.snapshotJson(session.battle);
1669 }),
1670 [](const std::string& json) { return Value(json); });
1671 });
1672 battle.addFunc("restoreJson", [vm](ScriptTacticsBattle* value, const std::string& json) {
1673 if (json.empty())
1674 return script::projectResult(
1676 Diagnostic::error(DiagnosticCode::InvalidArgument, "snapshot text must not be empty", "json")));
1677 return script::projectResult(
1678 vm, withScriptBattle<Result<void>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1679 return module.restoreJson(session.battle, json);
1680 }));
1681 });
1682 battle.addFunc("commandLogJson", [vm](ScriptTacticsBattle* value, std::int64_t revision) {
1683 if (revision < 0)
1684 return script::projectResult(
1685 vm,
1687 "command revision must be non-negative", "revision")),
1688 [](const std::string& json) { return Value(json); });
1689 return script::projectResult(
1690 vm,
1691 withScriptBattle<Result<std::string>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1692 return module.commandLogJson(session.battle, Revision(static_cast<std::uint64_t>(revision)));
1693 }),
1694 [](const std::string& json) { return Value(json); });
1695 });
1696 battle.addFunc("replayJson", [vm](ScriptTacticsBattle* value, const std::string& json) {
1697 if (json.empty())
1698 return script::projectResult(
1700 "command log text must not be empty", "json")));
1701 return script::projectResult(
1702 vm, withScriptBattle<Result<void>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1703 return module.replayJson(session.battle, json);
1704 }));
1705 });
1706 // Which digest produced a stored hash is observable rather than assumed: a script can record
1707 // it next to the data, and a build that registers a stronger hasher reports its own id.
1708 battle.addFunc("snapshotAlgorithm", [vm](ScriptTacticsBattle* value) {
1709 return script::projectResult(
1710 vm,
1711 withScriptBattle<Result<std::string>>(value, [](Tactics& module, TacticsBattleSession& session) {
1712 (void)session;
1713 return Result<std::string>::success(std::string(module.snapshotHashAlgorithm()));
1714 }),
1715 [](const std::string& id) { return Value(id); });
1716 });
1717 // Sight-aware range query: the cells a unit can both reach by metric and actually see. Uses
1718 // the registered sight policy when a board module provided one, otherwise the built-in grid
1719 // rule, and reports which through `lineOfSightAlgorithm`.
1720 battle.addFunc("visibleCellsInRange", [vm](ScriptTacticsBattle* value, int x, int y, int layer, int minimum,
1721 int maximum, const std::string& metric) {
1722 const auto projectCells = [](const std::vector<Cell>& cells) { return bindingCellArrayValue(cells); };
1723 CellRangeMetric parsed;
1724 if (metric == "manhattan")
1726 else if (metric == "chebyshev")
1728 else if (metric == "hex")
1729 parsed = CellRangeMetric::Hex;
1730 else
1731 return script::projectResult(
1732 vm,
1733 Result<std::vector<Cell>>::failure(
1734 Diagnostic::error(DiagnosticCode::InvalidArgument, "unknown tactics range metric", "metric")),
1735 projectCells);
1736 return script::projectResult(
1737 vm,
1738 withScriptBattle<Result<std::vector<Cell>>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1739 return module.visibleCellsInRange(session.battle, {x, y, layer}, minimum, maximum, parsed);
1740 }),
1741 projectCells);
1742 });
1743 battle.addFunc("lineOfSightAlgorithm", [vm](ScriptTacticsBattle* value) {
1744 return script::projectResult(
1745 vm,
1746 withScriptBattle<Result<std::string>>(value, [](Tactics& module, TacticsBattleSession& session) {
1747 (void)session;
1748 return Result<std::string>::success(std::string(module.lineOfSightAlgorithm()));
1749 }),
1750 [](const std::string& id) { return Value(id); });
1751 });
1752 // Start a client-owned interaction session for one unit. The context is a projection of the
1753 // current revision, so a UI rebuilds it after every state change: the returned object is a
1754 // value, not a subscription.
1755 battle.addFunc("newInteraction", [vm](ScriptTacticsBattle* value, const std::string& unitText) {
1756 auto subject = bindingSubject(unitText, "unit");
1757 if (!subject) return script::projectStatusResult(vm, subject.status());
1758 auto session = bindingSession(value);
1759 if (!session) return script::projectStatusResult(vm, session.status());
1760 Tactics* module = currentModule();
1761 if (module == nullptr)
1763 vm, Status::failure(Diagnostic::error(DiagnosticCode::StaleHandle, "Tactics module is no longer loaded",
1764 "battle")));
1765 auto context = module->interactionContext(session.value()->battle, subject.value());
1766 if (!context) return script::projectStatusResult(vm, context.status());
1767 auto created = InteractionSession::create(std::move(context).takeValue());
1768 auto object = script::makeOwnedSquirrelInstance<ScriptTacticsInteraction>(
1769 vm, std::make_unique<ScriptTacticsInteraction>(ScriptTacticsInteraction{std::move(created)}));
1770 if (!object) return script::projectStatusResult(vm, object.status());
1772 result.set("value", std::move(object).takeValue());
1773 result.set("ownership", std::string("owned"));
1774 return result;
1775 });
1776 // Presentation intents are events projected into data. A script consumer (or a renderer
1777 // bridge) gets the state, the geometry and the explicit revert contract in one value, so it
1778 // never has to guess when a highlight stops being true.
1779 battle.addFunc("presentationIntents", [vm](ScriptTacticsBattle* value, std::int64_t afterSequence,
1780 std::int64_t transientTicks) {
1781 const auto projectIntents = [](const std::vector<PresentationCommand>& commands) {
1782 Value::Array array;
1783 array.reserve(commands.size());
1784 for (const PresentationCommand& command : commands) {
1785 const PresentationIntent& intent = command.intent;
1787 path.reserve(intent.path.size());
1788 for (const Cell cell : intent.path) path.push_back(bindingCellValue(cell));
1789 array.push_back(Value(Value::Object{
1790 {"expiresAtTick", Value(std::to_string(intent.expiresAtTick.value()))},
1791 {"from", bindingCellValue(intent.from)},
1792 {"other", Value(intent.other.isValid() ? intent.other.format() : std::string{})},
1793 {"path", Value(std::move(path))},
1794 {"revert", Value(Value::Object{
1795 {"restingState", Value(std::string(unitVisualStateName(command.revert.restingState)))},
1796 {"sequence", Value(std::to_string(command.revert.sequence))},
1797 {"trigger", Value(std::string(presentationRevertTriggerName(command.revert.trigger)))}})},
1798 {"revision", Value(std::to_string(intent.revision.value()))},
1799 {"sequence", Value(std::to_string(intent.sequence))},
1800 {"state", Value(std::string(unitVisualStateName(intent.state)))},
1801 {"subject", Value(intent.subject.isValid() ? intent.subject.format() : std::string{})},
1802 {"tick", Value(std::to_string(intent.tick.value()))},
1803 {"to", bindingCellValue(intent.to)},
1804 }));
1805 }
1806 return Value(std::move(array));
1807 };
1808 if (afterSequence < 0 || transientTicks < 0)
1809 return script::projectResult(
1810 vm,
1811 Result<std::vector<PresentationCommand>>::failure(Diagnostic::error(
1812 DiagnosticCode::InvalidArgument, "presentation window must be non-negative", "afterSequence")),
1813 projectIntents);
1814 return script::projectResult(
1815 vm,
1816 withScriptBattle<Result<std::vector<PresentationCommand>>>(
1817 value, [&](Tactics& module, TacticsBattleSession& session) {
1818 return module.presentationIntents(session.battle, static_cast<std::uint64_t>(afterSequence),
1819 static_cast<std::uint64_t>(transientTicks));
1820 }),
1821 projectIntents);
1822 });
1823 // End a running battle and enter BattleEnd. Scripts previously had no way to
1824 // close a battle; only the devtools/MCP GameplayControl path could.
1825 battle.addFunc("finish", [vm](ScriptTacticsBattle* value) {
1826 return script::projectResult(
1827 vm, withScriptBattle<Result<void>>(value, [](Tactics& module, TacticsBattleSession& session) {
1828 return module.finish(session.battle);
1829 }));
1830 });
1831 // Directed edge facts: a spec refines exactly one direction, which is how a
1832 // script expresses a one-way drop or a door without duplicating cell facts.
1833 battle.addFunc("addEdge", [vm](ScriptTacticsBattle* value, int fx, int fy, int fl, int tx, int ty, int tl,
1834 const std::string& specJson) {
1835 auto spec = bindingEdgeStateJson(specJson);
1836 if (!spec) return script::projectResult(vm, Result<void>::failure(spec.status()));
1837 return script::projectResult(
1838 vm, withScriptBattle<Result<void>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1839 return module.addEdge(session.battle, {fx, fy, fl}, {tx, ty, tl}, std::move(spec).takeValue());
1840 }));
1841 });
1842 battle.addFunc("edge", [vm](ScriptTacticsBattle* value, int fx, int fy, int fl, int tx, int ty, int tl) {
1843 return script::projectResult(
1844 vm,
1845 withScriptBattle<Result<EdgeState>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1846 return module.edge(session.battle, {fx, fy, fl}, {tx, ty, tl});
1847 }),
1848 bindingEdgeValue);
1849 });
1850 // Declared-or-not is a plain query: an undeclared direction is the normal case.
1851 battle.addFunc("hasEdge", [vm](ScriptTacticsBattle* value, int fx, int fy, int fl, int tx, int ty, int tl) {
1852 auto session = bindingSession(value);
1853 if (!session) return Value(false);
1854 auto* battleValue = dynamic_cast<Battle*>(ecs::try_get(session.value()->battle));
1855 if (battleValue == nullptr) return Value(false);
1856 return Value(battleValue->board()->value.tryEdge({fx, fy, fl}, {tx, ty, tl}).has_value());
1857 });
1858 // Read side of the scheduling choice, so a UI need not infer it from ordering.
1859 battle.addFunc("policyId", [vm](ScriptTacticsBattle* value) {
1860 return script::projectResult(
1861 vm,
1862 withScriptBattle<Result<std::string>>(value, [](Tactics& module, TacticsBattleSession& session) {
1863 return module.policyId(session.battle);
1864 }),
1865 [](const std::string& id) { return Value(id); });
1866 });
1867 // The accepted command log is the replay substrate; scripts may read it but
1868 // never append to it, so replay authority stays with the simulation.
1869 battle.addFunc("commandsFrom", [vm](ScriptTacticsBattle* value, std::int64_t revision) {
1870 const auto project = [](const std::vector<BattleCommand>& commands) {
1871 Value::Array array;
1872 array.reserve(commands.size());
1873 for (const BattleCommand& command : commands) array.push_back(bindingCommandValue(command));
1874 return Value(std::move(array));
1875 };
1876 if (revision < 0)
1877 return script::projectResult(
1878 vm,
1879 Result<std::vector<BattleCommand>>::failure(Diagnostic::error(
1880 DiagnosticCode::InvalidArgument, "command revision must be non-negative", "revision")),
1881 project);
1882 return script::projectResult(
1883 vm,
1884 withScriptBattle<Result<std::vector<BattleCommand>>>(
1885 value, [&](Tactics& module, TacticsBattleSession& session) {
1886 return module.commandsFrom(session.battle, Revision(static_cast<std::uint64_t>(revision)));
1887 }),
1888 project);
1889 });
1890 battle.addFunc("defeatUnit", [vm](ScriptTacticsBattle* value, const std::string& unit) {
1891 auto subject = bindingSubject(unit, "unit");
1893 return script::projectResult(
1894 vm, withScriptBattle<Result<void>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1895 return module.defeatUnit(session.battle, subject.value());
1896 }));
1897 });
1898 battle.addFunc("roll", [vm](ScriptTacticsBattle* value, const std::string& streamText) {
1899 auto stream = bindingLogicalId(streamText, "stream");
1900 if (!stream)
1902 [](std::uint64_t value) { return Value(std::to_string(value)); });
1903 return script::projectResult(
1904 vm,
1905 withScriptBattle<Result<std::uint64_t>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1906 return module.roll(session.battle, stream.value());
1907 }),
1908 [](std::uint64_t value) { return Value(std::to_string(value)); });
1909 });
1910 // Open a deterministic reaction window for an already-emitted battle event.
1911 // Candidates are supplied as JSON so the ordering policy stays in C++.
1912 battle.addFunc("openReaction", [vm](ScriptTacticsBattle* value, std::int64_t triggerSequence,
1913 const std::string& candidatesJson) {
1914 const auto projectDepth = [](std::size_t depth) { return Value(static_cast<std::int64_t>(depth)); };
1915 if (triggerSequence < 0)
1919 "reaction trigger sequence must be non-negative", "triggerSequence")),
1920 projectDepth);
1921 auto candidates = bindingReactionCandidatesJson(candidatesJson);
1922 if (!candidates)
1923 return script::projectResult(vm, Result<std::size_t>::failure(candidates.status()), projectDepth);
1924 return script::projectResult(
1925 vm,
1926 withScriptBattle<Result<std::size_t>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1927 return module.openReaction(session.battle, static_cast<std::uint64_t>(triggerSequence),
1928 std::move(candidates).takeValue());
1929 }),
1930 projectDepth);
1931 });
1932 // Accept one eligible reaction. The receipt reports the remaining reaction
1933 // points so a script controller can decide whether to keep offering windows.
1934 battle.addFunc("acceptReaction", [vm](ScriptTacticsBattle* value, const std::string& reactorText,
1935 const std::string& actionText) {
1936 const auto projectReceipt = [](ReactionReceipt receipt) {
1937 return Value(Value::Object{{"action", Value(receipt.action.format())},
1938 {"reactor", Value(receipt.reactor.format())},
1939 {"remainingReactionPoints", Value(receipt.remainingReactionPoints)},
1940 {"triggerSequence", Value(std::to_string(receipt.triggerSequence))}});
1941 };
1942 auto reactor = bindingSubject(reactorText, "reactor");
1943 if (!reactor)
1944 return script::projectResult(vm, Result<ReactionReceipt>::failure(reactor.status()), projectReceipt);
1945 auto action = bindingLogicalId(actionText, "action");
1946 if (!action)
1947 return script::projectResult(vm, Result<ReactionReceipt>::failure(action.status()), projectReceipt);
1948 return script::projectResult(
1949 vm,
1950 withScriptBattle<Result<ReactionReceipt>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1951 return module.acceptReaction(session.battle, reactor.value(), action.value());
1952 }),
1953 projectReceipt);
1954 });
1955 // Decline and close the top reaction window without consuming resources.
1956 battle.addFunc("declineReaction", [vm](ScriptTacticsBattle* value) {
1957 return script::projectResult(
1958 vm, withScriptBattle<Result<void>>(value, [](Tactics& module, TacticsBattleSession& session) {
1959 return module.declineReaction(session.battle);
1960 }));
1961 });
1962 // Preview queries run the exact commit validator, so a script UI can trust a
1963 // green cell without duplicating the rules or risking a post-click refusal.
1964 battle.addFunc("previewMove", [vm](ScriptTacticsBattle* value, const std::string& actor, int x, int y, int layer) {
1965 auto subject = bindingSubject(actor, "actor");
1966 if (!subject)
1967 return script::projectResult(vm, Result<MoveReceipt>::failure(subject.status()), bindingMoveReceiptValue);
1968 return script::projectResult(
1969 vm,
1970 withScriptBattle<Result<MoveReceipt>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1971 return module.previewMove(session.battle, subject.value(), {x, y, layer});
1972 }),
1973 bindingMoveReceiptValue);
1974 });
1975 battle.addFunc("previewFace", [vm](ScriptTacticsBattle* value, const std::string& actor, int facing) {
1976 auto subject = bindingSubject(actor, "actor");
1978 return script::projectResult(
1979 vm, withScriptBattle<Result<void>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1980 return module.previewFace(session.battle, subject.value(), facing);
1981 }));
1982 });
1983 battle.addFunc("previewWait", [vm](ScriptTacticsBattle* value, const std::string& actor) {
1984 auto subject = bindingSubject(actor, "actor");
1986 return script::projectResult(
1987 vm, withScriptBattle<Result<void>>(value, [&](Tactics& module, TacticsBattleSession& session) {
1988 return module.previewWait(session.battle, subject.value());
1989 }));
1990 });
1991 // Reachability and range enumeration are pure queries: no reservation, no roll.
1992 battle.addFunc("reachable", [vm](ScriptTacticsBattle* value, const std::string& unit, int budget) {
1993 auto subject = bindingSubject(unit, "unit");
1994 if (!subject)
1995 return script::projectResult(vm, Result<Reachability>::failure(subject.status()), bindingReachabilityValue);
1996 if (budget < 0)
1997 return script::projectResult(
1998 vm,
2000 "reachability budget must be non-negative", "budget")),
2001 bindingReachabilityValue);
2002 return script::projectResult(
2003 vm,
2004 withScriptBattle<Result<Reachability>>(value, [&](Tactics& module, TacticsBattleSession& session) {
2005 return module.reachable(session.battle, subject.value(), budget);
2006 }),
2007 bindingReachabilityValue);
2008 });
2009 battle.addFunc("cellsInRange", [vm](ScriptTacticsBattle* value, int x, int y, int layer, int minimum, int maximum,
2010 const std::string& metric) {
2011 const auto projectCells = [](const std::vector<Cell>& cells) { return bindingCellArrayValue(cells); };
2012 CellRangeMetric parsed;
2013 if (metric == "manhattan")
2015 else if (metric == "chebyshev")
2017 else if (metric == "hex")
2018 parsed = CellRangeMetric::Hex;
2019 else
2020 return script::projectResult(
2021 vm,
2022 Result<std::vector<Cell>>::failure(
2023 Diagnostic::error(DiagnosticCode::InvalidArgument, "unknown tactics range metric", "metric")),
2024 projectCells);
2025 return script::projectResult(
2026 vm,
2027 withScriptBattle<Result<std::vector<Cell>>>(value, [&](Tactics& module, TacticsBattleSession& session) {
2028 return module.cellsInRange(session.battle, {x, y, layer}, minimum, maximum, parsed);
2029 }),
2030 projectCells);
2031 });
2032 // Read side of the action economy; `acted` was previously write-only.
2033 battle.addFunc("unitResources", [vm](ScriptTacticsBattle* value, const std::string& unit) {
2034 const auto projectResources = [](const TacticalUnit::TurnResources& resources) {
2035 return bindingTurnResourcesValue(resources);
2036 };
2037 auto subject = bindingSubject(unit, "unit");
2038 if (!subject)
2040 projectResources);
2041 return script::projectResult(
2042 vm,
2043 withScriptBattle<Result<TacticalUnit::TurnResources>>(
2044 value, [&](Tactics& module, TacticsBattleSession& session) {
2045 return module.unitResources(session.battle, subject.value());
2046 }),
2047 projectResources);
2048 });
2049 battle.addFunc("status", [vm](ScriptTacticsBattle* value) {
2050 return script::projectResult(
2051 vm, withScriptBattle<Result<BattleStatus>>(value, [](Tactics& module, TacticsBattleSession& session) {
2052 return module.status(session.battle);
2053 }),
2054 [](BattleStatus status) { return Value(std::string(statusName(status))); });
2055 });
2056 battle.addFunc("phase", [vm](ScriptTacticsBattle* value) {
2057 return script::projectResult(
2058 vm, withScriptBattle<Result<BattlePhase>>(value, [](Tactics& module, TacticsBattleSession& session) {
2059 return module.phase(session.battle);
2060 }),
2061 [](BattlePhase phase) { return Value(std::string(phaseName(phase))); });
2062 });
2063 battle.addFunc("activeUnit", [vm](ScriptTacticsBattle* value) {
2064 return script::projectResult(
2065 vm, withScriptBattle<Result<SubjectRef>>(value, [](Tactics& module, TacticsBattleSession& session) {
2066 return module.activeUnit(session.battle);
2067 }),
2068 [](SubjectRef subject) { return Value(subject.format()); });
2069 });
2070 battle.addFunc("unitCell", [vm](ScriptTacticsBattle* value, const std::string& unitText) {
2071 auto subject = bindingSubject(unitText, "unit");
2072 if (!subject)
2073 return script::projectResult(vm, Result<Cell>::failure(subject.status()), bindingCellValue);
2074 auto session = bindingSession(value);
2075 if (!session)
2076 return script::projectResult(vm, Result<Cell>::failure(session.status()), bindingCellValue);
2077 auto* battleValue = dynamic_cast<Battle*>(ecs::try_get(session.value()->battle));
2078 if (!battleValue)
2081 DiagnosticCode::StaleHandle, "tactics battle entity is stale", "battle")),
2082 bindingCellValue);
2083 const auto cell = battleValue->board()->value.position(subject.value());
2084 if (!cell)
2087 "tactics unit is not placed", "unit")),
2088 bindingCellValue);
2089 return script::projectResult(vm, Result<Cell>::success(*cell), bindingCellValue);
2090 });
2091 battle.addFunc("revision", [](ScriptTacticsBattle* value) -> std::int64_t {
2092 auto session = value ? Tactics::resolve(value->reference) : script::Borrowed<TacticsBattleSession>();
2093 auto* battleValue = session.isBound() ? dynamic_cast<Battle*>(ecs::try_get(session->battle)) : nullptr;
2094 if (!battleValue || battleValue->turn()->revision.value() > static_cast<std::uint64_t>(INT64_MAX)) return -1;
2095 return static_cast<std::int64_t>(battleValue->turn()->revision.value());
2096 });
2097 battle.addFunc("eventCount", [](ScriptTacticsBattle* value) -> std::int64_t {
2098 auto session = value ? Tactics::resolve(value->reference) : script::Borrowed<TacticsBattleSession>();
2099 auto* battleValue = session.isBound() ? dynamic_cast<Battle*>(ecs::try_get(session->battle)) : nullptr;
2100 return battleValue ? static_cast<std::int64_t>(battleValue->events()->values.size()) : 0;
2101 });
2102 battle.addFunc("eventAt", [vm](ScriptTacticsBattle* value, int index) {
2103 auto session = bindingSession(value);
2104 if (!session)
2105 return script::projectResult(vm, Result<BattleEvent>::failure(session.status()), eventProjection);
2106 auto* battleValue = dynamic_cast<Battle*>(ecs::try_get(session.value()->battle));
2107 if (!battleValue || index < 0 || static_cast<std::size_t>(index) >= battleValue->events()->values.size())
2110 DiagnosticCode::NotFound, "tactics event index is out of range", "index")),
2111 eventProjection);
2112 return script::projectResult(
2113 vm, Result<BattleEvent>::success(battleValue->events()->values[static_cast<std::size_t>(index)]),
2114 eventProjection);
2115 });
2116
2117 auto cls = table.addClass(name, Tactics::create, false);
2118 expose(cls);
2119}
2120
2121void Tactics::expose(ssq::Class& cls) {
2122 cls.addFunc("getName", &Tactics::getName);
2123 cls.addFunc("battleCount", [](Tactics* self) { return self->battleCount(); });
2124 cls.addFunc("unitCount", [](Tactics* self) { return self->unitCount(); });
2125 cls.addFunc("sideCount", [](Tactics* self) { return self->sideCount(); });
2126 cls.addFunc("newBattle", [vm = cls.getHandle()](Tactics*, const std::string& subject, std::int64_t seed) {
2127 auto parsed = bindingSubject(subject, "subject");
2128 if (!parsed) return script::projectStatusResult(vm, parsed.status());
2129 if (seed < 0)
2130 return script::projectResult(
2131 vm,
2132 Result<TacticsBattleSessionRef>::failure(
2133 Diagnostic::error(DiagnosticCode::InvalidArgument, "battle seed must be non-negative", "seed")),
2134 [](TacticsBattleSessionRef) { return Value(); });
2135 auto reference = Tactics::newSession(parsed.value(), static_cast<std::uint64_t>(seed));
2136 if (!reference) return script::projectStatusResult(vm, reference.status());
2137 const auto ref = std::move(reference).takeValue();
2138 auto object = script::makeOwnedSquirrelInstance<ScriptTacticsBattle>(
2139 vm, std::make_unique<ScriptTacticsBattle>(ref));
2140 if (!object) {
2141 const Status status = object.status();
2142 object.ignore("failed to create owned tactics battle proxy");
2143 Tactics::release(ref).ignore("rollback failed owned tactics battle allocation");
2145 }
2146 ssq::Object owned = std::move(object).takeValue();
2148 result.set("value", owned);
2149 result.set("ownership", std::string("owned"));
2150 result.set("ownerEpoch", static_cast<std::int64_t>(ref.ownerEpoch));
2151 result.set("handle", static_cast<std::int64_t>(ref.packed()));
2152 return result;
2153 });
2154}
2155
2156} // namespace eve::tactics
LogicalId target
double value
Value::Object payload
bool & active
float y
Definition AnimClip.cpp:738
float x
Definition AnimClip.cpp:738
int subject
Definition AnimSmr.cpp:163
std::string from
ActiveSource owned
AuthorityStoreHandleRef reference
Definition Authority.cpp:24
int priority
Vec3 projected
Definition CaveMesh.cpp:122
float phase
Definition CaveMesh.cpp:58
struct SQVM * HSQUIRRELVM
HSQUIRRELVM vm
Definition ECS.cpp:20
HSQOBJECT cls
Definition ECS.cpp:21
int rows
float maximum[3]
float minimum[3]
wgpu::PopErrorScopeStatus status
glm::uvec4 ids
HexVec3 left
HexVec3 right
HexCoordinates to
Cell the unit walks towards on this segment.
Definition HexUnits.cpp:64
std::string text
Injectable line-of-sight and cover policies over the tactical board.
bool ended
std::string name
ModuleFacing facing
const std::string * tag
#define Module_IMPL(ModuleName, newExpr)
Definition Module.h:26
std::vector< std::weak_ptr< DeviceBytes > > resources
Definition OnnxGpgpu.cpp:55
Topology topology
TileLayer * layer
std::uint64_t revision
std::array< PixelCell, kPixelChunkSize *kPixelChunkSize > cells
std::string action
Definition PlayHost.cpp:117
std::string script
Definition PlayHost.cpp:116
std::string path
Definition PlayHost.cpp:110
std::vector< ActionSpec > actions
Definition PlayHost.cpp:126
std::string id
Definition PlayHost.cpp:108
std::uint32_t seed
Definition PointSet.cpp:807
PrimitiveHandle handle
V3 origin
Definition RoadBake.cpp:138
double number
bool found
int created
MigrationFunction function
bool boolean
std::string string
Injectable content-digest provider for snapshots, with an honest default.
std::string element
The single Squirrel projection for common Result, Status and Value.
Grid line of sight for sensing consumers, answered from a bound tactics board.
Cell cell
TacticalUnit * unit
Battle::Events events
std::vector< UnitCandidate > units
TacticalUnit::TurnResources turn
SimulationTick tick
std::string policyId
ecs::EntityHandle side
std::optional< InteractionSession > session
Definition Tactics.cpp:39
Tactics domain composition profile facade.
Json object
float step
Definition TreeMesh.cpp:314
int sides
Definition TreeMesh.cpp:322
Pluggable deterministic turn-scheduling policies.
uint32_t index
const UnitySourceAsset & source
std::uint32_t depth
std::vector< VegetationPresetCommand > commands
const VegetationPresetContext & context
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
Human-readable, scoped identifier in the form namespace:name.
Definition Identity.h:411
const std::string & format() const noexcept
Returns the canonical namespace:name representation.
Definition Identity.h:436
static std::optional< LogicalId > parse(std::string_view text)
Parses a scoped logical name.
Definition Identity.cpp:36
virtual std::string getName() const =0
Returns the name.
void ignore(std::string_view reason={}) const noexcept
Explicitly discard this result after documenting the reason.
Definition Result.h:537
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
static Result failure(Status status)
Construct a failed result from a structured status.
Definition Result.h:175
Structured status and zero or more diagnostics for an operation.
Definition Status.h:68
static Status failure(StatusCode code, Diagnostic diagnostic)
Construct a failed status with one diagnostic.
Definition Status.h:84
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
std::string format() const
Return the canonical lower-case UUID spelling.
Definition SubjectRef.h:44
bool isValid() const noexcept
Return whether this reference is valid and non-nil.
Definition SubjectRef.h:38
The canonical owning dynamic value used by data-facing protocols.
Definition Value.h:31
static Result< Value > fromJson(std::string_view json)
Parse one strict JSON value into an owning Value.
Definition Value.cpp:57
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
const T * getIf() const
Return an immediately borrowed typed value.
Non-owning C++ observation with no implicit lifetime extension.
static std::vector< BattleCommand > commandsFrom(Battle &battle, Revision fromRevision)
Return an owning copy of commands whose result is newer than fromRevision.
static Result< void > replay(Battle &battle, std::span< const BattleCommand > commands)
Apply commands in sequence and verify revision/sequence agreement after every commit.
static Result< AbilityReceipt > previewAbility(Battle &battle, SubjectRef actor, const LogicalId &action, Cell targetCell, SubjectRef targetUnit={}, std::string_view payload={})
Preview an ability declaration with the exact validator used by commit.
static Result< void > endTurn(Battle &battle, SubjectRef actor)
End the currently acting unit's turn and enter TurnEnd.
static Result< void > defeatUnit(Battle &battle, SubjectRef unit)
Apply a tactical defeat outcome and evaluate affected objectives atomically.
static Result< std::size_t > openReaction(Battle &battle, std::uint64_t triggerSequence, std::vector< ReactionCandidate > candidates)
Open a sorted reaction window for an existing battle event sequence.
static Result< AbilityReceipt > useAbility(Battle &battle, SubjectRef actor, const LogicalId &action, Cell targetCell, SubjectRef targetUnit={}, std::string payload={})
Declare an ability activation by the active unit.
static Result< void > addObjective(Battle &battle, ObjectiveSpec objective)
Register a validated built-in objective during setup.
static Result< std::uint64_t > roll(Battle &battle, const LogicalId &stream)
Consume one value from a battle-owned named deterministic stream.
static Result< void > declineReaction(Battle &battle)
Decline and close the top reaction window without consuming resources.
static Result< void > finish(Battle &battle)
End a running battle and enter BattleEnd.
static Result< MoveReceipt > previewMove(Battle &battle, SubjectRef actor, Cell destination)
Preview movement with the exact validator used by commit, without mutation.
static Result< void > addSide(Battle &battle, ecs::EntityHandle side)
Register a side during setup using a generation-checked handle.
static Result< void > faceUnit(Battle &battle, SubjectRef actor, int facing)
Change the active unit's logical facing without consuming movement points.
static Result< ReactionReceipt > acceptReaction(Battle &battle, SubjectRef reactor, const LogicalId &action)
Accept one eligible reaction, consume one reaction point and close the top window.
static Result< void > previewFace(Battle &battle, SubjectRef actor, int facing)
Preview a facing action with the exact validator used by commit.
static Result< BattlePhase > advance(Battle &battle, const SimulationStep &step)
Advance exactly one observable automatic phase transition.
static Result< void > waitUnit(Battle &battle, SubjectRef actor)
Commit a wait action and enter TurnEnd for the active unit.
static Result< MoveReceipt > moveUnit(Battle &battle, SubjectRef actor, Cell destination)
Validate and atomically commit movement for the active unit.
static Result< void > addUnit(Battle &battle, ecs::EntityHandle unit, ecs::EntityHandle side, Cell cell)
Register and place a unit during setup atomically.
static Result< void > previewWait(Battle &battle, SubjectRef actor)
Preview a wait action with the exact validator used by commit.
static Result< void > start(Battle &battle, std::string policyId)
Start a configured battle and emit battle.started.
Battle short-root and authoritative owner of board and turn state.
Answers "can @p from see @p to" for one board.
Definition LineOfSight.h:61
virtual std::string_view id() const noexcept=0
Stable id of this implementation, for diagnostics and configuration.
static InteractionSession create(InteractionContext context)
Start a session from an immutable context.
static Result< std::vector< Cell > > cellsInRange(const BoardState &board, Cell origin, int minimum, int maximum, CellRangeMetric metric)
Enumerate existing cells whose logical distance is within an inclusive range.
static Result< Reachability > reachable(const BoardState &board, SubjectRef subject, int budget)
Compute all unoccupied reachable cells for a placed subject.
Projects authoritative events into bounded, revertible presentation intents.
Result< std::vector< PresentationCommand > > project(const BattleEvent &event, const BattleCommand *command, const PresentationFrame &frame) const
Project one authoritative event into presentation commands.
Tactical side short-root entity.
Tactical unit short-root entity.
Result< void > bindBattle(ecs::EntityHandle battle)
Bind the battle this adapter answers for.
Result< void > unbindBattle(ecs::EntityHandle battle)
Release the binding when battle is the bound one; otherwise NoOp.
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.
Owns tactics short-root entities and coordinates checked battle setup.
Definition Tactics.h:52
std::size_t battleCount() const noexcept
Return the number of live facade-owned battles.
Definition Tactics.cpp:1283
Result< void > addEdge(ecs::EntityHandle battle, Cell from, Cell to, EdgeState state={})
Declare a directed edge between two adjacent cells during setup.
Definition Tactics.cpp:1022
std::size_t sideCount() const noexcept
Return the number of live facade-owned sides.
Definition Tactics.cpp:1285
Result< ecs::EntityHandle > newUnit(ecs::EntityHandle battle, ecs::EntityHandle side, SubjectRef subject, LogicalId definition, Cell cell, TurnResourceSpec resources={})
Create, register and place a tactical unit atomically.
Definition Tactics.cpp:764
Result< Reachability > reachable(ecs::EntityHandle battle, SubjectRef subject, int budget)
Return the cells a placed subject can reach within an explicit budget.
Definition Tactics.cpp:1005
Result< std::vector< GameplayEvent > > gameplayEvents(const GameplaySession &session, SubjectRef instance, std::uint64_t afterSequence) const override
Return events strictly newer than an instance-local sequence cursor.
Definition Tactics.cpp:697
Result< BattlePhase > phase(ecs::EntityHandle battle)
Return an owning snapshot of the current battle phase.
Definition Tactics.cpp:966
Result< GameplayCommandReceipt > submitGameplay(const GameplaySession &session, SubjectRef instance, const GameplayCommand &command) override
Validate authority and atomically submit one domain command.
Definition Tactics.cpp:600
Result< std::string > policyId(ecs::EntityHandle battle)
Return the stable id of the policy scheduling this battle.
Definition Tactics.cpp:1051
Result< std::uint64_t > roll(ecs::EntityHandle battle, const LogicalId &stream)
Consume one value from a named battle random stream.
Definition Tactics.cpp:950
Result< MoveReceipt > previewMove(ecs::EntityHandle battle, SubjectRef actor, Cell destination)
Validate a move with the exact validator used by commit, without mutating.
Definition Tactics.cpp:983
~Tactics() override
Destroy live entities created through this facade.
Definition Tactics.cpp:489
std::string_view gameplayDomain() const noexcept override
Stable provider domain used for discovery and routing.
Definition Tactics.cpp:497
Result< void > attachLineOfSightBoard(ecs::EntityHandle battle)
Bind one battle as the board that grid line-of-sight queries answer for.
Definition Tactics.cpp:1222
Result< GameplayObservation > advanceGameplay(const GameplaySession &session, SubjectRef instance, const SimulationStep &step) override
Advance one provider-owned instance using injected deterministic time.
Definition Tactics.cpp:685
std::vector< SubjectRef > gameplayInstances() const override
Instances currently served, in publication order.
Definition Tactics.cpp:499
Result< void > previewWait(ecs::EntityHandle battle, SubjectRef actor)
Validate a wait activation with the exact validator used by commit.
Definition Tactics.cpp:998
Result< EdgeState > edge(ecs::EntityHandle battle, Cell from, Cell to)
Return a declared directed edge, or NotFound when none is declared.
Definition Tactics.cpp:1037
static script::Borrowed< TacticsBattleSession > resolve(TacticsBattleSessionRef reference) noexcept
Resolve a live script battle session as a synchronous borrowed view.
Definition Tactics.cpp:1298
Result< void > finish(ecs::EntityHandle battle)
Finish a running battle.
Definition Tactics.cpp:904
Result< void > replay(ecs::EntityHandle battle, std::span< const BattleCommand > commands)
Replay checked commands against a module-owned battle.
Definition Tactics.cpp:1111
Result< void > declineReaction(ecs::EntityHandle battle)
Decline the top reaction window.
Definition Tactics.cpp:929
std::size_t unitCount() const noexcept
Return the number of live facade-owned units.
Definition Tactics.cpp:1284
Result< std::vector< PresentationCommand > > presentationIntents(ecs::EntityHandle battle, std::uint64_t afterSequence, std::uint64_t transientTicks=20)
Project authoritative events after afterSequence into presentable intents.
Definition Tactics.cpp:1232
Result< void > previewFace(ecs::EntityHandle battle, SubjectRef actor, int facing)
Validate a facing change with the exact validator used by commit.
Definition Tactics.cpp:991
Result< AbilityReceipt > useAbility(ecs::EntityHandle battle, SubjectRef actor, const LogicalId &action, Cell targetCell, SubjectRef targetUnit={}, std::string payload={})
Declare an ability activation for the active unit.
Definition Tactics.cpp:863
Result< void > detachLineOfSightBoard(ecs::EntityHandle battle)
Release the line-of-sight binding when battle is the bound one; otherwise NoOp.
Definition Tactics.cpp:1228
Result< ecs::EntityHandle > newBattle(SubjectRef subject, std::uint64_t seed=0)
Create a battle root with a valid stable subject and explicit seed.
Definition Tactics.cpp:724
Result< std::size_t > openReaction(ecs::EntityHandle battle, std::uint64_t triggerSequence, std::vector< ReactionCandidate > candidates)
Open a deterministic reaction window.
Definition Tactics.cpp:911
Result< void > addObjective(ecs::EntityHandle battle, ObjectiveSpec objective)
Add one built-in objective during battle setup.
Definition Tactics.cpp:936
std::string_view lineOfSightAlgorithm() const noexcept
Id of the line-of-sight policy visibleCellsInRange currently uses.
Definition Tactics.cpp:1217
Result< TacticalUnit::TurnResources > unitResources(ecs::EntityHandle battle, SubjectRef unit)
Return an owning snapshot of a placed unit's per-turn resources.
Definition Tactics.cpp:1059
Result< InteractionContext > interactionContext(ecs::EntityHandle battle, SubjectRef controlledUnit)
Build the interaction projection for one controlled unit.
Definition Tactics.cpp:1182
Result< void > restoreJson(ecs::EntityHandle battle, std::string_view json)
Parse, verify and transactionally restore a snapshot from JSON text.
Definition Tactics.cpp:1130
Result< void > waitUnit(ecs::EntityHandle battle, SubjectRef actor)
End the active unit's activation through the wait resolver.
Definition Tactics.cpp:897
Result< MoveReceipt > moveUnit(ecs::EntityHandle battle, SubjectRef actor, Cell destination)
Validate and commit movement for the active unit.
Definition Tactics.cpp:882
Result< void > defeatUnit(ecs::EntityHandle battle, SubjectRef unit)
Apply a defeat outcome from an RPG/game settlement adapter.
Definition Tactics.cpp:943
std::optional< EdgeState > tryEdge(ecs::EntityHandle battle, Cell from, Cell to)
Return a declared directed edge, or empty when none is declared.
Definition Tactics.cpp:1045
Result< void > replayJson(ecs::EntityHandle battle, std::string_view json)
Replay a command log produced by commandLogJson.
Definition Tactics.cpp:1152
Result< GameplayObservation > observeGameplay(const GameplaySession &session, SubjectRef instance) const override
Observe one provider-owned gameplay instance without mutation.
Definition Tactics.cpp:511
static Result< void > release(TacticsBattleSessionRef reference)
Release a script battle session and its ECS aggregate.
Definition Tactics.cpp:1303
Result< std::vector< GameplayActionDescriptor > > availableGameplayActions(const GameplaySession &session, SubjectRef instance, SubjectRef subject) const override
Discover currently legal player-semantic actions for one subject.
Definition Tactics.cpp:562
Result< BattleStatus > status(ecs::EntityHandle battle)
Return an owning snapshot of the current battle status.
Definition Tactics.cpp:958
Result< void > setTopology(ecs::EntityHandle battle, BoardTopology topology)
Select square-four, square-eight, or axial-hex topology during setup.
Definition Tactics.cpp:818
Result< std::vector< Cell > > visibleCellsInRange(ecs::EntityHandle battle, Cell origin, int minimum, int maximum, CellRangeMetric metric)
Cells within a metric range of origin that actually see it.
Definition Tactics.cpp:1167
Tactics()
Construct an empty tactics composition profile.
Definition Tactics.cpp:470
Result< ecs::EntityHandle > newSide(ecs::EntityHandle battle, SubjectRef subject)
Create and register a side with a module-owned setup battle.
Definition Tactics.cpp:743
Result< void > endTurn(ecs::EntityHandle battle, SubjectRef actor)
End the active actor turn.
Definition Tactics.cpp:856
Result< std::string > snapshotJson(ecs::EntityHandle battle)
Seal a snapshot and serialize it as one JSON string.
Definition Tactics.cpp:1118
Result< void > faceUnit(ecs::EntityHandle battle, SubjectRef actor, int facing)
Change the active unit's logical facing.
Definition Tactics.cpp:890
Result< void > addCell(ecs::EntityHandle battle, Cell cell, CellState state={})
Add one authoritative cell to a module-owned setup battle.
Definition Tactics.cpp:803
std::string_view snapshotHashAlgorithm() const noexcept
Id of the content-digest algorithm snapshots currently use.
Definition Tactics.cpp:1165
Result< ReactionReceipt > acceptReaction(ecs::EntityHandle battle, SubjectRef reactor, const LogicalId &action)
Accept one eligible reaction.
Definition Tactics.cpp:920
Result< std::string > commandLogJson(ecs::EntityHandle battle, Revision revision)
Serialize the accepted command log after revision in its persisted shape.
Definition Tactics.cpp:1142
Result< void > start(ecs::EntityHandle battle, std::string_view policyId)
Start a module-owned battle with a registered turn policy.
Definition Tactics.cpp:841
static bool isStale(TacticsBattleSessionRef reference) noexcept
Return whether a script battle-session reference is stale.
Definition Tactics.cpp:1310
static Result< TacticsBattleSessionRef > newSession(SubjectRef subject, std::uint64_t seed=0)
Allocate a script-owned battle session with generation/epoch stale detection.
Definition Tactics.cpp:1287
Result< BattlePhase > advance(ecs::EntityHandle battle, const SimulationStep &step)
Advance one automatic phase using injected deterministic time.
Definition Tactics.cpp:848
Result< AbilityReceipt > previewAbility(ecs::EntityHandle battle, SubjectRef actor, const LogicalId &action, Cell targetCell, SubjectRef targetUnit={}, std::string_view payload={})
Ask whether an ability declaration would be accepted, without declaring it.
Definition Tactics.cpp:872
Result< void > restore(ecs::EntityHandle battle, const SnapshotEnvelope &snapshot, const SnapshotHashProvider &hashProvider)
Transactionally restore a module-owned identity-compatible battle snapshot.
Definition Tactics.cpp:1095
Result< SnapshotEnvelope > snapshot(ecs::EntityHandle battle, const SnapshotHashProvider &hashProvider)
Capture a sealed complete snapshot of a module-owned battle.
Definition Tactics.cpp:1086
Result< std::vector< Cell > > cellsInRange(ecs::EntityHandle battle, Cell origin, int minimum, int maximum, CellRangeMetric metric)
Enumerate existing cells whose logical distance falls in an inclusive range. @cost Linear in the numb...
Definition Tactics.cpp:1013
Result< SubjectRef > activeUnit(ecs::EntityHandle battle)
Return an owning snapshot of the current active unit subject.
Definition Tactics.cpp:1071
Result< std::vector< BattleCommand > > commandsFrom(ecs::EntityHandle battle, Revision revision)
Return accepted commands newer than the supplied battle revision.
Definition Tactics.cpp:1103
static TurnPolicyRegistry & builtins()
Return the process-wide registry carrying the built-in policies.
std::variant< std::monostate, std::int64_t, double, std::string, bool > Value
Definition Database.h:26
eve::StatusCode Status
ssq::Table project(HSQUIRRELVM vm, const eve::Result< void > &result)
Project a completed editing result that has no payload.
std::int32_t moveCost(const HexMap &map, HexCoordinates from, HexCoordinates to, HexDirection direction, const HexOccupancyQuery &occupied)
Cost of moving between two adjacent cells.
const EditorValue * field(const EditorValue &value, const char *name)
std::unordered_map< std::string, SkillDefinition > & table()
Definition Skill.cpp:65
ssq::Table projectStatusResult(HSQUIRRELVM vm, const Status &status)
Project a checked native status that carries no payload.
ssq::Table projectResult(HSQUIRRELVM vm, Result< void > &&result)
Consume and project a void native Result using the common schema.
Result< std::vector< Cell > > visibleCellsInRange(const BoardState &board, const ILineOfSightPolicy &policy, Cell origin, int minimum, int maximum, CellRangeMetric metric)
Cells within a metric range of origin that policy can actually see.
BattleStatus
Tactical battle lifecycle.
std::string_view statusName(BattleStatus status) noexcept
Return the stable protocol spelling of a battle status.
std::string_view interactionStateName(InteractionState state) noexcept
Human-readable stable spelling of an interaction state (protocol text).
std::string_view presentationRevertTriggerName(PresentationRevertTrigger trigger) noexcept
Human-readable stable spelling of a revert trigger (protocol text).
std::string_view unitVisualStateName(UnitVisualState state) noexcept
Human-readable stable spelling of a visual state (protocol text).
UnitVisualState
How a consumer should draw one unit.
@ Finished
Already finished this round.
@ Destroyed
Defeated; terminal.
@ Idle
Resting, nothing special.
std::shared_ptr< ILineOfSightPolicy > gridLineOfSightPolicy()
The process-wide built-in line-of-sight policy.
std::string_view commandKindName(BattleCommandKind kind) noexcept
Return the stable protocol spelling of an accepted command kind.
CellRangeMetric
Deterministic logical distance used by cellsInRange.
Definition TacticsPath.h:15
std::string_view interactionIntentKindName(InteractionIntentKind kind) noexcept
Human-readable stable spelling of an intent kind (protocol text).
Result< void > registerTacticsLineOfSightProvider()
Claim the grid coordinate spaces for the tactics adapter.
script::RuntimeHandleRef< TacticsBattleSessionTag > TacticsBattleSessionRef
Generation- and module-epoch-qualified battle-session reference.
Definition Tactics.h:25
BattlePhase
Explicit, externally observable battle phase.
std::string_view phaseName(BattlePhase phase) noexcept
Return the stable protocol spelling of a battle phase.
BoardTopology
Supported deterministic board-neighbour topologies.
TacticsLineOfSightAdapter & tacticsLineOfSightAdapter()
The process-wide adapter instance the capability answers with.
Result< SnapshotEnvelope > parseSnapshotEnvelope(std::string_view json, const SnapshotHashProvider &hashProvider)
Parse and verify an envelope from canonical or compatible JSON text.
Definition Snapshot.cpp:190
Result< std::string > serializeSnapshotEnvelope(const SnapshotEnvelope &snapshot)
Serialize an envelope as deterministic compact JSON.
Definition Snapshot.cpp:184
SnapshotHashProvider snapshotContentHashProvider()
A SnapshotHashProvider backed by the active hasher.
std::string_view activeSnapshotHashAlgorithm() noexcept
Id of the algorithm snapshotContentHashProvider currently uses.
std::function< Result< ContentId >(std::string_view canonicalInput)> SnapshotHashProvider
Injected content-digest implementation used by snapshots.
Definition Snapshot.h:36
Owning evidence that a gameplay authority accepted one command.
One player-semantic command submitted to a domain authority.
std::uint64_t expectedRevision
SimulationTick observedTick
Owning event projection safe across frames and process boundaries.
std::uint64_t sequence
Immutable owning observation of one authoritative gameplay instance.
Owning authorization context shared by player, script and automation adapters.
One deterministic fixed-step emitted by SimulationClock.
Definition Time.h:158
Stable outer format shared by persistence and cross-process snapshots.
Definition Snapshot.h:46
Handle plus owner-lifetime epoch for a non-ECS registry object.
One declared ability activation and the resource state it left behind.
int remainingActionPoints
Remaining action points after the activation was paid for.
SubjectRef targetUnit
The targeted unit, or an invalid subject when the ability targets a cell only.
One accepted replay command using only persistent identities and values.
One deterministic battle lifecycle event owned by Battle.
Owning persistent board record used by snapshots and replay inspection.
One authoritative board cell fact.
Logical board coordinate independent of rendering projection.
One authoritative directed edge fact between two adjacent cells.
Immutable projection of everything the interaction machine is allowed to know.
Definition Interaction.h:98
Committed tactical movement and remaining resource state.
Persistent objective definition using stable side identities.
What the projector knows about the battle beyond the event itself.
std::uint64_t transientTicks
How long a transient state stays valid, in ticks.
std::map< std::string, UnitVisualState > restingState
Resting state of each subject, used as the revert target.
Revision revision
Battle revision the frame was observed at.
One reachable cell and its minimum fixed-point movement cost.
Definition TacticsPath.h:18
~TacticsBattleSession() noexcept
Releases TacticsBattleSession resources.
Definition Tactics.cpp:479
Initial and per-round tactical resource values for one unit.