Skip to content

Bot harness hardening: XP, dodge, spawns, attack pace, quests, GM server commands - #3

Open
barrelin-oss wants to merge 63 commits into
Helbreath:masterfrom
barrelin-oss:bot-harness-fixes
Open

Bot harness hardening: XP, dodge, spawns, attack pace, quests, GM server commands#3
barrelin-oss wants to merge 63 commits into
Helbreath:masterfrom
barrelin-oss:bot-harness-fixes

Conversation

@barrelin-oss

Copy link
Copy Markdown

Summary

36 commits from a bot-driven hardening pass: a headless 50-bot harness (tools/bot) runs against the server as its integration and load test, and nearly everything below was found by it rather than by unit tests.

Server fixes

  • Party kills paid zero XP to everyone: party_member::current_map was never set, so members_in_map() matched nobody and distribute_npc_kill_exp returned silently. Eligibility now resolves each member through the live player record.
  • NPC dodge_rate was never wired: defense_ratio from the legacy cfg is the hit-chance denominator, not absorption; it stayed 0 so every swing landed at the 99% cap. Measured rates after the fix match the formula (Slime 90%, Giant-Ant 61%, Orc 21%).
  • Hunger int8_t overflow (100 + 30 became -126), shop-bought items without an owner (unequippable), shop buy_categories numbering, loot/shop references to items absent from the item set, missing legacy magic types (all 66 spells load), entity-id vs player-id confusion across magic/combat/effects.
  • Spawn points: respawn_time_ms is read from mapdata and each death schedules its own respawn, as the legacy spot-mob-generator did. The old single timer was reset by every death and never fired under load.

New mechanics

  • Attack pace per weapon speed and STR (combat/attack_timing.h): base_ms + speed * speed_step_ms, plus a penalty per STR point below the weapon's str_speed_req. Item.cfg field 19 ("Speed") was loaded but unused. Every player_attack_response now carries attack_interval_ms; knobs live in the new attack_speed section of server.yaml.
  • City hall quests end to end: loader for the legacy quests.yaml (hunt / go-place), quest_list/accept/abandon/complete/journal messages plus the quest_update push (docs/protocol/quest.md), dialog actions open_quests / claim_rewards on Kennedy and William, rewards paid from the bridge, kill hook from the NPC death callback.
  • Stat points can be spent (stat_point_request), spells can be learned when INT allows, experience_update on XP gain, party JSON protocol, dynamic ground-field spells (Spike-Field, Ice-Storm, Cloud-Kill).
  • GM /reloadconfig and /shutdown [seconds] [reason] | cancel on the same paths as the admin web API.

Bot harness

tools/bot/bot.mjs (50 autonomous warriors/mages: hunting, potions, eating, shopping, equipping, selling, parties, stat allocation, spell learning, quests) plus quest-smoke.mjs, an 18-check end-to-end smoke of the quest flow against a running server.

Test plan

  • hgserver_tests: 2551 tests pass.
  • 50-bot runs: after the fixes a run holds 250-290 kills per 5 minutes with 0 deaths and 0 protocol errors, versus a run that degraded from 1374 to 11 kills/hour before.
  • node tools/bot/quest-smoke.mjs against a running server (needs an admin account): 18/18.

🤖 Generated with Claude Code

barrelin-oss and others added 30 commits August 30, 2026 11:22
Adds a dedicated experience_update server->client message sent whenever
a player gains XP (solo/party NPC kill XP, crusade rewards, login reward
delivery). Carries experience_gained, new total experience and level;
on level-up also levels_gained, new max_hp/max_mp/max_sp and unspent
stat_points.

Implemented as an experience_gain callback on player_system (fired from
add_experience only when XP actually changes; silent at max level),
wired in game_handlers so every add_experience call site is covered.

Also fixes pre-existing MSVC build breaks: missing NOMINMAX include
order in three bridge translation units and unguarded POSIX time
functions (timegm/gmtime_r/localtime_r) in auth_system and a crusade
test.

Documented in docs/protocol/player.md, docs/JSON_PROTOCOL.md and
docs/protocol/items.md; PROGRESS.md updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y, headless bot client

- JSON party messages (invite/accept/leave/update) + handlers and docs
- Fix loot gold credited to ECS entity id instead of player_id
- npc_registry YAML: parse gold_min/gold_max; npcs.yaml exp/gold keys fixed
- Perf: O(1) player lookup in find_aggro_target; sees_all counter gates
  the far-admin scan in get_players_who_can_see
- Case-insensitive .amd loading (ARESDEN/ELVINE were silently skipped),
  lowercase map names; characters start in their nation town
- Registration rate limit configurable (auth.max_registration_attempts)
- mapdata configs for default/aresden/elvine (spawners, merchants,
  initial points validated by tools/bot/scan-map.mjs)
- tools/bot: headless bot client (hunt/loot/shop/party), gen-bots, scanner
- posix_time_compat shim (pre-existing local build fix, included as-is)

WIP checkpoint requested by worktree session relaxed-bell-f49dbe for merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The modernized magic_type enum was missing six values used by the HBX
Magic.cfg data (14, 19, 21, 25, 26, 27), so 15 spells were skipped at
load as invalid magic_type - including all high-circle attack magic
(Blizzard, Meteor-Strike, Lightning-Strike, Bloody-Shock-Wave).

- Add create_dynamic(14), damage_linear(19), damage_area_no_center(21),
  damage_area_sp_down(25), armor_break(26), ice_linear(27) to the enum
