Files

864 lines
30 KiB
C++

#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "rpg/AttributeTypes.h"
#include "rpg/AttributeSystem.h"
#include "rpg/RPGActor.h"
#include "rpg/Effect.h"
#include "rpg/StatusSystem.h"
#include "rpg/Skill.h"
#include "rpg/SkillSystem.h"
#include "rpg/Settlement.h"
#include "rpg/RPG.h"
#include <cmath>
#include <string>
#include <vector>
using namespace eve::rpg;
namespace {
bool approxEq(double a, double b, double eps = 1e-9) { return std::abs(a - b) < eps; }
} // namespace
// ---------------------------------------------------------------------------
// Attribute math (pure function, no ECS)
// ---------------------------------------------------------------------------
TEST_CASE("rpg.attribute.pureCompute.addMulAddMulMul") {
AttributeValue av;
av.base = 100.0;
av.modifiers.push_back({"m1", "src", "add", 10.0, 0});
av.modifiers.push_back({"m2", "src", "mulAdd", 0.5, 0}); // +50%
av.modifiers.push_back({"m3", "src", "mulMul", 0.1, 0}); // *1.1
// (100 + 10) * (1 + 0.5) * (1 + 0.1) = 181.5
CHECK(approxEq(computeAttributeValue(av), 181.5));
}
TEST_CASE("rpg.attribute.pureCompute.overridePriorityWins") {
AttributeValue av;
av.base = 5.0;
av.modifiers.push_back({"a", "src", "override", 50.0, 5});
av.modifiers.push_back({"b", "src", "override", 20.0, 1}); // lower priority: applied first
// sequential ascending priority: override 20 first, then override 50 -> final 50
CHECK_EQ(computeAttributeValue(av), 50.0);
}
TEST_CASE("rpg.attribute.pureCompute.clamp") {
AttributeValue av;
av.base = 200.0;
av.modifiers.push_back({"a", "src", "clampMax", 100.0, 0});
av.modifiers.push_back({"b", "src", "clampMin", 10.0, 0});
CHECK_EQ(computeAttributeValue(av), 100.0);
}
TEST_CASE("rpg.attribute.pureCompute.customOp") {
AttributeOpTable table;
table.registerOp("square", [](double, double value) { return value * value; });
AttributeValue av;
av.base = 1.0;
av.modifiers.push_back({"a", "src", "square", 4.0, 0});
CHECK_EQ(computeAttributeValue(av, &table), 16.0);
// Unknown custom op falls back to leaving result untouched.
AttributeValue av2;
av2.base = 7.0;
av2.modifiers.push_back({"a", "src", "totallyUnknownOp", 4.0, 0});
CHECK_EQ(computeAttributeValue(av2, &table), 7.0);
}
// ---------------------------------------------------------------------------
// AttributeSystem (actor-facing, with caching)
// ---------------------------------------------------------------------------
TEST_CASE("rpg.attributeSystem.setGetFinalWithModifiers") {
RPGActor *actor = RPGActor::createActor();
actor->setBaseAttribute("strength", 10.0);
CHECK_EQ(actor->getBaseAttribute("strength"), 10.0);
CHECK(actor->hasAttribute("strength"));
CHECK(!actor->hasAttribute("nope"));
CHECK_EQ(actor->getFinalAttribute("strength"), 10.0);
std::string modId = actor->addAttributeModifier("strength", "gear:sword", "add", 5.0);
CHECK(!modId.empty());
CHECK_EQ(actor->getFinalAttribute("strength"), 15.0);
actor->modifyBaseAttribute("strength", 2.0);
CHECK_EQ(actor->getBaseAttribute("strength"), 12.0);
CHECK_EQ(actor->getFinalAttribute("strength"), 17.0);
CHECK(actor->removeAttributeModifier("strength", modId));
CHECK_EQ(actor->getFinalAttribute("strength"), 12.0);
// Removing again is a no-op.
CHECK(!actor->removeAttributeModifier("strength", modId));
actor->release();
}
TEST_CASE("rpg.attributeSystem.removeBySource") {
RPGActor *actor = RPGActor::createActor();
actor->setBaseAttribute("hp", 100.0);
actor->addAttributeModifier("hp", "buffA", "add", 10.0);
actor->addAttributeModifier("hp", "buffA", "add", 5.0);
actor->addAttributeModifier("mana", "buffA", "add", 20.0);
actor->setBaseAttribute("mana", 50.0);
CHECK_EQ(actor->getFinalAttribute("hp"), 115.0);
CHECK_EQ(actor->removeAttributeModifiersBySource("hp", "buffA"), 2);
CHECK_EQ(actor->getFinalAttribute("hp"), 100.0);
CHECK_EQ(actor->getFinalAttribute("mana"), 70.0);
CHECK_EQ(actor->removeAllAttributeModifiersBySource("buffA"), 1);
CHECK_EQ(actor->getFinalAttribute("mana"), 50.0);
actor->release();
}
// ---------------------------------------------------------------------------
// Effect registry
// ---------------------------------------------------------------------------
TEST_CASE("rpg.effect.registryCRUD") {
EffectDefinition def;
def.id = "test.effect.crud";
def.durationPolicy = "duration";
def.duration = 3.f;
def.modifiers.push_back({"attack", "mulAdd", 0.2, 0});
def.tags = {"buff"};
EffectRegistry::registerEffect(def);
const EffectDefinition *found = EffectRegistry::find("test.effect.crud");
REQUIRE(found != nullptr);
CHECK_EQ(found->duration, 3.f);
CHECK(found->hasTag("buff"));
CHECK(EffectRegistry::remove("test.effect.crud"));
CHECK(EffectRegistry::find("test.effect.crud") == nullptr);
}
TEST_CASE("rpg.effect.loadFromJsonArray") {
const char *json = R"([
{"id":"test.effect.json.a","durationPolicy":"duration","duration":2,
"modifiers":[{"attribute":"speed","op":"add","value":3}]},
{"id":"test.effect.json.b","durationPolicy":"infinite","period":1,
"stackPolicy":"stack","maxStacks":3,"tags":["dot","poison"]}
])";
std::string err;
int n = EffectRegistry::loadFromJson(json, &err);
CHECK_EQ(n, 2);
CHECK(err.empty());
const EffectDefinition *a = EffectRegistry::find("test.effect.json.a");
REQUIRE(a != nullptr);
CHECK_EQ(a->duration, 2.f);
REQUIRE(a->modifiers.size() == 1);
CHECK_EQ(a->modifiers[0].attribute, "speed");
const EffectDefinition *b = EffectRegistry::find("test.effect.json.b");
REQUIRE(b != nullptr);
CHECK_EQ(b->period, 1.f);
CHECK_EQ(b->maxStacks, 3);
CHECK(b->hasTag("poison"));
EffectRegistry::remove("test.effect.json.a");
EffectRegistry::remove("test.effect.json.b");
}
TEST_CASE("rpg.effect.loadFromJsonExtraAndGetExtra") {
const char *json = R"({
"id":"test.effect.extra","durationPolicy":"duration","duration":3,
"tags":["buff"],
"extra":{"icon":"ui/power.png","vfx":"fx.glow"}
})";
std::string err;
CHECK_EQ(EffectRegistry::loadFromJson(json, &err), 1);
CHECK(err.empty());
const EffectDefinition *def = EffectRegistry::find("test.effect.extra");
REQUIRE(def != nullptr);
CHECK_EQ(def->getExtra("icon"), "ui/power.png");
CHECK_EQ(def->getExtra("vfx"), "fx.glow");
CHECK_EQ(def->getExtra("missing", "fallback"), "fallback");
EffectRegistry::remove("test.effect.extra");
}
// ---------------------------------------------------------------------------
// StatusSystem: instant / duration / stacking / periodic ticks
// ---------------------------------------------------------------------------
TEST_CASE("rpg.status.instantEffectAppliesImmediatelyNoInstance") {
EffectDefinition heal;
heal.id = "test.status.instantHeal";
heal.durationPolicy = "instant";
heal.modifiers.push_back({"health", "add", 25.0, 0});
EffectRegistry::registerEffect(heal);
RPGActor *actor = RPGActor::createActor();
actor->setBaseAttribute("health", 50.0);
int result = actor->applyEffect("test.status.instantHeal");
CHECK_EQ(result, 0);
CHECK_EQ(actor->getBaseAttribute("health"), 75.0);
CHECK_EQ(actor->getStatusCount(), 0);
actor->release();
EffectRegistry::remove("test.status.instantHeal");
}
TEST_CASE("rpg.status.durationBuffAppliesAndExpires") {
EffectDefinition buff;
buff.id = "test.status.durationBuff";
buff.durationPolicy = "duration";
buff.duration = 1.0f;
buff.modifiers.push_back({"attack", "add", 10.0, 0});
EffectRegistry::registerEffect(buff);
RPGActor *actor = RPGActor::createActor();
actor->setBaseAttribute("attack", 20.0);
int instanceId = actor->applyEffect("test.status.durationBuff", "test-source");
CHECK(instanceId > 0);
CHECK_EQ(actor->getFinalAttribute("attack"), 30.0);
CHECK_EQ(actor->getStatusCount(), 1);
CHECK(actor->hasEffect("test.status.durationBuff"));
CHECK(StatusSystem::update(0.5).ok());
CHECK_EQ(actor->getStatusCount(), 1);
CHECK_EQ(actor->getFinalAttribute("attack"), 30.0);
CHECK(StatusSystem::update(0.6).ok()); // total elapsed 1.1s > duration 1.0s
CHECK_EQ(actor->getStatusCount(), 0);
CHECK_EQ(actor->getFinalAttribute("attack"), 20.0);
CHECK(!actor->hasEffect("test.status.durationBuff"));
actor->release();
EffectRegistry::remove("test.status.durationBuff");
}
TEST_CASE("rpg.status.stackPolicyNoneRejectsDuplicate") {
EffectDefinition def;
def.id = "test.status.stackNone";
def.durationPolicy = "infinite";
def.stackPolicy = "none";
EffectRegistry::registerEffect(def);
RPGActor *actor = RPGActor::createActor();
int id1 = actor->applyEffect("test.status.stackNone");
CHECK(id1 > 0);
int id2 = actor->applyEffect("test.status.stackNone");
CHECK_EQ(id2, -1);
CHECK_EQ(actor->getStatusCount(), 1);
actor->removeStatusByEffect("test.status.stackNone");
actor->release();
EffectRegistry::remove("test.status.stackNone");
}
TEST_CASE("rpg.status.stackPolicyRefreshAndExtend") {
EffectDefinition refreshDef;
refreshDef.id = "test.status.refresh";
refreshDef.durationPolicy = "duration";
refreshDef.duration = 2.0f;
refreshDef.stackPolicy = "refresh";
EffectRegistry::registerEffect(refreshDef);
EffectDefinition extendDef;
extendDef.id = "test.status.extend";
extendDef.durationPolicy = "duration";
extendDef.duration = 2.0f;
extendDef.stackPolicy = "extend";
EffectRegistry::registerEffect(extendDef);
RPGActor *actor = RPGActor::createActor();
int r1 = actor->applyEffect("test.status.refresh");
CHECK(StatusSystem::update(1.5).ok());
CHECK(approxEq(actor->getStatusRemaining(0), 0.5, 1e-4));
int r2 = actor->applyEffect("test.status.refresh");
CHECK_EQ(r1, r2);
CHECK(approxEq(actor->getStatusRemaining(0), 2.0, 1e-4));
actor->removeStatusByEffect("test.status.refresh");
int e1 = actor->applyEffect("test.status.extend");
CHECK(StatusSystem::update(1.0).ok());
int e2 = actor->applyEffect("test.status.extend");
CHECK_EQ(e1, e2);
// remaining was ~1.0 before extend, +2.0 duration = ~3.0
CHECK(approxEq(actor->getStatusRemaining(0), 3.0, 1e-4));
actor->removeStatusByEffect("test.status.extend");
actor->release();
EffectRegistry::remove("test.status.refresh");
EffectRegistry::remove("test.status.extend");
}
TEST_CASE("rpg.status.stackPolicyStackScalesModifierAndCaps") {
EffectDefinition def;
def.id = "test.status.stack";
def.durationPolicy = "infinite";
def.stackPolicy = "stack";
def.maxStacks = 2;
def.modifiers.push_back({"armor", "add", 5.0, 0});
EffectRegistry::registerEffect(def);
RPGActor *actor = RPGActor::createActor();
actor->setBaseAttribute("armor", 0.0);
int id1 = actor->applyEffect("test.status.stack");
CHECK_EQ(actor->getFinalAttribute("armor"), 5.0);
CHECK_EQ(actor->getStatusStacks(0), 1);
int id2 = actor->applyEffect("test.status.stack");
CHECK_EQ(id1, id2);
CHECK_EQ(actor->getStatusStacks(0), 2);
CHECK_EQ(actor->getFinalAttribute("armor"), 10.0);
// Already at maxStacks: stays capped.
actor->applyEffect("test.status.stack");
CHECK_EQ(actor->getStatusStacks(0), 2);
CHECK_EQ(actor->getFinalAttribute("armor"), 10.0);
actor->removeStatus(id1);
CHECK_EQ(actor->getFinalAttribute("armor"), 0.0);
CHECK_EQ(actor->getStatusCount(), 0);
actor->release();
EffectRegistry::remove("test.status.stack");
}
TEST_CASE("rpg.status.periodicEffectProducesTickEvents") {
EffectDefinition dot;
dot.id = "test.status.periodicDot";
dot.durationPolicy = "duration";
dot.duration = 2.5f;
dot.period = 1.0f;
EffectRegistry::registerEffect(dot);
RPGActor *actor = RPGActor::createActor();
int instanceId = actor->applyEffect("test.status.periodicDot", "caster-1");
CHECK(instanceId > 0);
// Periodic effects do not touch attributes directly.
CHECK(!actor->hasAttribute("health"));
std::vector<StatusTickEvent> ticks;
CHECK(StatusSystem::update(1.0).ok());
StatusSystem::pollTicks(ticks);
REQUIRE(ticks.size() == 1);
CHECK_EQ(ticks[0].actor, actor);
CHECK_EQ(ticks[0].effectId, "test.status.periodicDot");
CHECK_EQ(ticks[0].source, "caster-1");
ticks.clear();
CHECK(StatusSystem::update(1.0).ok()); // t=2.0 -> another tick
StatusSystem::pollTicks(ticks);
REQUIRE(ticks.size() == 1);
ticks.clear();
CHECK(StatusSystem::update(1.0).ok()); // t=3.0 > duration 2.5 -> expires; no more ticks after expiry
StatusSystem::pollTicks(ticks);
CHECK(ticks.size() <= 1); // may or may not tick exactly at expiry boundary depending on order
CHECK_EQ(actor->getStatusCount(), 0);
ticks.clear();
CHECK(StatusSystem::update(5.0).ok());
StatusSystem::pollTicks(ticks);
CHECK_EQ(ticks.size(), 0u); // expired: no further ticks ever
actor->release();
EffectRegistry::remove("test.status.periodicDot");
}
TEST_CASE("rpg.status.removeByTagAndBySource") {
EffectDefinition a;
a.id = "test.status.tagA";
a.durationPolicy = "infinite";
a.tags = {"debuff", "curse"};
EffectRegistry::registerEffect(a);
EffectDefinition b;
b.id = "test.status.tagB";
b.durationPolicy = "infinite";
b.tags = {"debuff"};
EffectRegistry::registerEffect(b);
RPGActor *actor = RPGActor::createActor();
actor->applyEffect("test.status.tagA", "dispellSrc");
actor->applyEffect("test.status.tagB", "otherSrc");
CHECK_EQ(actor->getStatusCount(), 2);
CHECK_EQ(actor->removeStatusByTag("curse"), 1);
CHECK_EQ(actor->getStatusCount(), 1);
CHECK(!actor->hasEffect("test.status.tagA"));
CHECK_EQ(actor->removeStatusBySource("otherSrc"), 1);
CHECK_EQ(actor->getStatusCount(), 0);
actor->release();
EffectRegistry::remove("test.status.tagA");
EffectRegistry::remove("test.status.tagB");
}
TEST_CASE("rpg.buff.applyConditionRejectsAndEmitsChange") {
// Drain any leftover change events from prior cases.
std::vector<StatusChangeEvent> drain;
StatusSystem::pollChanges(drain);
EffectDefinition buff;
buff.id = "test.buff.immuneTarget";
buff.durationPolicy = "duration";
buff.duration = 5.f;
buff.tags = {"buff", "magic"};
EffectRegistry::registerEffect(buff);
StatusSystem::registerApplyCondition("blockMagic",
[](RPGActor *, const EffectDefinition &def,
const std::string &, std::string &reason) {
if (def.hasTag("magic")) {
reason = "immune:magic";
return false;
}
return true;
});
CHECK(StatusSystem::hasApplyCondition("blockMagic"));
RPGActor *actor = RPGActor::createActor();
const int rejected = actor->applyBuff("test.buff.immuneTarget");
CHECK_EQ(rejected, -1);
CHECK(!actor->hasBuff("test.buff.immuneTarget"));
CHECK_EQ(actor->getBuffCount(), 0);
std::vector<StatusChangeEvent> changes;
StatusSystem::pollChanges(changes);
REQUIRE(changes.size() == 1);
CHECK_EQ(changes[0].action, "reject");
CHECK_EQ(changes[0].effectId, "test.buff.immuneTarget");
CHECK_EQ(changes[0].reason, "immune:magic");
StatusSystem::unregisterApplyCondition("blockMagic");
int id = actor->applyBuff("test.buff.immuneTarget", "caster");
CHECK(id > 0);
CHECK(actor->hasBuff("test.buff.immuneTarget"));
CHECK(actor->hasBuffTag("buff"));
CHECK_EQ(actor->getBuffSource(0), "caster");
actor->setBuffProp(id, "iconOverride", "ui/custom.png");
CHECK_EQ(actor->getBuffProp(id, "iconOverride"), "ui/custom.png");
CHECK_EQ(actor->getBuffProp(id, "missing", "n/a"), "n/a");
changes.clear();
StatusSystem::pollChanges(changes);
REQUIRE(changes.size() == 1);
CHECK_EQ(changes[0].action, "apply");
CHECK_EQ(changes[0].instanceId, id);
CHECK(actor->removeBuff(id));
changes.clear();
StatusSystem::pollChanges(changes);
REQUIRE(changes.size() == 1);
CHECK_EQ(changes[0].action, "remove");
actor->release();
EffectRegistry::remove("test.buff.immuneTarget");
StatusSystem::clearApplyConditions();
}
TEST_CASE("rpg.buff.customStackPolicyAndLifecycleHook") {
std::vector<StatusChangeEvent> drain;
StatusSystem::pollChanges(drain);
EffectDefinition def;
def.id = "test.buff.customStack";
def.durationPolicy = "duration";
def.duration = 2.f;
def.stackPolicy = "additiveDuration";
def.modifiers.push_back({"attack", "add", 3.0, 0});
EffectRegistry::registerEffect(def);
StatusSystem::registerStackPolicy(
"additiveDuration",
[](RPGActor *, StatusInstance &existing, const EffectDefinition &effect,
const std::string &) {
// Each re-apply adds duration and one stack, uncapped for the test.
existing.stacks += 1;
if (existing.remaining >= 0.f) existing.remaining += effect.duration;
return existing.instanceId;
});
CHECK(StatusSystem::hasStackPolicy("additiveDuration"));
int hookCalls = 0;
std::string lastAction;
StatusSystem::registerLifecycleHook("counter", [&](const StatusChangeEvent &ev) {
++hookCalls;
lastAction = ev.action;
});
RPGActor *actor = RPGActor::createActor();
actor->setBaseAttribute("attack", 10.0);
int id1 = actor->applyBuff("test.buff.customStack");
CHECK(id1 > 0);
CHECK_EQ(actor->getFinalAttribute("attack"), 13.0);
CHECK_EQ(hookCalls, 1);
CHECK_EQ(lastAction, "apply");
int id2 = actor->applyBuff("test.buff.customStack");
CHECK_EQ(id2, id1);
CHECK_EQ(actor->getBuffStacks(0), 2);
CHECK(approxEq(actor->getBuffRemaining(0), 4.0, 1e-4));
CHECK_EQ(actor->getFinalAttribute("attack"), 16.0); // 3 * 2 stacks
CHECK_EQ(lastAction, "stack");
CHECK(StatusSystem::update(4.1).ok());
CHECK_EQ(actor->getBuffCount(), 0);
CHECK_EQ(actor->getFinalAttribute("attack"), 10.0);
CHECK_EQ(lastAction, "expire");
std::vector<StatusChangeEvent> changes;
StatusSystem::pollChanges(changes);
CHECK(changes.size() >= 3);
CHECK_EQ(changes.back().action, "expire");
actor->release();
EffectRegistry::remove("test.buff.customStack");
StatusSystem::clearStackPolicies();
StatusSystem::clearLifecycleHooks();
}
TEST_CASE("rpg.buff.facadeStatusChangeEvents") {
std::vector<StatusChangeEvent> drain;
StatusSystem::pollChanges(drain);
RPG *rpg = RPG::create();
rpg->clearEffectDefinitions();
CHECK_EQ(rpg->registerEffectsFromJson(R"([
{"id":"buff.facade","durationPolicy":"duration","duration":0.5,
"stackPolicy":"refresh","tags":["buff"],
"extra":{"icon":"ui/buff.png"}}
])"),
1);
RPGActor *actor = rpg->newActor();
int id = actor->applyBuff("buff.facade");
CHECK(id > 0);
// apply happens before update; changes are polled inside update().
rpg->update(0.f);
CHECK_EQ(rpg->getStatusChangeEventCount(), 1);
CHECK_EQ(rpg->getStatusChangeEventAction(0), "apply");
CHECK_EQ(rpg->getStatusChangeEventEffectId(0), "buff.facade");
CHECK_EQ(rpg->getStatusChangeEventInstanceId(0), id);
rpg->update(0.6f);
CHECK_EQ(rpg->getStatusChangeEventCount(), 1);
CHECK_EQ(rpg->getStatusChangeEventAction(0), "expire");
CHECK_EQ(actor->getBuffCount(), 0);
actor->release();
rpg->clearEffectDefinitions();
}
// ---------------------------------------------------------------------------
// Skill / SkillSystem
// ---------------------------------------------------------------------------
TEST_CASE("rpg.skill.registryAndCanCastChecks") {
SkillDefinition def;
def.id = "test.skill.bolt";
def.cooldown = 4.0f;
def.castTime = 0.f;
auto boltCost = eve::resource::CostSpec::single("mana", 10);
REQUIRE(boltCost.hasValue());
def.cost = std::move(boltCost).takeValue();
SkillRegistry::registerSkill(def);
RPGActor *actor = RPGActor::createActor();
actor->setBaseAttribute("mana", 5.0);
CHECK(!actor->canCastSkill("test.skill.bolt"));
CHECK_EQ(actor->canCastSkillReason("test.skill.bolt"), "not_learned");
actor->learnSkill("test.skill.bolt");
CHECK(actor->knowsSkill("test.skill.bolt"));
CHECK(!actor->canCastSkill("test.skill.bolt"));
CHECK_EQ(actor->canCastSkillReason("test.skill.bolt"), "insufficient_cost");
actor->setBaseAttribute("mana", 50.0);
CHECK(actor->canCastSkill("test.skill.bolt"));
actor->release();
SkillRegistry::remove("test.skill.bolt");
}
TEST_CASE("rpg.skill.instantCastDeductsCostSetsCooldownAndAppliesEffect") {
EffectDefinition burn;
burn.id = "test.skill.effect.burn";
burn.durationPolicy = "duration";
burn.duration = 5.f;
burn.modifiers.push_back({"defense", "add", -3.0, 0});
EffectRegistry::registerEffect(burn);
SkillDefinition def;
def.id = "test.skill.instant";
def.cooldown = 3.0f;
def.castTime = 0.f;
auto instantCost = eve::resource::CostSpec::single("mana", 10);
REQUIRE(instantCost.hasValue());
def.cost = std::move(instantCost).takeValue();
def.grantedEffects.push_back("test.skill.effect.burn");
SkillRegistry::registerSkill(def);
RPGActor *caster = RPGActor::createActor();
RPGActor *target = RPGActor::createActor();
caster->setBaseAttribute("mana", 20.0);
target->setBaseAttribute("defense", 10.0);
caster->learnSkill("test.skill.instant");
CHECK(caster->beginCastSkill("test.skill.instant", target));
CHECK_EQ(caster->getBaseAttribute("mana"), 10.0);
CHECK_EQ(caster->getSkillCooldown("test.skill.instant"), 3.0f);
CHECK(!caster->canCastSkill("test.skill.instant"));
CHECK_EQ(caster->canCastSkillReason("test.skill.instant"), "on_cooldown");
CHECK_EQ(target->getFinalAttribute("defense"), 7.0);
std::vector<SkillCastEvent> events;
SkillSystem::pollCastEvents(events);
REQUIRE(events.size() == 1);
CHECK_EQ(events[0].caster, caster);
CHECK_EQ(events[0].target, target);
CHECK_EQ(events[0].skillId, "test.skill.instant");
SkillSystem::update(3.1f);
CHECK(caster->canCastSkill("test.skill.instant"));
caster->release();
target->release();
SkillRegistry::remove("test.skill.instant");
EffectRegistry::remove("test.skill.effect.burn");
}
TEST_CASE("rpg.skill.castTimeProgressAndResolution") {
SkillDefinition def;
def.id = "test.skill.channeled";
def.cooldown = 0.f;
def.castTime = 2.0f;
SkillRegistry::registerSkill(def);
RPGActor *actor = RPGActor::createActor();
actor->learnSkill("test.skill.channeled");
CHECK(actor->beginCastSkill("test.skill.channeled"));
CHECK(actor->isCastingSkill());
CHECK_EQ(actor->getCastingSkillId(), "test.skill.channeled");
CHECK_EQ(actor->getCastProgress(), 0.f);
SkillSystem::update(1.0f);
CHECK(actor->isCastingSkill());
CHECK(approxEq(actor->getCastProgress(), 0.5, 1e-4));
std::vector<SkillCastEvent> events;
SkillSystem::pollCastEvents(events);
CHECK(events.empty()); // not resolved yet
SkillSystem::update(1.1f);
CHECK(!actor->isCastingSkill());
SkillSystem::pollCastEvents(events);
REQUIRE(events.size() == 1);
CHECK_EQ(events[0].skillId, "test.skill.channeled");
actor->release();
SkillRegistry::remove("test.skill.channeled");
}
TEST_CASE("rpg.skill.cancelCastStopsResolution") {
SkillDefinition def;
def.id = "test.skill.cancelable";
def.castTime = 5.0f;
SkillRegistry::registerSkill(def);
RPGActor *actor = RPGActor::createActor();
actor->learnSkill("test.skill.cancelable");
actor->beginCastSkill("test.skill.cancelable");
CHECK(actor->isCastingSkill());
actor->cancelCastSkill();
CHECK(!actor->isCastingSkill());
SkillSystem::update(10.0f);
std::vector<SkillCastEvent> events;
SkillSystem::pollCastEvents(events);
CHECK(events.empty());
actor->release();
SkillRegistry::remove("test.skill.cancelable");
}
TEST_CASE("rpg.skill.customCastConditionBlocksCast") {
SkillDefinition def;
def.id = "test.skill.guarded";
SkillRegistry::registerSkill(def);
RPGActor *actor = RPGActor::createActor();
actor->learnSkill("test.skill.guarded");
SkillSystem::registerCastCondition("no_silence", [](RPGActor *a, const SkillDefinition &,
std::string &reason) {
if (a->hasEffect("silence")) {
reason = "silenced";
return false;
}
return true;
});
CHECK(actor->canCastSkill("test.skill.guarded"));
EffectDefinition silence;
silence.id = "silence";
silence.durationPolicy = "infinite";
EffectRegistry::registerEffect(silence);
actor->applyEffect("silence");
CHECK(!actor->canCastSkill("test.skill.guarded"));
CHECK_EQ(actor->canCastSkillReason("test.skill.guarded"), "silenced");
SkillSystem::unregisterCastCondition("no_silence");
CHECK(actor->canCastSkill("test.skill.guarded"));
actor->removeStatusByEffect("silence");
actor->release();
SkillRegistry::remove("test.skill.guarded");
EffectRegistry::remove("silence");
}
// ---------------------------------------------------------------------------
// Settlement pipeline
// ---------------------------------------------------------------------------
TEST_CASE("rpg.settlement.stagesRunInPriorityOrder") {
SettlementPipeline::clearPipeline("test.pipeline.order");
std::vector<std::string> order;
SettlementPipeline::registerStage("test.pipeline.order", "second", 10,
[&order](SettlementContext &) { order.push_back("second"); });
SettlementPipeline::registerStage("test.pipeline.order", "first", 1,
[&order](SettlementContext &) { order.push_back("first"); });
SettlementPipeline::registerStage("test.pipeline.order", "third", 20,
[&order](SettlementContext &) { order.push_back("third"); });
SettlementContext ctx;
SettlementPipeline::run("test.pipeline.order", ctx);
REQUIRE(order.size() == 3);
CHECK_EQ(order[0], "first");
CHECK_EQ(order[1], "second");
CHECK_EQ(order[2], "third");
SettlementPipeline::clearPipeline("test.pipeline.order");
}
TEST_CASE("rpg.settlement.cancelStopsLaterStages") {
SettlementPipeline::clearPipeline("test.pipeline.cancel");
bool laterRan = false;
SettlementPipeline::registerStage("test.pipeline.cancel", "dodge", 0,
[](SettlementContext &ctx) { ctx.cancelled = true; });
SettlementPipeline::registerStage("test.pipeline.cancel", "damage", 10,
[&laterRan](SettlementContext &) { laterRan = true; });
SettlementContext ctx;
SettlementPipeline::run("test.pipeline.cancel", ctx);
CHECK(!laterRan);
CHECK(ctx.cancelled);
SettlementPipeline::clearPipeline("test.pipeline.cancel");
}
TEST_CASE("rpg.settlement.damageCalcExampleWithCrit") {
SettlementPipeline::clearPipeline("test.pipeline.damage");
SettlementPipeline::registerStage(
"test.pipeline.damage", "base", 0,
[](SettlementContext &ctx) { ctx.set("result", ctx.get("attack") - ctx.get("defense")); });
SettlementPipeline::registerStage("test.pipeline.damage", "crit", 10, [](SettlementContext &ctx) {
if (ctx.hasTag("crit")) ctx.set("result", ctx.get("result") * 2.0);
});
SettlementPipeline::registerStage("test.pipeline.damage", "minFloor", 20, [](SettlementContext &ctx) {
if (ctx.get("result") < 1.0) ctx.set("result", 1.0);
});
SettlementContext ctx;
ctx.set("attack", 50.0);
ctx.set("defense", 20.0);
ctx.addTag("crit");
SettlementPipeline::run("test.pipeline.damage", ctx);
CHECK_EQ(ctx.get("result"), 60.0); // (50-20) * 2
SettlementContext ctx2;
ctx2.set("attack", 5.0);
ctx2.set("defense", 50.0);
SettlementPipeline::run("test.pipeline.damage", ctx2);
CHECK_EQ(ctx2.get("result"), 1.0); // floored
CHECK_EQ(SettlementPipeline::stageCount("test.pipeline.damage"), 3);
CHECK(SettlementPipeline::setStageEnabled("test.pipeline.damage", "crit", false));
SettlementContext ctx3;
ctx3.set("attack", 50.0);
ctx3.set("defense", 20.0);
ctx3.addTag("crit");
SettlementPipeline::run("test.pipeline.damage", ctx3);
CHECK_EQ(ctx3.get("result"), 30.0); // crit stage disabled
CHECK(SettlementPipeline::unregisterStage("test.pipeline.damage", "minFloor"));
CHECK_EQ(SettlementPipeline::stageCount("test.pipeline.damage"), 2);
SettlementPipeline::clearPipeline("test.pipeline.damage");
CHECK_EQ(SettlementPipeline::stageCount("test.pipeline.damage"), 0);
}
// ---------------------------------------------------------------------------
// RPG module facade smoke test (end-to-end via the same entry points scripts use)
// ---------------------------------------------------------------------------
TEST_CASE("rpg.module.endToEndSkillTriggersEffectViaFacade") {
auto *rpg = RPG::create();
rpg->clearEffectDefinitions();
rpg->clearSkillDefinitions();
int effectsLoaded = rpg->registerEffectsFromJson(R"([
{"id":"e2e.poison","durationPolicy":"duration","duration":2,"period":1}
])");
CHECK_EQ(effectsLoaded, 1);
int skillsLoaded = rpg->registerSkillsFromJson(R"([
{"id":"e2e.dart","cooldown":1,"castTime":0,
"costs":[{"attribute":"stamina","amount":5}],
"grantedEffects":["e2e.poison"]}
])");
CHECK_EQ(skillsLoaded, 1);
RPGActor *caster = rpg->newActor();
RPGActor *target = rpg->newActor();
caster->setBaseAttribute("stamina", 10.0);
caster->learnSkill("e2e.dart");
CHECK(caster->beginCastSkill("e2e.dart", target));
rpg->update(1.0f);
CHECK_EQ(rpg->getCastEventCount(), 1);
CHECK_EQ(rpg->getCastEventCaster(0), caster);
CHECK_EQ(rpg->getCastEventTarget(0), target);
CHECK_EQ(rpg->getCastEventSkillId(0), "e2e.dart");
CHECK_EQ(rpg->getTickEventCount(), 1);
CHECK_EQ(rpg->getTickEventActor(0), target);
CHECK_EQ(rpg->getTickEventEffectId(0), "e2e.poison");
rpg->update(1.5f); // effect (duration 2, already 1s elapsed) expires
CHECK_EQ(target->getStatusCount(), 0);
caster->release();
target->release();
rpg->clearEffectDefinitions();
rpg->clearSkillDefinitions();
}