Skip to content

Commit f539c04

Browse files
committed
agents: experience-based buffering; pause work for critical needs
1 parent 58b5f66 commit f539c04

7 files changed

Lines changed: 313 additions & 25 deletions

File tree

docs/architecture/agents/agent-implementation-status.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
| Need 数值模型(decay/threshold) | `docs/architecture/proposals/agents/agent-personality-big5.md`(部分) | `include/genesis/agents/Needs.hpp` | Spec | 当前以 Need 本身作为“属性”,无独立 Attributes 层。 |
1414
| NeedSystem 推进 | 同上 | `src/agents/NeedSystem.cpp` | Spec | 仅做线性 decay;无 baseCurve/jitter。 |
1515
| 选点/决策(Planner/Selector) | 同上(理念) | `src/agents/NeedSatisfier.cpp` | Spec | 规则打分 + Big5 权重缩放 + 确定性 jitter(用于打破稳态收敛)。 |
16+
| 经验/学习(最小闭环) | `docs/architecture/proposals/agents/agent-personality-big5.md`(理念) | `include/genesis/agents/Experience.hpp``src/agents/ActionSystem.cpp``src/agents/NeedSatisfier.cpp` | Spec | 从“抢占打断/关键补给失败”学习,提高生存 Need 的缓冲倾向并随时间遗忘;同时允许“先补给再继续作业”。 |
1617
| 决策可观测性(PlannerDecision) | 同上(链路) | `include/genesis/agents/Planner.hpp` | Spec | 仅用于 Telemetry/拥挤惩罚计数。 |
17-
| 行动队列(Move/Consume/Take/Produce) | 同上(链路) | `include/genesis/agents/ActionSystem.hpp``src/agents/ActionSystem.cpp` | Spec | 具备“生产作业时间+临界需求抢占中断”。 |
18+
| 行动队列(Move/Consume/Take/Produce) | 同上(链路) | `include/genesis/agents/ActionSystem.hpp``src/agents/ActionSystem.cpp` | Spec | 具备“生产作业时间+临界需求抢占(优先补给,必要时才中断)”。 |
1819
| 自动补链(缺货→上游生产) | 文档未明确 | `src/agents/ProductionPlanner.cpp` | Spec | 当前是工程式 recovery 规划器,不代表“角色策略”。 |
1920
| Movement(直线/跨图 Portal) | 文档提到 | `include/genesis/agents/Movement2D.hpp``src/simulation/Movement2DSystem.cpp` | Spec | Tile 级路径未实现。 |
2021
| 工坊配方(meta.workshop) | 文档提到 | worldgen→Runtime 写 meta;执行见 `src/agents/ActionSystem.cpp` | Spec | recipe 选择目前发生在 recovery/执行层。 |

docs/architecture/agents/agent-model-v0.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
- **人格(Big5)**
1212
- `genesis::agents::AgentPersonalityBig5`:OCEAN(0~1)人格向量(`include/genesis/agents/Personality.hpp`)。
1313
- 生成方式:Agent 创建时若未显式指定,则按 `entityId + mapId` 的确定性 RNG 生成(可复现、无外部脚本介入)。
14+
- **经验(最小学习)**
15+
- `genesis::agents::components::AgentExperience`:按 Need 维度记录“缓冲偏好”(`bufferMultiplier`),并随时间遗忘(`include/genesis/agents/Experience.hpp`)。
16+
- 目前只学习一件事:当工坊作业被“临界需求抢占打断”或关键补给出现反复失败时,会提高对应 Need 的提前准备与一次性补给量(通过 NeedSatisfier 的 prepareMargin / unitsPerRequest 缩放体现)。
1417
- **位置/移动**
1518
- `genesis::agents::components::AgentLocation2D`:Agent 当前所在 `mapId` 与 2D 坐标(`include/genesis/agents/Movement2D.hpp`)。
1619
- `genesis::agents::components::MovementIntent2D`:当前运动目标与速度(`include/genesis/agents/Movement2D.hpp`)。
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#pragma once
2+
3+
#include <array>
4+
#include <cstddef>
5+
6+
#include "genesis/agents/Needs.hpp"
7+
8+
namespace genesis::agents::components {
9+
10+
struct AgentExperience {
11+
// Multiplicative safety buffer per need. 1.0 means neutral.
12+
std::array<float, static_cast<std::size_t>(genesis::agents::NeedType::Count)> bufferMultiplier{};
13+
14+
// Linear decay rate toward 1.0 per simulated second.
15+
float forgetPerSecond{0.15f};
16+
17+
AgentExperience() {
18+
bufferMultiplier.fill(1.0f);
19+
}
20+
};
21+
22+
} // namespace genesis::agents::components
23+