- Implement line targeting (find_line_targets): Bresenham trace from
  caster toward target up to 12 tiles, faction and safe-zone filtered
- damage_area_sp_down drains SP from player targets (effect2 dice,
  parsed into new sp_drain field)
- armor_break deals pure damage (ignores_defense now honored via
  deal_pure_damage)
- Cancellation becomes an offensive dispel (debuff category, removes
  the target effects) instead of hitting the utility stub
- Fix 4 placeholder rows in magic.yaml copied from Lightning-Strike in
  the original cfg: Cancellation type 28, Resurrection 32,
  Illusion-Movement and Mass-Illusion-Movement 16
- create_dynamic spells (Spike-Field, Ice-Storm, Cloud-Kill) stay
  skipped with an info log until the dynamic ground-object subsystem
  exists (tracked in PROGRESS.md)
- New registry test covering the legacy types; 2522 tests pass

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
loot_tables.yaml and shops.yaml were authored against a different item
numbering than items.yaml - 22 referenced IDs did not exist (375+
validation warnings per boot) and several existing IDs pointed at the
wrong item entirely (bosses dropping Tomato/Hoe/Garlic/Carrot as
placeholders for Ice/Merien gear; potion shops selling a MagicNecklace;
blacksmiths selling Dagger variants labeled ShortSword/MainGauche).

Audited every reference against items.yaml using the intent recorded in
the line comments:
- Remapped 16 references whose intended item exists under another ID
  (BlackShadowSword 926, The_Devastator 923, BarbarianHammer 928,
  KlonessAxe 929, StormBringer 924, GiantSword 46, Flameberge+1 55,
  MagicWand(MS20) 256, KnecklaceOfStoneGolem 647, SapphireRing 336 ...)
- Removed 32 pool entries whose intended item does not exist in this
  item set (AncientTablets, CritCandy, SSS/E.S.W/I.M.C manuals,
  XelimaCap/Hat/Helm, NecklaceOfXelima, DragonWand MS40, HolyBlade,
  GiantBattleHammer) plus the vegetable placeholders
- Rewrote ~50 stale comments to the real item names (no behavior change)
- shops.yaml: ShopKeeper-E/W sell RedPotion/BluePotion/GreenPotion
  (91/93/95); Gandlf/William sell Dagger/ShortSword/MainGauche (1/8/12)

All references now resolve; no in-pool duplicates; 2522 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Entity ids from the shared entity_manager are not player ids; casting one
to the other made get_player() return nullptr (or the wrong player) and
silently skipped guarded blocks. Standard fix: get_player_by_entity().

- magic_system: 21 lookup sites (mana/HP/SP costs, silenced/level/stat
  checks, range and safe-zone checks, damage/heal scaling, SP drain,
  resurrection, debuff resist); AOE/line target finders now push the real
  p.ecs_entity instead of fabricating entity{player_id.value, 0}
- application: periodic heal/mana_drain/mana_restore effect ticks never
  applied to players
- game_handlers_combat: spell-cast broadcast target resolution; respawn
  invulnerability was keyed on a fabricated entity and never matched
- player_system::remove_player: effect cleanup used entity{id.value},
  leaking active effects on logout
- wave4 legacy handlers: attack/cast fabricated caster entities from
  player_id, so spell knowledge/cooldowns could never match the JSON path
- tests: fixtures now resolve the real ecs_entity (old fabricated form
  only passed because fixtures spawn no NPCs, keeping counters aligned)

Legitimate uses kept: admin API player_id from the wire, inventory/trade
entity_id{pid.value} keying (consistent), spatial entity_id{index}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng spells

- magic_system.cpp: 20x get_player(player_id{entity.id}) -> get_player_by_entity;
  mana cost, range checks, INT scaling and player-target effects were silently
  skipped (ECS entity ids are not player ids)
- auth_handlers: grant qualifying spells on first login (magic_data '[]')
- tools/bot: mage role (Magic-Missile at range, self-Heal, mana potions),
  combat-aware potion use, resting state that waits for natural regen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tick_ms + hp/mp/sp_interval_ms wired through server_config into
player_system_config (set_config was never called before — defaults only).
Amounts keep the legacy formulas; intervals control how fast a full roll lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New world::dynamic_object_system managing temporary tile objects,
modernized from the legacy CDynamicObject system. The three create_
dynamic (type 14) spells now load and cast:

- Spike-Field: 25 spike traps in a 5x5 area; 2d4 physical damage when
  an entity steps on a trap tile (movement-triggered, owner-immune)
- Ice-Storm: single field ticking 3d3+5 ice damage in 5x5 every ~1s,
  applies freeze (20s) to players
- Cloud-Kill: single cloud ticking 1d8 poison damage in 3x3 (power 40
  from cfg), applies poison DoT to players

Field data is parsed from magic.yaml effect3 {object type, rx, ry}
(legacy Magic.cfg effect10/11/12 columns). Spawn validation: walkable
tile, not a safe zone, one object per tile; area ticks skip players in
safe zones. Damage is attributed to the caster so kill credit flows
through the normal combat/death pipeline.

Protocol: new dynamic_object_spawn / dynamic_object_removed broadcasts
to visible players, plus re-send of visible fields on enter_game and
teleport. Documented in docs/protocol/combat.md and JSON_PROTOCOL.md.

Not ported (documented in PROGRESS.md): weather shortening fire
duration, fire-ice mutual duration reduction, coal fire spreading,
NPC-move spike triggers.

