Files
EVEngine/test/resource_gameplay_adapters.cpp

497 lines
21 KiB
C++

#include "attributes/AttributeResourceAccount.h"
#include "attributes/AttributeSetResourceAccount.h"
#include "card/Card.h"
#include "card/CardContainers.h"
#include "card/CardPlay.h"
#include "economy/EconomyLedger.h"
#include "inventory/InventoryResourceAccount.h"
#include "inventory/Item.h"
#include "orders/CommandQueue.h"
#include "production/Production.h"
#include "rts/RTSEconomy.h"
#include "rts/RTSProductionAction.h"
#include "transaction/AtomicResourcePayment.h"
#include "weapon/WeaponAction.h"
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include <cstdint>
#include <string>
#include <string_view>
#include <utility>
namespace {
eve::resource::CostSpec makeCost(std::string_view resource, std::int64_t amount) {
auto result = eve::resource::CostSpec::single(resource, amount);
REQUIRE(result.ok());
return std::move(result).takeValue();
}
eve::Status injectedFailure(std::string_view path) {
return eve::Status::failure(eve::StatusCode::Rejected,
eve::Diagnostic::error(eve::DiagnosticCode::PreconditionViolation,
"failure injected by resource adapter test", std::string(path)));
}
class TestEffect final : public eve::transaction::ITransactionParticipant {
public:
explicit TestEffect(int& visibleValue) : visibleValue_(visibleValue) {}
std::string_view name() const noexcept override { return "test-effect"; }
[[nodiscard]] eve::Result<void> prepare(const eve::transaction::TransactionContext&) override {
if (failPrepare) return eve::Result<void>::failure(injectedFailure("effect.prepare"));
if (prepared || committed) return eve::Result<void>::failure(injectedFailure("effect.reentry"));
prepared = true;
return eve::Result<void>::success();
}
[[nodiscard]] eve::Result<void> commit(const eve::transaction::TransactionContext&) override {
if (!prepared || committed) return eve::Result<void>::failure(injectedFailure("effect.commit.state"));
if (failCommit) return eve::Result<void>::failure(injectedFailure("effect.commit"));
++visibleValue_;
committed = true;
return eve::Result<void>::success();
}
[[nodiscard]] eve::Result<void> rollback(const eve::transaction::TransactionContext&) override {
if (committed) return eve::Result<void>::failure(injectedFailure("effect.rollback.committed"));
prepared = false;
return eve::Result<void>::success();
}
[[nodiscard]] eve::Result<void> compensate(const eve::transaction::TransactionContext&) override {
if (!committed) return eve::Result<void>::success();
--visibleValue_;
committed = false;
prepared = false;
return eve::Result<void>::success();
}
bool failPrepare = false;
bool failCommit = false;
bool prepared = false;
bool committed = false;
private:
int& visibleValue_;
};
} // namespace
TEST_CASE("resource.gameplay.attributeViewsReuseOneCanonicalAccount") {
eve::attributes::AttributeSet attributes;
attributes.setBase("mana", 10.0);
attributes.setBase("stamina", 7.0);
eve::attributes::AttributeSetResourceAccount base(attributes);
eve::attributes::ManaAccountAdapter mana(base, eve::attributes::AttributeResourceKind::Mana);
eve::attributes::StaminaAccountAdapter stamina(base, eve::attributes::AttributeResourceKind::Stamina);
const auto manaCost = makeCost("mana", 3);
const auto staminaCost = makeCost("stamina", 2);
auto manaReservation = mana.reserve(manaCost);
REQUIRE(manaReservation.ok());
auto committed = mana.commit(manaReservation.value());
REQUIRE(committed.ok());
CHECK_EQ(attributes.getBase("mana"), 7.0);
CHECK_EQ(attributes.getBase("stamina"), 7.0);
auto wrongView = mana.reserve(staminaCost);
CHECK(!wrongView.ok());
auto staminaReservation = stamina.reserve(staminaCost);
REQUIRE(staminaReservation.ok());
auto staminaRollback = stamina.rollback(staminaReservation.value());
REQUIRE(staminaRollback.ok());
}
TEST_CASE("resource.gameplay.cardManaPaymentIsAtomicAndUsesPlayerAccount") {
ecs::Table world;
ecs::ScopedTable guard(world);
eve::card::CardData* card = eve::card::CardData::createCard();
REQUIRE(card != nullptr);
card->state()->phase = eve::card::CardState::Hand;
eve::card::CardDefinition definition;
definition.id = "firebolt";
definition.cost = 3;
eve::attributes::AttributeSet playerAttributes;
playerAttributes.setBase("mana", 10.0);
eve::attributes::AttributeSetResourceAccount playerAccount(playerAttributes);
auto paid = eve::card::CardPlayPaymentAdapter::play(*card, definition, playerAccount, "card.firebolt.1");
REQUIRE(paid.ok());
CHECK_EQ(static_cast<int>(card->state()->phase), static_cast<int>(eve::card::CardState::Played));
CHECK_EQ(playerAttributes.getBase("mana"), 7.0);
card->release();
}
TEST_CASE("resource.gameplay.cardInsufficientManaLeavesEffectUnchanged") {
ecs::Table world;
ecs::ScopedTable guard(world);
eve::card::CardData* card = eve::card::CardData::createCard();
REQUIRE(card != nullptr);
card->state()->phase = eve::card::CardState::Hand;
eve::card::CardDefinition definition;
definition.id = "expensive";
definition.cost = 5;
eve::attributes::AttributeSet playerAttributes;
playerAttributes.setBase("mana", 2.0);
eve::attributes::AttributeSetResourceAccount playerAccount(playerAttributes);
auto rejected = eve::card::CardPlayPaymentAdapter::play(*card, definition, playerAccount, "card.expensive.1");
CHECK(!rejected.ok());
CHECK_EQ(static_cast<int>(card->state()->phase), static_cast<int>(eve::card::CardState::Hand));
CHECK_EQ(playerAttributes.getBase("mana"), 2.0);
card->release();
}
TEST_CASE("resource.gameplay.weaponAmmoUsesInventoryAndPrepareFailureDoesNotCharge") {
eve::inventory::ItemRegistry::clear();
eve::inventory::ItemDefinition ammo;
ammo.id = "ammo";
ammo.maxStack = 99;
eve::inventory::ItemRegistry::registerItem(ammo);
eve::inventory::Bag bag(1);
bag.setId("player.inventory");
const int ammoAdded = bag.addItem("ammo", 5);
CHECK_EQ(ammoAdded, 5);
eve::inventory::InventoryResourceAccount account(bag);
eve::weapon::WeaponDefinition weapon;
weapon.id = "rifle";
weapon.resource.kind = eve::weapon::ResourceKind::Ammo;
weapon.resource.cost = 1.0f;
int effectValue = 0;
TestEffect effect(effectValue);
auto fired = eve::weapon::WeaponActionAdapter::fire(weapon, account, effect, "weapon.rifle.1");
REQUIRE(fired.ok());
CHECK_EQ(bag.countItem("ammo"), 4);
CHECK_EQ(effectValue, 1);
effect.failPrepare = true;
auto rejected = eve::weapon::WeaponActionAdapter::fire(weapon, account, effect, "weapon.rifle.2");
CHECK(!rejected.ok());
CHECK_EQ(bag.countItem("ammo"), 4);
CHECK_EQ(effectValue, 1);
effect.failPrepare = false;
effect.failCommit = true;
auto commitRejected = eve::weapon::WeaponActionAdapter::fire(weapon, account, effect, "weapon.rifle.3");
CHECK(!commitRejected.ok());
CHECK_EQ(bag.countItem("ammo"), 4);
CHECK_EQ(effectValue, 1);
eve::inventory::ItemRegistry::clear();
}
TEST_CASE("resource.gameplay.itemCostAndAccountNonceAreNotCrossAccount") {
eve::inventory::ItemRegistry::clear();
eve::inventory::ItemDefinition potion;
potion.id = "potion";
potion.maxStack = 20;
eve::inventory::ItemRegistry::registerItem(potion);
eve::inventory::Bag firstBag(1);
eve::inventory::Bag secondBag(1);
const int potionAdded = firstBag.addItem("potion", 3);
CHECK_EQ(potionAdded, 3);
eve::inventory::InventoryResourceAccount first(firstBag);
eve::inventory::InventoryResourceAccount second(secondBag);
auto costResult = eve::inventory::ItemCostAdapter::itemCost("potion", 2);
REQUIRE(costResult.ok());
const auto cost = std::move(costResult).takeValue();
auto reservation = first.reserve(cost);
REQUIRE(reservation.ok());
auto foreignCommit = second.commit(reservation.value());
CHECK(!foreignCommit.ok());
auto rollback = first.rollback(reservation.value());
REQUIRE(rollback.ok());
CHECK_EQ(firstBag.countItem("potion"), 3);
eve::inventory::ItemRegistry::clear();
}
TEST_CASE("resource.gameplay.rtsEconomyUsesLedgerTransaction") {
eve::economy::EconomyLedger ledger;
eve::rts::RTSEconomyAdapter economy(ledger);
const auto income = makeCost("gold", 10);
auto credited = economy.credit(income);
REQUIRE(credited.ok());
CHECK_EQ(ledger.get("gold"), 10);
const auto price = makeCost("gold", 4);
int effectValue = 0;
TestEffect effect(effectValue);
auto paid = economy.pay(price, effect, "rts.build.1");
REQUIRE(paid.ok());
CHECK_EQ(ledger.get("gold"), 6);
CHECK_EQ(effectValue, 1);
effect.failPrepare = true;
auto rejected = economy.pay(price, effect, "rts.build.2");
CHECK(!rejected.ok());
CHECK_EQ(ledger.get("gold"), 6);
}
TEST_CASE("resource.gameplay.cardPlayCompositionIsOneAtomicCrossWrite") {
eve::card::Card cards;
REQUIRE_EQ(cards.registerCardsFromJson(R"([{"id":"bolt","kind":"spell","cost":3}])"), 1);
auto* deck = cards.newDeck();
auto* hand = cards.newHand(cards.newConfig());
auto* card = cards.newCard("bolt");
REQUIRE(deck != nullptr);
REQUIRE(hand != nullptr);
REQUIRE(card != nullptr);
deck->push(card);
eve::card::CardContainerAdapter source(eve::container::ContainerId("card:atomic-deck"),
eve::card::CardContainerKind::Deck, deck);
eve::card::CardContainerAdapter destination(eve::container::ContainerId("card:atomic-hand"),
eve::card::CardContainerKind::Hand, nullptr, hand, nullptr,
eve::container::Capacity::fixed(2));
eve::attributes::AttributeSet attributes;
attributes.setBase("mana", 10.0);
eve::attributes::AttributeSetResourceAccount account(attributes);
int effectValue = 0;
TestEffect effect(effectValue);
eve::card::CardDefinition definition;
definition.id = "bolt";
definition.kind = "spell";
definition.cost = 3;
eve::card::CardPlayRequest request;
request.card = card;
request.definition = &definition;
request.playerAccount = &account;
request.transactionId = "card.atomic.success";
request.composition.source = &source;
request.composition.destination = &destination;
request.composition.sourceSlot = eve::container::SlotIndex(0);
request.composition.destinationSlot = eve::container::SlotIndex(0);
request.composition.effect = &effect;
auto played = eve::card::CardPlayPaymentAdapter::play(std::move(request));
REQUIRE(played.ok());
CHECK_EQ(deck->count(), 0);
CHECK_EQ(hand->count(), 1);
CHECK_EQ(attributes.getBase("mana"), 7.0);
CHECK_EQ(effectValue, 1);
auto* rejectedCard = cards.newCard("bolt");
REQUIRE(rejectedCard != nullptr);
deck->push(rejectedCard);
TestEffect rejectedEffect(effectValue);
rejectedEffect.failCommit = true;
eve::card::CardPlayRequest rejectedRequest;
rejectedRequest.card = rejectedCard;
rejectedRequest.definition = &definition;
rejectedRequest.playerAccount = &account;
rejectedRequest.transactionId = "card.atomic.effect-failure";
rejectedRequest.composition.source = &source;
rejectedRequest.composition.destination = &destination;
rejectedRequest.composition.sourceSlot = eve::container::SlotIndex(0);
rejectedRequest.composition.destinationSlot = eve::container::SlotIndex(1);
rejectedRequest.composition.effect = &rejectedEffect;
auto rejected = eve::card::CardPlayPaymentAdapter::play(std::move(rejectedRequest));
CHECK(!rejected.ok());
CHECK_EQ(deck->count(), 1);
CHECK_EQ(hand->count(), 1);
CHECK_EQ(attributes.getBase("mana"), 7.0);
CHECK_EQ(effectValue, 1);
}
TEST_CASE("resource.gameplay.rtsBuildUsesSharedActionOrdersProductionPayment") {
eve::economy::EconomyLedger ledger;
REQUIRE_EQ(ledger.credit("gold", 20), 20);
eve::rts::RTSEconomyAdapter economy(ledger);
eve::production::WorkQueue production;
eve::orders::CommandQueue orders;
eve::action::ActionRuntime action;
auto productionSnapshot = [&production]() {
auto result = production.snapshot();
return std::move(result).expect("production snapshot must serialize");
};
auto duration = eve::Duration::fromSeconds(1.0);
REQUIRE(duration.ok());
const auto cost = makeCost("gold", 5);
eve::rts::RTSBuildRequest request;
request.production = &production;
request.orders = &orders;
request.action = &action;
request.account = &economy.account();
request.cost = cost;
request.owner = "faction:one";
request.productionKind = "unit";
request.product = "worker";
request.duration = std::move(duration).takeValue();
request.transactionId = "rts.build.worker";
auto built = eve::rts::RTSProductionActionAdapter::build(std::move(request));
REQUIRE(built.ok());
CHECK_EQ(ledger.get("gold"), 15);
CHECK_EQ(production.taskCount(), 1);
CHECK_EQ(orders.orderCount(), 1);
CHECK_EQ(action.executionCount(), 1u);
const auto productionBeforeActionFailure = productionSnapshot();
const auto ordersBeforeActionFailure = orders.orderCount();
eve::rts::RTSBuildRequest actionFailure;
actionFailure.production = &production;
actionFailure.orders = &orders;
actionFailure.action = &action;
actionFailure.account = &economy.account();
actionFailure.cost = cost;
actionFailure.owner = "faction:one";
actionFailure.productionKind = "unit";
actionFailure.product = "tank";
actionFailure.duration = eve::Duration::fromNanoseconds(1000000000);
actionFailure.transactionId = "rts.build.action-failure";
actionFailure.actionDefinition.condition = eve::decision::Condition::hasTag("missing");
auto failedAction = eve::rts::RTSProductionActionAdapter::build(std::move(actionFailure));
CHECK(!failedAction.ok());
CHECK_EQ(ledger.get("gold"), 15);
CHECK_EQ(productionSnapshot(), productionBeforeActionFailure);
CHECK_EQ(orders.orderCount(), ordersBeforeActionFailure);
eve::rts::RTSBuildRequest queueFailure;
queueFailure.production = &production;
queueFailure.orders = &orders;
queueFailure.action = &action;
queueFailure.account = &economy.account();
queueFailure.cost = cost;
queueFailure.owner = "faction:one";
queueFailure.productionKind = "unit";
queueFailure.product = "tank";
queueFailure.duration = eve::Duration::fromNanoseconds(1000000000);
queueFailure.orderKind.clear();
queueFailure.transactionId = "rts.build.queue-failure";
auto failedQueue = eve::rts::RTSProductionActionAdapter::build(std::move(queueFailure));
CHECK(!failedQueue.ok());
CHECK_EQ(ledger.get("gold"), 15);
CHECK_EQ(productionSnapshot(), productionBeforeActionFailure);
CHECK_EQ(orders.orderCount(), ordersBeforeActionFailure);
}
TEST_CASE("resource.gameplay.rtsProductionCancellationRefundsAllResourcesAtomically") {
eve::economy::EconomyLedger ledger;
REQUIRE_EQ(ledger.credit("gold", 20), 20);
REQUIRE_EQ(ledger.credit("gas", 10), 10);
eve::rts::RTSEconomyAdapter economy(ledger);
eve::production::WorkQueue production;
eve::orders::CommandQueue orders;
eve::action::ActionRuntime action;
auto costResult = eve::resource::CostSpec::from({{"gold", 5}, {"gas", 3}});
REQUIRE(costResult.ok());
auto cost = std::move(costResult).takeValue();
eve::rts::RTSBuildRequest build;
build.production = &production;
build.orders = &orders;
build.action = &action;
build.account = &economy.account();
build.cost = cost;
build.owner = "faction:one";
build.productionKind = "unit";
build.product = "tank";
build.duration = eve::Duration::fromNanoseconds(1000000000);
auto built = eve::rts::RTSProductionActionAdapter::build(std::move(build));
REQUIRE(built.ok());
auto receipt = std::move(built).takeValue();
CHECK_EQ(ledger.get("gold"), 15);
CHECK_EQ(ledger.get("gas"), 7);
eve::rts::RTSCancelProductionRequest invalid;
invalid.production = &production;
invalid.orders = &orders;
invalid.account = &economy.account();
invalid.refund = cost;
invalid.productionTaskId = "missing-task";
invalid.orderId = receipt.orderId;
const auto productionBefore = production.snapshot().expect("production cancellation snapshot");
auto failed = eve::rts::RTSProductionActionAdapter::cancel(std::move(invalid));
CHECK(!failed.ok());
CHECK_EQ(production.snapshot().expect("unchanged production snapshot"), productionBefore);
CHECK_EQ(ledger.get("gold"), 15);
CHECK_EQ(ledger.get("gas"), 7);
eve::rts::RTSCancelProductionRequest cancel;
cancel.production = &production;
cancel.orders = &orders;
cancel.account = &economy.account();
cancel.refund = cost;
cancel.productionTaskId = receipt.productionTaskId;
cancel.orderId = receipt.orderId;
auto cancelled = eve::rts::RTSProductionActionAdapter::cancel(std::move(cancel));
REQUIRE(cancelled.ok());
CHECK_EQ(ledger.get("gold"), 20);
CHECK_EQ(ledger.get("gas"), 10);
auto task = production.find(receipt.productionTaskId);
auto order = orders.find(receipt.orderId);
REQUIRE(task.has_value());
REQUIRE(order.has_value());
CHECK_EQ(static_cast<int>(task->get().state),
static_cast<int>(eve::production::TaskState::Cancelled));
CHECK_EQ(static_cast<int>(order->get().state),
static_cast<int>(eve::orders::OrderState::Cancelled));
}
TEST_CASE("resource.gameplay.rtsProductionPriorityProtectsCanonicalResourceFloor") {
eve::economy::EconomyLedger ledger;
REQUIRE_EQ(ledger.credit("gold", 10), 10);
eve::rts::RTSEconomyAdapter economy(ledger);
eve::production::WorkQueue production;
eve::orders::CommandQueue orders;
eve::action::ActionRuntime action;
auto duration = eve::Duration::fromSeconds(1.0).expect("production duration");
const auto reserveCost = eve::resource::ResourceCost::create("gold", 6).expect("reserve cost");
const auto makeRequest = [&](int priority, std::string id) {
eve::rts::RTSBuildRequest request;
request.production = &production;
request.orders = &orders;
request.action = &action;
request.account = &economy.account();
request.cost = makeCost("gold", 5);
request.owner = "faction:one";
request.productionKind = "unit";
request.product = "soldier";
request.duration = duration;
request.priority = priority;
request.transactionId = std::move(id);
request.resourceReserves.push_back({reserveCost, 10});
return request;
};
auto low = eve::rts::RTSProductionActionAdapter::build(makeRequest(9, "rts.floor.low"));
CHECK(!low.ok());
CHECK_EQ(ledger.get("gold"), 10);
CHECK_EQ(production.taskCount(), 0);
CHECK_EQ(orders.orderCount(), 0);
CHECK_EQ(action.executionCount(), 0u);
auto high = eve::rts::RTSProductionActionAdapter::build(makeRequest(10, "rts.floor.high"));
REQUIRE(high.ok());
CHECK_EQ(ledger.get("gold"), 5);
CHECK_EQ(production.taskCount(), 1);
}
TEST_CASE("resource.gameplay.economySnapshotRestoresBalancesAndAccounting") {
eve::economy::EconomyLedger ledger;
REQUIRE_EQ(ledger.credit("gold", 50), 50);
REQUIRE(ledger.debit("gold", 12));
REQUIRE_EQ(ledger.credit("gas", 9), 9);
const auto saved = ledger.snapshot();
REQUIRE(ledger.debit("gold", 20));
REQUIRE_EQ(ledger.credit("gas", 4), 4);
ledger.restore(saved);
CHECK_EQ(ledger.get("gold"), 38);
CHECK_EQ(ledger.getIncome("gold"), 50);
CHECK_EQ(ledger.getExpense("gold"), 12);
CHECK_EQ(ledger.get("gas"), 9);
CHECK_EQ(ledger.getIncome("gas"), 9);
CHECK_EQ(ledger.getExpense("gas"), 0);
}