aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/game/action.go33
-rw-r--r--internal/game/action_burn.go4
-rw-r--r--internal/game/action_clean.go4
-rw-r--r--internal/game/action_farm.go4
-rw-r--r--internal/game/action_fletch.go7
-rw-r--r--internal/game/action_gather.go2
-rw-r--r--internal/game/action_identify.go2
-rw-r--r--internal/game/action_room.go4
-rw-r--r--internal/game/action_talk.go6
-rw-r--r--internal/game/action_use.go2
-rw-r--r--internal/game/cmd_attack.go31
-rw-r--r--internal/game/cmd_consume.go11
-rw-r--r--internal/game/cmd_hide.go28
-rw-r--r--internal/game/cmd_move.go8
-rw-r--r--internal/game/cmd_queued.go11
-rw-r--r--internal/game/cmd_stop.go2
-rw-r--r--internal/game/cmd_trigger_combat.go14
-rw-r--r--internal/game/command_queue.go134
-rw-r--r--internal/game/condition_test.go11
-rw-r--r--internal/game/core_production.go3
-rw-r--r--internal/game/core_startup.go80
-rw-r--r--internal/game/flagstore.go42
-rw-r--r--internal/game/game.go66
-rw-r--r--internal/game/safespot_manager.go85
-rw-r--r--internal/game/sys_labor.go36
-rw-r--r--internal/game/sys_safespot.go55
-rw-r--r--internal/game/sys_technology.go116
-rw-r--r--internal/game/ui_combat.go11
-rw-r--r--internal/game/ui_xpdrop.go31
-rw-r--r--internal/net/server.go45
-rw-r--r--internal/net/server_test.go64
-rw-r--r--internal/object/item_store.go2
32 files changed, 661 insertions, 293 deletions
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)