8 new tests; registry test updated for type-14 loading; 2530 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the magic type cycle: sp_down_area(5), sp_up_area(7),
create(10), possession(15) and tremor(22) join the enum, removing the
last invalid magic_type skips at boot (66/66 spells loadable).

- Staminar-Drain: direct area SP drain (effect1 average); the
  sp_down_spot single-target variant now drains SP too instead of
  applying an empty effect. Celebrating-Light (zero dice) is a
  harmless visual cast.
- Staminar-Recovery / Great-Staminar-Recov.: area ally SP restore;
  healing path branches on sp_up_spot/sp_up_area to restore SP
  instead of HP (sp_up_spot previously healed HP by mistake)
- Create-Food: drops a random basic food (Baguette/Meat/Fish) at the
  caster via item_ops::drop_loot + ground item broadcast
- Tremor: area earthquake damage 3d4+3 (legacy knockback not ported,
  documented)
- Possession: accepted as no-op (legacy ground-item ownership does
  not exist in the modern server)
- Fixed two more entity-id-as-player-id lookups found while wiring:
  on_spell_cast caster resolution (game_handlers_combat.cpp) and the
  healing apply_heal pid (magic_system.cpp)

New registry test covering the five types; 2531 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
recipes.yaml and fishing.yaml referenced potions from a different item
set (same class as the loot/shop audit). Remapped by function, results
are name-referenced: HealthPotion -> RedPotion, ManaPotion ->
BluePotion, RevitalizingPotion -> GreenPotion (and Big variants);
fishing rare catch SuperPowerGreenPotion -> SuperGreenPotion (391).

Eliminates the last 7 registry warnings at startup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Overlap with helbreathx-52 (20 magic_system lookup sites, on_spell_cast
caster, healing apply_heal pid) resolved in favor of master. Remaining
delta: AOE/line target fabrication, damage/heal scaling lookups, SP
drain, debuff resists, application.cpp effect ticks, respawn
invulnerability, logout effect cleanup, wave4 legacy handlers, test
fixtures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hunger_state::consume used std::min<int8_t>(100, level + amount), which
converts the sum to int8_t BEFORE comparing. Eating near full wrapped
around (100 + 30 -> -126), so is_starving() became true and
update_regeneration skipped the entire tick - HP, MP and SP froze. Clamp
in int instead, in both consume and decay.

The bug was unreachable until now because no shop sold food: ShopKeeper-W
and ShopKeeper-E only stocked potions, so nothing in the game could
restore hunger. Add Meat (item 99) to both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 50-bot run froze after ~13 minutes: bots stayed alive and walked
around with mobs in sight but stopped fighting entirely. Three causes,
each masking the next.

Hunger: characters persist hunger_level across sessions and bots never
ate, so they logged in starving with regeneration fully blocked
server-side. Track hunger from enter_game and hunger_update, buy food
with top priority, and eat via autoEat(). A starving bot with no food
now goes shopping *before* the flee branch - fleeing recovers nothing
while regen is off - and walks toward its nation's town when no
merchant is visible, instead of freezing in place.

MP: the server sends mp/max_mp inside stat_update (send_vitals_update),
not the mp_update the bot was listening for. Mage MP only ever moved on
a successful cast, so mages sat at "MP 0" forever and rested for a
threshold that could never be reached. Read mp/max_mp from stat_update.

Purchases never worked: the shop catalog exposes item_id but
shop_buy_request requires item_template_id, so every buy silently
failed - which is why every bot ran with "pots 0" and no weapon.
Normalize the catalog and log failed purchases so this cannot hide
again. inventory_item_update (v1) also sends the item flattened while
add/v2 wrap it in {item:{...}}; the handler threw and bought items
vanished. Accept both shapes.

Also: cap resting at 60s so a stalled regen can never freeze a bot
again, and buy a weapon (50 gold) before potions - a weaponless bot
deals ~1 damage and never earns its way out.

Verified over a 17-minute run: 544 kills at a steady 25-45/min with no
decay, 0 deaths, 0 protocol errors, no hunger value outside 0-100.
Previously the run peaked at 35/min and flatlined to 0 by minute 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
handle_shop_buy (the v1 path) created the item with create_from_template
and added it to the inventory, but never called set_owner. The owner
stayed entity_id{}, and item_ops::equip_item rejects any item whose
owner != player with "Item not owned by player" - so every weapon bought
through this path was impossible to equip. The v2 path already got this
right via item_ops::shop_buy.

handle_player_equip also discarded result.error and replied with a bare
false ack, which made a rejected equip indistinguishable from a dropped
packet. Log the reason. That log is what surfaced the second cause here:
bots were retrying a 0-durability weapon every 5 seconds forever, with
the server answering "Item is broken" into the void.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The legend at the top of shops.yaml described a category scheme that no
item in items.yaml ever used (20=accessory_ring, 30=consumable_potion,
...), while items carry the legacy numbering (46=accessories, 21=potions,
5=shields, 6=body armor, 31=food). Every buy_categories list was matching
against numbers that do not exist, so the general shopkeepers refused to
buy back potions and had no buyer at all for accessories - the most
common and most valuable loot.

