feat(economy): add resource collection MVP module

Data-driven ResourceType registry, per-player EconomyLedger (caps, waste, income/expense), GatherNode (deposits, regen, worker slots) and the WorkerTrip collector state machine (select -> approach -> gather -> carry -> deposit), exposed to Squirrel and reachable cross-module via IEconomy in common.

Adds economy unit tests (registry, ledger invariants, worker-trip fixtures, capability provider) plus a printable demo scenario, and wires the module into the module manifest and the test link list.
This commit is contained in:
西风逍遥游 committed 2026-08-22 23:00:16 +08:00
1 parent 7e1d86a0e1
commit 0057cdfd63
17 files changed
+1350

No files matched your search

+2
View File
@@ -89,6 +89,8 @@ eve_declare_module(NAME database LAYER 0 SCRIPT Database
THIRDPARTY poco_data poco)
eve_declare_module(NAME rpg LIB EVRPG LAYER 0 SCRIPT RPG)
eve_declare_module(NAME inventory LAYER 0 SCRIPT Inventory)
eve_declare_module(NAME economy LAYER 0 SCRIPT Economy SLOT economy
GROUP minimal 2d 3d web)
# PlacementWorld.cpp includes data/JsonDocument.h and Poco JSON (save/load).
# THIRDPARTY poco is required so MSVC compiles those TUs with
# POCO_NO_AUTOMATIC_LIBS; otherwise the obj records a link of
+108
View File
@@ -0,0 +1,108 @@
// EVEngine economy 模块 MVP 演示(eve run -r <本文件>)
//
// 演示内容:
// 1) SC2 式矿物采集:一个双槽矿点 + 两个采集者,各自完成
// "接近 -> 采集 -> 满载 -> 返回 -> 卸货入账" 的 WorkerTrip 循环;
// 2) 满仓浪费:玩家持有上限 10,节点储量 25,超限部分记 waste 事件。
//
// 运行方式(无需窗口/Vulkan):
// build/win32-debug/src/engine/eve.exe run -r examples/economy/economy_demo.nut
local eco = eve.Economy();
function pad(s, w) {
while (s.len() < w) s += " ";
return s;
}
function dump_events(from) {
while (eco.getEventCount() > from) {
local i = from;
local action = pad(eco.getEventAction(i), 6);
print(format(" [event] %s %-10s x%d (player %d)\n",
action, eco.getEventType(i), eco.getEventAmount(i),
eco.getEventPlayer(i)));
from++;
}
return from;
}
print("====================================================================\n");
print(" EVEngine economy MVP demo\n");
print("====================================================================\n");
// ---------------------------------------------------------------------------
// 场景 1:双工人矿物采集(SC2 式 WorkerTrip)
// ---------------------------------------------------------------------------
print("\n[1] 矿物采集:矿点 30,双槽位,工人 A(载荷5, 速率1, 单程2t) + 工人 B(载荷3, 速率2, 单程3t)\n");
eco.registerResourceType("minerals", "stock", 100, "finite");
eco.clearEvents();
local mine = eve.GatherNode("minerals", 30, 2, 0);
local workerA = eve.Collector(5, 1, 2);
local workerB = eve.Collector(3, 2, 3);
print(" 槽位: " + mine.freeSlots() + " 空闲\n");
workerA.assign(mine);
print(" A 进入矿点, 槽位: " + mine.freeSlots() + " 空闲\n");
workerB.assign(mine);
print(" B 进入矿点, 槽位: " + mine.freeSlots() + " 空闲\n");
local events = 0;
local doneTick = -1;
for (local t = 0; t < 300; ++t) {
workerA.tick(1);
workerB.tick(1);
events = dump_events(events);
if (workerA.isIdle() && workerB.isIdle()) {
doneTick = t;
break;
}
}
print("\n 矿点采空于 t=" + doneTick + "\n");
print(" A: 往返 " + workerA.trips() + " 次, 累计入账 " + workerA.totalGathered() + "\n");
print(" B: 往返 " + workerB.trips() + " 次, 累计入账 " + workerB.totalGathered() + "\n");
print(" 玩家1 矿物余额: " + eco.get(1, "minerals") + " / 上限 " + eco.getCap(1, "minerals") +
", 浪费 " + eco.getWasted(1, "minerals") + "\n");
print(" 矿点剩余: " + mine.amount() + "\n");
workerA.destroy();
workerB.destroy();
mine.destroy();
// ---------------------------------------------------------------------------
// 场景 2:持有上限导致的浪费
// ---------------------------------------------------------------------------
print("\n[2] 满仓浪费:玩家持有上限 10,节点 25,载荷 5(第 3 次起全部浪费)\n");
eco.registerResourceType("gold", "stock", 10, "finite");
eco.clearEvents();
local goldNode = eve.GatherNode("gold", 25, 1, 0);
local miner = eve.Collector(5, 1, 1);
miner.assign(goldNode);
events = 0;
doneTick = -1;
for (local t = 0; t < 300; ++t) {
miner.tick(2);
events = dump_events(events);
if (miner.isIdle()) {
doneTick = t;
break;
}
}
print("\n 采空于 t=" + doneTick + "\n");
print(" 玩家2 黄金余额: " + eco.get(2, "gold") + " / 上限 " + eco.getCap(2, "gold") +
", 浪费 " + eco.getWasted(2, "gold") + "\n");
print(" 矿工往返 " + miner.trips() + " 次, 入账 " + miner.totalGathered() + "\n");
miner.destroy();
goldNode.destroy();
print("\n====================================================================\n");
print(" 演示结束\n");
print("====================================================================\n");
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <string>
namespace eve::economy {
/**
* @brief 经济账本的跨模块查询/操作接口(提供方:economy 模块)。
*
* 消费方(AI、UI、生产队列)通过 eve::cap::query<IEconomy>() 获取实现,
* 不直接 include economy 模块;无提供方时返回 nullptr,调用方自行兜底。
*/
class IEconomy {
public:
static constexpr const char* capabilityName = "IEconomy";
virtual ~IEconomy() = default;
/**
* @brief 为玩家入账 amount,受资源类型上限约束。
* @return 实际入账量(超出上限的部分记为浪费)。
*/
virtual int credit(int player, const std::string& type, int amount) = 0;
/** @brief 为玩家出账 amount;余额不足返回 false 且不扣款。 */
virtual bool debit(int player, const std::string& type, int amount) = 0;
/** @brief 玩家当前持有量。 */
virtual int get(int player, const std::string& type) const = 0;
/** @brief 玩家该类型的持有上限(0=不限)。 */
virtual int getCap(int player, const std::string& type) const = 0;
/** @brief 因满仓浪费的累计量。 */
virtual int getWasted(int player, const std::string& type) const = 0;
/** @brief 累计入账量。 */
virtual int getIncome(int player, const std::string& type) const = 0;
/** @brief 累计出账量。 */
virtual int getExpense(int player, const std::string& type) const = 0;
};
} // namespace eve::economy
+127
View File
@@ -0,0 +1,127 @@
#include "economy/Collector.h"
#include "common/Assert.h"
#include "economy/EconomySystem.h"
#include "economy/GatherNode.h"
#include <algorithm>
namespace eve::economy {
Collector::Collector(int carryCapacity, int gatherRatePerTick, int travelTicks)
: carryCapacity_(carryCapacity), gatherRatePerTick_(gatherRatePerTick),
travelTicks_(travelTicks) {
EV_PARAM_CHECK(carryCapacity > 0, "carry capacity must be > 0");
EV_PARAM_CHECK(gatherRatePerTick > 0, "gather rate must be > 0");
EV_PARAM_CHECK(travelTicks >= 0, "travel ticks must be >= 0");
}
bool Collector::assign(GatherNode* node) {
if (node == node_) return true;
if (node_) {
node_->releaseSlot();
node_ = nullptr;
}
if (node == nullptr || !node->tryOccupySlot()) {
state_ = State::Idle;
return false;
}
node_ = node;
type_ = node->resourceType();
cargo_ = 0;
startMoveToNode();
return true;
}
void Collector::clearAssignment() {
if (node_) node_->releaseSlot();
node_ = nullptr;
state_ = State::Idle;
}
void Collector::tick(int player) {
if (state_ == State::Idle) return;
if (node_ == nullptr) {
state_ = State::Idle;
return;
}
switch (state_) {
case State::MovingToNode:
if (--travelRemaining_ <= 0) state_ = State::Gathering;
break;
case State::Gathering: {
const int freeSpace = carryCapacity_ - cargo_;
const int take = std::min({freeSpace, node_->amount(), gatherRatePerTick_});
if (take > 0) cargo_ += node_->extract(take);
if (cargo_ >= carryCapacity_ || node_->depleted()) startMoveToDrop();
break;
}
case State::MovingToDrop:
if (--travelRemaining_ <= 0) state_ = State::Depositing;
break;
case State::Depositing:
deposit(player);
break;
}
}
bool Collector::isIdle() const { return state_ == State::Idle; }
Collector::State Collector::state() const { return state_; }
std::string Collector::stateName() const {
switch (state_) {
case State::Idle:
return "idle";
case State::MovingToNode:
return "moving_to_node";
case State::Gathering:
return "gathering";
case State::MovingToDrop:
return "moving_to_drop";
case State::Depositing:
return "depositing";
}
return "idle";
}
int Collector::cargo() const { return cargo_; }
int Collector::carryCapacity() const { return carryCapacity_; }
std::string Collector::resourceType() const { return type_; }
int Collector::totalGathered() const { return totalGathered_; }
int Collector::trips() const { return trips_; }
void Collector::startMoveToNode() {
state_ = State::MovingToNode;
travelRemaining_ = travelTicks_;
}
void Collector::startMoveToDrop() {
state_ = State::MovingToDrop;
travelRemaining_ = travelTicks_;
}
void Collector::deposit(int player) {
if (cargo_ > 0) {
totalGathered_ += EconomySystem::credit(player, type_, cargo_);
++trips_;
cargo_ = 0;
}
if (node_ && !node_->depleted()) {
startMoveToNode();
} else {
if (node_) {
node_->releaseSlot();
node_ = nullptr;
}
state_ = State::Idle;
}
}
void Collector::destroy() { delete this; }
} // namespace eve::economy
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include <string>
namespace eve::economy {
class GatherNode;
/**
* @brief 采集者:WorkerTrip 基线的状态机
* (选目标→接近→采集→载荷判定→运输→卸货入账)。
*/
class Collector {
public:
/** @brief 采集状态机的相位。 */
enum class State { Idle, MovingToNode, Gathering, MovingToDrop, Depositing };
/**
* @param carryCapacity 单次最大携带量。
* @param gatherRatePerTick 每 tick 入舱量。
* @param travelTicks 单程移动耗时(去程/回程共用)。
*/
Collector(int carryCapacity, int gatherRatePerTick, int travelTicks);
/** @brief 指派目标节点并占用其采集槽位;无空位时保持待命并返回 false。 */
bool assign(GatherNode* node);
/** @brief 取消指派并释放槽位。 */
void clearAssignment();
/** @brief 推进一个 tick;卸货时入账到指定玩家的账本。 */
void tick(int player);
/** @brief 是否处于待命状态。 */
bool isIdle() const;
/** @brief 当前相位。 */
State state() const;
/** @brief 当前相位的字符串名(脚本友好)。 */
std::string stateName() const;
/** @brief 当前携带量。 */
int cargo() const;
/** @brief 单次最大携带量。 */
int carryCapacity() const;
/** @brief 正在采集的资源类型 id。 */
std::string resourceType() const;
/** @brief 累计入账量(不含浪费)。 */
int totalGathered() const;
/** @brief 完成的往返次数。 */
int trips() const;
/** @brief 脚本生命周期入口。 */
void destroy();
private:
void startMoveToNode();
void startMoveToDrop();
void deposit(int player);
GatherNode* node_ = nullptr;
State state_ = State::Idle;
int carryCapacity_ = 0;
int gatherRatePerTick_ = 0;
int travelTicks_ = 0;
int travelRemaining_ = 0;
int cargo_ = 0;
std::string type_;
int totalGathered_ = 0;
int trips_ = 0;
};
} // namespace eve::economy
+159
View File
@@ -0,0 +1,159 @@
#include "economy/Economy.h"
#include "common/Capability.h"
#include "economy/Collector.h"
#include "economy/EconomySystem.h"
#include "economy/GatherNode.h"
#include "economy/ResourceType.h"
#include <simplesquirrel/simplesquirrel.hpp>
namespace eve::economy {
Module_IMPL(Economy, new Economy());
Economy::Economy() { eve::cap::provide<eve::economy::IEconomy>(this); }
int Economy::credit(int player, const std::string& type, int amount) {
return EconomySystem::credit(player, type, amount);
}
bool Economy::debit(int player, const std::string& type, int amount) {
return EconomySystem::debit(player, type, amount);
}
int Economy::get(int player, const std::string& type) const { return EconomySystem::get(player, type); }
int Economy::getCap(int player, const std::string& type) const {
return EconomySystem::getCap(player, type);
}
int Economy::getWasted(int player, const std::string& type) const {
return EconomySystem::getWasted(player, type);
}
int Economy::getIncome(int player, const std::string& type) const {
return EconomySystem::getIncome(player, type);
}
int Economy::getExpense(int player, const std::string& type) const {
return EconomySystem::getExpense(player, type);
}
bool Economy::registerResourceType(const std::string& id, const std::string& category, int stockMax,
const std::string& depletion) {
ResourceTypeDef def;
def.id = id;
def.category = category;
def.stockMax = stockMax;
def.displayName = id;
if (depletion == "renewable")
def.depletion = DepletionModel::Renewable;
else if (depletion == "infinite")
def.depletion = DepletionModel::Infinite;
else if (depletion == "growing")
def.depletion = DepletionModel::Growing;
else
def.depletion = DepletionModel::Finite;
return ResourceTypeRegistry::registerType(def);
}
void Economy::clearTypes() { ResourceTypeRegistry::clear(); }
int Economy::typeCount() { return ResourceTypeRegistry::count(); }
bool Economy::hasType(const std::string& id) { return ResourceTypeRegistry::find(id) != nullptr; }
int Economy::getStockMax(const std::string& id) {
const auto* def = ResourceTypeRegistry::find(id);
return def ? def->stockMax : 0;
}
void Economy::clearEvents() { EconomySystem::clearEvents(); }
int Economy::eventCount() { return EconomySystem::eventCount(); }
std::string Economy::eventAction(int index) {
if (index < 0 || index >= EconomySystem::eventCount()) return {};
return EconomySystem::eventAt(index).action;
}
int Economy::eventPlayer(int index) {
if (index < 0 || index >= EconomySystem::eventCount()) return 0;
return EconomySystem::eventAt(index).player;
}
std::string Economy::eventType(int index) {
if (index < 0 || index >= EconomySystem::eventCount()) return {};
return EconomySystem::eventAt(index).type;
}
int Economy::eventAmount(int index) {
if (index < 0 || index >= EconomySystem::eventCount()) return 0;
return EconomySystem::eventAt(index).amount;
}
void Economy::expose(ssq::Table& table) {
auto cls = table.addClass(name, Economy::create, false);
expose(cls);
auto node = table.addClass<GatherNode>(
"GatherNode",
std::function<GatherNode*(std::string, int, int, int)>(
[](std::string type, int capacity, int slots, int regen) {
return new GatherNode(std::move(type), capacity, slots, regen);
}),
true);
node.addFunc("destroy", &GatherNode::destroy);
node.addFunc("resourceType", &GatherNode::resourceType);
node.addFunc("amount", &GatherNode::amount);
node.addFunc("capacity", &GatherNode::capacity);
node.addFunc("depleted", &GatherNode::depleted);
node.addFunc("freeSlots", &GatherNode::freeSlots);
node.addFunc("regenTick", &GatherNode::regenTick);
auto collector = table.addClass<Collector>(
"Collector",
std::function<Collector*(int, int, int)>(
[](int capacity, int rate, int travel) { return new Collector(capacity, rate, travel); }),
true);
collector.addFunc("destroy", &Collector::destroy);
collector.addFunc("assign", &Collector::assign);
collector.addFunc("clearAssignment", &Collector::clearAssignment);
collector.addFunc("tick", &Collector::tick);
collector.addFunc("isIdle", &Collector::isIdle);
collector.addFunc("stateName", &Collector::stateName);
collector.addFunc("cargo", &Collector::cargo);
collector.addFunc("carryCapacity", &Collector::carryCapacity);
collector.addFunc("resourceType", &Collector::resourceType);
collector.addFunc("totalGathered", &Collector::totalGathered);
collector.addFunc("trips", &Collector::trips);
}
void Economy::expose(ssq::Class& cls) {
cls.addFunc("getName", &Economy::getName);
cls.addFunc("registerResourceType",
[](Economy*, std::string id, std::string category, int stockMax,
std::string depletion) {
return Economy::registerResourceType(id, category, stockMax, depletion);
});
cls.addFunc("clearTypes", [](Economy*) { Economy::clearTypes(); });
cls.addFunc("typeCount", [](Economy*) { return Economy::typeCount(); });
cls.addFunc("hasType", [](Economy*, std::string id) { return Economy::hasType(id); });
cls.addFunc("getStockMax", [](Economy*, std::string id) { return Economy::getStockMax(id); });
cls.addFunc("credit", &Economy::credit);
cls.addFunc("debit", &Economy::debit);
cls.addFunc("get", &Economy::get);
cls.addFunc("getCap", &Economy::getCap);
cls.addFunc("getWasted", &Economy::getWasted);
cls.addFunc("getIncome", &Economy::getIncome);
cls.addFunc("getExpense", &Economy::getExpense);
cls.addFunc("clearEvents", [](Economy*) { Economy::clearEvents(); });
cls.addFunc("getEventCount", [](Economy*) { return Economy::eventCount(); });
cls.addFunc("getEventAction", [](Economy*, int index) { return Economy::eventAction(index); });
cls.addFunc("getEventPlayer", [](Economy*, int index) { return Economy::eventPlayer(index); });
cls.addFunc("getEventType", [](Economy*, int index) { return Economy::eventType(index); });
cls.addFunc("getEventAmount", [](Economy*, int index) { return Economy::eventAmount(index); });
}
} // namespace eve::economy
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include "common/IEconomy.h"
#include "common/Module.h"
#include <string>
namespace eve::economy {
/**
* @brief 经济模块(eve.Economy):资源类型注册 + 玩家账本 + 事件 + 采集流水线。
*/
class Economy : public Module, public IEconomy {
public:
Module_REG(Economy);
Economy();
~Economy() override = default;
// ---- IEconomy ----
int credit(int player, const std::string& type, int amount) override;
bool debit(int player, const std::string& type, int amount) override;
int get(int player, const std::string& type) const override;
int getCap(int player, const std::string& type) const override;
int getWasted(int player, const std::string& type) const override;
int getIncome(int player, const std::string& type) const override;
int getExpense(int player, const std::string& type) const override;
// ---- 注册表门面 ----
/** @brief 注册资源类型;depletion 取值 finite/renewable/infinite/growing。 */
static bool registerResourceType(const std::string& id, const std::string& category,
int stockMax, const std::string& depletion);
/** @brief 清空资源类型(测试用)。 */
static void clearTypes();
/** @brief 已注册类型数量。 */
static int typeCount();
/** @brief 类型是否已注册。 */
static bool hasType(const std::string& id);
/** @brief 类型持有上限(未注册返回 0)。 */
static int getStockMax(const std::string& id);
// ---- 事件 ----
/** @brief 清空事件队列。 */
static void clearEvents();
/** @brief 事件队列长度。 */
static int eventCount();
/** @brief 第 index 个事件的动作(credit/debit/waste;越界返回空串)。 */
static std::string eventAction(int index);
/** @brief 第 index 个事件的玩家(越界返回 0)。 */
static int eventPlayer(int index);
/** @brief 第 index 个事件的资源类型(越界返回空串)。 */
static std::string eventType(int index);
/** @brief 第 index 个事件的数值(越界返回 0)。 */
static int eventAmount(int index);
};
} // namespace eve::economy
+64
View File
@@ -0,0 +1,64 @@
#include "economy/EconomyLedger.h"
#include "economy/ResourceType.h"
#include <algorithm>
namespace eve::economy {
int EconomyLedger::credit(const std::string& type, int amount) {
if (amount <= 0) return 0;
const auto* def = ResourceTypeRegistry::find(type);
const int cap = def ? def->stockMax : 0;
int& current = current_[type];
int accepted = amount;
if (cap > 0 && current + amount > cap) accepted = std::max(0, cap - current);
current += accepted;
income_[type] += accepted;
if (accepted < amount) wasted_[type] += amount - accepted;
return accepted;
}
bool EconomyLedger::debit(const std::string& type, int amount) {
if (amount <= 0) return true;
auto it = current_.find(type);
const int current = it == current_.end() ? 0 : it->second;
if (current < amount) return false;
it->second = current - amount;
expense_[type] += amount;
return true;
}
bool EconomyLedger::canAfford(const std::string& type, int amount) const {
if (amount <= 0) return true;
auto it = current_.find(type);
return it != current_.end() && it->second >= amount;
}
int EconomyLedger::get(const std::string& type) const {
auto it = current_.find(type);
return it == current_.end() ? 0 : it->second;
}
int EconomyLedger::getCap(const std::string& type) const {
const auto* def = ResourceTypeRegistry::find(type);
return def ? def->stockMax : 0;
}
int EconomyLedger::getWasted(const std::string& type) const {
auto it = wasted_.find(type);
return it == wasted_.end() ? 0 : it->second;
}
int EconomyLedger::getIncome(const std::string& type) const {
auto it = income_.find(type);
return it == income_.end() ? 0 : it->second;
}
int EconomyLedger::getExpense(const std::string& type) const {
auto it = expense_.find(type);
return it == expense_.end() ? 0 : it->second;
}
} // namespace eve::economy
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <string>
#include <unordered_map>
namespace eve::economy {
/**
* @brief 单个玩家的资源账本:当前量、上限、收支与浪费统计。
*/
class EconomyLedger {
public:
/**
* @brief 入账 amount。
* @return 实际入账量(受类型上限约束,超限部分记为浪费)。
*/
int credit(const std::string& type, int amount);
/** @brief 出账 amount;余额不足时返回 false 且不扣款。 */
bool debit(const std::string& type, int amount);
/** @brief 是否可负担 amount。 */
bool canAfford(const std::string& type, int amount) const;
/** @brief 当前持有量。 */
int get(const std::string& type) const;
/** @brief 类型上限(来自注册表;未注册返回 0=不限)。 */
int getCap(const std::string& type) const;
/** @brief 因满仓而浪费的累计量。 */
int getWasted(const std::string& type) const;
/** @brief 累计入账。 */
int getIncome(const std::string& type) const;
/** @brief 累计出账。 */
int getExpense(const std::string& type) const;
private:
std::unordered_map<std::string, int> current_;
std::unordered_map<std::string, int> wasted_;
std::unordered_map<std::string, int> income_;
std::unordered_map<std::string, int> expense_;
};
} // namespace eve::economy
+81
View File
@@ -0,0 +1,81 @@
#include "economy/EconomySystem.h"
#include "common/Assert.h"
#include <utility>
namespace eve::economy {
std::unordered_map<int, EconomyLedger>& EconomySystem::ledgers() {
static std::unordered_map<int, EconomyLedger> s_ledgers;
return s_ledgers;
}
std::vector<EconomyEvent>& EconomySystem::events() {
static std::vector<EconomyEvent> s_events;
return s_events;
}
std::unordered_map<std::string, EconomyHook>& EconomySystem::hooks() {
static std::unordered_map<std::string, EconomyHook> s_hooks;
return s_hooks;
}
EconomyLedger& EconomySystem::ledger(int player) { return ledgers()[player]; }
void EconomySystem::clear() {
ledgers().clear();
events().clear();
hooks().clear();
}
int EconomySystem::credit(int player, const std::string& type, int amount) {
const int accepted = ledger(player).credit(type, amount);
emit(EconomyEvent{player, type, "credit", accepted});
const int wasted = amount - accepted;
if (wasted > 0) emit(EconomyEvent{player, type, "waste", wasted});
return accepted;
}
bool EconomySystem::debit(int player, const std::string& type, int amount) {
if (!ledger(player).debit(type, amount)) return false;
emit(EconomyEvent{player, type, "debit", amount});
return true;
}
int EconomySystem::get(int player, const std::string& type) { return ledger(player).get(type); }
int EconomySystem::getCap(int player, const std::string& type) { return ledger(player).getCap(type); }
int EconomySystem::getWasted(int player, const std::string& type) { return ledger(player).getWasted(type); }
int EconomySystem::getIncome(int player, const std::string& type) { return ledger(player).getIncome(type); }
int EconomySystem::getExpense(int player, const std::string& type) { return ledger(player).getExpense(type); }
void EconomySystem::registerHook(const std::string& name, EconomyHook fn) {
EV_PARAM_CHECK(!name.empty(), "hook name must not be empty");
hooks()[name] = std::move(fn);
}
void EconomySystem::unregisterHook(const std::string& name) { hooks().erase(name); }
void EconomySystem::clearEvents() { events().clear(); }
int EconomySystem::eventCount() { return static_cast<int>(events().size()); }
const EconomyEvent& EconomySystem::eventAt(int index) {
const bool valid = index >= 0 && size_t(index) < events().size();
EV_PARAM_CHECK(valid, "event index out of range");
return events()[static_cast<size_t>(index)];
}
void EconomySystem::emit(const EconomyEvent& ev) {
events().push_back(ev);
for (auto& [name, fn] : hooks()) {
(void)name;
fn(ev);
}
}
} // namespace eve::economy
+65
View File
@@ -0,0 +1,65 @@
#pragma once
#include "economy/EconomyLedger.h"
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>
namespace eve::economy {
/** @brief 一次经济事件(credit / debit / waste)。 */
struct EconomyEvent {
int player = 0;
std::string type;
std::string action; // "credit" | "debit" | "waste"
int amount = 0;
};
using EconomyHook = std::function<void(const EconomyEvent&)>;
/**
* @brief 经济系统门面:按玩家持有账本、转发事件、注册钩子。
*/
class EconomySystem {
public:
/** @brief 重置所有玩家账本、事件与钩子(测试用)。 */
static void clear();
/** @brief 入账并广播事件。@return 实际入账量。 */
static int credit(int player, const std::string& type, int amount);
/** @brief 出账并广播事件;余额不足返回 false。 */
static bool debit(int player, const std::string& type, int amount);
/** @brief 玩家当前持有量。 */
static int get(int player, const std::string& type);
/** @brief 玩家该类型持有上限(0=不限)。 */
static int getCap(int player, const std::string& type);
/** @brief 因满仓浪费的累计量。 */
static int getWasted(int player, const std::string& type);
/** @brief 累计入账量。 */
static int getIncome(int player, const std::string& type);
/** @brief 累计出账量。 */
static int getExpense(int player, const std::string& type);
/** @brief 注册事件钩子(同名替换)。 */
static void registerHook(const std::string& name, EconomyHook fn);
/** @brief 注销事件钩子。 */
static void unregisterHook(const std::string& name);
/** @brief 事件队列(按发生顺序)。 */
static void clearEvents();
static int eventCount();
static const EconomyEvent& eventAt(int index);
private:
static EconomyLedger& ledger(int player);
static void emit(const EconomyEvent& ev);
static std::unordered_map<int, EconomyLedger>& ledgers();
static std::vector<EconomyEvent>& events();
static std::unordered_map<std::string, EconomyHook>& hooks();
};
} // namespace eve::economy
+56
View File
@@ -0,0 +1,56 @@
#include "economy/GatherNode.h"
#include "common/Assert.h"
#include <algorithm>
#include <utility>
namespace eve::economy {
GatherNode::GatherNode(std::string type, int capacity, int workerSlots, int regenPerTick)
: type_(std::move(type)), capacity_(capacity), amount_(capacity), regenPerTick_(regenPerTick),
workerSlots_(workerSlots) {
EV_PARAM_CHECK(capacity >= 0, "node capacity must be >= 0");
EV_PARAM_CHECK(workerSlots > 0, "node workerSlots must be > 0");
EV_PARAM_CHECK(regenPerTick >= 0, "node regenPerTick must be >= 0");
if (capacity < 0) capacity_ = amount_ = 0;
if (workerSlots <= 0) workerSlots_ = 1;
if (regenPerTick < 0) regenPerTick_ = 0;
}
std::string GatherNode::resourceType() const { return type_; }
int GatherNode::amount() const { return amount_; }
int GatherNode::capacity() const { return capacity_; }
bool GatherNode::depleted() const { return amount_ <= 0; }
int GatherNode::extract(int amount) {
if (amount <= 0 || amount_ <= 0) return 0;
const int take = std::min(amount, amount_);
amount_ -= take;
return take;
}
bool GatherNode::tryOccupySlot() {
if (occupiedSlots_ >= workerSlots_) return false;
++occupiedSlots_;
return true;
}
void GatherNode::releaseSlot() {
EV_ASSERT(occupiedSlots_ > 0, "releaseSlot without an occupied slot");
if (occupiedSlots_ > 0) --occupiedSlots_;
}
int GatherNode::freeSlots() const { return workerSlots_ - occupiedSlots_; }
void GatherNode::regenTick() {
if (regenPerTick_ > 0 && amount_ < capacity_)
amount_ = std::min(capacity_, amount_ + regenPerTick_);
}
void GatherNode::destroy() { delete this; }
} // namespace eve::economy
+53
View File
@@ -0,0 +1,53 @@
#pragma once
#include <string>
namespace eve::economy {
/**
* @brief 地图上的资源节点:储量、再生、采集槽位。
*/
class GatherNode {
public:
/**
* @param type 资源类型 id(须已在注册表中)。
* @param capacity 总储量。
* @param workerSlots 同时采集槽位上限(>0)。
* @param regenPerTick 每 tick 再生量(0=不再生)。
*/
GatherNode(std::string type, int capacity, int workerSlots = 1, int regenPerTick = 0);
/** @brief 资源类型 id。 */
std::string resourceType() const;
/** @brief 当前储量。 */
int amount() const;
/** @brief 总储量。 */
int capacity() const;
/** @brief 是否已采空。 */
bool depleted() const;
/** @brief 尝试取出 amount;返回实际取出量(不超过当前储量)。 */
int extract(int amount);
/** @brief 占用一个采集槽位;无空位返回 false。 */
bool tryOccupySlot();
/** @brief 释放一个采集槽位。 */
void releaseSlot();
/** @brief 剩余空槽数。 */
int freeSlots() const;
/** @brief 每 tick 再生(Growing/Renewable 模型)。 */
void regenTick();
/** @brief 脚本生命周期入口。 */
void destroy();
private:
std::string type_;
int capacity_ = 0;
int amount_ = 0;
int regenPerTick_ = 0;
int workerSlots_ = 1;
int occupiedSlots_ = 0;
};
} // namespace eve::economy
+26
View File
@@ -0,0 +1,26 @@
#include "economy/ResourceType.h"
namespace eve::economy {
std::unordered_map<std::string, ResourceTypeDef>& ResourceTypeRegistry::types() {
static std::unordered_map<std::string, ResourceTypeDef> s_types;
return s_types;
}
bool ResourceTypeRegistry::registerType(const ResourceTypeDef& def) {
if (def.id.empty()) return false;
types()[def.id] = def;
return true;
}
const ResourceTypeDef* ResourceTypeRegistry::find(const std::string& id) {
auto& all = types();
auto it = all.find(id);
return it == all.end() ? nullptr : &it->second;
}
int ResourceTypeRegistry::count() { return static_cast<int>(types().size()); }
void ResourceTypeRegistry::clear() { types().clear(); }
} // namespace eve::economy
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include <string>
#include <unordered_map>
namespace eve::economy {
/** @brief 资源节点的供给模型。 */
enum class DepletionModel {
Finite, // 采完即枯竭(SC 矿簇)
Renewable, // 可再生(AoE 农田复种)
Infinite, // 无限供给
Growing // 随时间增长(C&C 泰伯利亚)
};
/**
* @brief 资源类型的静态定义(数据驱动配置的最小单元)。
*/
struct ResourceTypeDef {
std::string id; // 唯一标识("minerals" / "food" ...)
std::string category = "stock"; // 存量型 / 速率型 / 特殊(人口)
int stockMax = 0; // 玩家持有上限;0 表示不限
DepletionModel depletion = DepletionModel::Finite; // 供给模型
std::string displayName; // UI 显示名(可空)
};
/**
* @brief 资源类型注册表:注册 / 查询 / 清理。
*/
class ResourceTypeRegistry {
public:
/** @brief 注册或替换一个资源类型定义。@return false 当 id 为空。 */
static bool registerType(const ResourceTypeDef& def);
/** @brief 按 id 查询;未注册时返回 nullptr。 */
static const ResourceTypeDef* find(const std::string& id);
/** @brief 已注册类型数量。 */
static int count();
/** @brief 清空注册表(测试用)。 */
static void clear();
private:
static std::unordered_map<std::string, ResourceTypeDef>& types();
};
} // namespace eve::economy
+1
View File
@@ -331,6 +331,7 @@ set(EVELIBS
EVTensor
EVNetwork
EVDatabase
EVEconomy
EVData
EVI18n
EVSound
+360
View File
@@ -0,0 +1,360 @@
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "common/Capability.h"
#include "common/IEconomy.h"
#include "economy/Collector.h"
#include "economy/Economy.h"
#include "economy/EconomySystem.h"
#include "economy/GatherNode.h"
#include "economy/ResourceType.h"
#include <cstdio>
#include <string>
using namespace eve::economy;
namespace {
ResourceTypeDef makeType(const std::string& id, int stockMax,
const std::string& category = "stock") {
ResourceTypeDef def;
def.id = id;
def.category = category;
def.stockMax = stockMax;
def.depletion = DepletionModel::Finite;
def.displayName = id;
return def;
}
/** 每个用例从干净状态开始(进程内单测)。 */
struct Reset {
Reset() {
ResourceTypeRegistry::clear();
EconomySystem::clear();
}
~Reset() {
ResourceTypeRegistry::clear();
EconomySystem::clear();
}
};
/** 推进 worker 直到待命;返回消耗的 tick 数(超时返回 maxTicks)。 */
int runUntilIdle(Collector& worker, int player, int maxTicks = 1000) {
int ticks = 0;
while (!worker.isIdle() && ticks < maxTicks) {
worker.tick(player);
++ticks;
}
return ticks;
}
} // namespace
TEST_CASE("economy.registry.registerFindClear") {
Reset reset;
CHECK_EQ(ResourceTypeRegistry::count(), 0);
CHECK(ResourceTypeRegistry::find("minerals") == nullptr);
CHECK(ResourceTypeRegistry::registerType(makeType("minerals", 200)));
CHECK_EQ(ResourceTypeRegistry::count(), 1);
REQUIRE(ResourceTypeRegistry::find("minerals") != nullptr);
CHECK_EQ(ResourceTypeRegistry::find("minerals")->stockMax, 200);
CHECK_EQ(ResourceTypeRegistry::find("minerals")->category, "stock");
// 重复注册替换旧定义
CHECK(ResourceTypeRegistry::registerType(makeType("minerals", 500)));
CHECK_EQ(ResourceTypeRegistry::count(), 1);
CHECK_EQ(ResourceTypeRegistry::find("minerals")->stockMax, 500);
ResourceTypeRegistry::clear();
CHECK_EQ(ResourceTypeRegistry::count(), 0);
}
TEST_CASE("economy.registry.rejectsEmptyId") {
Reset reset;
ResourceTypeDef empty;
CHECK(!ResourceTypeRegistry::registerType(empty));
CHECK_EQ(ResourceTypeRegistry::count(), 0);
}
TEST_CASE("economy.ledger.creditRespectsCapAndTracksWaste") {
Reset reset;
ResourceTypeRegistry::registerType(makeType("minerals", 10));
EconomyLedger ledger;
int accepted = ledger.credit("minerals", 6);
CHECK_EQ(accepted, 6);
CHECK_EQ(ledger.get("minerals"), 6);
CHECK_EQ(ledger.getWasted("minerals"), 0);
accepted = ledger.credit("minerals", 6);
CHECK_EQ(accepted, 4); // 只收 4,超限 2 记为浪费
CHECK_EQ(ledger.get("minerals"), 10);
CHECK_EQ(ledger.getWasted("minerals"), 2);
// 未注册类型视为无上限
CHECK_EQ(ledger.credit("unregistered", 5), 5);
CHECK_EQ(ledger.get("unregistered"), 5);
}
TEST_CASE("economy.ledger.debitNeverGoesNegative") {
Reset reset;
ResourceTypeRegistry::registerType(makeType("gold", 100));
EconomyLedger ledger;
ledger.credit("gold", 5);
CHECK(ledger.canAfford("gold", 5));
CHECK(!ledger.canAfford("gold", 6));
CHECK(ledger.debit("gold", 3));
CHECK_EQ(ledger.get("gold"), 2);
CHECK(!ledger.debit("gold", 3));
CHECK_EQ(ledger.get("gold"), 2); // 失败不扣款
CHECK_EQ(ledger.getIncome("gold"), 5);
CHECK_EQ(ledger.getExpense("gold"), 3);
}
TEST_CASE("economy.system.emitsEventsAndHooks") {
Reset reset;
ResourceTypeRegistry::registerType(makeType("wood", 20));
EconomySystem::clearEvents();
int hookCalls = 0;
EconomySystem::registerHook("test", [&](const EconomyEvent& ev) {
++hookCalls;
if (ev.action == "credit") CHECK_EQ(ev.amount, 5);
});
EconomySystem::credit(1, "wood", 5);
CHECK_EQ(EconomySystem::get(1, "wood"), 5);
CHECK_EQ(EconomySystem::eventCount(), 1);
CHECK(EconomySystem::eventAt(0).action == "credit");
CHECK_EQ(EconomySystem::eventAt(0).player, 1);
CHECK(EconomySystem::eventAt(0).type == "wood");
CHECK_EQ(EconomySystem::eventAt(0).amount, 5);
CHECK_EQ(hookCalls, 1);
CHECK(EconomySystem::debit(1, "wood", 2));
CHECK_EQ(EconomySystem::eventCount(), 2);
CHECK(EconomySystem::eventAt(1).action == "debit");
// 当前 3,上限 20 → 收 17,浪费 2
EconomySystem::credit(1, "wood", 19);
CHECK_EQ(EconomySystem::getWasted(1, "wood"), 2);
CHECK_EQ(EconomySystem::eventCount(), 4);
CHECK(EconomySystem::eventAt(2).action == "credit");
CHECK(EconomySystem::eventAt(3).action == "waste");
CHECK_EQ(EconomySystem::eventAt(3).amount, 2);
}
TEST_CASE("economy.workerTrip.gathersUntilNodeDepleted") {
Reset reset;
ResourceTypeRegistry::registerType(makeType("minerals", 1000));
GatherNode node("minerals", 30, 2);
Collector worker(5, 1, 2);
const int player = 1;
CHECK(worker.assign(&node));
const int ticks = runUntilIdle(worker, player);
CHECK(ticks < 1000);
CHECK(worker.isIdle());
CHECK(node.depleted());
CHECK_EQ(worker.cargo(), 0);
CHECK_EQ(worker.totalGathered(), 30);
CHECK_EQ(EconomySystem::get(player, "minerals"), 30);
CHECK_EQ(EconomySystem::getWasted(player, "minerals"), 0);
// 6 次往返,每次 5 单位
int deposits = 0;
for (int i = 0; i < EconomySystem::eventCount(); ++i) {
if (EconomySystem::eventAt(i).action == "credit") ++deposits;
}
CHECK_EQ(deposits, 6);
CHECK_EQ(worker.trips(), 6);
}
TEST_CASE("economy.workerTrip.wastesWhenCapReached") {
Reset reset;
ResourceTypeRegistry::registerType(makeType("minerals", 10));
GatherNode node("minerals", 100, 2);
Collector worker(5, 1, 1);
const int player = 2;
CHECK(worker.assign(&node));
const int ticks = runUntilIdle(worker, player);
CHECK(ticks < 1000);
CHECK_EQ(EconomySystem::get(player, "minerals"), 10);
CHECK_EQ(EconomySystem::getWasted(player, "minerals"), 90);
CHECK_EQ(worker.totalGathered(), 10);
CHECK_EQ(worker.trips(), 20);
}
TEST_CASE("economy.workerTrip.depositsPartialLoadWhenNodeDepletes") {
Reset reset;
ResourceTypeRegistry::registerType(makeType("food", 100));
GatherNode node("food", 3, 2);
Collector worker(5, 1, 2);
const int player = 3;
CHECK(worker.assign(&node));
const int ticks = runUntilIdle(worker, player);
CHECK(ticks < 1000);
CHECK_EQ(EconomySystem::get(player, "food"), 3);
CHECK_EQ(worker.trips(), 1);
CHECK_EQ(worker.totalGathered(), 3);
}
TEST_CASE("economy.workerTrip.saturationSlotsLimitWorkers") {
Reset reset;
ResourceTypeRegistry::registerType(makeType("minerals", 1000));
GatherNode node("minerals", 30, 1); // 单槽位
Collector a(5, 1, 1);
Collector b(5, 1, 1);
const int player = 4;
CHECK(a.assign(&node));
CHECK_EQ(node.freeSlots(), 0);
CHECK(!b.assign(&node)); // 槽位已满,保持待命
CHECK(b.isIdle());
a.clearAssignment();
CHECK_EQ(node.freeSlots(), 1);
CHECK(b.assign(&node));
CHECK_EQ(node.freeSlots(), 0);
const int ticks = runUntilIdle(b, player);
CHECK(ticks < 1000);
CHECK(node.depleted());
CHECK_EQ(EconomySystem::get(player, "minerals"), 30);
CHECK_EQ(node.freeSlots(), 1);
}
TEST_CASE("economy.capability.moduleProvidesIEconomy") {
Reset reset;
auto* eco = Economy::create();
REQUIRE(eco != nullptr);
auto* q = eve::cap::query<eve::economy::IEconomy>();
REQUIRE(q != nullptr);
Economy::registerResourceType("gold", "stock", 50, "finite");
CHECK_EQ(q->credit(3, "gold", 60), 50);
CHECK_EQ(q->get(3, "gold"), 50);
CHECK_EQ(q->getWasted(3, "gold"), 10);
CHECK(q->debit(3, "gold", 20));
CHECK_EQ(q->get(3, "gold"), 30);
CHECK_EQ(q->getCap(3, "gold"), 50);
CHECK_EQ(q->getIncome(3, "gold"), 50);
CHECK_EQ(q->getExpense(3, "gold"), 20);
}
TEST_CASE("economy.demoScenario") {
Reset reset;
printf("====================================================================\n");
printf(" EVEngine economy MVP demo\n");
printf("====================================================================\n");
// ---- 场景 1:双工人矿物采集(SC2 式 WorkerTrip) ----
ResourceTypeRegistry::registerType(makeType("minerals", 100));
EconomySystem::clearEvents();
GatherNode mine("minerals", 30, 2);
Collector workerA(5, 1, 2);
Collector workerB(3, 2, 3);
const int player = 1;
printf("\n[1] 矿物采集: 矿点 30, 双槽位, A(载荷5/速率1/单程2t) + B(载荷3/速率2/单程3t)\n");
printf(" 矿点槽位: %d 空闲\n", mine.freeSlots());
CHECK(workerA.assign(&mine));
printf(" A 进入矿点 -> 槽位: %d 空闲\n", mine.freeSlots());
CHECK(workerB.assign(&mine));
printf(" B 进入矿点 -> 槽位: %d 空闲\n", mine.freeSlots());
printf("\n 采集循环:\n");
int lastEvents = 0;
int doneTick = -1;
for (int t = 0; t < 300; ++t) {
workerA.tick(player);
workerB.tick(player);
if (t % 12 == 0) {
printf(" t=%3d A[%-13s cargo=%d] B[%-13s cargo=%d] node=%2d balance=%d\n", t,
workerA.stateName().c_str(), workerA.cargo(), workerB.stateName().c_str(),
workerB.cargo(), mine.amount(), EconomySystem::get(player, "minerals"));
}
while (EconomySystem::eventCount() > lastEvents) {
const auto& ev = EconomySystem::eventAt(lastEvents);
printf(" -> %s %s x%d (balance=%d)\n", ev.action.c_str(), ev.type.c_str(),
ev.amount, EconomySystem::get(player, "minerals"));
++lastEvents;
}
if (workerA.isIdle() && workerB.isIdle()) {
doneTick = t;
break;
}
}
printf("\n 矿点采空于 t=%d\n", doneTick);
printf(" A: 往返 %d 次, 累计入账 %d\n", workerA.trips(), workerA.totalGathered());
printf(" B: 往返 %d 次, 累计入账 %d\n", workerB.trips(), workerB.totalGathered());
printf(" 玩家%d %s 余额 %d / 上限 %d, 浪费 %d\n", player, "minerals",
EconomySystem::get(player, "minerals"), EconomySystem::getCap(player, "minerals"),
EconomySystem::getWasted(player, "minerals"));
printf(" 矿点剩余 %d\n", mine.amount());
CHECK(doneTick >= 0);
CHECK(mine.depleted());
CHECK_EQ(EconomySystem::get(player, "minerals"), 30);
CHECK_EQ(EconomySystem::getWasted(player, "minerals"), 0);
CHECK_EQ(mine.freeSlots(), 2);
CHECK_EQ(workerA.totalGathered() + workerB.totalGathered(), 30);
// ---- 场景 2:持有上限导致的浪费 ----
ResourceTypeRegistry::registerType(makeType("gold", 10));
EconomySystem::clearEvents();
GatherNode goldNode("gold", 25, 1);
Collector miner(5, 1, 1);
const int player2 = 2;
CHECK(miner.assign(&goldNode));
printf("\n[2] 满仓浪费: 玩家上限 10, 节点 25, 载荷 5 (第 3 次起全部浪费)\n");
printf(" 采集循环:\n");
lastEvents = 0;
doneTick = -1;
for (int t = 0; t < 300; ++t) {
miner.tick(player2);
while (EconomySystem::eventCount() > lastEvents) {
const auto& ev = EconomySystem::eventAt(lastEvents);
printf(" -> %s %s x%d (balance=%d)\n", ev.action.c_str(), ev.type.c_str(),
ev.amount, EconomySystem::get(player2, "gold"));
++lastEvents;
}
if (miner.isIdle()) {
doneTick = t;
break;
}
}
printf("\n 采空于 t=%d\n", doneTick);
printf(" 玩家%d 黄金余额 %d / 上限 %d, 浪费 %d\n", player2,
EconomySystem::get(player2, "gold"), EconomySystem::getCap(player2, "gold"),
EconomySystem::getWasted(player2, "gold"));
printf(" 矿工往返 %d 次, 入账 %d\n", miner.trips(), miner.totalGathered());
CHECK(doneTick >= 0);
CHECK_EQ(EconomySystem::get(player2, "gold"), 10);
CHECK_EQ(EconomySystem::getWasted(player2, "gold"), 15);
CHECK_EQ(miner.trips(), 5);
CHECK_EQ(miner.totalGathered(), 10);
printf("\n====================================================================\n");
printf(" 演示结束\n");
printf("====================================================================\n");
}