diff options
Diffstat (limited to 'internal')
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) |