Rewrite the legend from the actual distribution in items.yaml and fix the
four shops. Category 42 is deliberately left out of every list: it holds
Gold itself alongside guild tickets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bots ended every long run broke (avg 31 gold), unarmed, and hitting for
~1 damage. sellableJunk() only ever returned spare weapons, and only from
the second one on, so 20-30 looted items sat in inventory as dead weight.
It now returns everything not equipped and not reserved (best spare
weapon when nothing is equipped, potions and food up to the buy limits),
routes each item to a merchant that actually buys its type, and runs
before any purchase while the bot is broke - otherwise a bot with 21 gold
and 15 items spent the 21 on food and never liquidated anything.

Two protocol mismatches kept bought items invisible to the bot:

- inventory_item_msg (what inventory_item_update carries, and that is how
  every shop purchase arrives) uses item_type/equip_pos as legacy numbers
  and carries no price or damage, while serialize_item (inventory_data,
  item_add) uses type/equip_pos strings plus price and damage. A bought
  weapon never satisfied `it.type === "weapon"`, so the bot believed it
  was still unarmed and rebought one every shop cooldown. mergeItem()
  now folds both shapes together and keeps the richer fields.
- equipment_change, the authoritative confirmation, arrived 83 times per
  run and fell through to the unknown-broadcast default, so this.equipment
  was never updated from it.

Also: skip weapons at 0 durability (the server rejects them as broken and
the bot retried the same one every 5s forever), let essential needs
override the "no shopping with mobs nearby" guard and walk to town when no
merchant is in sight, and buy a weapon before potions.

Measured over a mature 50-bot run: avg gold 31 -> 2358 for warriors,
53231 gold raised across 45 sales, 48-53 kills/min sustained, 0 protocol
errors. Weapon adoption is better but still incomplete - 12 warriors hold
enough gold and never trigger a blacksmith trip; that is unsolved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gandlf and William sell Dagger, ShortSword and MainGauche for 50 gold
each, but the Dagger requires level 10 and the other two require nothing.
The shop catalog only carried name, price and base_price, so a client
picking "the cheapest affordable weapon" always landed on the Dagger and
then could not equip it - with no way to know why. Send level_limit and
category with each catalog entry.

handle_player_equip had six early returns that replied with a bare false
ack and no log, which is what made this take so long to find: a rejected
equip looked exactly like a dropped packet. Log each reason. The one that
cracked it was "stat/level requirements not met".

Same treatment for the post-purchase inventory_item_update in
handle_shop_buy: if the inventory, the entry, or the message could not be
built, the client silently never learned it owned the item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Warriors were sitting on thousands of gold without a weapon, hitting for
~1 damage. Two independent causes.

They bought weapons they could not use. All three blacksmith weapons cost
50 gold, so "cheapest affordable" always chose the Dagger, which requires
level 10 - most bots are level 1-8. The bot then retried equipping it
every 5 seconds forever while the server answered "stat/level
requirements not met" into the void, and rebought another one each shop
cooldown. Filter catalog candidates by level_limit (now sent by the
server), and mirror the server's check_requirements in canUse() so
unusable looted weapons stop counting as owned weapons - they become
sellable junk and stop blocking the purchase of one that fits. Character
stats come from enter_game.

equipBestWeapon() also sat after restingTick(), which returns early. A
mage at 0 MP rests almost continuously and never reached the line, and
neither did anyone recovering. Equipping costs nothing and matters most
to whoever is fleeing, so it now runs before the rest and flee checks.

Warriors carrying a usable weapon went from 18/30 to 23/30 in a mature
50-bot run, with 8 successful equips and no rejections after the fix.
Kill rate held at 52-54/min.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fleeing stayed true until HP reached recoverHpThreshold. With regen
stalled - starving, no potions, gold at zero - that threshold never
arrives and the bot circles forever, the same deadlock restingTick had.

Cap the flee at 45s with a 20s cooldown, gated by shouldFlee() so low HP
cannot immediately re-trigger it. The cooldown also blocks resting: the
first version passed its test but was useless in practice, because a bot
leaving the flee at 12/50 HP was grabbed by restingTick in the very same
tick and parked again 200ms later. Blocking both makes the window
usable - the bot now targets a mob 200ms after the cap instead.

Verified twice: forced with a 3s cap (fired at 3.07s, re-fled after
8.15s of cooldown) and in production at 45s (fired at 45.2s, then went
straight back to hunting).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
player_system::add_stat_point already had the right stat indices
(0=str 1=dex 2=vit 3=int 4=mag 5=cha) but nothing ever called it: no
protocol message, no handler, no caller anywhere. It was dead code, and
the 3 points awarded per level were permanently stuck.

Add stat_point_request/stat_point_response with range validation, wire
the handler, and send a stat_update after so the client sees the derived
stats change.

Also send stat_points in the enter_game character payload. The count
lived only in the persistence struct, so a client learned its unspent
points solely from the next level-up's experience_update - characters
logged in blind to a backlog they already had. One bot turned out to be
sitting on 20 unspent points.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Warriors raise INT only until Protection-From-Magic becomes reachable
(int_req 32 in magic.yaml) and never touch MAG; past that DEX is the
priority and STR follows in second. Mages keep pushing INT, with MAG
trailing to cover spell costs.

Two caveats worth stating. STR here is a stand-in: the spec calls for
"enough STR for max weapon speed", but weapons carry no str_req in this
server and the attack rate is a flat 100ms, so there is no threshold to
aim at - it is encoded as a ratio (STR about half of DEX) and should
become a real threshold once weapon speed exists. And reaching INT 32
does not by itself grant the spell: spells are only granted at first
login from INT/MAG, still marked TODO in auth_handlers.

