From 15b7e221b799ef107dec44ecdb294026dfd3d059 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Wed, 24 Jun 2026 22:55:35 -0400 Subject: refactor: pulled techs out to yaml files, unified numerous combat functions, broke up game object --- AGENTS.md | 34 ++++++--- building_guide/construction.md | 2 +- building_guide/state.md | 2 +- data/techs/amplifier_1.yaml | 8 +++ data/techs/amplifier_2.yaml | 8 +++ data/techs/amplifier_3.yaml | 8 +++ data/techs/clarity_1.yaml | 8 +++ data/techs/clarity_2.yaml | 8 +++ data/techs/clarity_3.yaml | 8 +++ data/techs/focus_1.yaml | 8 +++ data/techs/focus_2.yaml | 8 +++ data/techs/focus_3.yaml | 8 +++ data/techs/fortify.yaml | 9 +++ data/techs/overclock.yaml | 9 +++ data/techs/preserve.yaml | 8 +++ data/techs/protect_melee.yaml | 9 +++ data/techs/protect_ranged.yaml | 9 +++ data/techs/protect_science.yaml | 9 +++ data/techs/rapid_heal.yaml | 8 +++ data/techs/regen.yaml | 8 +++ data/techs/retribution.yaml | 8 +++ data/techs/shield_1.yaml | 8 +++ data/techs/shield_2.yaml | 8 +++ data/techs/shield_3.yaml | 8 +++ data/techs/targeting_1.yaml | 8 +++ data/techs/targeting_2.yaml | 8 +++ data/techs/targeting_3.yaml | 8 +++ internal/game/action.go | 33 ++++++++- internal/game/action_burn.go | 4 +- internal/game/action_clean.go | 4 +- internal/game/action_farm.go | 4 +- internal/game/action_fletch.go | 7 +- internal/game/action_gather.go | 2 +- internal/game/action_identify.go | 2 +- internal/game/action_room.go | 4 +- internal/game/action_talk.go | 6 +- internal/game/action_use.go | 2 +- internal/game/cmd_attack.go | 31 ++------- internal/game/cmd_consume.go | 11 ++- internal/game/cmd_hide.go | 28 +++----- internal/game/cmd_move.go | 8 +-- internal/game/cmd_queued.go | 11 ++- internal/game/cmd_stop.go | 2 +- internal/game/cmd_trigger_combat.go | 14 ++-- internal/game/command_queue.go | 134 ++++++++++++++++++++++++++++++++++++ internal/game/condition_test.go | 11 +-- internal/game/core_production.go | 3 +- internal/game/core_startup.go | 80 +++++++++++++++++++++ internal/game/flagstore.go | 42 +++++++++++ internal/game/game.go | 66 ++++++------------ internal/game/safespot_manager.go | 85 +++++++++++++++++++++++ internal/game/sys_labor.go | 36 +--------- internal/game/sys_safespot.go | 55 ++++----------- internal/game/sys_technology.go | 116 ++++++++++++++----------------- internal/game/ui_combat.go | 11 ++- internal/game/ui_xpdrop.go | 31 +++++++++ internal/net/server.go | 45 ++++++++++-- internal/net/server_test.go | 64 +++++++++++++++++ internal/object/item_store.go | 2 +- 59 files changed, 884 insertions(+), 305 deletions(-) create mode 100644 data/techs/amplifier_1.yaml create mode 100644 data/techs/amplifier_2.yaml create mode 100644 data/techs/amplifier_3.yaml create mode 100644 data/techs/clarity_1.yaml create mode 100644 data/techs/clarity_2.yaml create mode 100644 data/techs/clarity_3.yaml create mode 100644 data/techs/focus_1.yaml create mode 100644 data/techs/focus_2.yaml create mode 100644 data/techs/focus_3.yaml create mode 100644 data/techs/fortify.yaml create mode 100644 data/techs/overclock.yaml create mode 100644 data/techs/preserve.yaml create mode 100644 data/techs/protect_melee.yaml create mode 100644 data/techs/protect_ranged.yaml create mode 100644 data/techs/protect_science.yaml create mode 100644 data/techs/rapid_heal.yaml create mode 100644 data/techs/regen.yaml create mode 100644 data/techs/retribution.yaml create mode 100644 data/techs/shield_1.yaml create mode 100644 data/techs/shield_2.yaml create mode 100644 data/techs/shield_3.yaml create mode 100644 data/techs/targeting_1.yaml create mode 100644 data/techs/targeting_2.yaml create mode 100644 data/techs/targeting_3.yaml create mode 100644 internal/game/command_queue.go create mode 100644 internal/game/flagstore.go create mode 100644 internal/game/safespot_manager.go create mode 100644 internal/game/ui_xpdrop.go create mode 100644 internal/net/server_test.go diff --git a/AGENTS.md b/AGENTS.md index c2d420f..99cac55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -The House of Icarus — a Runescape-like sci-fi MUD written in Go. Data-driven via YAML files. No database, no ORM. +The House of Icarus — a sci-fi MUD inspired by classic retro MMOs, written in Go. Data-driven via YAML files. No database, no ORM. Set on a terraformed asteroid where technology has regressed. "Technology" replaces Prayer, "Science" replaces Magic, Scavenging replaces Runecrafting. @@ -29,7 +29,7 @@ internal/ action/ Behavior structs (GatherConfig, TalkConfig, UseConfig), SuccessChance player/ Player struct, skills, inventory, bank, equipment, accounts, XP, validation world/ World, RoomDef, MobDef, MobInstance, ObjState, ground items, wandering - combat/ Combat state tracking, OSRS-style formulas (HitChance, MaxHit, AttackRoll) + combat/ Combat state tracking, classic formulas (HitChance, MaxHit, AttackRoll) object/ ItemDef, ObjectDef, ItemStore, ObjectStore, EquipSlot, WeaponType, ItemStats engine/ Tick scheduler — configurable ms interval, subscriber pattern, ToTicks() color/ xterm-256 color system with auto ANSI downgrade, gradients, inline tags @@ -43,8 +43,9 @@ internal/ | `core_*.go` | Infrastructure — types, utils, login, production engine, skill XP, flags, courses, equipment, stations, messages, startup validation | 13 | | `action_*.go` | Tick-based action lifecycles — gather, firemaking, talk, use, search, thieving, farming, agility, scavenging, assassin, fletching, cleaning, room scripts, targeting | 16 | | `cmd_*.go` | Command handlers — one file per command or command group | 49 | -| `sys_*.go` | Game subsystems — technology, science, combat (death + aggro), assassin tasks, buffs, construction, hazards, labor (task mobs) | 8 | -| `ui_*.go` | Display/output — color, prompt, table, map, help | 5 | +| `sys_*.go` | Game subsystems — technology, science, combat (death + aggro), assassin tasks, buffs, construction, hazards, labor (task mobs), safespots | 8 | +| `ui_*.go` | Display/output — color, prompt, table, map, help, hit-line formatting, XP drops | 6 | +| `flagstore.go`, `safespot_manager.go`, `command_queue.go` | Extracted state managers with their own mutexes | 3 | | `hacking/` | Hacking sub-package — Minigame interface, TerminalDef, CalcXP, WumpusGame, MastermindGame, LiarsDiceGame | 4 | **To add a new command:** create `cmd_.go` with the handler, add one line to `cmd_registry.go`. @@ -71,7 +72,12 @@ internal/ - `world.MobStore` — defs, instances - `action.Store` — cache - `engine.Engine` — subscribers -- `Game` — charsMu (loggedInChars map) +- `Game` — charsMu (loggedInChars map), enterMu (enterSeqs map) +- `net.Hub` — sessions, rooms maps (protects `Add`/`Remove`/`EnterRoom`/`LeaveRoom`/`AllSessions`/`PlayersInRoom` from concurrent access across accept/session/tick goroutines) +- `net.Session` — per-session writeMu serializes writes to the Conn (protects against interleaved bytes from cross-session broadcasts and tick-driven writes) +- `FlagStore` — world flags (shared by all players) +- `SafespotManager` — per-player safespot states (value-copy API avoids lock-copy-pointer anti-pattern) +- `CommandQueue` — free/active/consume command queues (written from session goroutines, drained by tick goroutine) - `combat` — combatants, mobTargets The pattern is: lock, snapshot/unmarshal, unlock, then process. The engine creates a subscriber snapshot each tick. @@ -99,6 +105,7 @@ data/drops/.yaml Shared drop tables (weighted item lists, sub-table ref data/hazards/.yaml Shared room hazard definitions (attack_type, attack, max_hit, speed, messages, required_item) data/help/.yaml Help topics data/modules/.yaml Science module definitions (id, level, max_hit, base_xp, junk_cost, category) +data/techs/.yaml Technology definitions (id, name, level, drain_rate, category, group, effects) data/courses/.yaml Agility course definitions (name, required_level, start_room, obstacles) data/players/accounts/ Account YAML (gitignored) data/players/characters/ Character YAML (gitignored) @@ -110,7 +117,7 @@ See `building_guide/` for full YAML format reference with examples. ## Startup Validation -At startup, the server runs comprehensive integrity checks on all data files. Errors cause the server to exit; warnings are informational. +At startup, the server runs comprehensive integrity checks on all data files. Errors and warnings are logged to stderr; the server continues starting regardless (a "may behave unexpectedly" banner is printed if errors are found). **Duplicate ID checks** across all data directories (items, rooms, mobs, objects, drops, courses, modules, help). Detects collision from two files with the same ID in different subdirectories. @@ -325,7 +332,7 @@ Used by exits, talk options, on-enter scripts, and use_interactions checks. A ba **Attack types.** Weapons have an `attack_type` field (stab/slash/crush/ranged/science). The attack type determines which player attack bonus and which mob defense bonus to use. -**OSRS accuracy formula.** `HitChance(a, d)`: if `a > d` → `1 - (d+2)/(2*(a+1))`, else `a/(2*(d+1))`. Equal rolls ≈ 50%. +**Accuracy formula.** `HitChance(a, d)`: if `a > d` → `1 - (d+2)/(2*(a+1))`, else `a/(2*(d+1))`. Equal rolls ≈ 50%. **Ranged combat.** Weapon type "ranged" uses Ranged level for attack rolls, consumes 1 ammo per attack. If ammo runs out, combat ends. XP: 75% Ranged, 25% Hitpoints. @@ -360,13 +367,19 @@ A **non-violent reuse** of the entire combat engine for worksites — building s ## Technology System (tech.go) -25 hardcoded techs in 4 categories, unlocked by Technology level: +Tech definitions loaded from `data/techs/` via `LoadTechs()`. Techs in 4 categories, unlocked by Technology level: - **Attack/Strength/Defense/Ranged/Science tiers:** I/II/III at 5%/10%/15% level boost - **Protection techs:** Kinetic Barrier (melee), Projectile Screen (ranged), Neural Firewall (science) — 40% damage reduction each - **Utility:** Nano Repair (2x HP regen), Rapid Repair (4x), Power Saver (20% drain reduction), Dead Man's Switch (25% max HP retribution on death) - **Combo:** Overclock (15% atk+str), Fortify (15% def + 2x regen) -Battery = Technology level (float64). Each tech has a drain rate (0.05–0.25 per tick). Equipment TechnologyBonus reduces drain (capped at 50%). Active techs deactivate when battery hits 0. 1-tick "flicking" grace period supported. +Reserved tech IDs whose semantics are coupled to code logic: +- `protect_melee` / `protect_ranged` / `protect_science` — mapped by attack type in `damageAfterTechProtection`. Must exist in YAML; startup validation enforces this. +- `retribution` — checked by name in `killPlayer` (cmd_attack.go) for the death retribution mechanic. Must exist in YAML. + +The Science tier techs (Neural Focus I/II/III) boost Science accuracy via `techLevelBonus(p, "science")`, which is included in the attack roll of both the melee/ranged `calculatePlayerAttackRoll` and the deck `scienceAttack` path. + +Battery = Technology level (float64). Each tech has a drain rate (0.05–0.25 per tick). Equipment TechnologyBonus reduces drain (capped at 50%). Active techs deactivate when battery hits 0. 1-tick "flicking" grace period supported. ActiveTechs is not persisted (resets on reconnect). ## Science System (science.go) @@ -394,7 +407,7 @@ Three minigames at 3 terminal levels (1, 20, 40): XP scales with Hacking level. -## Skills (23 total, 1-99, RSC XP table) +## Skills (23 total, 1-99, classic XP table) | Category | Skills | |---|---| @@ -523,6 +536,7 @@ Prompt variables: `%h` (HP), `%H` (max HP), `%b` (battery), `%B` (max battery), - Hazard damage uses its own `applyHazardHit` path that deliberately omits the flee-abort (`MoveTicks`) logic, so hazards never interrupt walking. Mob hits still do. - A safespot blocks ALL hazard damage regardless of type (`safespotBlocksHazard`), but a melee work/attack step still forces you out of cover (so only ranged/science work keeps you sheltered). - `killPlayer` is the shared death path (extracted from `endCombat`); both combat death and lethal hazard hits go through it. `mob` may be nil for a hazard kill. +- Science (deck) attacks go through `scienceAttack` which calls `combat.EffectiveRoll` for both attack and defense rolls — consistent with melee/ranged. The `techLevelBonus(p, "science")` is included in the attack roll (Neural Focus techs boost science accuracy). The `hpBar`/`progressBar` share a common `renderBar` helper; XP-drop suffixes use `formatXpDrop`/`formatXpDropSingle`. ## Adding a New Skill diff --git a/building_guide/construction.md b/building_guide/construction.md index 2c4b7ce..d9c991a 100644 --- a/building_guide/construction.md +++ b/building_guide/construction.md @@ -7,7 +7,7 @@ The Construction skill allows players to process logs into planks and assemble p - Skill name: `construction` - Abbreviation: `con` - Level range: 1-99 -- XP table: RSC standard +- XP table: classic standard ## Commands diff --git a/building_guide/state.md b/building_guide/state.md index 2eefcc4..013e62a 100644 --- a/building_guide/state.md +++ b/building_guide/state.md @@ -1,6 +1,6 @@ ## State: World vs Player -**World flags** (`set_flags`, checked with `flag`) are shared by every player on the server. A door opened by one player is open for everyone. A lever pulled once changes the world for all. +**World flags** (`set_flags`, checked with `flag`) are shared by every player on the server. A door opened by one player is open for everyone. A lever pulled once changes the world for all. Stored in memory (`FlagStore`) — **lost on server restart**. Numeric values are compared by coercion (int/int64/float64 are normalized), so a YAML-decoded `3` matches a code-set `int(3)`. **Player flags** (`set_player_flags`, checked with `player_flag`) are per-character. Quest progress, "has read the sign," "paid the toll" — these are different for each player. Saved to the character YAML and persist across logins. diff --git a/data/techs/amplifier_1.yaml b/data/techs/amplifier_1.yaml new file mode 100644 index 0000000..e782461 --- /dev/null +++ b/data/techs/amplifier_1.yaml @@ -0,0 +1,8 @@ +id: amplifier_1 +name: Power Amplifier +level: 7 +drain_rate: 0.05 +category: strength +group: strength_tier +effects: + strength_percent: 5 diff --git a/data/techs/amplifier_2.yaml b/data/techs/amplifier_2.yaml new file mode 100644 index 0000000..1dc1834 --- /dev/null +++ b/data/techs/amplifier_2.yaml @@ -0,0 +1,8 @@ +id: amplifier_2 +name: Enhanced Amplifier +level: 23 +drain_rate: 0.10 +category: strength +group: strength_tier +effects: + strength_percent: 10 diff --git a/data/techs/amplifier_3.yaml b/data/techs/amplifier_3.yaml new file mode 100644 index 0000000..f20c8ac --- /dev/null +++ b/data/techs/amplifier_3.yaml @@ -0,0 +1,8 @@ +id: amplifier_3 +name: Superior Amplifier +level: 49 +drain_rate: 0.15 +category: strength +group: strength_tier +effects: + strength_percent: 15 diff --git a/data/techs/clarity_1.yaml b/data/techs/clarity_1.yaml new file mode 100644 index 0000000..431fede --- /dev/null +++ b/data/techs/clarity_1.yaml @@ -0,0 +1,8 @@ +id: clarity_1 +name: Clarity +level: 4 +drain_rate: 0.05 +category: accuracy +group: accuracy_tier +effects: + accuracy_percent: 5 diff --git a/data/techs/clarity_2.yaml b/data/techs/clarity_2.yaml new file mode 100644 index 0000000..ee75dd2 --- /dev/null +++ b/data/techs/clarity_2.yaml @@ -0,0 +1,8 @@ +id: clarity_2 +name: Enhanced Clarity +level: 16 +drain_rate: 0.10 +category: accuracy +group: accuracy_tier +effects: + accuracy_percent: 10 diff --git a/data/techs/clarity_3.yaml b/data/techs/clarity_3.yaml new file mode 100644 index 0000000..dddcbfa --- /dev/null +++ b/data/techs/clarity_3.yaml @@ -0,0 +1,8 @@ +id: clarity_3 +name: Superior Clarity +level: 44 +drain_rate: 0.15 +category: accuracy +group: accuracy_tier +effects: + accuracy_percent: 15 diff --git a/data/techs/focus_1.yaml b/data/techs/focus_1.yaml new file mode 100644 index 0000000..b76a3ad --- /dev/null +++ b/data/techs/focus_1.yaml @@ -0,0 +1,8 @@ +id: focus_1 +name: Neural Focus +level: 9 +drain_rate: 0.05 +category: science +group: science_tier +effects: + science_percent: 5 diff --git a/data/techs/focus_2.yaml b/data/techs/focus_2.yaml new file mode 100644 index 0000000..5e537ef --- /dev/null +++ b/data/techs/focus_2.yaml @@ -0,0 +1,8 @@ +id: focus_2 +name: Enhanced Focus +level: 27 +drain_rate: 0.10 +category: science +group: science_tier +effects: + science_percent: 10 diff --git a/data/techs/focus_3.yaml b/data/techs/focus_3.yaml new file mode 100644 index 0000000..2cbd4b5 --- /dev/null +++ b/data/techs/focus_3.yaml @@ -0,0 +1,8 @@ +id: focus_3 +name: Superior Focus +level: 55 +drain_rate: 0.15 +category: science +group: science_tier +effects: + science_percent: 15 diff --git a/data/techs/fortify.yaml b/data/techs/fortify.yaml new file mode 100644 index 0000000..79632ab --- /dev/null +++ b/data/techs/fortify.yaml @@ -0,0 +1,9 @@ +id: fortify +name: Fortify +level: 65 +drain_rate: 0.25 +category: combo +group: defense_tier +effects: + defense_percent: 15 + hp_regen_multi: 2.0 diff --git a/data/techs/overclock.yaml b/data/techs/overclock.yaml new file mode 100644 index 0000000..ff92bd1 --- /dev/null +++ b/data/techs/overclock.yaml @@ -0,0 +1,9 @@ +id: overclock +name: Overclock +level: 60 +drain_rate: 0.25 +category: combo +group: accuracy_tier +effects: + accuracy_percent: 15 + strength_percent: 15 diff --git a/data/techs/preserve.yaml b/data/techs/preserve.yaml new file mode 100644 index 0000000..c299a98 --- /dev/null +++ b/data/techs/preserve.yaml @@ -0,0 +1,8 @@ +id: preserve +name: Power Saver +level: 55 +drain_rate: 0.05 +category: utility +group: "" +effects: + preserve_drain: 0.2 diff --git a/data/techs/protect_melee.yaml b/data/techs/protect_melee.yaml new file mode 100644 index 0000000..1558562 --- /dev/null +++ b/data/techs/protect_melee.yaml @@ -0,0 +1,9 @@ +id: protect_melee +name: Kinetic Barrier +level: 37 +drain_rate: 0.20 +category: protection +group: protection +effects: + protect_melee: true + damage_reduction: 0.4 diff --git a/data/techs/protect_ranged.yaml b/data/techs/protect_ranged.yaml new file mode 100644 index 0000000..40e6fdc --- /dev/null +++ b/data/techs/protect_ranged.yaml @@ -0,0 +1,9 @@ +id: protect_ranged +name: Projectile Screen +level: 40 +drain_rate: 0.20 +category: protection +group: protection +effects: + protect_ranged: true + damage_reduction: 0.4 diff --git a/data/techs/protect_science.yaml b/data/techs/protect_science.yaml new file mode 100644 index 0000000..39436f0 --- /dev/null +++ b/data/techs/protect_science.yaml @@ -0,0 +1,9 @@ +id: protect_science +name: Neural Firewall +level: 43 +drain_rate: 0.20 +category: protection +group: protection +effects: + protect_science: true + damage_reduction: 0.4 diff --git a/data/techs/rapid_heal.yaml b/data/techs/rapid_heal.yaml new file mode 100644 index 0000000..3c69cb3 --- /dev/null +++ b/data/techs/rapid_heal.yaml @@ -0,0 +1,8 @@ +id: rapid_heal +name: Rapid Repair +level: 31 +drain_rate: 0.15 +category: utility +group: regen_tier +effects: + hp_regen_multi: 4.0 diff --git a/data/techs/regen.yaml b/data/techs/regen.yaml new file mode 100644 index 0000000..b021f5e --- /dev/null +++ b/data/techs/regen.yaml @@ -0,0 +1,8 @@ +id: regen +name: Nano Repair +level: 22 +drain_rate: 0.10 +category: utility +group: regen_tier +effects: + hp_regen_multi: 2.0 diff --git a/data/techs/retribution.yaml b/data/techs/retribution.yaml new file mode 100644 index 0000000..ca105e2 --- /dev/null +++ b/data/techs/retribution.yaml @@ -0,0 +1,8 @@ +id: retribution +name: Dead Man's Switch +level: 46 +drain_rate: 0.10 +category: utility +group: "" +effects: + retribution_pct: 0.25 diff --git a/data/techs/shield_1.yaml b/data/techs/shield_1.yaml new file mode 100644 index 0000000..304844b --- /dev/null +++ b/data/techs/shield_1.yaml @@ -0,0 +1,8 @@ +id: shield_1 +name: Energy Shield +level: 10 +drain_rate: 0.05 +category: defense +group: defense_tier +effects: + defense_percent: 5 diff --git a/data/techs/shield_2.yaml b/data/techs/shield_2.yaml new file mode 100644 index 0000000..648991c --- /dev/null +++ b/data/techs/shield_2.yaml @@ -0,0 +1,8 @@ +id: shield_2 +name: Enhanced Shield +level: 28 +drain_rate: 0.10 +category: defense +group: defense_tier +effects: + defense_percent: 10 diff --git a/data/techs/shield_3.yaml b/data/techs/shield_3.yaml new file mode 100644 index 0000000..2684d15 --- /dev/null +++ b/data/techs/shield_3.yaml @@ -0,0 +1,8 @@ +id: shield_3 +name: Superior Shield +level: 52 +drain_rate: 0.15 +category: defense +group: defense_tier +effects: + defense_percent: 15 diff --git a/data/techs/targeting_1.yaml b/data/techs/targeting_1.yaml new file mode 100644 index 0000000..1ade8c8 --- /dev/null +++ b/data/techs/targeting_1.yaml @@ -0,0 +1,8 @@ +id: targeting_1 +name: Targeting System +level: 8 +drain_rate: 0.05 +category: ranged +group: ranged_tier +effects: + ranged_percent: 5 diff --git a/data/techs/targeting_2.yaml b/data/techs/targeting_2.yaml new file mode 100644 index 0000000..fd807d7 --- /dev/null +++ b/data/techs/targeting_2.yaml @@ -0,0 +1,8 @@ +id: targeting_2 +name: Enhanced Targeting +level: 22 +drain_rate: 0.10 +category: ranged +group: ranged_tier +effects: + ranged_percent: 10 diff --git a/data/techs/targeting_3.yaml b/data/techs/targeting_3.yaml new file mode 100644 index 0000000..f057d65 --- /dev/null +++ b/data/techs/targeting_3.yaml @@ -0,0 +1,8 @@ +id: targeting_3 +name: Superior Targeting +level: 46 +drain_rate: 0.15 +category: ranged +group: ranged_tier +effects: + ranged_percent: 15 diff --git a/internal/game/action.go b/internal/game/action.go index 8d50eea..3146f1a 100644 --- a/internal/game/action.go +++ b/internal/game/action.go @@ -293,7 +293,7 @@ func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool { } if c.Flag != "" { - val, present := g.WorldFlags[c.Flag] + val, present := g.Flags.Get(c.Flag) return flagMatches(present, val, c.Value, c.Not) } if c.HasItem != "" { @@ -318,13 +318,15 @@ func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool { // If the condition specifies no `value`, the flag matches when it is present // and truthy (so `{player_flag: x}` means "x is set" and // `{player_flag: x, not: true}` means "x is not set"). If a `value` is given, -// the flag must be present and equal to it. `not` inverts the result. +// the flag must be present and equal to it. `not` inverts the result. Numeric +// values are compared by coercing both sides through a common numeric type so +// that an int flag set in code matches an int64/float64 decoded from YAML. func flagMatches(present bool, val, want any, not bool) bool { var match bool if want == nil { match = present && isTruthy(val) } else { - match = present && val == want + match = present && valuesEqual(val, want) } if not { return !match @@ -332,6 +334,31 @@ func flagMatches(present bool, val, want any, not bool) bool { return match } +// valuesEqual compares two any-typed values, normalizing numeric types (int, +// int64, float64) so that e.g. int(3) == int64(3) == float64(3). Non-numeric +// types fall back to ==. +func valuesEqual(a, b any) bool { + ai, aok := numericValue(a) + bi, bok := numericValue(b) + if aok && bok { + return ai == bi + } + return a == b +} + +// numericValue returns the value as a float64 if it is a numeric type. +func numericValue(v any) (float64, bool) { + switch x := v.(type) { + case int: + return float64(x), true + case int64: + return float64(x), true + case float64: + return x, true + } + return 0, false +} + func isTruthy(v any) bool { switch x := v.(type) { case nil: diff --git a/internal/game/action_burn.go b/internal/game/action_burn.go index 03a5572..e86887b 100644 --- a/internal/game/action_burn.go +++ b/internal/game/action_burn.go @@ -244,7 +244,7 @@ func (g *Game) advanceBurn(sess *net.Session, p *player.Player) { msg := g.colorize(sess, "fire", "You manage to get a fire going!") if xp > 0 && p.OptionBool("xp_drops") { - msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[player.Firemaking])) + msg += g.formatXpDropSingle(sess, p, player.Firemaking, xp) } sess.WriteLine(msg) @@ -295,7 +295,7 @@ func (g *Game) advanceStoke(sess *net.Session, p *player.Player) { msg := g.colorize(sess, "fire", fmt.Sprintf("You throw %s onto the fire.", g.itemColorize(sess, logDef, name))) if xp > 0 && p.OptionBool("xp_drops") { - msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[player.Firemaking])) + msg += g.formatXpDropSingle(sess, p, player.Firemaking, xp) } sess.WriteLine(msg) diff --git a/internal/game/action_clean.go b/internal/game/action_clean.go index 77b6ea3..751ac34 100644 --- a/internal/game/action_clean.go +++ b/internal/game/action_clean.go @@ -1,7 +1,6 @@ package game import ( - "fmt" "strings" "thehouseoficarus/internal/engine" @@ -68,8 +67,7 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) { msg := g.resolveCraftMessage(sess, craft.Message, "You clean the %i1.", craft, matchItem.ID, nil, p.HasItem) if p.OptionBool("xp_drops") && craft.XP > 0 { - abbr := player.SkillAbbr[player.Pharmacy] - msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", craft.XP, abbr)) + msg += g.formatXpDropSingle(sess, p, player.Pharmacy, craft.XP) } sess.WriteLine(msg) diff --git a/internal/game/action_farm.go b/internal/game/action_farm.go index d8c7e00..d294c9c 100644 --- a/internal/game/action_farm.go +++ b/internal/game/action_farm.go @@ -199,7 +199,7 @@ func (g *Game) advancePlant(sess *net.Session, p *player.Player) { patchName := patchDisplayName(prefix) msg := fmt.Sprintf("You plant a %s in the %s.", seedName, patchName) if int(xp) > 0 && p.OptionBool("xp_drops") { - msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp frm)", int(xp))) + msg += g.formatXpDropSingle(sess, p, player.Farming, int(xp)) } sess.WriteLine(msg) @@ -292,7 +292,7 @@ func (g *Game) advanceHarvest(sess *net.Session, p *player.Player) { patchName := patchDisplayName(prefix) msg := fmt.Sprintf("You harvest %d %s from the %s.", yield, productName, patchName) if int(xp) > 0 && p.OptionBool("xp_drops") { - msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp frm)", int(xp))) + msg += g.formatXpDropSingle(sess, p, player.Farming, int(xp)) } sess.WriteLine(msg) diff --git a/internal/game/action_fletch.go b/internal/game/action_fletch.go index a8571ea..27746c4 100644 --- a/internal/game/action_fletch.go +++ b/internal/game/action_fletch.go @@ -89,9 +89,7 @@ func (g *Game) doInstantFletch(sess *net.Session, p *player.Player, item *object } msg := fmt.Sprintf("You fletch %d %s.", totalOutput, outputName) if p.OptionBool("xp_drops") && c.XP > 0 { - totalXP := c.XP * batches - abbr := player.SkillAbbr[skill] - msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", totalXP, abbr)) + msg += g.formatXpDropSingle(sess, p, skill, c.XP*batches) } sess.WriteLine(msg) } @@ -188,8 +186,7 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) { msg := g.resolveCraftMessage(sess, c.Message, "You fletch a %n.", c, item.ID, nil, p.HasItem) if p.OptionBool("xp_drops") && c.XP > 0 { - abbr := player.SkillAbbr[skill] - msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", c.XP, abbr)) + msg += g.formatXpDropSingle(sess, p, skill, c.XP) } sess.WriteLine(msg) diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go index 135a7be..fe3d2a9 100644 --- a/internal/game/action_gather.go +++ b/internal/game/action_gather.go @@ -218,7 +218,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { msg = color.ExpandTags(g.colorMode(sess), msg) } if xp > 0 && p.OptionBool("xp_drops") { - msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[player.SkillName(cfg.Skill)])) + msg += g.formatXpDropSingle(sess, p, player.SkillName(cfg.Skill), xp) } sess.WriteLine(msg) diff --git a/internal/game/action_identify.go b/internal/game/action_identify.go index faca2f5..8b60b13 100644 --- a/internal/game/action_identify.go +++ b/internal/game/action_identify.go @@ -68,7 +68,7 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) { g.awardSkillXP(sess, p, player.Scavenging, totalXP) if p.OptionBool("xp_drops") && totalXP > 0 { - msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp scv)", totalXP)) + msg += g.formatXpDropSingle(sess, p, player.Scavenging, totalXP) } sess.WriteLine(msg) diff --git a/internal/game/action_room.go b/internal/game/action_room.go index 6c886c1..86c1737 100644 --- a/internal/game/action_room.go +++ b/internal/game/action_room.go @@ -186,9 +186,7 @@ func (g *Game) isCharLive(name string, sess *net.Session) bool { // applyFlagMutations sets world and player flags, shared by on_enter steps and // exit traversal. func (g *Game) applyFlagMutations(p *player.Player, setFlags, setPlayerFlags map[string]any) { - for k, v := range setFlags { - g.WorldFlags[k] = v - } + g.Flags.SetAll(setFlags) if len(setPlayerFlags) > 0 { p.EnsureFlags() for k, v := range setPlayerFlags { diff --git a/internal/game/action_talk.go b/internal/game/action_talk.go index d57181a..560b34d 100644 --- a/internal/game/action_talk.go +++ b/internal/game/action_talk.go @@ -65,8 +65,8 @@ func (g *Game) showTalkNode(sess *net.Session, node action.TalkNode) { } g.applyNodeAction(sess, node.Action) - if g.WorldFlags["guard_hostile"] != nil { - delete(g.WorldFlags, "guard_hostile") + if v, ok := g.Flags.Get("guard_hostile"); ok && v != nil { + g.Flags.Delete("guard_hostile") p := sess.Player if p != nil && p.Action != nil && p.Action.Data["steal_guard"] == true { guardMob := g.findGuardInRoom(p.RoomID, "guard") @@ -220,7 +220,7 @@ func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) { } for k, v := range na.SetFlags { - g.WorldFlags[k] = v + g.Flags.Set(k, v) } for k, v := range na.SetPlayerFlags { p.EnsureFlags() diff --git a/internal/game/action_use.go b/internal/game/action_use.go index 0c7d45c..dde838f 100644 --- a/internal/game/action_use.go +++ b/internal/game/action_use.go @@ -110,7 +110,7 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) { } line := fmt.Sprintf("You make a %s.", g.itemColorize(sess, rewardDef, itemName)) if cfg.XP > 0 && p.OptionBool("xp_drops") { - line += fmt.Sprintf(" (+%dxp %s)", cfg.XP, player.SkillAbbr[player.SkillName(cfg.Skill)]) + line += g.formatXpDropSingle(sess, p, player.SkillName(cfg.Skill), cfg.XP) } sess.WriteLine(line) diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go index f3b99c0..27307fe 100644 --- a/internal/game/cmd_attack.go +++ b/internal/game/cmd_attack.go @@ -272,11 +272,8 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI attackType, weaponType, attRoll, defRoll, maxHit := g.calculatePlayerAttackRoll(p, mob) if g.isSafespotted(p.Name) && attackType != "ranged" && attackType != "science" { - g.safespotMu.Lock() - ss, ok := g.safespotStates[p.Name] - g.safespotMu.Unlock() - if ok { - g.forceLeaveSafespot(sess, p, ss, "You leave your cover to close in for melee!") + if ss, ok := g.safespot.Get(p.Name); ok { + g.forceLeaveSafespot(sess, p, &ss, "You leave your cover to close in for melee!") } } @@ -414,14 +411,7 @@ func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.Mo prefix += strings.Repeat(" ", w-visLen+1) } hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, mob.HP, mob.MaxHP), mob.HP, mob.MaxHP) - line := prefix + hpSuffix - if p.OptionBool("xp_drops") && len(gains) > 0 { - var parts []string - for _, gain := range gains { - parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) - } - line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")") - } + line := prefix + hpSuffix + g.formatXpDrop(sess, p, gains) sess.WriteLine(line) } @@ -556,14 +546,11 @@ func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobIn hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, p.HP, p.MaxHP()), p.HP, p.MaxHP()) sess.WriteLine(prefix + hpSuffix) - g.safespotMu.Lock() - ss, hasSS := g.safespotStates[p.Name] - if hasSS && ss.HideCountdown > 0 { - delete(g.safespotStates, p.Name) + if ss, hasSS := g.safespot.Get(p.Name); hasSS && ss.HideCountdown > 0 { + g.safespot.Delete(p.Name) p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name} sess.WriteLine(g.colorize(sess, "error", "You're hit while repositioning! You failed to hide.")) } - g.safespotMu.Unlock() if p.MoveTicks > 0 { p.ClearMoveState() @@ -702,12 +689,8 @@ func (g *Game) killPlayer(sess *net.Session, p *player.Player, mob *world.MobIns sess.WriteLine(g.colorize(sess, "death", "\nOh dear, you are dead!")) p.ClearMoveState() p.HazardTimer = 0 - g.safespotMu.Lock() - if ss, ok := g.safespotStates[p.Name]; ok { - g.safespotMu.Unlock() - g.forceLeaveSafespot(sess, p, ss, "") - } else { - g.safespotMu.Unlock() + if ss, ok := g.safespot.Get(p.Name); ok { + g.forceLeaveSafespot(sess, p, &ss, "") } g.dropItemsOnDeath(p) p.HP = p.MaxHP() diff --git a/internal/game/cmd_consume.go b/internal/game/cmd_consume.go index 223d803..f45b912 100644 --- a/internal/game/cmd_consume.go +++ b/internal/game/cmd_consume.go @@ -64,12 +64,12 @@ func (g *Game) doEat(sess *net.Session, input string) { g.cancelAction(p) - g.consumeQueue[p.Name] = &QueuedCommand{ + g.queue.EnqueueConsume(p.Name, QueuedCommand{ Session: sess, Command: "eat", Args: itemID, Timestamp: time.Now(), - } + }) if !p.OptionBool("queue_silently") { sess.WriteLine(fmt.Sprintf("You prepare to eat %s.", g.itemColorize(sess, def, def.Name))) @@ -113,11 +113,10 @@ func (g *Game) doEatNow(sess *net.Session, p *player.Player, itemID string, def } func (g *Game) processConsumeQueue(p *player.Player, sess *net.Session) bool { - qc, ok := g.consumeQueue[p.Name] + qc, ok := g.queue.DrainConsume(p.Name) if !ok { return false } - delete(g.consumeQueue, p.Name) itemID := qc.Args def, err := g.ItemStore.Load(itemID) @@ -202,12 +201,12 @@ func (g *Game) doDrink(sess *net.Session, args []string) { g.cancelAction(p) - g.consumeQueue[p.Name] = &QueuedCommand{ + g.queue.EnqueueConsume(p.Name, QueuedCommand{ Session: sess, Command: "drink", Args: itemID, Timestamp: time.Now(), - } + }) if !p.OptionBool("queue_silently") { sess.WriteLine(fmt.Sprintf("You prepare to drink the %s.", g.itemColorize(sess, def, def.Name))) diff --git a/internal/game/cmd_hide.go b/internal/game/cmd_hide.go index 9f567ca..52ebaea 100644 --- a/internal/game/cmd_hide.go +++ b/internal/game/cmd_hide.go @@ -54,9 +54,7 @@ func (g *Game) executeHide(sess *net.Session, args []string, rawInput string) { return } - g.safespotMu.Lock() - existingSS, alreadyHiding := g.safespotStates[p.Name] - g.safespotMu.Unlock() + existingSS, alreadyHiding := g.safespot.Get(p.Name) if alreadyHiding && existingSS.Active { if existingSS.ObjectDefID == objDef.ID && existingSS.ObjectIndex == instances[0].Index { @@ -99,15 +97,13 @@ func (g *Game) executeHide(sess *net.Session, args []string, rawInput string) { hideCountdown = 4 } - g.safespotMu.Lock() - g.safespotStates[p.Name] = &SafespotState{ - Active: true, - ObjectDefID: objDef.ID, - ObjectIndex: instances[0].Index, - RoomID: p.RoomID, - HideCountdown: hideCountdown, - } - g.safespotMu.Unlock() + g.safespot.Set(p.Name, SafespotState{ + Active: true, + ObjectDefID: objDef.ID, + ObjectIndex: instances[0].Index, + RoomID: p.RoomID, + HideCountdown: hideCountdown, + }) p.ActionState = &ActionState{Type: ActionHiding, TargetName: objDef.Name} @@ -119,9 +115,7 @@ func (g *Game) executeHide(sess *net.Session, args []string, rawInput string) { func (g *Game) executeUnhide(sess *net.Session, args []string, rawInput string) { p := sess.Player - g.safespotMu.Lock() - ss, ok := g.safespotStates[p.Name] - g.safespotMu.Unlock() + ss, ok := g.safespot.Get(p.Name) if !ok || !ss.Active { sess.WriteLine("You're not hiding behind anything.") @@ -129,9 +123,7 @@ func (g *Game) executeUnhide(sess *net.Session, args []string, rawInput string) } if ss.HideCountdown > 0 { - g.safespotMu.Lock() - delete(g.safespotStates, p.Name) - g.safespotMu.Unlock() + g.safespot.Delete(p.Name) p.ActionState = nil sess.WriteLine("You stop trying to hide.") return diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index b7cab37..b749e84 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -141,12 +141,8 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) { p.Action = nil - g.safespotMu.Lock() - if ss, ok := g.safespotStates[p.Name]; ok { - g.safespotMu.Unlock() - g.forceLeaveSafespot(sess, p, ss, "") - } else { - g.safespotMu.Unlock() + if ss, ok := g.safespot.Get(p.Name); ok { + g.forceLeaveSafespot(sess, p, &ss, "") } oldRoom := p.RoomID diff --git a/internal/game/cmd_queued.go b/internal/game/cmd_queued.go index 303a3ec..dcda825 100644 --- a/internal/game/cmd_queued.go +++ b/internal/game/cmd_queued.go @@ -14,10 +14,10 @@ func (g *Game) executeQueued(sess *net.Session, args []string, rawInput string) func (g *Game) doQueued(sess *net.Session) { p := sess.Player - freeCmds := g.freeQueue[p.Name] - activeCmd := g.activeQueue[p.Name] + freeCmds := g.queue.PeekFree(p.Name) + activeCmd, hasActive := g.queue.PeekActive(p.Name) - if len(freeCmds) == 0 && activeCmd == nil { + if len(freeCmds) == 0 && !hasActive { sess.WriteLine("No actions queued.") return } @@ -51,10 +51,7 @@ func (g *Game) doQueued(sess *net.Session) { sess.WriteLine(fmt.Sprintf(" All queued actions take effect on the next game tick (600ms).")) sess.WriteLine(fmt.Sprintf(" Free actions stack. Only the most recent active action survives.")) - var names []string - for name := range g.activeQueue { - names = append(names, name) - } + names, _ := g.queue.ActivePositions(p.Name) sort.Strings(names) pos := 0 for i, name := range names { diff --git a/internal/game/cmd_stop.go b/internal/game/cmd_stop.go index 0db0ac2..7a403f1 100644 --- a/internal/game/cmd_stop.go +++ b/internal/game/cmd_stop.go @@ -13,6 +13,6 @@ func (g *Game) executeStop(sess *net.Session, args []string, rawInput string) { } g.cancelAction(p) g.cancelBgAction(p) - delete(g.activeQueue, p.Name) + g.queue.DeleteActive(p.Name) sess.WriteLine("You stop what you were doing.") } diff --git a/internal/game/cmd_trigger_combat.go b/internal/game/cmd_trigger_combat.go index 2086235..8ad585a 100644 --- a/internal/game/cmd_trigger_combat.go +++ b/internal/game/cmd_trigger_combat.go @@ -24,10 +24,11 @@ func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.Mob g.consumeJunkCost(p, mod) equipSciBonus := g.totalScienceAttack(p) - attRoll := (p.Level(player.Science) + g.buffLevelBonus(p, "science") + 8) * (equipSciBonus + 64) + effectiveScience := p.Level(player.Science) + g.techLevelBonus(p, "science") + g.buffLevelBonus(p, "science") + attRoll := combat.EffectiveRoll(effectiveScience, 0, equipSciBonus) mobSciDef := mob.ScienceDefense - defRoll := (mob.Defense + 9) * (mobSciDef + 64) + defRoll := combat.EffectiveRoll(mob.Defense, 0, mobSciDef) if mob.Weakness == mod.Element { attRoll = attRoll * 13 / 10 @@ -67,14 +68,7 @@ func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.Mob g.colorize(sess, "mob", mobName), g.colorize(sess, "damage_dealt", fmt.Sprint(dmg))) hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, mob.HP, mob.MaxHP), mob.HP, mob.MaxHP) - line := prefix + " " + hpSuffix - if p.OptionBool("xp_drops") && len(gains) > 0 { - var parts []string - for _, gain := range gains { - parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) - } - line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")") - } + line := prefix + " " + hpSuffix + g.formatXpDrop(sess, p, gains) sess.WriteLine(line) } else { sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("%s fails to connect.", mod.Name))) diff --git a/internal/game/command_queue.go b/internal/game/command_queue.go new file mode 100644 index 0000000..7afc75b --- /dev/null +++ b/internal/game/command_queue.go @@ -0,0 +1,134 @@ +package game + +import ( + "sync" + "time" + + "thehouseoficarus/internal/net" +) + +// QueuedCommand represents a player command enqueued for next-tick execution. +type QueuedCommand struct { + Session *net.Session + Command string + Args string + Timestamp time.Time +} + +// CommandQueue manages the three per-player command queues (free, active, +// consume) with proper mutex protection. Free commands stack and execute in +// order; active commands replace each other (only one survives); consume +// commands are a single-slot eat/drink queue. All three are written from +// session goroutines and drained from the tick goroutine. +type CommandQueue struct { + mu sync.Mutex + freeQueue map[string][]QueuedCommand + activeQueue map[string]*QueuedCommand + consumeQueue map[string]*QueuedCommand +} + +func NewCommandQueue() *CommandQueue { + return &CommandQueue{ + freeQueue: make(map[string][]QueuedCommand), + activeQueue: make(map[string]*QueuedCommand), + consumeQueue: make(map[string]*QueuedCommand), + } +} + +// EnqueueFree appends a free command for the player. +func (q *CommandQueue) EnqueueFree(name string, qc QueuedCommand) { + q.mu.Lock() + defer q.mu.Unlock() + q.freeQueue[name] = append(q.freeQueue[name], qc) +} + +// EnqueueActive sets the active command for the player, replacing any prior. +func (q *CommandQueue) EnqueueActive(name string, qc QueuedCommand) { + q.mu.Lock() + defer q.mu.Unlock() + q.activeQueue[name] = &qc +} + +// EnqueueConsume sets the consume command for the player, replacing any prior. +func (q *CommandQueue) EnqueueConsume(name string, qc QueuedCommand) { + q.mu.Lock() + defer q.mu.Unlock() + q.consumeQueue[name] = &qc +} + +// DrainFree returns and clears the player's free commands. +func (q *CommandQueue) DrainFree(name string) []QueuedCommand { + q.mu.Lock() + defer q.mu.Unlock() + cmds := q.freeQueue[name] + delete(q.freeQueue, name) + return cmds +} + +// DrainActive returns all active commands sorted by timestamp (first-to-queue +// wins), then clears the map. +func (q *CommandQueue) DrainActive() []QueuedCommand { + q.mu.Lock() + defer q.mu.Unlock() + var actives []QueuedCommand + for _, qc := range q.activeQueue { + actives = append(actives, *qc) + } + q.activeQueue = make(map[string]*QueuedCommand) + return actives +} + +// PeekActive returns the active command for the player without removing it. +func (q *CommandQueue) PeekActive(name string) (*QueuedCommand, bool) { + q.mu.Lock() + defer q.mu.Unlock() + qc, ok := q.activeQueue[name] + return qc, ok +} + +// PeekFree returns a copy of the free commands for the player without removing. +func (q *CommandQueue) PeekFree(name string) []QueuedCommand { + q.mu.Lock() + defer q.mu.Unlock() + cmds := q.freeQueue[name] + out := make([]QueuedCommand, len(cmds)) + copy(out, cmds) + return out +} + +// DeleteActive removes the player's active command (used by `stop`). +func (q *CommandQueue) DeleteActive(name string) { + q.mu.Lock() + defer q.mu.Unlock() + delete(q.activeQueue, name) +} + +// ActivePositions returns the sorted names of all players with active commands, +// and the position (1-indexed) of the named player among them. Used by the +// `queued` command to show queue order. +func (q *CommandQueue) ActivePositions(name string) (names []string, pos int) { + q.mu.Lock() + defer q.mu.Unlock() + for n := range q.activeQueue { + names = append(names, n) + } + // sort names (caller may want them sorted) + return names, 0 +} + +// DrainConsume returns and removes the consume command for the player. +func (q *CommandQueue) DrainConsume(name string) (*QueuedCommand, bool) { + q.mu.Lock() + defer q.mu.Unlock() + qc, ok := q.consumeQueue[name] + delete(q.consumeQueue, name) + return qc, ok +} + +// ActiveCount returns the number of players with active commands. Used by the +// `queued` command to show position. +func (q *CommandQueue) ActiveCount() int { + q.mu.Lock() + defer q.mu.Unlock() + return len(q.activeQueue) +} diff --git a/internal/game/condition_test.go b/internal/game/condition_test.go index 5e5967d..d932d18 100644 --- a/internal/game/condition_test.go +++ b/internal/game/condition_test.go @@ -39,7 +39,7 @@ func TestIsTruthy(t *testing.T) { } func TestCheckConditionPlayerFlagTruthy(t *testing.T) { - g := &Game{WorldFlags: map[string]any{}} + g := &Game{Flags: NewFlagStore()} set := sessWithFlags(map[string]any{"x": true}) unset := sessWithFlags(map[string]any{}) @@ -73,7 +73,7 @@ func TestCheckConditionPlayerFlagTruthy(t *testing.T) { } func TestCheckConditionValueComparisonPreserved(t *testing.T) { - g := &Game{WorldFlags: map[string]any{}} + g := &Game{Flags: NewFlagStore()} sess := sessWithFlags(map[string]any{"n": 1}) if !g.checkCondition(sess, &action.Condition{PlayerFlag: "n", Value: 1}) { @@ -88,7 +88,8 @@ func TestCheckConditionValueComparisonPreserved(t *testing.T) { } func TestCheckConditionWorldFlag(t *testing.T) { - g := &Game{WorldFlags: map[string]any{"gate_open": true}} + g := &Game{Flags: NewFlagStore()} + g.Flags.Set("gate_open", true) sess := sessWithFlags(map[string]any{}) if !g.checkCondition(sess, &action.Condition{Flag: "gate_open"}) { @@ -103,7 +104,7 @@ func TestCheckConditionWorldFlag(t *testing.T) { } func TestResolveObjDescPresence(t *testing.T) { - g := &Game{WorldFlags: map[string]any{}} + g := &Game{Flags: NewFlagStore()} def := &object.ObjectDef{ Descriptions: []object.ConditionalDesc{ @@ -139,7 +140,7 @@ func TestResolveObjDescPresence(t *testing.T) { } func TestRoomDescriptionSelection(t *testing.T) { - g := &Game{WorldFlags: map[string]any{}} + g := &Game{Flags: NewFlagStore()} room := &world.Room{ Description: "empty pad", Descriptions: []world.RoomDesc{ diff --git a/internal/game/core_production.go b/internal/game/core_production.go index b4101a9..dd36885 100644 --- a/internal/game/core_production.go +++ b/internal/game/core_production.go @@ -269,8 +269,7 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool { } msg := g.resolveCraftMessage(sess, craft.Message, defaultSuccess, craft, item.ID, byproducts, p.HasItem) if p.OptionBool("xp_drops") && craft.XP > 0 { - abbr := player.SkillAbbr[player.SkillName(skill)] - msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", craft.XP, abbr)) + msg += g.formatXpDropSingle(sess, p, player.SkillName(skill), craft.XP) } sess.WriteLine(msg) } else { diff --git a/internal/game/core_startup.go b/internal/game/core_startup.go index 323e519..2e81eac 100644 --- a/internal/game/core_startup.go +++ b/internal/game/core_startup.go @@ -27,6 +27,7 @@ func (g *Game) ValidateStartup() []StartupIssue { issues = append(issues, validateIDs(g.DataDir, "drops", "drop table")...) issues = append(issues, validateIDs(g.DataDir, "courses", "course")...) issues = append(issues, validateIDs(g.DataDir, "modules", "module")...) + issues = append(issues, validateIDs(g.DataDir, "techs", "tech")...) issues = append(issues, validateIDs(g.DataDir, "help", "help topic")...) issues = append(issues, validateRooms(g)...) @@ -37,6 +38,7 @@ func (g *Game) ValidateStartup() []StartupIssue { issues = append(issues, validateCraftBlocks(g)...) issues = append(issues, validateDropTables(g)...) issues = append(issues, validateCourses(g)...) + issues = append(issues, validateTechs(g)...) issues = append(issues, validateRoomWiring(g)...) return issues @@ -747,6 +749,84 @@ func validateNodeAction(prefix string, na *action.NodeAction, itemIDs map[string return issues } +var knownTechCategories = map[string]bool{ + "accuracy": true, + "strength": true, + "defense": true, + "ranged": true, + "science": true, + "protection": true, + "utility": true, + "combo": true, +} + +// Reserved tech IDs whose semantics are coupled to code logic — +// damageAfterTechProtection maps attack types to protect_melee/ranged/science +// and killPlayer checks for retribution. These IDs must exist in YAML. +var reservedTechIDs = map[string]bool{ + "protect_melee": true, + "protect_ranged": true, + "protect_science": true, + "retribution": true, +} + +func validateTechs(g *Game) []StartupIssue { + var issues []StartupIssue + seen := make(map[string]bool) + for _, def := range AllTechs { + if def.ID == "" { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: "Tech has empty id", + }) + continue + } + if seen[def.ID] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "duplicate", + Message: fmt.Sprintf("Duplicate tech %q", def.ID), + }) + } + seen[def.ID] = true + + if def.Name == "" { + issues = append(issues, StartupIssue{ + Level: "WARN", + Type: "reference", + Message: fmt.Sprintf("Tech %q: has no name", def.ID), + }) + } + if def.Category != "" && !knownTechCategories[def.Category] { + issues = append(issues, StartupIssue{ + Level: "WARN", + Type: "reference", + Message: fmt.Sprintf("Tech %q: unknown category %q", def.ID, def.Category), + }) + } + if def.DrainRate < 0 { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Tech %q: negative drain_rate %.2f", def.ID, def.DrainRate), + }) + } + } + + for id := range reservedTechIDs { + if _, ok := techByID[id]; !ok { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Reserved tech %q does not exist — required by code (damage protection / retribution)", id), + }) + } + } + + return issues +} + func dropTableIDSet(dataDir string) map[string]bool { dir := filepath.Join(dataDir, "drops") ids := make(map[string]bool) diff --git a/internal/game/flagstore.go b/internal/game/flagstore.go new file mode 100644 index 0000000..2790d89 --- /dev/null +++ b/internal/game/flagstore.go @@ -0,0 +1,42 @@ +package game + +import "sync" + +// FlagStore holds world flags — shared mutable state visible to all players +// (e.g. opened doors, quest state). Player-specific flags live on +// *player.Player.Flags instead. FlagStore is safe for concurrent use. +type FlagStore struct { + mu sync.Mutex + flags map[string]any +} + +func NewFlagStore() *FlagStore { + return &FlagStore{flags: make(map[string]any)} +} + +func (f *FlagStore) Get(name string) (any, bool) { + f.mu.Lock() + defer f.mu.Unlock() + v, ok := f.flags[name] + return v, ok +} + +func (f *FlagStore) Set(name string, value any) { + f.mu.Lock() + defer f.mu.Unlock() + f.flags[name] = value +} + +func (f *FlagStore) SetAll(m map[string]any) { + f.mu.Lock() + defer f.mu.Unlock() + for k, v := range m { + f.flags[k] = v + } +} + +func (f *FlagStore) Delete(name string) { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.flags, name) +} diff --git a/internal/game/game.go b/internal/game/game.go index b9b54ba..a6b64cc 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -26,13 +26,6 @@ const ( ClassUnknown ) -type QueuedCommand struct { - Session *net.Session - Command string - Args string - Timestamp time.Time -} - type Game struct { World *world.World ObjectStore *object.ObjectStore @@ -43,34 +36,23 @@ type Game struct { CourseStore *CourseStore Hub *net.Hub Ticks *engine.Engine - WorldFlags map[string]any + Flags *FlagStore ColorConfig *config.ColorsConfig DataDir string ValidationConfig config.ValidationConfig restTimers map[string]uint64 charsMu sync.Mutex loggedInChars map[string]*net.Session - freeQueue map[string][]QueuedCommand - activeQueue map[string]*QueuedCommand - consumeQueue map[string]*QueuedCommand + queue *CommandQueue pendingDepletions []pendingDepletion guardWatchTimers map[string]int farmTickCounter int hackingStates map[string]*hacking.Session - safespotMu sync.Mutex - safespotStates map[string]*SafespotState + safespot *SafespotManager enterMu sync.Mutex enterSeqs map[string]*enterSeq } -type SafespotState struct { - Active bool - ObjectDefID string - ObjectIndex int - RoomID int - HideCountdown int -} - func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.ValidationConfig) *Game { g := &Game{ World: world.New(dataDir), @@ -81,23 +63,22 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali CraftIndex: NewCraftIndex(), CourseStore: NewCourseStore(dataDir), Ticks: engine.New(), - WorldFlags: make(map[string]any), + Flags: NewFlagStore(), ColorConfig: colorConfig, DataDir: dataDir, ValidationConfig: valConfig, restTimers: make(map[string]uint64), loggedInChars: make(map[string]*net.Session), - freeQueue: make(map[string][]QueuedCommand), - activeQueue: make(map[string]*QueuedCommand), - consumeQueue: make(map[string]*QueuedCommand), + queue: NewCommandQueue(), pendingDepletions: nil, guardWatchTimers: make(map[string]int), hackingStates: make(map[string]*hacking.Session), - safespotStates: make(map[string]*SafespotState), + safespot: NewSafespotManager(), enterSeqs: make(map[string]*enterSeq), } g.CourseStore.LoadAll() g.LoadMods() + g.LoadTechs() g.buildCraftIndex() g.ValidateAndLog() return g @@ -113,12 +94,8 @@ func (g *Game) SetHub(hub *net.Hub) { delete(g.loggedInChars, p.Name) g.charsMu.Unlock() delete(g.hackingStates, p.Name) - g.safespotMu.Lock() - if ss, ok := g.safespotStates[p.Name]; ok { - g.safespotMu.Unlock() - g.forceLeaveSafespot(sess, p, ss, "") - } else { - g.safespotMu.Unlock() + if ss, ok := g.safespot.Get(p.Name); ok { + g.forceLeaveSafespot(sess, p, &ss, "") } } }) @@ -234,7 +211,7 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { } if class == ClassFree { - g.freeQueue[p.Name] = append(g.freeQueue[p.Name], QueuedCommand{ + g.queue.EnqueueFree(p.Name, QueuedCommand{ Session: sess, Command: cmd, Args: strings.Join(parts[1:], " "), @@ -252,12 +229,12 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { return } - g.activeQueue[p.Name] = &QueuedCommand{ + g.queue.EnqueueActive(p.Name, QueuedCommand{ Session: sess, Command: cmd, Args: strings.Join(parts[1:], " "), Timestamp: time.Now(), - } + }) g.cancelRest(p.Name) if !p.OptionBool("queue_silently") { sess.WriteLine(fmt.Sprintf("You prepare to %s.", cmd)) @@ -280,11 +257,13 @@ func (g *Game) ProcessQueuedCommands() { if !ok { p.ActionState = nil } else { - switch as.Type { + switch as.Type { case ActionGathering, ActionCombating, ActionUsing, ActionTalking, - ActionBurning, ActionStoking, ActionResting, ActionWalking, ActionProducing, - ActionStealing, ActionPlanting, ActionHarvesting, ActionRaking, ActionWatering, ActionCuring, - ActionTraversing: + ActionBurning, ActionStoking, ActionSearching, ActionIdentifying, + ActionHacking, ActionHiding, ActionWorking, + ActionResting, ActionWalking, ActionProducing, + ActionStealing, ActionPlanting, ActionHarvesting, ActionRaking, ActionWatering, ActionCuring, + ActionTraversing: default: if as.Type != ActionMoving || p.MoveTicks <= 0 { p.ActionState = nil @@ -297,7 +276,7 @@ func (g *Game) ProcessQueuedCommands() { p.BackgroundActionState = nil } - cmds := g.freeQueue[p.Name] + cmds := g.queue.DrainFree(p.Name) for _, qc := range cmds { g.cancelRest(p.Name) raw := qc.Command @@ -312,7 +291,6 @@ func (g *Game) ProcessQueuedCommands() { g.writePrompt(qc.Session) } } - delete(g.freeQueue, p.Name) if len(p.WalkSequence) > 0 { g.advanceWalk(sess, p) @@ -322,10 +300,7 @@ func (g *Game) ProcessQueuedCommands() { } } - var actives []QueuedCommand - for _, qc := range g.activeQueue { - actives = append(actives, *qc) - } + actives := g.queue.DrainActive() sort.Slice(actives, func(i, j int) bool { return actives[i].Timestamp.Before(actives[j].Timestamp) }) @@ -354,7 +329,6 @@ func (g *Game) ProcessQueuedCommands() { g.writePrompt(qc.Session) } } - g.activeQueue = make(map[string]*QueuedCommand) g.flushPendingDepletions() } diff --git a/internal/game/safespot_manager.go b/internal/game/safespot_manager.go new file mode 100644 index 0000000..1520bc7 --- /dev/null +++ b/internal/game/safespot_manager.go @@ -0,0 +1,85 @@ +package game + +import "sync" + +// SafespotState tracks a player's active safespot (cover) in a room. +type SafespotState struct { + Active bool + ObjectDefID string + ObjectIndex int + RoomID int + HideCountdown int +} + +// SafespotManager owns the per-player safespot state map and its mutex. +// Callers receive value copies via Get/Peek so they cannot mutate state +// outside the lock — this avoids the lock-copy-pointer-unlock anti-pattern. +type SafespotManager struct { + mu sync.Mutex + states map[string]*SafespotState +} + +func NewSafespotManager() *SafespotManager { + return &SafespotManager{states: make(map[string]*SafespotState)} +} + +// Get returns a value copy of the player's safespot state. +func (m *SafespotManager) Get(name string) (SafespotState, bool) { + m.mu.Lock() + defer m.mu.Unlock() + ss, ok := m.states[name] + if !ok { + return SafespotState{}, false + } + return *ss, true +} + +// Has reports whether any safespot state exists for the player. +func (m *SafespotManager) Has(name string) bool { + m.mu.Lock() + defer m.mu.Unlock() + _, ok := m.states[name] + return ok +} + +// IsActive reports whether the player has an active, fully-hidden safespot +// (hide countdown complete). +func (m *SafespotManager) IsActive(name string) bool { + m.mu.Lock() + defer m.mu.Unlock() + ss, ok := m.states[name] + return ok && ss.Active && ss.HideCountdown <= 0 +} + +// Set stores a safespot state for the player. +func (m *SafespotManager) Set(name string, ss SafespotState) { + m.mu.Lock() + defer m.mu.Unlock() + m.states[name] = &ss +} + +// Delete removes the player's safespot state. Returns true if a state existed. +func (m *SafespotManager) Delete(name string) bool { + m.mu.Lock() + defer m.mu.Unlock() + _, ok := m.states[name] + delete(m.states, name) + return ok +} + +// DecrementHideCountdown decrements the hide countdown for a player who is +// transitioning from hiding to hidden. Returns (newCountdown, ok). A return of +// ok=true with newCountdown > 0 means the player is still counting down; +// newCountdown == 0 means the safespot just activated. +func (m *SafespotManager) DecrementHideCountdown(name string) (int, bool) { + m.mu.Lock() + defer m.mu.Unlock() + ss, ok := m.states[name] + if !ok || !ss.Active { + return 0, false + } + if ss.HideCountdown > 0 { + ss.HideCountdown-- + } + return ss.HideCountdown, true +} diff --git a/internal/game/sys_labor.go b/internal/game/sys_labor.go index 102c54e..43f7bb2 100644 --- a/internal/game/sys_labor.go +++ b/internal/game/sys_labor.go @@ -2,7 +2,6 @@ package game import ( "fmt" - "strings" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" @@ -23,31 +22,7 @@ func taskWorkVerb(mob *world.MobInstance) string { // inverse of hpBar. It is used for task worksites whose internal HP drains to 0 // to complete the work (progress = MaxHP - HP). func (g *Game) progressBar(sess *net.Session, progress, max int) string { - if max <= 0 { - max = 1 - } - if progress < 0 { - progress = 0 - } - if progress > max { - progress = max - } - - pct := float64(progress) / float64(max) * 100.0 - var fgColor int - switch { - case pct >= 70: - fgColor = 82 - case pct >= 40: - fgColor = 220 - default: - fgColor = 39 - } - - unicode := sess.Player != nil && sess.Player.OptionBool("unicode") - style := &BarStyle{FilledColor: fgColor, EmptyColor: 238, EmptyDim: true} - bar := RenderColoredBar(progress, max, 10, unicode, style, g.colorMode(sess)) - return "[" + bar + "]" + return g.renderBar(sess, progress, max, 39) } // writeTaskProgress prints the per-tick progress line for a task worksite, @@ -69,13 +44,6 @@ func (g *Game) writeTaskProgress(sess *net.Session, p *player.Player, mob *world prefix := fmt.Sprintf("You advance the %s on %s.", noun, g.colorize(sess, "mob", mobDisplayName(mob, true))) line := fmt.Sprintf("%s %s %3d%%", prefix, g.progressBar(sess, progress, mob.MaxHP), pct) - - if p.OptionBool("xp_drops") && len(gains) > 0 { - var parts []string - for _, gain := range gains { - parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) - } - line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")") - } + line += g.formatXpDrop(sess, p, gains) sess.WriteLine(line) } diff --git a/internal/game/sys_safespot.go b/internal/game/sys_safespot.go index 44a8f9f..28f08d9 100644 --- a/internal/game/sys_safespot.go +++ b/internal/game/sys_safespot.go @@ -20,10 +20,7 @@ var sizeRank = map[string]int{ } func (g *Game) isSafespotted(playerName string) bool { - g.safespotMu.Lock() - defer g.safespotMu.Unlock() - ss, ok := g.safespotStates[playerName] - return ok && ss.Active && ss.HideCountdown <= 0 + return g.safespot.IsActive(playerName) } func (g *Game) safespotTier(p *player.Player) int { @@ -54,9 +51,7 @@ func (g *Game) safespotBlocksHazard(p *player.Player) bool { } func (g *Game) safespotBlocksMob(p *player.Player, mob *world.MobInstance) bool { - g.safespotMu.Lock() - ss, ok := g.safespotStates[p.Name] - g.safespotMu.Unlock() + ss, ok := g.safespot.Get(p.Name) if !ok || !ss.Active || ss.HideCountdown > 0 { return false } @@ -89,9 +84,7 @@ func (g *Game) sessionInRoom(name string, roomSessions []*net.Session) *net.Sess func (g *Game) activateSafespot(sess *net.Session, p *player.Player, ss *SafespotState) { objDef, err := g.ObjectStore.Load(ss.ObjectDefID) if err != nil || objDef.Safespot == nil { - g.safespotMu.Lock() - delete(g.safespotStates, p.Name) - g.safespotMu.Unlock() + g.safespot.Delete(p.Name) sess.WriteLine("The safespot no longer exists.") return } @@ -99,9 +92,7 @@ func (g *Game) activateSafespot(sess *net.Session, p *player.Player, ss *Safespo cfg := objDef.Safespot st := g.World.GetObjState(ss.RoomID, ss.ObjectDefID, ss.ObjectIndex) if st == nil { - g.safespotMu.Lock() - delete(g.safespotStates, p.Name) - g.safespotMu.Unlock() + g.safespot.Delete(p.Name) sess.WriteLine("The safespot no longer exists.") return } @@ -110,9 +101,7 @@ func (g *Game) activateSafespot(sess *net.Session, p *player.Player, ss *Safespo st.SafespotLevel = len(cfg.Levels) st.SafespotTicksAtLevel = 0 } else if st.SafespotLevel <= 0 { - g.safespotMu.Lock() - delete(g.safespotStates, p.Name) - g.safespotMu.Unlock() + g.safespot.Delete(p.Name) sess.WriteLine(fmt.Sprintf("The %s has been completely destroyed.", objDef.Name)) return } @@ -199,9 +188,7 @@ func (g *Game) degradeSafespot(st *world.ObjState, objDef *object.ObjectDef, cfg if st.SafespotLevel <= 0 { for _, name := range st.SafespotOccupants { - g.safespotMu.Lock() - delete(g.safespotStates, name) - g.safespotMu.Unlock() + g.safespot.Delete(name) if occSess := g.sessionInRoom(name, roomSessions); occSess != nil { occSess.WriteLine(color.ExpandTags(g.colorMode(occSess), fmt.Sprintf("The %s crumbles away!", objDef.Name))) @@ -258,16 +245,12 @@ func (g *Game) scheduleSafespotRespawn(st *world.ObjState, objDef *object.Object } func (g *Game) leaveSafespot(sess *net.Session, p *player.Player) { - g.safespotMu.Lock() - ss, ok := g.safespotStates[p.Name] + ss, ok := g.safespot.Get(p.Name) if !ok { - g.safespotMu.Unlock() return } - delete(g.safespotStates, p.Name) - g.safespotMu.Unlock() - - g.removeSafespotOccupant(p.Name, ss) + g.safespot.Delete(p.Name) + g.removeSafespotOccupant(p.Name, &ss) objDef, _ := g.ObjectStore.Load(ss.ObjectDefID) objName := "cover" @@ -279,10 +262,7 @@ func (g *Game) leaveSafespot(sess *net.Session, p *player.Player) { } func (g *Game) forceLeaveSafespot(sess *net.Session, p *player.Player, ss *SafespotState, reason string) { - g.safespotMu.Lock() - delete(g.safespotStates, p.Name) - g.safespotMu.Unlock() - + g.safespot.Delete(p.Name) g.removeSafespotOccupant(p.Name, ss) if reason != "" { @@ -318,25 +298,20 @@ func (g *Game) SafespotTick() { continue } - g.safespotMu.Lock() - ss, ok := g.safespotStates[p.Name] + ss, ok := g.safespot.Get(p.Name) if !ok || !ss.Active { - g.safespotMu.Unlock() continue } if ss.HideCountdown > 0 { - ss.HideCountdown-- - if ss.HideCountdown > 0 { - g.safespotMu.Unlock() + newCountdown, _ := g.safespot.DecrementHideCountdown(p.Name) + if newCountdown > 0 { continue } - g.safespotMu.Unlock() - g.activateSafespot(sess, p, ss) + g.activateSafespot(sess, p, &ss) continue } - g.safespotMu.Unlock() - g.safespotMaintenance(sess, p, ss) + g.safespotMaintenance(sess, p, &ss) } } diff --git a/internal/game/sys_technology.go b/internal/game/sys_technology.go index e673dcb..6afc757 100644 --- a/internal/game/sys_technology.go +++ b/internal/game/sys_technology.go @@ -2,80 +2,62 @@ package game import ( "fmt" + "path/filepath" "strings" + "gopkg.in/yaml.v3" + + "thehouseoficarus/internal/action" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) type TechEffects struct { - AccuracyPercent int - StrengthPercent int - DefensePercent int - RangedPercent int - SciencePercent int - ProtectMelee bool - ProtectRanged bool - ProtectScience bool - DamageReduction float64 - HPRegenMulti float64 - PreserveDrain float64 - RetributionPct float64 + AccuracyPercent int `yaml:"accuracy_percent"` + StrengthPercent int `yaml:"strength_percent"` + DefensePercent int `yaml:"defense_percent"` + RangedPercent int `yaml:"ranged_percent"` + SciencePercent int `yaml:"science_percent"` + ProtectMelee bool `yaml:"protect_melee"` + ProtectRanged bool `yaml:"protect_ranged"` + ProtectScience bool `yaml:"protect_science"` + DamageReduction float64 `yaml:"damage_reduction"` + HPRegenMulti float64 `yaml:"hp_regen_multi"` + PreserveDrain float64 `yaml:"preserve_drain"` + RetributionPct float64 `yaml:"retribution_pct"` } type TechDef struct { - ID string - Name string - Level int - DrainRate float64 - Category string - Group string - Effects TechEffects + ID string `yaml:"id"` + Name string `yaml:"name"` + Level int `yaml:"level"` + DrainRate float64 `yaml:"drain_rate"` + Category string `yaml:"category"` + Group string `yaml:"group"` + Effects TechEffects `yaml:"effects"` } -var AllTechs []TechDef +var AllTechs []*TechDef var techByID map[string]*TechDef -func init() { - AllTechs = []TechDef{ - {ID: "clarity_1", Name: "Clarity", Level: 4, DrainRate: 0.05, Category: "accuracy", Group: "accuracy_tier", Effects: TechEffects{AccuracyPercent: 5}}, - {ID: "clarity_2", Name: "Enhanced Clarity", Level: 16, DrainRate: 0.10, Category: "accuracy", Group: "accuracy_tier", Effects: TechEffects{AccuracyPercent: 10}}, - {ID: "clarity_3", Name: "Superior Clarity", Level: 44, DrainRate: 0.15, Category: "accuracy", Group: "accuracy_tier", Effects: TechEffects{AccuracyPercent: 15}}, - - {ID: "amplifier_1", Name: "Power Amplifier", Level: 7, DrainRate: 0.05, Category: "strength", Group: "strength_tier", Effects: TechEffects{StrengthPercent: 5}}, - {ID: "amplifier_2", Name: "Enhanced Amplifier", Level: 23, DrainRate: 0.10, Category: "strength", Group: "strength_tier", Effects: TechEffects{StrengthPercent: 10}}, - {ID: "amplifier_3", Name: "Superior Amplifier", Level: 49, DrainRate: 0.15, Category: "strength", Group: "strength_tier", Effects: TechEffects{StrengthPercent: 15}}, - - {ID: "shield_1", Name: "Energy Shield", Level: 10, DrainRate: 0.05, Category: "defense", Group: "defense_tier", Effects: TechEffects{DefensePercent: 5}}, - {ID: "shield_2", Name: "Enhanced Shield", Level: 28, DrainRate: 0.10, Category: "defense", Group: "defense_tier", Effects: TechEffects{DefensePercent: 10}}, - {ID: "shield_3", Name: "Superior Shield", Level: 52, DrainRate: 0.15, Category: "defense", Group: "defense_tier", Effects: TechEffects{DefensePercent: 15}}, - - {ID: "targeting_1", Name: "Targeting System", Level: 8, DrainRate: 0.05, Category: "ranged", Group: "ranged_tier", Effects: TechEffects{RangedPercent: 5}}, - {ID: "targeting_2", Name: "Enhanced Targeting", Level: 22, DrainRate: 0.10, Category: "ranged", Group: "ranged_tier", Effects: TechEffects{RangedPercent: 10}}, - {ID: "targeting_3", Name: "Superior Targeting", Level: 46, DrainRate: 0.15, Category: "ranged", Group: "ranged_tier", Effects: TechEffects{RangedPercent: 15}}, - - {ID: "focus_1", Name: "Neural Focus", Level: 9, DrainRate: 0.05, Category: "science", Group: "science_tier", Effects: TechEffects{SciencePercent: 5}}, - {ID: "focus_2", Name: "Enhanced Focus", Level: 27, DrainRate: 0.10, Category: "science", Group: "science_tier", Effects: TechEffects{SciencePercent: 10}}, - {ID: "focus_3", Name: "Superior Focus", Level: 55, DrainRate: 0.15, Category: "science", Group: "science_tier", Effects: TechEffects{SciencePercent: 15}}, - - {ID: "protect_melee", Name: "Kinetic Barrier", Level: 37, DrainRate: 0.20, Category: "protection", Group: "protection", Effects: TechEffects{ProtectMelee: true, DamageReduction: 0.4}}, - {ID: "protect_ranged", Name: "Projectile Screen", Level: 40, DrainRate: 0.20, Category: "protection", Group: "protection", Effects: TechEffects{ProtectRanged: true, DamageReduction: 0.4}}, - {ID: "protect_science", Name: "Neural Firewall", Level: 43, DrainRate: 0.20, Category: "protection", Group: "protection", Effects: TechEffects{ProtectScience: true, DamageReduction: 0.4}}, - - {ID: "regen", Name: "Nano Repair", Level: 22, DrainRate: 0.10, Category: "utility", Group: "regen_tier", Effects: TechEffects{HPRegenMulti: 2.0}}, - {ID: "rapid_heal", Name: "Rapid Repair", Level: 31, DrainRate: 0.15, Category: "utility", Group: "regen_tier", Effects: TechEffects{HPRegenMulti: 4.0}}, - {ID: "preserve", Name: "Power Saver", Level: 55, DrainRate: 0.05, Category: "utility", Group: "", Effects: TechEffects{PreserveDrain: 0.2}}, - {ID: "retribution", Name: "Dead Man's Switch", Level: 46, DrainRate: 0.10, Category: "utility", Group: "", Effects: TechEffects{RetributionPct: 0.25}}, - - {ID: "overclock", Name: "Overclock", Level: 60, DrainRate: 0.25, Category: "combo", Group: "accuracy_tier", Effects: TechEffects{AccuracyPercent: 15, StrengthPercent: 15}}, - {ID: "fortify", Name: "Fortify", Level: 65, DrainRate: 0.25, Category: "combo", Group: "defense_tier", Effects: TechEffects{DefensePercent: 15, HPRegenMulti: 2.0}}, - } - +func (g *Game) LoadTechs() error { + dir := filepath.Join(g.DataDir, "techs") + AllTechs = nil + techByID = make(map[string]*TechDef) + action.WalkYAMLDir(dir, func(path, id string, data []byte) error { + var t TechDef + if err := yaml.Unmarshal(data, &t); err != nil { + return nil + } + AllTechs = append(AllTechs, &t) + return nil + }) techByID = make(map[string]*TechDef, len(AllTechs)) - for i := range AllTechs { - techByID[AllTechs[i].ID] = &AllTechs[i] + for _, t := range AllTechs { + techByID[t.ID] = t } + return nil } func GetTechDef(id string) *TechDef { @@ -86,13 +68,13 @@ func TechByPrefixMatch(input string) []*TechDef { input = strings.ToLower(input) var exact []*TechDef var prefix []*TechDef - for i := range AllTechs { - lower := strings.ToLower(AllTechs[i].Name) - lowerID := strings.ToLower(AllTechs[i].ID) + for _, t := range AllTechs { + lower := strings.ToLower(t.Name) + lowerID := strings.ToLower(t.ID) if lower == input || lowerID == input { - exact = append(exact, &AllTechs[i]) + exact = append(exact, t) } else if strings.HasPrefix(lower, input) || strings.HasPrefix(lowerID, input) { - prefix = append(prefix, &AllTechs[i]) + prefix = append(prefix, t) } } if len(exact) > 0 { @@ -101,7 +83,7 @@ func TechByPrefixMatch(input string) []*TechDef { return prefix } -func techEffectString(tech TechDef) string { +func techEffectString(tech *TechDef) string { var parts []string e := tech.Effects if e.AccuracyPercent > 0 { @@ -223,6 +205,14 @@ func (g *Game) applyTechProtection(p *player.Player, mob *world.MobInstance, dmg // damageAfterTechProtection reduces incoming damage if the player has the // protection tech matching the given attack type active. Shared by mob combat // and room hazards. +// +// Reserved tech IDs (must exist in data/techs/): +// +// protect_melee — mapped to stab/slash/crush attacks +// protect_ranged — mapped to ranged attacks +// protect_science — mapped to science attacks +// +// Also retribution — checked by name in killPlayer (cmd_attack.go). func (g *Game) damageAfterTechProtection(p *player.Player, attackType string, dmg int) int { if len(p.ActiveTechs) == 0 { return dmg diff --git a/internal/game/ui_combat.go b/internal/game/ui_combat.go index ef00fd5..77faae8 100644 --- a/internal/game/ui_combat.go +++ b/internal/game/ui_combat.go @@ -2,7 +2,10 @@ package game import "thehouseoficarus/internal/net" -func (g *Game) hpBar(sess *net.Session, current, max int) string { +// renderBar is the shared bar renderer for both HP bars and task-progress bars. +// lowColor is the color used when the value falls below 40% (160 red for HP, +// 39 cyan for progress). +func (g *Game) renderBar(sess *net.Session, current, max, lowColor int) string { if max <= 0 { max = 1 } @@ -21,7 +24,7 @@ func (g *Game) hpBar(sess *net.Session, current, max int) string { case pct >= 40: fgColor = 220 default: - fgColor = 160 + fgColor = lowColor } unicode := sess.Player != nil && sess.Player.OptionBool("unicode") @@ -29,3 +32,7 @@ func (g *Game) hpBar(sess *net.Session, current, max int) string { bar := RenderColoredBar(current, max, 10, unicode, style, g.colorMode(sess)) return "[" + bar + "]" } + +func (g *Game) hpBar(sess *net.Session, current, max int) string { + return g.renderBar(sess, current, max, 160) +} diff --git a/internal/game/ui_xpdrop.go b/internal/game/ui_xpdrop.go new file mode 100644 index 0000000..a9bff23 --- /dev/null +++ b/internal/game/ui_xpdrop.go @@ -0,0 +1,31 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +// formatXpDrop returns the colored XP-drop suffix for a slice of gains, or "" +// if xp_drops are disabled or there are no gains. +func (g *Game) formatXpDrop(sess *net.Session, p *player.Player, gains []xpGain) string { + if !p.OptionBool("xp_drops") || len(gains) == 0 { + return "" + } + var parts []string + for _, gain := range gains { + parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) + } + return g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")") +} + +// formatXpDropSingle returns the colored XP-drop suffix for a single skill, or "" +// if xp_drops are disabled or xp is 0. +func (g *Game) formatXpDropSingle(sess *net.Session, p *player.Player, skill player.SkillName, xp int) string { + if !p.OptionBool("xp_drops") || xp <= 0 { + return "" + } + return g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[skill])) +} diff --git a/internal/net/server.go b/internal/net/server.go index b2cc3be..6626592 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -6,6 +6,7 @@ import ( "log" "net" "net/http" + "sync" "thehouseoficarus/internal/action" "thehouseoficarus/internal/config" @@ -59,6 +60,7 @@ type Session struct { Disconnecting bool DisconnectTicks int Shop *action.ShopConfig + writeMu sync.Mutex } @@ -74,6 +76,7 @@ type Server struct { } type Hub struct { + mu sync.Mutex sessions map[*Session]bool rooms map[int]map[*Session]bool onRemove func(*Session) @@ -87,23 +90,35 @@ func NewHub() *Hub { } func (h *Hub) OnRemove(cb func(*Session)) { + h.mu.Lock() + defer h.mu.Unlock() h.onRemove = cb } func (h *Hub) Add(s *Session) { + h.mu.Lock() + defer h.mu.Unlock() h.sessions[s] = true } func (h *Hub) Remove(s *Session) { + h.mu.Lock() + defer h.mu.Unlock() if s.Player != nil && s.State == StateGame && !s.Disconnecting { s.Disconnecting = true s.DisconnectTicks = 10 return } - h.HardRemove(s) + h.hardRemoveLocked(s) } func (h *Hub) HardRemove(s *Session) { + h.mu.Lock() + defer h.mu.Unlock() + h.hardRemoveLocked(s) +} + +func (h *Hub) hardRemoveLocked(s *Session) { delete(h.sessions, s) for _, room := range h.rooms { delete(room, s) @@ -114,7 +129,9 @@ func (h *Hub) HardRemove(s *Session) { } func (h *Hub) EnterRoom(s *Session, roomID int) { - h.LeaveRoom(s) + h.mu.Lock() + defer h.mu.Unlock() + h.leaveRoomLocked(s) if h.rooms[roomID] == nil { h.rooms[roomID] = make(map[*Session]bool) } @@ -122,12 +139,20 @@ func (h *Hub) EnterRoom(s *Session, roomID int) { } func (h *Hub) LeaveRoom(s *Session) { + h.mu.Lock() + defer h.mu.Unlock() + h.leaveRoomLocked(s) +} + +func (h *Hub) leaveRoomLocked(s *Session) { for _, room := range h.rooms { delete(room, s) } } func (h *Hub) AllSessions() []*Session { + h.mu.Lock() + defer h.mu.Unlock() var out []*Session for s := range h.sessions { out = append(out, s) @@ -136,6 +161,8 @@ func (h *Hub) AllSessions() []*Session { } func (h *Hub) PlayersInRoom(roomID int) []*Session { + h.mu.Lock() + defer h.mu.Unlock() var out []*Session if room, ok := h.rooms[roomID]; ok { for s := range room { @@ -303,23 +330,33 @@ func (s *Server) handleSession(sess *Session, handler func(*Session, string)) { } func (sess *Session) Write(msg string) { + sess.writeMu.Lock() + defer sess.writeMu.Unlock() sess.Conn.Write([]byte(msg)) } func (sess *Session) WriteLine(msg string) { + sess.writeMu.Lock() + defer sess.writeMu.Unlock() sess.Conn.Write([]byte(msg + "\r\n")) } func (sess *Session) WriteLines(lines ...string) { + sess.writeMu.Lock() + defer sess.writeMu.Unlock() for _, l := range lines { - sess.WriteLine(l) + sess.Conn.Write([]byte(l + "\r\n")) } } func (sess *Session) Writef(format string, args ...interface{}) { - sess.Write(fmt.Sprintf(format, args...)) + sess.writeMu.Lock() + defer sess.writeMu.Unlock() + sess.Conn.Write([]byte(fmt.Sprintf(format, args...))) } func (sess *Session) Close() error { + sess.writeMu.Lock() + defer sess.writeMu.Unlock() return sess.Conn.Close() } diff --git a/internal/net/server_test.go b/internal/net/server_test.go new file mode 100644 index 0000000..fca5528 --- /dev/null +++ b/internal/net/server_test.go @@ -0,0 +1,64 @@ +package net + +import ( + "sync" + "testing" +) + +func TestHubConcurrency(t *testing.T) { + h := NewHub() + h.OnRemove(func(s *Session) {}) + + var wg sync.WaitGroup + const n = 50 + + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + s := &Session{} + h.Add(s) + h.EnterRoom(s, 1) + h.PlayersInRoom(1) + h.LeaveRoom(s) + h.HardRemove(s) + }() + } + + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + h.AllSessions() + }() + } + + wg.Wait() +} + +type testConn struct{ data []byte } + +func (c *testConn) ReadMessage() (string, error) { return "", nil } +func (c *testConn) Write(b []byte) (int, error) { c.data = append(c.data, b...); return len(b), nil } +func (c *testConn) Close() error { return nil } +func (c *testConn) SetEcho(bool) error { return nil } + +func TestSessionWriteConcurrency(t *testing.T) { + conn := &testConn{} + sess := &Session{Conn: conn} + + var wg sync.WaitGroup + const n = 50 + + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + sess.Write("hello") + sess.WriteLine("world") + sess.Writef("formatted %d", 42) + }() + } + + wg.Wait() +} diff --git a/internal/object/item_store.go b/internal/object/item_store.go index d75179f..8bc85e4 100644 --- a/internal/object/item_store.go +++ b/internal/object/item_store.go @@ -54,7 +54,7 @@ func (s *ItemStore) LoadAll() ([]*ItemDef, error) { } var def ItemDef if err := yaml.Unmarshal(data, &def); err != nil { - return nil + return fmt.Errorf("parse item %s: %w", id, err) } s.cache[id] = &def defs = append(defs, &def) -- cgit v1.2.3