src/agents/ActionSystem.cpp

Lines changed: 174 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
#include <nlohmann/json.hpp>
1414

1515
#include "genesis/agents/CarriedResources.hpp"
16+
#include "genesis/agents/Experience.hpp"
1617
#include "genesis/agents/Needs.hpp"
1718
#include "genesis/agents/ProductionPlanner.hpp"
1819
#include "genesis/world/MapPathfinding.hpp"
@@ -37,13 +38,61 @@ constexpr const char* kReasonPlanningFailed = "PlanningFailed";
3738
constexpr const char* kReasonUnreachable = "Unreachable";
3839
constexpr const char* kReasonPreemptedCriticalNeed = "PreemptedCriticalNeed";
3940

41+
constexpr float kExperienceMinMultiplier = 1.0f;
42+
constexpr float kExperienceMaxMultiplier = 3.0f;
43+
constexpr float kExperienceBumpPreempt = 0.18f;
44+
constexpr float kExperienceBumpStockoutVital = 0.06f;
45+
4046
[[nodiscard]] std::string preemptedWithNeed(const char* needName) {
4147
std::string out(kReasonPreemptedCriticalNeed);
4248
out.push_back(':');
4349
out.append(needName);
4450
return out;
4551
}
4652

53+
[[nodiscard]] bool isVitalNeed(NeedType need) noexcept {
54+
switch (need) {
55+
case NeedType::Hunger:
56+
case NeedType::Thirst:
57+
return true;
58+
default:
59+
return false;
60+
}
61+
}
62+
63+
void decayExperience(entt::registry& registry, float deltaSeconds) {
64+
if (!(deltaSeconds > 0.0f)) {
65+
return;
66+
}
67+
auto view = registry.view<components::AgentExperience>();
68+
for (auto entity : view) {
69+
auto& exp = view.get<components::AgentExperience>(entity);
70+
const float k = std::clamp(exp.forgetPerSecond * deltaSeconds, 0.0f, 1.0f);
71+
if (k <= 0.0f) {
72+
continue;
73+
}
74+
for (auto& m : exp.bufferMultiplier) {
75+
const float delta = m - 1.0f;
76+
m = 1.0f + delta * (1.0f - k);
77+
m = std::clamp(m, kExperienceMinMultiplier, kExperienceMaxMultiplier);
78+
}
79+
}
80+
}
81+
82+
void bumpNeedBuffer(entt::entity entity, NeedType need, float bump, entt::registry& registry) {
83+
if (!(bump > 0.0f)) {
84+
return;
85+
}
86+
auto& exp = registry.get_or_emplace<components::AgentExperience>(entity);
87+
const auto idx = needIndex(need);
88+
if (idx >= exp.bufferMultiplier.size()) {
89+
return;
90+
}
91+
float m = std::clamp(exp.bufferMultiplier[idx], kExperienceMinMultiplier, kExperienceMaxMultiplier);
92+
m *= (1.0f + bump);
93+
exp.bufferMultiplier[idx] = std::clamp(m, kExperienceMinMultiplier, kExperienceMaxMultiplier);
94+
}
95+
4796
[[nodiscard]] const char* needTypeName(NeedType need) {
4897
switch (need) {
4998
case NeedType::Hunger:
@@ -283,16 +332,115 @@ void ActionExecutor::requestConsume(entt::entity entity,
283332
queue.tasks.push_back(consume);
284333
}
285334

286-
void ActionExecutor::update(entt::registry& registry, float /*deltaSeconds*/) {
335+
void ActionExecutor::update(entt::registry& registry, float deltaSeconds) {
287336
m_workshopAttempts.clear();
288337
m_resourceAttempts.clear();
289338

339+
decayExperience(registry, deltaSeconds);
340+
290341
auto view = registry.view<ActionQueue, components::AgentLocation2D>();
291342

292343
for (auto entity : view) {
293344
auto& queue = view.get<ActionQueue>(entity);
294345
auto& location = view.get<components::AgentLocation2D>(entity);
295346

347+
const auto findBestInteractionFor = [&](genesis::world::ResourceType type) -> genesis::world::InteractionId {
348+
genesis::world::InteractionId best = 0;
349+
float bestCost = std::numeric_limits<float>::infinity();
350+
351+
m_resources.forEachSpawn(registry, [&](const auto& spawn, const auto& inventory) {
352+
if (spawn.type != type) {
353+
return;
354+
}
355+
356+
const auto inter = m_db.findInteraction(spawn.interaction);
357+
if (!inter) {
358+
return;
359+
}
360+
361+
float cost = 0.0f;
362+
if (inter->mapId == location.mapId) {
363+
const auto coord = inter->worldCoord();
364+
const float dx = static_cast<float>(coord.first) - location.x;
365+
const float dy = static_cast<float>(coord.second) - location.y;
366+
cost = dx * dx + dy * dy;
367+
} else {
368+
const auto path = genesis::world::shortestMapPath(m_db, location.mapId, inter->mapId);
369+
if (!path) {
370+
return;
371+
}
372+
cost = 500.0f + static_cast<float>(path->totalCost);
373+
}
374+
375+
// Prefer non-empty targets when possible, but still allow empty workshops.
376+
if (inventory.current == 0U) {
377+
cost += 250.0f;
378+
}
379+
380+
if (cost < bestCost) {
381+
bestCost = cost;
382+
best = spawn.interaction;
383+
}
384+
});
385+
386+
return best;
387+
};
388+
389+
const auto ensureCriticalConsume = [&](NeedType criticalNeed, genesis::world::ResourceType criticalResource) -> bool {
390+
for (const auto& t : queue.tasks) {
391+
if (t.type == ActionType::ConsumeResource && t.need == criticalNeed) {
392+
return true;
393+
}
394+
}
395+
396+
const auto target = findBestInteractionFor(criticalResource);
397+
if (target == 0) {
398+
return false;
399+
}
400+
401+
float buffer = 1.0f;
402+
if (const auto* exp = registry.try_get<components::AgentExperience>(entity)) {
403+
const auto idx = needIndex(criticalNeed);
404+
if (idx < exp->bufferMultiplier.size()) {
405+
buffer = std::clamp(exp->bufferMultiplier[idx], kExperienceMinMultiplier, kExperienceMaxMultiplier);
406+
}
407+
}
408+
409+
std::uint32_t baseUnits = 1U;
410+
float reliefPerUnit = 12.0f;
411+
switch (criticalNeed) {
412+
case NeedType::Hunger:
413+
baseUnits = 2U;
414+
reliefPerUnit = 12.0f;
415+
break;
416+
case NeedType::Thirst:
417+
baseUnits = 2U;
418+
reliefPerUnit = 12.0f;
419+
break;
420+
case NeedType::Social:
421+
baseUnits = 1U;
422+
reliefPerUnit = 20.0f;
423+
break;
424+
default:
425+
baseUnits = 1U;
426+
reliefPerUnit = 12.0f;
427+
break;
428+
}
429+
430+
const auto scaled = static_cast<std::uint32_t>(std::lround(static_cast<double>(baseUnits) * static_cast<double>(buffer)));
431+
432+
ActionTask consume{};
433+
consume.type = ActionType::ConsumeResource;
434+
consume.interaction = target;
435+
consume.need = criticalNeed;
436+
consume.resource = criticalResource;
437+
consume.amount = std::max<std::uint32_t>(1U, scaled);
438+
consume.retries = 0;
439+
consume.reliefPerUnit = reliefPerUnit;
440+
queue.tasks.emplace(queue.tasks.begin(), consume);
441+
return true;
442+
};
443+
296444
const auto abandonAllActions = [&]() {
297445
queue.tasks.clear();
298446
if (registry.any_of<components::MovementIntent2D>(entity)) {
@@ -327,6 +475,8 @@ void ActionExecutor::update(entt::registry& registry, float /*deltaSeconds*/) {
327475
releaseWorkshopSlot(job.interaction, entity);
328476
registry.remove<components::WorkshopJob>(entity);
329477
abandonAllActions();
478+
479+
bumpNeedBuffer(entity, criticalNeed, kExperienceBumpPreempt, registry);
330480
};
331481

332482
if (auto* needs = registry.try_get<NeedComponent>(entity)) {
@@ -335,12 +485,20 @@ void ActionExecutor::update(entt::registry& registry, float /*deltaSeconds*/) {
335485

336486
if (auto* job = registry.try_get<components::WorkshopJob>(entity)) {
337487
if (job->outputType != criticalResource) {
338-
abortWorkshopJob(*job, criticalNeed);
339-
continue;
488+
// Take a break instead of throwing away progress: eat/drink first, then resume work.
489+
bumpNeedBuffer(entity, criticalNeed, kExperienceBumpPreempt, registry);
490+
releaseWorkshopSlot(job->interaction, entity);
491+
if (!ensureCriticalConsume(criticalNeed, criticalResource)) {
492+
abortWorkshopJob(*job, criticalNeed);
493+
continue;
494+
}
340495
}
341496
} else if (!queue.tasks.empty() && queue.tasks.front().type == ActionType::ProduceResource) {
342497
if (queue.tasks.front().resource != criticalResource) {
343-
abandonAllActions();
498+
bumpNeedBuffer(entity, criticalNeed, kExperienceBumpPreempt, registry);
499+
if (!ensureCriticalConsume(criticalNeed, criticalResource)) {
500+
abandonAllActions();
501+
}
344502
}
345503
}
346504
}
@@ -633,11 +791,19 @@ void ActionExecutor::processConsume(entt::entity entity,
633791
m_resourceAttempts.push_back(std::move(attempt));
634792
queue.tasks.pop_front();
635793
queue.tasks.insert(queue.tasks.begin(), plan->begin(), plan->end());
794+
795+
if (isVitalNeed(task.need)) {
796+
bumpNeedBuffer(entity, task.need, kExperienceBumpStockoutVital, registry);
797+
}
636798
return;
637799
}
638800

639801
attempt.failureReason = kReasonPlanningFailed;
640802
m_resourceAttempts.push_back(std::move(attempt));
803+
804+
if (isVitalNeed(task.need)) {
805+
bumpNeedBuffer(entity, task.need, kExperienceBumpStockoutVital * 0.5f, registry);
806+
}
641807
queue.tasks.pop_front();
642808
return;
643809
}
@@ -799,6 +965,10 @@ bool ActionExecutor::processProduce(entt::entity entity,
799965
return true;
800966
}
801967

968+
if (!acquireWorkshopSlot(job->interaction, entity, slots)) {
969+
return false;
970+
}
971+
802972
if (job->workRemainingTicks > 0U) {
803973
job->workRemainingTicks--;
804974
}

0 commit comments

Comments
 (0)