Verified both branches. The INT branch ran on its own (113 allocations,
no failures); the DEX/STR branch was forced by temporarily lowering the
target to 12, which warriors had already passed - DEX climbed 13 to 17
while STR held at 14 - then the target was restored to 32.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spells were only ever granted at first login, from the character's INT
and MAG, with a TODO in auth_handlers saying to replace it with a real
flow. That made stat allocation pointless for magic: a warrior born at
INT 14 could raise INT forever and still never gain a spell.

Add learn_spell_request/learn_spell_response. The handler reuses
validate_npc_interaction for range, then checks that this NPC teaches
the spell, that the player meets int_req/mag_req, has the gold, and does
not already know it. On success it deducts, learns, and returns the
updated gold and spell list.

The gold price was already in magic.yaml as "cost" but nothing read it -
only mana_cost was parsed. Carry it through as gold_cost in both spell
structs and the registry-to-runtime conversion, and expose the spell
catalogue (int_req, mag_req, cost, known) in the shop interact response
so a client can decide before asking.

The catalogue hangs off the ShopKeepers rather than a dedicated teacher:
npc_category is derived from npc_type and every NPC becomes a merchant,
so there is no way to mark a trainer in config today. Moving it later is
just config - add the NPC, spawn it, move the spells: block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Warriors go after Create-Food and Protection-From-Magic, mages after the
full set. A reachable spell - INT and gold both sufficient - becomes a
reason to visit the shop, since that is the only way the allocated INT
turns into anything useful.

This closes the loop end to end for the first time: level up, three stat
points, INT rises, crosses int_req, the bot walks to the teacher, pays,
and learns. Verified with warriors born at INT 14 who crossed 18 and
bought Create-Food - impossible before, when spells were frozen at
whatever first login granted. A mature run produced 5 Create-Food, 1
Invisibility and 1 Protection-From-Magic, the last being the warrior
target the INT policy aims at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The items table has had a name column all along, defaulting to '', but
the INSERT never listed it and item_row had no such field. Every item
was stored nameless and the name was reconstructed from template_id on
load. The game did not care, but it left the table opaque: answering
"what are the bots carrying" meant joining template_id against
items.yaml by hand.

Carry the name through item_row and write it, for the bank rows as well
as the inventory ones - that path was missing it too. Verified after a
full autosave: 330 of 330 rows now carry a name matching their
template_id.

The template stays the source of truth; the stored name is for
inspection, noted in the field comment so nobody later treats the column
as authoritative and drifts from a renamed template.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
npcs.yaml is generated by tools/convert from the legacy .cfg format and
names two fields defense_ratio and size. The registry read "defense" and
"body_size" - names that appear zero times in the file - so both were
always 0 across all 85 NPCs.

The obvious fix, renaming defense_ratio to defense, would have been
wrong. In the legacy format defense_ratio is not damage absorption: it
is the denominator of the hit chance, calc_hit_chance(hit_rate,
dodge_rate). npc.defense in this codebase is a damage-reduction
percentage capped at 80, so feeding it values of 10-450 would have made
every mob absorb 50-80% of incoming damage. It belongs in dodge_rate.

Two links in that chain were broken: the registry never read the value,
and npc_system never copied dodge_rate from the template to the spawned
NPC. With dodge_rate at 0, calc_hit_chance did max(1, 0) and every
attack in the game - players and mobs alike - landed at the 99% cap.

npc.defense stays 0 on purpose: the legacy format has no absorption
field, so there is nothing to map onto it.

Hit chance now varies by mob as intended - roughly 87% against a Slime
(dodge 20), 58% against a Giant-Ant (30) and 23% against an Orc (75) at
current bot stats. Kill rate held at 21-49/min with no errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
attack_result_msg carried a single hit flag, and hit=false was used for
both a swing that missed and roughly a dozen rejections - out of range,
target dead, attacking too fast, not in combat mode. A client could not
tell them apart, so measuring an actual hit rate was impossible; the
dodge_rate figures in the previous commit were arithmetic, not
observation.

Add resolved (the server actually rolled the attack) and dodged (the
target evaded, as opposed to the attacker missing), and set them on the
one path that resolves combat.

Measured against the computed expectations right after: Slime 86% vs
~87% predicted, Giant-Ant 58% vs ~58%, Orc 25% vs ~23%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only hits were logged, so a miss was indistinguishable from a swing that
never happened. Count resolved swings and hits, log the miss with
whether it was a dodge, and carry the running rate in the status line.

Confirms the dodge_rate wiring with observation rather than arithmetic:
Slime 86%, Giant-Ant 58%, Orc 25%, against predictions of 87/58/23.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The single respawn timer was reset by every death and never fired under
50 bots, so spawners stopped repopulating. A serial queue (one respawn
per respawn_time_ms) fixed that but capped each spawner at 12/min, which
became the kill-rate ceiling of the bot runs. Each death now carries its
own due time, as the legacy spot-mob-generator did, and respawn_time_ms
is read from mapdata (spot_mob_generator.respawn_time_ms, default 60 s).

