373 lines
12 KiB
C++
373 lines
12 KiB
C++
#include "zeroerr/assert.h"
|
||
#include "zeroerr/unittest.h"
|
||
|
||
#include "rpg/RPGActor.h"
|
||
#include "rpg/Battle.h"
|
||
#include "rpg/BattleSystem.h"
|
||
#include "rpg/Skill.h"
|
||
#include "rpg/SkillSystem.h"
|
||
#include "rpg/RPG.h"
|
||
|
||
#include <cmath>
|
||
|
||
using namespace eve::rpg;
|
||
|
||
namespace {
|
||
bool approxEq(double a, double b, double eps = 1e-9) { return std::abs(a - b) < eps; }
|
||
|
||
RPGActor *makeFighter(double atk, double def, double hp, double speed) {
|
||
RPGActor *a = RPGActor::createActor();
|
||
a->setBaseAttribute("attack", atk);
|
||
a->setBaseAttribute("defense", def);
|
||
a->setBaseAttribute("hp", hp);
|
||
a->setBaseAttribute("speed", speed);
|
||
a->setCurrent("hp", hp);
|
||
return a;
|
||
}
|
||
} // namespace
|
||
|
||
TEST_CASE("rpg.battle.formulaEvaluation") {
|
||
RPGActor *a = makeFighter(10, 0, 100, 5);
|
||
RPGActor *b = makeFighter(0, 2, 100, 1);
|
||
CHECK(approxEq(BattleSystem::evaluateFormula("a.attack * 4 - b.defense * 2", a, b), 36.0));
|
||
CHECK(approxEq(BattleSystem::evaluateFormula("a.attack * b.defense", a, b), 20.0));
|
||
CHECK(approxEq(BattleSystem::evaluateFormula("(1 + 2) * 3", a, b), 9.0));
|
||
CHECK(approxEq(BattleSystem::evaluateFormula("a.missing + 5", a, b), 5.0)); // 未知参数按 0
|
||
a->release();
|
||
b->release();
|
||
}
|
||
|
||
TEST_CASE("rpg.battle.formulaValidationRejectsMalformedDefinitionsAtomically") {
|
||
RPGActor *a = makeFighter(10, 0, 100, 5);
|
||
RPGActor *b = makeFighter(0, 2, 100, 1);
|
||
|
||
auto unary = BattleSystem::evaluateFormulaChecked("-(a.attack - b.defense)", a, b);
|
||
REQUIRE(unary.ok());
|
||
CHECK(approxEq(unary.value(), -8.0));
|
||
|
||
auto missingParen = BattleSystem::evaluateFormulaChecked("(a.attack + 2", a, b);
|
||
CHECK(!missingParen);
|
||
REQUIRE(missingParen.error() != nullptr);
|
||
CHECK(missingParen.error()->code() == eve::DiagnosticCode::ParseError);
|
||
|
||
auto trailing = BattleSystem::evaluateFormulaChecked("a.attack garbage", a, b);
|
||
CHECK(!trailing);
|
||
|
||
auto division = BattleSystem::evaluateFormulaChecked("a.attack / 0", a, b);
|
||
CHECK(!division);
|
||
|
||
BattleSystem::clearSkillDamage();
|
||
SkillDamageSpec valid{"hp", "a.attack / b.defense", "", 0.0, 100};
|
||
auto published = BattleSystem::registerSkillDamageChecked("strike", valid);
|
||
REQUIRE(published.ok());
|
||
CHECK(BattleSystem::findSkillDamage("strike") != nullptr);
|
||
|
||
SkillDamageSpec invalid{"hp", "a.attack + )", "", 0.0, 100};
|
||
auto rejected = BattleSystem::registerSkillDamageChecked("strike", invalid);
|
||
CHECK(!rejected);
|
||
const SkillDamageSpec *retained = BattleSystem::findSkillDamage("strike");
|
||
REQUIRE(retained != nullptr);
|
||
CHECK_EQ(retained->formula, std::string("a.attack / b.defense"));
|
||
|
||
BattleSystem::clearSkillDamage();
|
||
a->release();
|
||
b->release();
|
||
}
|
||
|
||
TEST_CASE("rpg.battle.resolveHitCritAndElement") {
|
||
auto *rpg = RPG::create();
|
||
rpg->clearTraitDefinitions();
|
||
int registered = rpg->registerTraitsFromJson(
|
||
R"([{"id":"crit","traits":[{"kind":"exParam","target":"critRate","value":1.0}]}])");
|
||
CHECK_EQ(registered, 1);
|
||
|
||
RPGActor *a = makeFighter(50, 0, 100, 5);
|
||
RPGActor *b = makeFighter(0, 5, 100, 1);
|
||
int tid = a->applyTrait("crit", "test");
|
||
CHECK(tid > 0);
|
||
|
||
// 必暴击(critRate=1.0)
|
||
SkillDamageSpec spec;
|
||
spec.formula = "a.attack - b.defense";
|
||
spec.element = "";
|
||
DamageResult r = BattleSystem::resolveHit(a, b, spec, 42);
|
||
CHECK(r.hit);
|
||
CHECK(r.crit);
|
||
CHECK(approxEq(r.amount, (50 - 5) * 3.0));
|
||
|
||
// 元素减伤:火耐性 0.5(用不带暴击特征的攻击方)
|
||
RPGActor *a2 = makeFighter(50, 0, 100, 5);
|
||
RPGActor *c = makeFighter(0, 0, 100, 1);
|
||
rpg->registerTraitsFromJson(R"([{"id":"fire_resist2","traits":[{"kind":"elementRate","target":"fire","value":0.5}]}])");
|
||
c->applyTrait("fire_resist2", "x");
|
||
SkillDamageSpec fireSpec;
|
||
fireSpec.formula = "a.attack";
|
||
fireSpec.element = "fire";
|
||
DamageResult fr = BattleSystem::resolveHit(a2, c, fireSpec, 7);
|
||
CHECK(fr.hit);
|
||
CHECK(approxEq(fr.elementRate, 0.5));
|
||
CHECK(approxEq(fr.amount, 50.0 * 0.5)); // 未暴击
|
||
|
||
rpg->clearTraitDefinitions();
|
||
a->release();
|
||
b->release();
|
||
a2->release();
|
||
c->release();
|
||
}
|
||
|
||
TEST_CASE("rpg.battle.roundVictory") {
|
||
RPGActor *hero = makeFighter(30, 0, 100, 10);
|
||
RPGActor *slime = makeFighter(5, 0, 20, 1);
|
||
slime->setCurrent("hp", 20);
|
||
|
||
Battle b;
|
||
b.addActor(hero, BattleSide::Party);
|
||
b.addActor(slime, BattleSide::Enemies);
|
||
b.setAction(hero, "", nullptr); // 普攻
|
||
b.autoEnemyActions();
|
||
b.startRound();
|
||
while (b.executeNextAction() && !b.isFinished()) {}
|
||
CHECK(b.isFinished());
|
||
CHECK(b.isVictory());
|
||
CHECK(!b.isActorAlive(slime));
|
||
// 普攻 30 - 0 = 30 ≥ 20 → slime 死亡
|
||
CHECK_EQ(b.getTurn(), 1);
|
||
|
||
b.pollEvents();
|
||
bool sawDamage = false, sawVictory = false;
|
||
for (int i = 0; i < b.getEventCount(); ++i) {
|
||
auto ev = b.getEvent(i);
|
||
if (ev.action == "damage") sawDamage = true;
|
||
if (ev.action == "victory") sawVictory = true;
|
||
}
|
||
CHECK(sawDamage);
|
||
CHECK(sawVictory);
|
||
|
||
hero->release();
|
||
slime->release();
|
||
}
|
||
|
||
TEST_CASE("rpg.battle.defeatWhenPartyDies") {
|
||
RPGActor *hero = makeFighter(1, 0, 10, 1);
|
||
RPGActor *boss = makeFighter(100, 0, 100, 20);
|
||
boss->setCurrent("hp", 100);
|
||
|
||
Battle b;
|
||
b.addActor(hero, BattleSide::Party);
|
||
b.addActor(boss, BattleSide::Enemies);
|
||
b.setAction(hero, "", nullptr);
|
||
b.autoEnemyActions();
|
||
b.startRound();
|
||
while (b.executeNextAction() && !b.isFinished()) {}
|
||
CHECK(b.isFinished());
|
||
CHECK(b.isDefeat());
|
||
|
||
hero->release();
|
||
boss->release();
|
||
}
|
||
|
||
TEST_CASE("rpg.battle.skillDamageViaRegistry") {
|
||
auto *rpg = RPG::create();
|
||
rpg->clearSkillDefinitions();
|
||
rpg->clearSkillDamage();
|
||
int sk = rpg->registerSkillsFromJson(
|
||
R"([{"id":"fireball","targetType":"enemySingle","castTime":0}])");
|
||
CHECK_EQ(sk, 1);
|
||
|
||
// 注册 fireball 的伤害:火元素,公式 a.attack*2
|
||
BattleSystem::registerSkillDamage(
|
||
"fireball", SkillDamageSpec{"hp", "a.attack * 2", "fire", 0.0, 100});
|
||
|
||
RPGActor *hero = makeFighter(20, 0, 100, 10);
|
||
RPGActor *slime = makeFighter(5, 0, 40, 1);
|
||
slime->setCurrent("hp", 40);
|
||
hero->learnSkill("fireball");
|
||
|
||
Battle b;
|
||
b.addActor(hero, BattleSide::Party);
|
||
b.addActor(slime, BattleSide::Enemies);
|
||
b.setAction(hero, "fireball", slime);
|
||
b.autoEnemyActions();
|
||
b.startRound();
|
||
while (b.executeNextAction() && !b.isFinished()) {}
|
||
CHECK(b.isVictory());
|
||
// 40 伤害(无元素修正)→ slime 死
|
||
CHECK(!b.isActorAlive(slime));
|
||
|
||
rpg->clearSkillDefinitions();
|
||
rpg->clearSkillDamage();
|
||
hero->release();
|
||
slime->release();
|
||
}
|
||
|
||
TEST_CASE("rpg.battle.dynamicResourceDamage") {
|
||
// damageType 直接用任意资源名;"XHeal" 表示治疗
|
||
auto *rpg = RPG::create();
|
||
rpg->clearSkillDefinitions();
|
||
rpg->clearSkillDamage();
|
||
rpg->registerSkillsFromJson(R"([{"id":"mana_drain","targetType":"enemySingle","castTime":0}])");
|
||
// 伤害到 mana(非 hp/mp 的任意资源)
|
||
BattleSystem::registerSkillDamage("mana_drain",
|
||
SkillDamageSpec{"mana", "a.attack", "", 0.0, 100});
|
||
|
||
RPGActor *caster = makeFighter(30, 0, 100, 10);
|
||
RPGActor *victim = makeFighter(5, 0, 100, 1);
|
||
victim->setBaseAttribute("mana", 50.0);
|
||
victim->setCurrent("mana", 50.0);
|
||
caster->learnSkill("mana_drain");
|
||
|
||
Battle b;
|
||
b.addActor(caster, BattleSide::Party);
|
||
b.addActor(victim, BattleSide::Enemies);
|
||
b.setAction(caster, "mana_drain", victim);
|
||
b.autoEnemyActions();
|
||
b.startRound();
|
||
while (b.executeNextAction() && !b.isFinished()) {}
|
||
// 对 mana 造成 30 伤害,hp 不受影响
|
||
CHECK(approxEq(victim->getCurrent("mana"), 20.0));
|
||
CHECK(approxEq(victim->getCurrent("hp"), 100.0));
|
||
|
||
rpg->clearSkillDefinitions();
|
||
rpg->clearSkillDamage();
|
||
caster->release();
|
||
victim->release();
|
||
}
|
||
|
||
TEST_CASE("rpg.battle.multiSideWinner") {
|
||
// 三方 A(0/玩家) B(1) C(2),逐个互殴,最终仅剩一方 → 由 getWinnerSide 判定
|
||
RPGActor *a = makeFighter(50, 0, 5, 1);
|
||
RPGActor *b = makeFighter(50, 0, 5, 2);
|
||
RPGActor *c = makeFighter(50, 0, 5, 3);
|
||
|
||
Battle btl;
|
||
btl.addActor(a, 0);
|
||
btl.addActor(b, 1);
|
||
btl.addActor(c, 2);
|
||
btl.setPlayerSide(0);
|
||
// 先攻:C(3) → B(2) → A(1)
|
||
btl.setAction(a, "", b); // A 打 B
|
||
btl.setAction(b, "", c); // B 打 C
|
||
btl.setAction(c, "", a); // C 打 A
|
||
btl.startRound();
|
||
while (btl.executeNextAction() && !btl.isFinished()) {}
|
||
CHECK(btl.isFinished());
|
||
CHECK(btl.getSide(0) == 0);
|
||
CHECK(btl.getSide(1) == 1);
|
||
CHECK(btl.getSide(2) == 2);
|
||
// C 先杀 A(玩家) → A 死;B 再杀 C → 仅 B(1) 存活 → 玩家败
|
||
CHECK(btl.getWinnerSide() == 1);
|
||
CHECK(!btl.isVictory());
|
||
CHECK(btl.isDefeat());
|
||
|
||
a->release();
|
||
b->release();
|
||
c->release();
|
||
}
|
||
|
||
TEST_CASE("rpg.battle.playerSideConfigurable") {
|
||
// 玩家侧设为 2(非默认 0)也能正确判定
|
||
RPGActor *p = makeFighter(50, 0, 5, 2); // 玩家侧 2
|
||
RPGActor *e = makeFighter(50, 0, 5, 1); // 敌侧 1
|
||
|
||
Battle btl;
|
||
btl.addActor(p, 2);
|
||
btl.addActor(e, 1);
|
||
btl.setPlayerSide(2);
|
||
btl.setAction(p, "", e);
|
||
btl.autoEnemyActions();
|
||
btl.startRound();
|
||
while (btl.executeNextAction() && !btl.isFinished()) {}
|
||
CHECK(btl.isVictory());
|
||
CHECK(btl.getWinnerSide() == 2);
|
||
|
||
p->release();
|
||
e->release();
|
||
}
|
||
|
||
TEST_CASE("rpg.battle.checkedActionRejectsIllegalTargetsAndDuplicatesAtomically") {
|
||
SkillRegistry::clear();
|
||
SkillDefinition heal;
|
||
heal.id = "self_heal";
|
||
heal.targetType = "self";
|
||
SkillRegistry::registerSkill(heal);
|
||
|
||
RPGActor *hero = makeFighter(30, 0, 100, 10);
|
||
RPGActor *ally = makeFighter(5, 0, 100, 5);
|
||
RPGActor *enemy = makeFighter(5, 0, 20, 1);
|
||
RPGActor *outsider = makeFighter(1, 0, 10, 1);
|
||
hero->learnSkill("self_heal");
|
||
|
||
Battle battle;
|
||
battle.addActor(hero, BattleSide::Party);
|
||
battle.addActor(ally, BattleSide::Party);
|
||
battle.addActor(enemy, BattleSide::Enemies);
|
||
|
||
auto friendlyFire = battle.setActionChecked(hero, "", ally);
|
||
CHECK(!friendlyFire.ok());
|
||
auto outsiderTarget = battle.setActionChecked(hero, "", outsider);
|
||
CHECK(!outsiderTarget.ok());
|
||
auto invalidSelfTarget = battle.setActionChecked(hero, "self_heal", enemy);
|
||
CHECK(!invalidSelfTarget.ok());
|
||
|
||
auto queued = battle.setActionChecked(hero, "", enemy);
|
||
REQUIRE(queued.ok());
|
||
auto duplicate = battle.setActionChecked(hero, "", enemy);
|
||
CHECK(!duplicate.ok());
|
||
battle.autoEnemyActions();
|
||
battle.startRound();
|
||
while (battle.executeNextAction() && !battle.isFinished()) {}
|
||
CHECK(battle.isVictory());
|
||
|
||
hero->release();
|
||
ally->release();
|
||
enemy->release();
|
||
outsider->release();
|
||
SkillRegistry::clear();
|
||
}
|
||
|
||
TEST_CASE("rpg.battle.policyTargetsLowestHealthLegalParticipantDeterministically") {
|
||
SkillRegistry::clear();
|
||
SkillDefinition aid;
|
||
aid.id = "ally_aid";
|
||
aid.targetType = "allySingle";
|
||
SkillRegistry::registerSkill(aid);
|
||
SkillDamageSpec aidSpec;
|
||
aidSpec.damageType = "hpHeal";
|
||
aidSpec.formula = "20";
|
||
BattleSystem::registerSkillDamage("ally_aid", aidSpec);
|
||
|
||
RPGActor *hero = makeFighter(20, 0, 100, 10);
|
||
RPGActor *ally = makeFighter(10, 0, 100, 8);
|
||
RPGActor *enemyHigh = makeFighter(5, 0, 100, 2);
|
||
RPGActor *enemyLow = makeFighter(5, 0, 100, 1);
|
||
hero->learnSkill("ally_aid");
|
||
ally->setCurrent("hp", 25.0);
|
||
enemyHigh->setCurrent("hp", 80.0);
|
||
enemyLow->setCurrent("hp", 20.0);
|
||
|
||
Battle battle;
|
||
battle.addActor(hero, BattleSide::Party);
|
||
battle.addActor(ally, BattleSide::Party);
|
||
battle.addActor(enemyHigh, BattleSide::Enemies);
|
||
battle.addActor(enemyLow, BattleSide::Enemies);
|
||
CHECK(!battle.setActionByPolicyChecked(hero, "ally_aid",
|
||
BattleTargetPolicy::LowestHealthEnemy).ok());
|
||
REQUIRE(battle.setActionByPolicyChecked(hero, "ally_aid",
|
||
BattleTargetPolicy::LowestHealthAlly).ok());
|
||
battle.startRound();
|
||
REQUIRE(battle.executeNextAction());
|
||
battle.pollEvents();
|
||
bool healedAlly = false;
|
||
for (int index = 0; index < battle.getEventCount(); ++index)
|
||
if (battle.getEventAction(index) == "heal" && battle.getEventTarget(index) == ally)
|
||
healedAlly = true;
|
||
CHECK(healedAlly);
|
||
CHECK_EQ(ally->getCurrent("hp"), 45.0);
|
||
|
||
hero->release();
|
||
ally->release();
|
||
enemyHigh->release();
|
||
enemyLow->release();
|
||
SkillRegistry::clear();
|
||
}
|