The test maps also grade the field: Slimes everywhere, Giant-Ants and
Orcs only on the side away from the initial points, because 20 Orcs on
the spawn tile killed level-1 characters in about 12 s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
autoPotion() fired a use_item_request every 200 ms tick before the server
had answered with the new HP: 481 of 3159 potion uses in the last run
were repeats of the same HP, and the buy-drink-buy loop replaced hunting
(1374 kills/h down to 11). potionReady() now requires potionCooldownMs
and a changed value (or potionSettleMs elapsed), with separate timers for
red and blue potions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The server only refused swings closer than 100 ms; Item.cfg field 19
("Speed", 0..15) was loaded but never used. combat/attack_timing.h now
paces every swing: base_ms + speed * speed_step_ms, plus str_penalty_ms
per STR point below the weapon str_speed_req (derived as
speed * str_per_speed when the item declares none), clamped to
[min_ms, max_ms]. str_speed_req never blocks equipping; that stays with
str_requirement.

Every player_attack_response carries attack_interval_ms, refused swings
included, so clients pace themselves without knowing the formula. The
knobs live in the new attack_speed section of server.yaml. Bots adopt the
reported interval and no longer count attack_too_fast as a swing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
barrelin-oss and others added 30 commits September 4, 2026 23:16
gm_command_context gains config, broadcast_all and request_shutdown hooks
injected from application.cpp, so the admin module still does not depend
on application.h. /reloadconfig re-reads server.yaml from the boot path
and reports which sections apply live; /shutdown [seconds] [reason]
broadcasts warnings at 5 min, 60 s, 30 s and 10 s on the shutdown_countdown
scheduler tag shared with the admin web API, /shutdown cancel aborts, and
/shutdown alone stops at once.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Recent Changes for the attack pace, party XP fix, potion cooldown, spawn
rescheduling, city hall quests and GM server commands. Quest NPCs and
server management are marked done. Object interaction (doors, chests) is
dropped: the original game has neither, only the Heldenian gate doors
that the war system already owns.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Once a bot hit its carry limit the server put the item back ("Too heavy
to carry", pickup_result success=false) but the bot ignored that reply,
deleted the ground item optimistically, saw it re-broadcast and tried
again every 200 ms tick without ever fighting again. With respawn
working, drops piled up and bots froze one by one: run 3 went from 281
to 42 kills per 5 minutes with bots at full HP next to 84 mobs.

Failed pickups now blacklist that ground item for lootSkipMs and two in
a row switch the bot to gold-only looting for overweightMs. The bot also
tracks inventory_weight_update: above lootWeightCap of max weight only
gold is picked up, above sellWeightRatio it goes to sell whatever junk it
has, however little.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Logs in with an admin account, runs /reloadconfig (and the /reload
alias), schedules /shutdown 20, waits for the 20 s and 10 s system-chat
warnings, cancels, checks the usage errors, then issues /shutdown 0 and
verifies the connection is closed by a clean server exit. 16 checks;
--no-final-shutdown skips the last step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
hunger_update, skill_update and skill_progress shared one case that did
this.hunger = d.level, so every skill progress message (level 5, 8...)
became "hunger 5". The bot then believed it was starving, ate constantly
and spent its gold on food: run 4 had 187 Meat purchases against 5
swords and a third of the warriors unarmed with ~20 gold, while the
server-side hunger drains 1 point per minute. HANDOFF items 6.2 and 6.3
trace back to this.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…reachable targets

Status lines carry the aiTick branch in effect (acao=...), stepTowards
logs when it gives up after 9 refused moves, and a shop trip that does
not reach the merchant in shopTripMaxMs is abandoned and logged. That
showed why kill rates decayed: bots stood for minutes against
blocked_terrain (mobs that wandered outside the walkable area) and
blocked_occupied (crowds around the merchant tiles) without a log line.

stepTowards now tries the neighbouring directions when the direct step
is refused and keeps a successful detour for detourSteps steps instead
of turning back into the same obstacle; a target abandoned as
unreachable is skipped for avoidTargetMs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ix::WebSocketServer was built with port and host only, so it kept the
library defaults: maxConnections = 128 and a TCP backlog of 5.
websocket.max_connections in server.yaml never reached it, and the 129th
client was dropped during the handshake (close 1006) with nothing in the
server log. Bots 129-200 of the scale test all died that way. The
server now passes the configured limit and a backlog of 256 and logs
both at startup; bots reconnect after reconnectMs when the socket closes
outside a shutdown.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
application.cpp turned a mapdata spawner rect into center + radius =
max(w, h) / 2, and spawn_point::get_spawn_position sampled a square of
that radius: a 50x70 rect spawned NPCs up to 10 tiles past its narrow
sides, behind walls. Bots piled up at x=195-199 in Aresden trying to
reach mobs born there; with 200 bots that alone took the kill rate from
686 to 43 per 5 minutes. spawn_point now carries radius_x/radius_y and
samples inside the rect (the square radius still applies when unset).

spawn_npc_at set the NPC home to the rect center, so with wander_range 5
an NPC born near the edge of a 70-tile generator never wandered again.
Home is now the tile it spawned on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
With every spawn now inside the generator rect the test arena got
denser, and warriors stood in pockets of 3-4 mobs drinking a potion
every 7 s until they died: 470 potions and 14 deaths in 25 minutes of
run 9. shouldFlee() now also triggers below swarmFleeHp (60% HP) when
swarmFleeCount (3) monsters are adjacent, reusing the existing
flee/recover flow. Run 10 on the same arena: 1142 kills in 15 minutes,
2 potions, 0 deaths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
NPCs born near the edge of a spot-mob generator drifted wander_range
tiles past it, into pockets behind walls where nobody could reach them.
They never died there, so the reachable population of the area shrank
over every run and bots ended up chasing mobs through walls: run 10
went from 492 to 80 kills per 5 minutes in 20 minutes with zero deaths.

ai_runtime_state carries the generator rect (home_min/home_max, set in
spawn_npc_at from the spawn point half extents) and process_wander_state
refuses steps outside it. Chasing still leaves the rect and return_home
brings the NPC back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A reconnect kept the previous session's entities and ground items, so
bots chased ghost mobs (58 "visible" with 60 in the whole map) and died
to the real ones: deaths went from 7 to 19 in the 5 minutes after the
first server restart under load. resetSession() now clears entities,
ground items, target, loot and party state on close; inventory and
equipment are resent by the server on enter.

With NPCs kept inside their generator rect and roaming properly, the
test arenas were too dense for 25 bots per city; Slimes were doing most
of the killing. Generators are now 30 Slimes, 20 Giant-Ants and 10 Orcs
per city.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…b generator

tools/convert/mapdata-legacy.mjs converts the legacy MapData/*.txt
(Helbreath 3.82, centuu/HelbreathServer) into bin/mapdata/<map>.yaml:
233 spot-mob generators, 824 teleports, 48 safe zones, initial, fish,
mineral and way points, level limits, random_mob_generator with the
legacy maximum-object as max_mobs, and the interior maps' fixed NPCs as
1-tile generators. Installed for every map with an .amd; aresden and
elvine keep the bot test arenas, their legacy versions sit next to them
as *.legacy.yaml.

Spawners may name the NPC (npc_name) and the server prefers it over the
legacy numeric type, whose mapping only knew 22 types. Boot registers
209 spawn points; 24 are skipped for NPC templates missing from
npcs.yaml.

npc_system::update_random_mobs is the legacy MobGenerator: nothing
called spawn_random_mob() periodically, so the 24 maps that rely on the
random generator stayed empty. Each check tops every enabled map up by a
small batch of free-roaming mobs of its level, up to min(map max_mobs,
random_mob_cap).

tools/bot/map-peek.mjs teleports the admin character to a map tile,
takes one step and lists the NPCs in view.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The GM teleport does not push player_teleport, so entities seen on the
origin map stayed in the list and inflated the destination count.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…onsters

Every side-0 template with action_limit 0 was classified as a town NPC, so
Rabbit, Cat and Unicorn were is_friendly(): attacks on them were refused and
the random mob generator, which only counts is_monster() roamers, never counted
them. elvfarm (level 1) reached 305 roaming NPCs against a cap of 150 because
160 were rabbits and cats. They are now monsters with is_aggressive = false;
Guard-Neutral (side 0) is a guard. Town NPCs are action_limit 2/6 and unaffected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
/teleport, /goto, /summonplayer, the admin web tool teleport and the apocalypse
teleports called player_system::execute_teleport directly, which moves the
player server-side only: the client got no player_teleport, no destination
entities, teleporters, ground items or environment, so a GM kept looking at the
old map. They now route through game_handlers::execute_player_teleport (public,
returns the error string) via gm_command_context::teleport_player,
admin_web_handlers::set_teleport_fn and application::teleport_player_synced.
Without a bridge (unit tests) the GM commands fall back to the bare move.

tools/bot/hunt-peek.mjs teleports next to the first NPC with a given name in
view and sends one attack; map-peek no longer needs to clear the view by hand.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
npcs.yaml gains Giant-Crayfish, Giant-Lizard, McGaffin, Perry, Devlin, Gail,
YW-Aresden and Sor-Aresden, mapped column by column from the legacy NPC.cfg
(centuu, 3.82); exp scaled to this file's range. Boot registers 221 spawn
points, 12 skipped (legacy types 7/8/9 with no name in NPC.cfg). Verified in
game on dglv2, druncncity, cmdhall_1 and procella.

dialogs.yaml: McGaffin, Perry and Devlin offer open_quests/claim_rewards; Gail
offers the crusade job selection.

map-peek/hunt-peek heal GmSmoke first and stop when the teleport is refused
instead of reporting the origin map.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reading the centuu MapData with the alphabetical 3.51 table put 140 Abaddons
in procella, Giant-Crayfish in maze and Sor-Aresden in druncncity. The
converter now defaults to the Korean table taken from the generator comments
(71 Fire-Wyvern, 72 Barlog, 73 Tentocle, 74 Centaurus, 75 Giant-Lizard,
76 Minotaurs, 80 Giant-Plant, 81 MasterMage-Orc, 82 Nizie; 70 Claw-Turtle is
an assumption, marked in the YAML); --intl-types keeps the NPC.cfg numbering.

NPC.cfg names are translated to this distribution's spelling (Giant-Cray-Fish,
Lizard, Minotaurus, Master-Mage-Orc), so the duplicate Giant-Crayfish and
Giant-Lizard templates are gone again. Verified in game on procella, maze and
druncncity.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ide alone

tools/cerca/cerca.mjs sees every Bash/PowerShell command and every Write/Edit
path and exits 2 with a reason for: rewriting published history or pushing
to the read-only upstream, merging or releasing through gh, discarding local
work (hard reset, checkout --, restore, clean, branch -D), privilege
elevation, piping downloads into a shell, package publish, dropping or
truncating database objects, stopping PostgreSQL, killing processes that are
not the project's, recursive deletes outside the workspace, and writes to
credential files, the fence itself and the settings that install it.

Idea from GitArika/agents-orchestrator (hooks/cerca.sh): a hook is not
overridable by an existing allow rule and sees the whole command line.
cerca.test.mjs gives every rule a blocking case and an innocent neighbour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
tools/convert/merge-legacy-items.ts adds to items.yaml the items of a 3.x
Item*.cfg that have no equivalent here (same id with the same
type/equip_pos/sprite/frame is the same item under another label; long
names within two edits are spelling variants): 103 new items from the
centuu 3.82 files, legacy ids kept when free, 13 renumbered from 1001.
Existing ids never change. merge-legacy-npcs.mjs adds the 27 missing
templates (Ice-Golem, the gates, 24 faction war units).

spot_mob_mapping.h knows type 28 (Troll), so quests 21/22 load.

Bots: a refused request arrives as type error with error_code; the harness
mirrors it into data.error and, on dead, asks for a respawn; a character
that logs in dead respawns on enter. quest-smoke teleports to the arena
city hall before looking for Kennedy instead of trusting the saved position.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
player_interact_response for an NPC dialog wrote each option's action as the
raw enum number while dialog_choice_response and docs/protocol/npc.md use
names (goto_node, open_quests, ...). npc::dialog_action_name() is now the
one place that spells an action, used by both responses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…up first

No NPC ever got the banker/warehouse category (town templates all became
merchant), so player_interact_request never opened the bank. Howard and Tom,
the warehouse keepers, are warehouse now and a click on them returns the
bank contents.

/setgold pushes inventory_gold_update to the target, so the client's gold
matches without relogging.

quest-smoke abandons quests left by another session before measuring: the
officer lists active quests regardless of level, and a client that accepted
one made the level-1 check fail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
get_characters_response gains "equipment": {slot: {appr, color}}, the
shape of the entity spawn, read from character_equipment + items in one
query per account and resolved through the item registry, so the character
select can draw the figure dressed instead of naked.

npcs.yaml: 19 templates on sprites 100-112 (our numbering; the packs come
from the Helbreath Olympia client): Scarecrow, Ghost, Princess, Bat, the
two officers, Guard-Archer/Axe/Sword per side, the three chests and the
Black-Beholder. The spawners (scarecrows on the farms, bats and ghosts in
dglv2) live in bin/mapdata, which is not versioned.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…chanics

docs/olympia-reference.md catalogues the Helbreath Olympia client folder
(data files, 36 patch notes, client strings) and describes its mechanics
in porting order: specialties, elites, daily quests, achievements,
enchanting with shards, talents and rebirth.

The quest loader takes giver/giver_map (a named NPC instead of the city
hall officer), name/description, a second kill target, gathered items and
a gather-only row type. quests.yaml 200-231 are the 33 Olympia quests (31
ported; the two elite-kill ones wait for elites) given by ten persons from
persons.json, added to npcs.yaml, dialogs.yaml and placed on their maps.
Exp scaled /50, gold /5. Checked live at every person.

items.yaml gains description on the 69 items whose Olympia tooltip text
applies here. Scarecrows, bats and ghosts get spawners on the farms and
dglv2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…, achievements

Specialties (monster mastery): kills per monster type unlock the ladder of
bonuses of specialties.yaml against that monster (damage, damage taken, hit
ratio, drop rate), applied in build_combat_context and the loot roll, saved
in characters.specialty_data, announced with specialty_update and a system
chat line.

Elites: elite_chance on a spawner makes some spawns "Elite " with 5x HP,
double dice, 5x exp, 3x gold and 3x drop chances; the wire carries
"elite": true; kill objectives take elite/elite2 and the two Olympia
quests that needed them are in.

Daily quests: period_hours on a row is a cooldown after the turn-in, kept
per template in the journal; accept answers on_cooldown and the list hides
them until ready.

Treasure chests: treasure_chests.yaml puts a bronze/silver/gold chest on a
random walkable tile of a listed map every interval; opening it gives the
tier's gold and the drops of loot_tables.yaml 109-111; silver announced to
the map, gold to everyone.

Achievements: achievements.yaml counters (kills, per type, elites, quests,
gold, chests, level, specialty levels) unlock points and titles, saved in
characters.achievement_data, announced with achievement_unlocked. Tests for
the three systems and the loader flags.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The template's description (the Olympia tooltip texts, plus the potions)
is copied to the instance and serialized; the client shows it in the
inventory tooltip.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- player_attack_request accepts attack_type "super"/3 and the client's
  per-weapon codes 20-27. With a charge left the hit is a guaranteed
  critical, the charge is spent and the action broadcast says
  "super_attack" (nearby clients play the shout). New push
  super_attack_update {charges}: after enter_game_response, after each
  super attack and on level-up; charges follow legacy level / 10.
- item.weapon was never filled, so every item serialized with
  weapon_type "none". Derived now from the template's legacy appearance
  value (weapon_type_from_appearance), with a test.
- send_stat_update carries the current hp/mp/sp too: drinking a potion
  only sends that message, and the client never saw the heal.
- NPC templates with action_limit 3/4/8 get the stationary AI flag
  (never set before). The Olympia Scarecrow is a training dummy now
  (action_limit 3): stands still, hits back with nothing.
- Docs: combat.md (attack types, super_attack_update), items-v2.md,
  JSON_PROTOCOL.md, PROGRESS.md. Tests for the parsing and the message.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Items carry the template's gender_limit (gender_requirement, serialized as
gender_req) and player_equip_request refuses a piece whose gender does not
match the character, as legacy did for the (M)/(W) armor sets. Test
requirement_check_test.gendered_armor; docs and PROGRESS updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant