aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-24 20:58:25 -0400
committerhistoria <[not public]>2026-06-24 20:58:25 -0400
commit9d4b799db4868bcab291b83599ff088763cd4ed6 (patch)
tree252f0fcc779acf35243983d643d6399ee2e0cd0b /internal/game
parentd25638d98fe63efdeab570ef77e6a6a97f2d7a60 (diff)
downloadthehouseoficarus-9d4b799db4868bcab291b83599ff088763cd4ed6.tar.gz
feat: new type of non-violent 'combat': work
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/action.go57
-rw-r--r--internal/game/action_room.go179
-rw-r--r--internal/game/action_state.go3
-rw-r--r--internal/game/cmd_attack.go275
-rw-r--r--internal/game/cmd_consume.go2
-rw-r--r--internal/game/cmd_look.go92
-rw-r--r--internal/game/cmd_move.go67
-rw-r--r--internal/game/cmd_registry.go1
-rw-r--r--internal/game/cmd_score.go4
-rw-r--r--internal/game/cmd_stats.go2
-rw-r--r--internal/game/condition_test.go155
-rw-r--r--internal/game/core_login_char.go21
-rw-r--r--internal/game/core_startup.go53
-rw-r--r--internal/game/game.go6
-rw-r--r--internal/game/map_test.go36
-rw-r--r--internal/game/sys_hazard.go136
-rw-r--r--internal/game/sys_labor.go81
-rw-r--r--internal/game/sys_safespot.go7
-rw-r--r--internal/game/sys_technology.go30
19 files changed, 1047 insertions, 160 deletions
diff --git a/internal/game/action.go b/internal/game/action.go
index 9a07419..8d50eea 100644
--- a/internal/game/action.go
+++ b/internal/game/action.go
@@ -284,22 +284,17 @@ func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool {
}
if c.PlayerFlag != "" {
- if p == nil || p.Flags == nil {
- return c.Not
+ var val any
+ present := false
+ if p != nil && p.Flags != nil {
+ val, present = p.Flags[c.PlayerFlag]
}
- val, ok := p.Flags[c.PlayerFlag]
- if c.Not {
- return !ok || val != c.Value
- }
- return ok && val == c.Value
+ return flagMatches(present, val, c.Value, c.Not)
}
if c.Flag != "" {
- val, ok := g.WorldFlags[c.Flag]
- if c.Not {
- return !ok || val != c.Value
- }
- return ok && val == c.Value
+ val, present := g.WorldFlags[c.Flag]
+ return flagMatches(present, val, c.Value, c.Not)
}
if c.HasItem != "" {
has := p != nil && p.HasItem(c.HasItem)
@@ -317,3 +312,41 @@ func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool {
}
return true
}
+
+// flagMatches evaluates a flag/player_flag condition.
+//
+// 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.
+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
+ }
+ if not {
+ return !match
+ }
+ return match
+}
+
+func isTruthy(v any) bool {
+ switch x := v.(type) {
+ case nil:
+ return false
+ case bool:
+ return x
+ case int:
+ return x != 0
+ case int64:
+ return x != 0
+ case float64:
+ return x != 0
+ case string:
+ return x != ""
+ default:
+ return true
+ }
+}
diff --git a/internal/game/action_room.go b/internal/game/action_room.go
index af5b99b..6c886c1 100644
--- a/internal/game/action_room.go
+++ b/internal/game/action_room.go
@@ -5,19 +5,194 @@ import (
"strings"
"thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
)
+// enterSeq is an in-flight on_enter cutscene for a single player. It is driven
+// one step at a time by EnterSeqTick.
+type enterSeq struct {
+ sess *net.Session
+ playerName string
+ roomID int
+ steps []world.EnterStep
+ wait int
+ started bool
+}
+
+// runEnterSteps evaluates a room's on_enter script for the player. Steps whose
+// condition fails are dropped. If none of the surviving steps are "timed"
+// (no delay, no flag mutation) the messages are printed synchronously, exactly
+// as before. Otherwise the surviving steps become a scheduled cutscene driven
+// by EnterSeqTick.
func (g *Game) runEnterSteps(sess *net.Session, roomID int) {
room, err := g.World.LoadRoom(roomID)
if err != nil || len(room.OnEnter) == 0 {
return
}
+ p := sess.Player
+ if p == nil {
+ return
+ }
+
+ var steps []world.EnterStep
+ cutscene := false
for _, step := range room.OnEnter {
if step.Condition != nil && !g.checkCondition(sess, step.Condition) {
continue
}
- if step.Message != "" {
- sess.WriteLine(fmt.Sprintf("%s", step.Message))
+ steps = append(steps, step)
+ if step.IsTimed() {
+ cutscene = true
+ }
+ }
+ if len(steps) == 0 {
+ return
+ }
+
+ if !cutscene {
+ for _, step := range steps {
+ if step.Message != "" {
+ sess.WriteLine(step.Message)
+ }
+ }
+ return
+ }
+
+ g.startEnterCutscene(sess, p, roomID, steps)
+}
+
+// startEnterCutscene registers a scheduled on_enter sequence and persists a
+// marker on the player so the cutscene can be resumed on reconnect if it is
+// interrupted (disconnect, server restart).
+func (g *Game) startEnterCutscene(sess *net.Session, p *player.Player, roomID int, steps []world.EnterStep) {
+ g.cancelEnterSeq(p.Name)
+ if p.CutsceneRoom != roomID {
+ p.CutsceneRoom = roomID
+ g.AccountStore.SaveCharacter(p)
+ }
+ g.enterMu.Lock()
+ g.enterSeqs[p.Name] = &enterSeq{
+ sess: sess,
+ playerName: p.Name,
+ roomID: roomID,
+ steps: steps,
+ wait: steps[0].Delay,
+ }
+ g.enterMu.Unlock()
+}
+
+// EnterSeqTick advances every in-flight cutscene by one tick. Sequences for a
+// player who is no longer connected are paused (left for resume); sequences are
+// removed from the map on completion or cancellation.
+func (g *Game) EnterSeqTick() {
+ g.enterMu.Lock()
+ seqs := make([]*enterSeq, 0, len(g.enterSeqs))
+ for _, s := range g.enterSeqs {
+ seqs = append(seqs, s)
+ }
+ g.enterMu.Unlock()
+
+ for _, seq := range seqs {
+ if !g.isCharLive(seq.playerName, seq.sess) {
+ continue
+ }
+
+ var jobs []world.EnterStep
+ done := false
+
+ g.enterMu.Lock()
+ if g.enterSeqs[seq.playerName] != seq {
+ g.enterMu.Unlock()
+ continue
+ }
+ seq.wait--
+ for seq.wait <= 0 && len(seq.steps) > 0 {
+ step := seq.steps[0]
+ seq.steps = seq.steps[1:]
+ jobs = append(jobs, step)
+ if len(seq.steps) > 0 {
+ seq.wait = seq.steps[0].Delay
+ } else {
+ seq.wait = 0
+ }
+ }
+ if len(seq.steps) == 0 {
+ done = true
+ delete(g.enterSeqs, seq.playerName)
+ }
+ g.enterMu.Unlock()
+
+ for _, step := range jobs {
+ g.fireEnterStep(seq, step)
+ }
+ if done {
+ g.completeEnterSeq(seq)
+ }
+ }
+}
+
+func (g *Game) fireEnterStep(seq *enterSeq, step world.EnterStep) {
+ p := seq.sess.Player
+ if p == nil || p.RoomID != seq.roomID {
+ return
+ }
+ if step.Message != "" {
+ // Break from the prompt on the first line only, so a multi-line
+ // cutscene reads as tight consecutive lines.
+ if !seq.started {
+ seq.sess.WriteLine("\n" + step.Message)
+ seq.started = true
+ } else {
+ seq.sess.WriteLine(step.Message)
+ }
+ }
+ if len(step.SetFlags) > 0 || len(step.SetPlayerFlags) > 0 {
+ g.applyFlagMutations(p, step.SetFlags, step.SetPlayerFlags)
+ g.AccountStore.SaveCharacter(p)
+ }
+}
+
+func (g *Game) completeEnterSeq(seq *enterSeq) {
+ if !g.isCharLive(seq.playerName, seq.sess) {
+ return
+ }
+ p := seq.sess.Player
+ if p == nil {
+ return
+ }
+ if p.CutsceneRoom == seq.roomID {
+ p.CutsceneRoom = 0
+ g.AccountStore.SaveCharacter(p)
+ }
+ g.writePrompt(seq.sess)
+}
+
+// cancelEnterSeq drops any in-flight cutscene for the player. It does not touch
+// the persisted CutsceneRoom marker, so a cutscene cancelled by disconnect can
+// still be resumed on reconnect.
+func (g *Game) cancelEnterSeq(name string) {
+ g.enterMu.Lock()
+ delete(g.enterSeqs, name)
+ g.enterMu.Unlock()
+}
+
+func (g *Game) isCharLive(name string, sess *net.Session) bool {
+ g.charsMu.Lock()
+ defer g.charsMu.Unlock()
+ return g.loggedInChars[name] == sess
+}
+
+// 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
+ }
+ if len(setPlayerFlags) > 0 {
+ p.EnsureFlags()
+ for k, v := range setPlayerFlags {
+ p.Flags[k] = v
}
}
}
diff --git a/internal/game/action_state.go b/internal/game/action_state.go
index ffce545..bed936d 100644
--- a/internal/game/action_state.go
+++ b/internal/game/action_state.go
@@ -30,6 +30,7 @@ const (
ActionTraversing ActionType = "traversing"
ActionHacking ActionType = "hacking"
ActionHiding ActionType = "hiding"
+ ActionWorking ActionType = "working"
)
type ActionState struct {
@@ -101,6 +102,8 @@ func (a *ActionState) Description() string {
return "jacked into a " + a.TargetName
case ActionHiding:
return "positioning behind a " + a.TargetName
+ case ActionWorking:
+ return "working on a " + a.TargetName
}
return ""
}
diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go
index 1f18d00..f3b99c0 100644
--- a/internal/game/cmd_attack.go
+++ b/internal/game/cmd_attack.go
@@ -35,7 +35,11 @@ func (g *Game) doAttack(sess *net.Session, input string) {
}
if mob.HP <= 0 {
- sess.WriteLine("That is already dead.")
+ if mob.IsTask() {
+ sess.WriteLine(fmt.Sprintf("%s is already complete.", mobDisplayName(mob, true)))
+ } else {
+ sess.WriteLine("That is already dead.")
+ }
return
}
@@ -133,6 +137,7 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn
combat.EnterCombat(p.Name, mob.InstanceID)
p.AttackTimer = 0
+ isTask := mob.IsTask()
autotriggerActive := p.AutotriggerMod != ""
if !autotriggerActive {
@@ -151,20 +156,35 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn
if len(styleParts) > 0 {
styleStr = " Style: " + string(p.AttackStyle) + " (" + strings.Join(styleParts, ", ") + ")"
}
- sess.WriteLine(fmt.Sprintf("You attack %s!%s", g.colorize(sess, "mob", mobDisplayName(mob, true)), styleStr))
+ if isTask {
+ sess.WriteLine(fmt.Sprintf("You begin to %s %s.%s", taskWorkVerb(mob), g.colorize(sess, "mob", mobDisplayName(mob, true)), styleStr))
+ } else {
+ sess.WriteLine(fmt.Sprintf("You attack %s!%s", g.colorize(sess, "mob", mobDisplayName(mob, true)), styleStr))
+ }
} else {
mod := GetMod(p.AutotriggerMod)
modName := p.AutotriggerMod
if mod != nil {
modName = mod.Name
}
- sess.WriteLine(fmt.Sprintf("You attack %s with %s!",
- g.colorize(sess, "mob", mobDisplayName(mob, true)),
- g.colorize(sess, "science_mod", modName)))
+ if isTask {
+ sess.WriteLine(fmt.Sprintf("You begin to %s %s using %s!",
+ taskWorkVerb(mob),
+ g.colorize(sess, "mob", mobDisplayName(mob, true)),
+ g.colorize(sess, "science_mod", modName)))
+ } else {
+ sess.WriteLine(fmt.Sprintf("You attack %s with %s!",
+ g.colorize(sess, "mob", mobDisplayName(mob, true)),
+ g.colorize(sess, "science_mod", modName)))
+ }
+ }
+ if isTask {
+ p.ActionState = &ActionState{Type: ActionWorking, TargetName: mob.Name}
+ g.broadcastAction(sess, "\n%s begins working on %s.", p.Name, mobDisplayName(mob, true))
+ } else {
+ p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name}
+ g.broadcastAction(sess, "\n%s attacks %s!", p.Name, mobDisplayName(mob, true))
}
- p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name}
-
- g.broadcastAction(sess, "\n%s attacks %s!", p.Name, mobDisplayName(mob, true))
g.Ticks.Subscribe(1, func() bool {
cs := combat.GetCombat(p.Name)
@@ -223,22 +243,24 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn
return true
})
- g.Ticks.Subscribe(engine.ToTicks(mob.Speed), func() bool {
- cs := combat.GetCombat(p.Name)
- if cs == nil || !cs.Active {
- return false
- }
- currentMob := g.MobStore.GetInstance(cs.MobID)
- if currentMob == nil || currentMob.HP <= 0 {
- return false
- }
- g.mobAttack(sess, p, currentMob)
- if p.HP <= 0 {
- g.endCombat(sess, p, currentMob)
- return false
- }
- return true
- })
+ if !isTask {
+ g.Ticks.Subscribe(engine.ToTicks(mob.Speed), func() bool {
+ cs := combat.GetCombat(p.Name)
+ if cs == nil || !cs.Active {
+ return false
+ }
+ currentMob := g.MobStore.GetInstance(cs.MobID)
+ if currentMob == nil || currentMob.HP <= 0 {
+ return false
+ }
+ g.mobAttack(sess, p, currentMob)
+ if p.HP <= 0 {
+ g.endCombat(sess, p, currentMob)
+ return false
+ }
+ return true
+ })
+ }
}
func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) {
@@ -299,8 +321,8 @@ func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstanc
equipAttack := combat.SelectBonus(attackType,
totals.StabAttack, totals.SlashAttack, totals.CrushAttack,
totals.ScienceAttack, totals.RangedAttack)
- effectiveAttack := p.Level(player.Attack) + g.techLevelBonus(p, "attack") + g.buffLevelBonus(p, "attack")
- attRoll = combat.EffectiveRoll(effectiveAttack, attBonus, equipAttack)
+ effectiveAccuracy := p.Level(player.Accuracy) + g.techLevelBonus(p, "accuracy") + g.buffLevelBonus(p, "accuracy")
+ attRoll = combat.EffectiveRoll(effectiveAccuracy, attBonus, equipAttack)
mobDefBonus := combat.SelectBonus(attackType,
mob.StabDefense, mob.SlashDefense, mob.CrushDefense,
@@ -331,8 +353,8 @@ func (g *Game) calculateUnarmedAttackRoll(p *player.Player, mob *world.MobInstan
weaponType = object.WeaponMelee
attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle))
- effectiveAttack := p.Level(player.Attack) + g.techLevelBonus(p, "attack") + g.buffLevelBonus(p, "attack")
- attRoll = combat.EffectiveRoll(effectiveAttack, attBonus, 0)
+ effectiveAccuracy := p.Level(player.Accuracy) + g.techLevelBonus(p, "accuracy") + g.buffLevelBonus(p, "accuracy")
+ attRoll = combat.EffectiveRoll(effectiveAccuracy, attBonus, 0)
mobDefBonus := combat.SelectBonus(attackType,
mob.StabDefense, mob.SlashDefense, mob.CrushDefense,
@@ -374,30 +396,34 @@ func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.Mo
g.consumeAmmo(sess, p)
}
- mobName := mobDisplayName(mob, true)
- attacker := mob.Name
- if !mob.Unique {
- attacker = "The " + mob.Name
- }
- w := len(fmt.Sprintf("You hit %s for %d damage.", mobName, 999))
- if w2 := len(fmt.Sprintf("%s hits you for %d damage.", attacker, 999)); w2 > w {
- w = w2
- }
- prefix := fmt.Sprintf("You hit %s for %s damage.", g.colorize(sess, "mob", mobName), g.colorize(sess, "damage_dealt", fmt.Sprint(dmg)))
- visLen := color.VisibleLen(prefix)
- if visLen < w {
- 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)]))
+ if mob.IsTask() {
+ g.writeTaskProgress(sess, p, mob, dmg, gains)
+ } else {
+ mobName := mobDisplayName(mob, true)
+ attacker := mob.Name
+ if !mob.Unique {
+ attacker = "The " + mob.Name
+ }
+ w := len(fmt.Sprintf("You hit %s for %d damage.", mobName, 999))
+ if w2 := len(fmt.Sprintf("%s hits you for %d damage.", attacker, 999)); w2 > w {
+ w = w2
+ }
+ prefix := fmt.Sprintf("You hit %s for %s damage.", g.colorize(sess, "mob", mobName), g.colorize(sess, "damage_dealt", fmt.Sprint(dmg)))
+ visLen := color.VisibleLen(prefix)
+ if visLen < w {
+ 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 += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")")
+ sess.WriteLine(line)
}
- sess.WriteLine(line)
if isRanged && p.AmmoQty <= 0 {
sess.WriteLine("You've run out of ammo!")
@@ -559,60 +585,51 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
p.ActionState = nil
if p.HP <= 0 {
- if p.HasActiveTech("retribution") {
- retDef := GetTechDef("retribution")
- if retDef != nil && mob != nil && mob.HP > 0 {
- retDmg := int(float64(p.MaxHP()) * retDef.Effects.RetributionPct)
- if retDmg > 0 {
- mob.HP -= retDmg
- if mob.HP < 0 {
- mob.HP = 0
- }
- sess.WriteLine(fmt.Sprintf("\nDead Man's Switch activates! %s takes %d damage!",
- mobDisplayName(mob, true), retDmg))
- }
- }
- }
- p.DeactivateAllTechs()
- p.Stats.RecordDeath()
- sess.WriteLine(g.colorize(sess, "death", "\nOh dear, you are dead!"))
- p.ClearMoveState()
- g.safespotMu.Lock()
- if ss, ok := g.safespotStates[p.Name]; ok {
- g.safespotMu.Unlock()
- g.forceLeaveSafespot(sess, p, ss, "")
- } else {
- g.safespotMu.Unlock()
- }
- g.dropItemsOnDeath(p)
- p.HP = p.MaxHP()
- p.RoomID = 1
- g.AccountStore.SaveCharacter(p)
- if g.Hub != nil {
- g.Hub.EnterRoom(sess, p.RoomID)
- }
- g.doLook(sess)
- g.writePrompt(sess)
+ g.killPlayer(sess, p, mob)
return
}
if mob != nil && mob.HP <= 0 {
+ isTask := mob.IsTask()
p.Stats.RecordMobKill(mob.DefID)
- sess.WriteLine(g.colorize(sess, "victory", fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true))))
- g.onAssassinKill(sess, p, mob)
+ if isTask {
+ complete := mob.CompleteMessage
+ if complete == "" {
+ complete = fmt.Sprintf("You finish your work on %s!", mobDisplayName(mob, true))
+ }
+ sess.WriteLine(g.colorize(sess, "victory", "\n"+complete))
+ } else {
+ sess.WriteLine(g.colorize(sess, "victory", fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true))))
+ g.onAssassinKill(sess, p, mob)
+ }
if g.Hub != nil {
for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
if other != sess && other.Player != nil {
- op := other.Player
- mobLvl := mobCombatLevel(mob)
- levelStr := g.levelColorize(other, op.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl))
- other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s has slain %s %s!", p.Name, mobDisplayName(mob, false), levelStr)))
+ if isTask {
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s finishes working on %s.", p.Name, mobDisplayName(mob, false))))
+ } else {
+ op := other.Player
+ mobLvl := mobCombatLevel(mob)
+ levelStr := g.levelColorize(other, op.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl))
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s has slain %s %s!", p.Name, mobDisplayName(mob, false), levelStr)))
+ }
}
}
}
+ dropLabel := func() string {
+ if isTask {
+ return "You receive:"
+ }
+ dropper := mob.Name
+ if !mob.Unique {
+ dropper = "The " + mob.Name
+ }
+ return dropper + " drops:"
+ }()
+
if mob.Drops.Remains != "" {
g.World.AddReservedItem(p.RoomID, mob.Drops.Remains, 1, p.Name)
def, _ := g.ItemStore.Load(mob.Drops.Remains)
@@ -620,12 +637,8 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
if def != nil {
name = def.Name
}
- dropper := mob.Name
- if !mob.Unique {
- dropper = "The " + mob.Name
- }
coloredName := g.itemColorize(sess, def, name)
- sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropper+" drops:"), coloredName))
+ sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropLabel), coloredName))
}
if len(mob.Drops.Loot) > 0 {
@@ -641,15 +654,11 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
if def != nil {
name = def.Name
}
- dropper := mob.Name
- if !mob.Unique {
- dropper = "The " + mob.Name
- }
coloredName := g.itemColorize(sess, def, name)
if qty > 1 {
- sess.WriteLine(fmt.Sprintf("%s %d x %s", g.colorize(sess, "drop_message", dropper+" drops:"), qty, coloredName))
+ sess.WriteLine(fmt.Sprintf("%s %d x %s", g.colorize(sess, "drop_message", dropLabel), qty, coloredName))
} else {
- sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropper+" drops:"), coloredName))
+ sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropLabel), coloredName))
}
}
}
@@ -667,6 +676,50 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
}
}
+// killPlayer handles a player death from any source (combat or a room hazard).
+// mob may be nil (e.g. a hazard kill); it is only used for Dead Man's Switch
+// retribution.
+func (g *Game) killPlayer(sess *net.Session, p *player.Player, mob *world.MobInstance) {
+ combat.LeaveCombat(p.Name)
+ p.ActionState = nil
+
+ if p.HasActiveTech("retribution") {
+ retDef := GetTechDef("retribution")
+ if retDef != nil && mob != nil && mob.HP > 0 {
+ retDmg := int(float64(p.MaxHP()) * retDef.Effects.RetributionPct)
+ if retDmg > 0 {
+ mob.HP -= retDmg
+ if mob.HP < 0 {
+ mob.HP = 0
+ }
+ sess.WriteLine(fmt.Sprintf("\nDead Man's Switch activates! %s takes %d damage!",
+ mobDisplayName(mob, true), retDmg))
+ }
+ }
+ }
+ p.DeactivateAllTechs()
+ p.Stats.RecordDeath()
+ 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()
+ }
+ g.dropItemsOnDeath(p)
+ p.HP = p.MaxHP()
+ p.RoomID = 1001
+ g.AccountStore.SaveCharacter(p)
+ if g.Hub != nil {
+ g.Hub.EnterRoom(sess, p.RoomID)
+ }
+ g.doLook(sess)
+ g.writePrompt(sess)
+}
+
func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRanged bool) []xpGain {
baseXP := dmg * 4
var gains []xpGain
@@ -679,7 +732,7 @@ func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRan
} else {
switch p.AttackStyle {
case player.Accurate:
- gains = []xpGain{{string(player.Attack), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}}
+ gains = []xpGain{{string(player.Accuracy), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}}
case player.Aggressive:
gains = []xpGain{{string(player.Strength), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}}
case player.Defensive:
@@ -687,7 +740,7 @@ func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRan
case player.Balanced:
quarter := baseXP / 4
gains = []xpGain{
- {string(player.Attack), quarter},
+ {string(player.Accuracy), quarter},
{string(player.Strength), quarter},
{string(player.Defense), quarter},
{string(player.Hitpoints), quarter},
@@ -722,6 +775,22 @@ func (g *Game) executeAttack(sess *net.Session, args []string, rawInput string)
}
}
+// executeWork is the non-violent alias of attack. It routes to the same engine;
+// doAttack auto-detects whether the target is a task worksite or a combat mob.
+func (g *Game) executeWork(sess *net.Session, args []string, rawInput string) {
+ if len(args) == 0 {
+ p := sess.Player
+ target := g.resolveDefaultMob(p.RoomID)
+ if target == "" {
+ sess.WriteLine("Work on what?")
+ return
+ }
+ g.doAttack(sess, target)
+ } else {
+ g.doAttack(sess, strings.Join(args, " "))
+ }
+}
+
func (g *Game) respawnMob(instanceID string) {
inst := g.MobStore.GetInstance(instanceID)
if inst == nil {
diff --git a/internal/game/cmd_consume.go b/internal/game/cmd_consume.go
index fa55d3d..223d803 100644
--- a/internal/game/cmd_consume.go
+++ b/internal/game/cmd_consume.go
@@ -258,7 +258,7 @@ func (g *Game) applyPotion(sess *net.Session, p *player.Player, itemID string, d
case "all_combat":
duration := engine.ToTicks(def.PotionDuration)
- buffs := []string{"attack", "strength", "defense"}
+ buffs := []string{"accuracy", "strength", "defense"}
for _, stat := range buffs {
p.ActiveBuffs = append(p.ActiveBuffs, player.PotionBuff{
Stat: stat,
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index 98d48fd..266cd15 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -62,7 +62,7 @@ func (g *Game) showRoomDescription(sess *net.Session, p *player.Player, room *wo
if descWidth <= 0 {
descWidth = 70
}
- rawLines := wrapText(room.Description, descWidth)
+ rawLines := wrapText(g.roomDescription(sess, room), descWidth)
mode := g.colorMode(sess)
roomDescSpec := g.resolveColor(sess, "room_desc")
lines := make([]string, len(rawLines))
@@ -72,6 +72,35 @@ func (g *Game) showRoomDescription(sess *net.Session, p *player.Player, room *wo
return lines
}
+// roomDescription resolves the room's effective description for this player,
+// picking the first conditional variant whose condition passes, else the base
+// Description.
+func (g *Game) roomDescription(sess *net.Session, room *world.Room) string {
+ for _, d := range room.Descriptions {
+ if d.Condition == nil || g.checkCondition(sess, d.Condition) {
+ return d.Text
+ }
+ }
+ return room.Description
+}
+
+// resolveObjDesc resolves the description an object shows to this player. For an
+// object with conditional Descriptions, the first variant whose condition
+// passes wins; if none pass the object is reported as not present (false), so
+// look falls through as if it were not there. Objects without Descriptions use
+// their plain Description and are always present.
+func (g *Game) resolveObjDesc(sess *net.Session, def *object.ObjectDef) (string, bool) {
+ if len(def.Descriptions) == 0 {
+ return def.Description, true
+ }
+ for _, d := range def.Descriptions {
+ if d.Condition == nil || g.checkCondition(sess, d.Condition) {
+ return d.Text, true
+ }
+ }
+ return "", false
+}
+
func (g *Game) showRoomMobs(sess *net.Session, p *player.Player, room *world.Room) []string {
mobs := g.MobStore.MobsInRoom(p.RoomID)
if len(mobs) == 0 {
@@ -91,7 +120,15 @@ func (g *Game) showRoomMobs(sess *net.Session, p *player.Player, room *world.Roo
for _, m := range mobs {
hp := ""
if m.HP < m.MaxHP {
- hp = fmt.Sprintf(" [%s/%dhp]", color.Render(g.colorMode(sess), color.Parse("167"), fmt.Sprint(m.HP)), m.MaxHP)
+ if m.IsTask() {
+ pct := 0
+ if m.MaxHP > 0 {
+ pct = (m.MaxHP - m.HP) * 100 / m.MaxHP
+ }
+ hp = fmt.Sprintf(" [%d%% complete]", pct)
+ } else {
+ hp = fmt.Sprintf(" [%s/%dhp]", color.Render(g.colorMode(sess), color.Parse("167"), fmt.Sprint(m.HP)), m.MaxHP)
+ }
}
var desc string
if combat.IsMobInCombat(m.InstanceID) {
@@ -467,6 +504,14 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
sess.WriteLine("You can't see anything that way.")
return
}
+ if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) {
+ msg := exitDef.BlockedMessage
+ if msg == "" {
+ msg = fmt.Sprintf("The way %s is blocked.", exitDir)
+ }
+ sess.WriteLine(msg)
+ return
+ }
g.World.SeedGroundItems(exitDef.Room)
g.seedRoomMobs(exitDef.Room)
origRoom := p.RoomID
@@ -521,8 +566,43 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
return
}
- instances := g.World.FindObjInstances(p.RoomID, lower)
- if len(instances) > 0 {
+ rawInstances := g.World.FindObjInstances(p.RoomID, lower)
+
+ // Group matched object instances by definition, keeping only those visible
+ // to this player (an object whose conditional descriptions all fail is
+ // absent). If more than one distinct object matches, disambiguate.
+ var presentDefs []string
+ byDef := map[string][]world.ObjState{}
+ descByDef := map[string]string{}
+ for _, ist := range rawInstances {
+ def, err := g.ObjectStore.Load(ist.DefID)
+ if err != nil {
+ continue
+ }
+ text, present := g.resolveObjDesc(sess, def)
+ if !present {
+ continue
+ }
+ if _, seen := byDef[ist.DefID]; !seen {
+ presentDefs = append(presentDefs, ist.DefID)
+ descByDef[ist.DefID] = text
+ }
+ byDef[ist.DefID] = append(byDef[ist.DefID], ist)
+ }
+
+ if len(presentDefs) > 1 {
+ sess.WriteLine("That's ambiguous, which one?")
+ for _, defID := range presentDefs {
+ if def, err := g.ObjectStore.Load(defID); err == nil {
+ sess.WriteLine(fmt.Sprintf(" %s", def.Name))
+ }
+ }
+ return
+ }
+
+ if len(presentDefs) == 1 {
+ instances := byDef[presentDefs[0]]
+ objDescText := descByDef[presentDefs[0]]
st := &instances[0]
def, _ := g.ObjectStore.Load(st.DefID)
@@ -538,8 +618,8 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
sess.WriteLine(def.Name)
}
- if def.Description != "" && !strings.Contains(def.Description, "{quality}") {
- sess.WriteLine(fmt.Sprintf(" %s", def.Description))
+ if objDescText != "" && !strings.Contains(objDescText, "{quality}") {
+ sess.WriteLine(fmt.Sprintf(" %s", objDescText))
}
if def.Safespot != nil {
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index 85abbdf..b7cab37 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -42,12 +42,34 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) {
targetID := exitDef.Room
- _, err = g.World.LoadRoom(targetID)
+ targetRoom, err := g.World.LoadRoom(targetID)
if err != nil {
sess.WriteLine("That path seems blocked.")
return
}
+ // Warn before walking from a safe room into a dangerous (hazardous) one.
+ // Dangerous->dangerous never prompts (the current room is already hazardous).
+ if targetRoom.Hazard != "" && room.Hazard == "" && p.OptionBool("danger_warning") {
+ if p.PendingDangerDir == dir {
+ p.PendingDangerDir = ""
+ } else {
+ p.PendingDangerDir = dir
+ p.WalkSequence = nil
+ sess.State = net.StateDangerConfirm
+ name := targetRoom.Hazard
+ if hz, herr := g.World.LoadHazard(targetRoom.Hazard); herr == nil && hz.Name != "" {
+ name = hz.Name
+ }
+ sess.WriteLine(g.colorize(sess, "warning", fmt.Sprintf("WARNING: that area is exposed to %s.", name)))
+ sess.WriteLine("Enter anyway? [Y/n]")
+ return
+ }
+ }
+
+ p.MovePendingFlags = exitDef.SetFlags
+ p.MovePendingPlayerFlags = exitDef.SetPlayerFlags
+
inCombat := combat.GetCombat(p.Name) != nil
if !inCombat && p.Action != nil {
@@ -108,6 +130,8 @@ func (g *Game) moveTicks(p *player.Player, multiplier float64) int {
func (g *Game) completeMove(sess *net.Session, p *player.Player) {
exitDir := p.MoveDirection
targetID := p.MoveTarget
+ pendingFlags := p.MovePendingFlags
+ pendingPlayerFlags := p.MovePendingPlayerFlags
p.ClearMoveState()
@@ -127,7 +151,16 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) {
oldRoom := p.RoomID
p.RoomID = targetID
+ p.HazardTimer = 0
p.Stats.RecordRoomVisit(targetID)
+
+ g.cancelEnterSeq(p.Name)
+ if p.CutsceneRoom != 0 && p.CutsceneRoom == oldRoom {
+ p.CutsceneRoom = 0
+ }
+ if len(pendingFlags) > 0 || len(pendingPlayerFlags) > 0 {
+ g.applyFlagMutations(p, pendingFlags, pendingPlayerFlags)
+ }
g.AccountStore.SaveCharacter(p)
g.World.SeedGroundItems(p.RoomID)
@@ -179,6 +212,35 @@ func (g *Game) executeMove(sess *net.Session, args []string, rawInput string) {
g.doMove(sess, strings.TrimSpace(rawInput), 1.0)
}
+// handleDangerConfirm processes the [Y/n] reply to the dangerous-area warning.
+// Pressing return (empty), "y", or "yes" enters; anything else cancels.
+func (g *Game) handleDangerConfirm(sess *net.Session, input string) {
+ p := sess.Player
+ if p == nil {
+ sess.State = net.StateGame
+ g.writePrompt(sess)
+ return
+ }
+
+ dir := p.PendingDangerDir
+ choice := strings.TrimSpace(strings.ToLower(input))
+ sess.State = net.StateGame
+
+ if choice != "" && choice != "y" && choice != "yes" {
+ p.PendingDangerDir = ""
+ sess.WriteLine("You decide against it.")
+ g.writePrompt(sess)
+ return
+ }
+
+ if dir == "" {
+ g.writePrompt(sess)
+ return
+ }
+ g.doMove(sess, dir, 1.0)
+ g.writePrompt(sess)
+}
+
func (g *Game) seedRoomObjects(roomID int) {
room, err := g.World.LoadRoom(roomID)
if err != nil {
@@ -195,6 +257,9 @@ func (g *Game) seedRoomObjects(roomID int) {
continue
}
g.World.SetObjName(roomID, obj.ID, def.Name)
+ if len(def.Aliases) > 0 {
+ g.World.SetObjAliases(roomID, obj.ID, def.Aliases)
+ }
if len(obj.WanderRooms) > 0 {
g.World.SetObjWander(roomID, obj.ID, obj.WanderRooms, obj.WanderInterval)
}
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index fe3d90a..be1c276 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -22,6 +22,7 @@ var commandRegistry = map[string]commandDef{
"drop": {(*Game).executeDrop, ClassActive},
"attack": {(*Game).executeAttack, ClassActive},
"kill": {(*Game).executeAttack, ClassActive},
+ "work": {(*Game).executeWork, ClassActive},
"style": {(*Game).executeStyle, ClassInstant},
"look": {(*Game).executeLook, ClassInstant},
"l": {(*Game).executeLook, ClassInstant},
diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go
index 9f3c786..635c4b4 100644
--- a/internal/game/cmd_score.go
+++ b/internal/game/cmd_score.go
@@ -43,7 +43,7 @@ func (g *Game) doScore(sess *net.Session) {
}
skillDisplay := map[player.SkillName]string{
- player.Attack: "Attack", player.Strength: "Strength", player.Defense: "Defense",
+ player.Accuracy: "Accuracy", player.Strength: "Strength", player.Defense: "Defense",
player.Hitpoints: "Hitpoints", player.Ranged: "Ranged", player.Science: "Science",
player.Technology: "Technology", player.Assassin: "Assassin",
player.Fishing: "Fishing", player.Woodcutting: "Woodcutting", player.Mining: "Mining",
@@ -61,7 +61,7 @@ func (g *Game) doScore(sess *net.Session) {
}
categories := []skillCat{
{"Combat", []player.SkillName{
- player.Attack, player.Strength, player.Defense, player.Hitpoints,
+ player.Accuracy, player.Strength, player.Defense, player.Hitpoints,
player.Ranged, player.Science, player.Technology, player.Assassin,
}},
{"Gathering", []player.SkillName{
diff --git a/internal/game/cmd_stats.go b/internal/game/cmd_stats.go
index 9987a1d..2466a5f 100644
--- a/internal/game/cmd_stats.go
+++ b/internal/game/cmd_stats.go
@@ -67,7 +67,7 @@ func (g *Game) doStats(sess *net.Session) {
equipAtt := combat.SelectBonus(attackType,
totals.StabAttack, totals.SlashAttack, totals.CrushAttack,
totals.ScienceAttack, totals.RangedAttack)
- attRoll = combat.EffectiveRoll(p.Level(player.Attack), attBonus, equipAtt)
+ attRoll = combat.EffectiveRoll(p.Level(player.Accuracy), attBonus, equipAtt)
maxHitVal = combat.MaxHit(p.Level(player.Strength), strBonus, totals.StrengthBonus)
}
diff --git a/internal/game/condition_test.go b/internal/game/condition_test.go
new file mode 100644
index 0000000..5e5967d
--- /dev/null
+++ b/internal/game/condition_test.go
@@ -0,0 +1,155 @@
+package game
+
+import (
+ "testing"
+
+ "thehouseoficarus/internal/action"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/object"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+func sessWithFlags(flags map[string]any) *net.Session {
+ return &net.Session{Player: &player.Player{Flags: flags}}
+}
+
+func TestIsTruthy(t *testing.T) {
+ cases := []struct {
+ v any
+ want bool
+ }{
+ {nil, false},
+ {true, true},
+ {false, false},
+ {0, false},
+ {1, true},
+ {int64(0), false},
+ {int64(2), true},
+ {0.0, false},
+ {1.5, true},
+ {"", false},
+ {"x", true},
+ }
+ for _, c := range cases {
+ if got := isTruthy(c.v); got != c.want {
+ t.Errorf("isTruthy(%v) = %v, want %v", c.v, got, c.want)
+ }
+ }
+}
+
+func TestCheckConditionPlayerFlagTruthy(t *testing.T) {
+ g := &Game{WorldFlags: map[string]any{}}
+
+ set := sessWithFlags(map[string]any{"x": true})
+ unset := sessWithFlags(map[string]any{})
+
+ // {player_flag: x} -> set means present+truthy
+ posCond := &action.Condition{PlayerFlag: "x"}
+ if !g.checkCondition(set, posCond) {
+ t.Error("expected positive flag check to pass when flag is true")
+ }
+ if g.checkCondition(unset, posCond) {
+ t.Error("expected positive flag check to fail when flag is unset")
+ }
+
+ // {player_flag: x, not: true} -> negative
+ negCond := &action.Condition{PlayerFlag: "x", Not: true}
+ if g.checkCondition(set, negCond) {
+ t.Error("expected negative flag check to fail when flag is true")
+ }
+ if !g.checkCondition(unset, negCond) {
+ t.Error("expected negative flag check to pass when flag is unset")
+ }
+
+ // flag explicitly set to false counts as not-set for truthy checks
+ falseSet := sessWithFlags(map[string]any{"x": false})
+ if g.checkCondition(falseSet, posCond) {
+ t.Error("expected positive check to fail when flag is false")
+ }
+ if !g.checkCondition(falseSet, negCond) {
+ t.Error("expected negative check to pass when flag is false")
+ }
+}
+
+func TestCheckConditionValueComparisonPreserved(t *testing.T) {
+ g := &Game{WorldFlags: map[string]any{}}
+ sess := sessWithFlags(map[string]any{"n": 1})
+
+ if !g.checkCondition(sess, &action.Condition{PlayerFlag: "n", Value: 1}) {
+ t.Error("value match should pass for equal int")
+ }
+ if g.checkCondition(sess, &action.Condition{PlayerFlag: "n", Value: 2}) {
+ t.Error("value match should fail for unequal int")
+ }
+ if !g.checkCondition(sess, &action.Condition{PlayerFlag: "n", Value: 2, Not: true}) {
+ t.Error("negated value match should pass for unequal int")
+ }
+}
+
+func TestCheckConditionWorldFlag(t *testing.T) {
+ g := &Game{WorldFlags: map[string]any{"gate_open": true}}
+ sess := sessWithFlags(map[string]any{})
+
+ if !g.checkCondition(sess, &action.Condition{Flag: "gate_open"}) {
+ t.Error("world flag truthy check should pass")
+ }
+ if g.checkCondition(sess, &action.Condition{Flag: "gate_open", Not: true}) {
+ t.Error("negated world flag should fail when set")
+ }
+ if g.checkCondition(sess, &action.Condition{Flag: "missing"}) {
+ t.Error("missing world flag should not pass")
+ }
+}
+
+func TestResolveObjDescPresence(t *testing.T) {
+ g := &Game{WorldFlags: map[string]any{}}
+
+ def := &object.ObjectDef{
+ Descriptions: []object.ConditionalDesc{
+ {Text: "lined", Condition: &action.Condition{PlayerFlag: "lined_up"}},
+ {Text: "milling", Condition: &action.Condition{PlayerFlag: "lined_up", Not: true}},
+ },
+ }
+
+ // not lined up -> milling, present
+ if text, present := g.resolveObjDesc(sessWithFlags(map[string]any{}), def); !present || text != "milling" {
+ t.Errorf("expected milling/present, got %q/%v", text, present)
+ }
+ // lined up -> lined, present
+ if text, present := g.resolveObjDesc(sessWithFlags(map[string]any{"lined_up": true}), def); !present || text != "lined" {
+ t.Errorf("expected lined/present, got %q/%v", text, present)
+ }
+
+ // gated entirely off -> absent
+ gated := &object.ObjectDef{
+ Descriptions: []object.ConditionalDesc{
+ {Text: "x", Condition: &action.Condition{PlayerFlag: "done", Not: true}},
+ },
+ }
+ if _, present := g.resolveObjDesc(sessWithFlags(map[string]any{"done": true}), gated); present {
+ t.Error("expected object to be absent when no variant matches")
+ }
+
+ // plain object (no Descriptions) always present
+ plain := &object.ObjectDef{Description: "plain"}
+ if text, present := g.resolveObjDesc(sessWithFlags(nil), plain); !present || text != "plain" {
+ t.Errorf("expected plain/present, got %q/%v", text, present)
+ }
+}
+
+func TestRoomDescriptionSelection(t *testing.T) {
+ g := &Game{WorldFlags: map[string]any{}}
+ room := &world.Room{
+ Description: "empty pad",
+ Descriptions: []world.RoomDesc{
+ {Text: "crowd", Condition: &action.Condition{PlayerFlag: "done", Not: true}},
+ },
+ }
+ if got := g.roomDescription(sessWithFlags(map[string]any{}), room); got != "crowd" {
+ t.Errorf("first visit: want crowd, got %q", got)
+ }
+ if got := g.roomDescription(sessWithFlags(map[string]any{"done": true}), room); got != "empty pad" {
+ t.Errorf("revisit: want empty pad, got %q", got)
+ }
+}
diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go
index f0d6863..dec6dae 100644
--- a/internal/game/core_login_char.go
+++ b/internal/game/core_login_char.go
@@ -41,8 +41,8 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) {
}
p := player.New(name)
- p.RoomID = 1
- p.Stats.RecordRoomVisit(1)
+ p.RoomID = 1001
+ p.Stats.RecordRoomVisit(1001)
if err := g.AccountStore.SaveCharacter(p); err != nil {
sess.WriteLine(fmt.Sprintf("Error creating character: %v", err))
@@ -101,6 +101,23 @@ func (g *Game) connectCharacter(sess *net.Session, name string) {
g.doLook(sess)
g.checkAggro(sess)
+
+ // Resume an interrupted on_enter cutscene (e.g. disconnected mid-sequence).
+ // Only fires when a persisted marker matches the room the player is in, so
+ // ordinary on_enter scripts never run on login.
+ if p.CutsceneRoom != 0 {
+ if p.CutsceneRoom == p.RoomID {
+ g.runEnterSteps(sess, p.RoomID)
+ }
+ g.enterMu.Lock()
+ _, active := g.enterSeqs[p.Name]
+ g.enterMu.Unlock()
+ if !active {
+ p.CutsceneRoom = 0
+ g.AccountStore.SaveCharacter(p)
+ }
+ }
+
sess.Write("\r\n> ")
}
diff --git a/internal/game/core_startup.go b/internal/game/core_startup.go
index 4bdb50d..323e519 100644
--- a/internal/game/core_startup.go
+++ b/internal/game/core_startup.go
@@ -31,6 +31,7 @@ func (g *Game) ValidateStartup() []StartupIssue {
issues = append(issues, validateRooms(g)...)
issues = append(issues, validateMobs(g)...)
+ issues = append(issues, validateHazards(g)...)
issues = append(issues, validateObjects(g)...)
issues = append(issues, validateItems(g)...)
issues = append(issues, validateCraftBlocks(g)...)
@@ -66,6 +67,7 @@ func validateRooms(g *Game) []StartupIssue {
itemIDs := g.ItemStore.IDSet()
mobIDs := g.MobStore.AllDefIDs()
objIDs := g.ObjectStore.IDSet()
+ hazardIDs := g.World.HazardIndex()
for id := range roomIndex {
room, err := g.World.LoadRoom(id)
@@ -164,6 +166,15 @@ func validateRooms(g *Game) []StartupIssue {
}
}
}
+
+ if room.Hazard != "" && !hazardIDs[room.Hazard] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: references nonexistent hazard %q",
+ id, room.Hazard),
+ })
+ }
}
return issues
@@ -193,6 +204,14 @@ func validateMobs(g *Game) []StartupIssue {
})
}
+ if def.Kind != "" && def.Kind != "combat" && def.Kind != "task" {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: invalid kind %q (must be \"combat\" or \"task\")", id, def.Kind),
+ })
+ }
+
if def.Drops.Remains != "" && !itemIDs[def.Drops.Remains] {
issues = append(issues, StartupIssue{
Level: "ERROR",
@@ -243,6 +262,40 @@ func validateMobs(g *Game) []StartupIssue {
return issues
}
+func validateHazards(g *Game) []StartupIssue {
+ var issues []StartupIssue
+ itemIDs := g.ItemStore.IDSet()
+ validTypes := map[string]bool{"stab": true, "slash": true, "crush": true, "ranged": true, "science": true}
+
+ for id := range g.World.HazardIndex() {
+ def, err := g.World.LoadHazard(id)
+ if err != nil {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Hazard %q: failed to load: %v", id, err),
+ })
+ continue
+ }
+ if def.AttackType != "" && !validTypes[def.AttackType] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Hazard %q: invalid attack_type %q (must be stab/slash/crush/ranged/science)", id, def.AttackType),
+ })
+ }
+ if def.RequiredItem != "" && !itemIDs[def.RequiredItem] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Hazard %q: required_item %q does not exist", id, def.RequiredItem),
+ })
+ }
+ }
+
+ return issues
+}
+
func validateObjects(g *Game) []StartupIssue {
var issues []StartupIssue
objIDs := g.ObjectStore.IDSet()
diff --git a/internal/game/game.go b/internal/game/game.go
index a1b7bbe..b9b54ba 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -59,6 +59,8 @@ type Game struct {
hackingStates map[string]*hacking.Session
safespotMu sync.Mutex
safespotStates map[string]*SafespotState
+ enterMu sync.Mutex
+ enterSeqs map[string]*enterSeq
}
type SafespotState struct {
@@ -92,6 +94,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali
guardWatchTimers: make(map[string]int),
hackingStates: make(map[string]*hacking.Session),
safespotStates: make(map[string]*SafespotState),
+ enterSeqs: make(map[string]*enterSeq),
}
g.CourseStore.LoadAll()
g.LoadMods()
@@ -105,6 +108,7 @@ func (g *Game) SetHub(hub *net.Hub) {
hub.OnRemove(func(sess *net.Session) {
if p := sess.Player; p != nil {
p.DeactivateAllTechs()
+ g.cancelEnterSeq(p.Name)
g.charsMu.Lock()
delete(g.loggedInChars, p.Name)
g.charsMu.Unlock()
@@ -167,6 +171,8 @@ func (g *Game) HandleSession(sess *net.Session, input string) {
g.handleColorChoice(sess, input)
case net.StateHacking:
g.handleHackingInput(sess, input)
+ case net.StateDangerConfirm:
+ g.handleDangerConfirm(sess, input)
}
}
diff --git a/internal/game/map_test.go b/internal/game/map_test.go
index b7d9f01..dded62e 100644
--- a/internal/game/map_test.go
+++ b/internal/game/map_test.go
@@ -21,32 +21,32 @@ func TestBuildTinyMap(t *testing.T) {
want []string // expected lines, or nil to just check count
}{
{
- name: "room 1 has east exit",
- roomID: 1,
+ name: "room 999991 has east exit",
+ roomID: 999991,
},
{
- name: "room 2 has west/north/east",
- roomID: 2,
+ name: "room 999992 has west/north/east",
+ roomID: 999992,
},
{
- name: "room 4 has west/north/east",
- roomID: 4,
+ name: "room 999994 has west/north/east",
+ roomID: 999994,
},
{
- name: "room 21 has west only",
- roomID: 21,
+ name: "room 9999921 has west only",
+ roomID: 9999921,
},
{
- name: "room 22 west of town square",
- roomID: 22,
+ name: "room 9999922 west of town square",
+ roomID: 9999922,
},
{
- name: "room 25 east of south of west",
- roomID: 25,
+ name: "room 9999925 east of south of west",
+ roomID: 9999925,
},
{
- name: "room 8 hacking lab",
- roomID: 8,
+ name: "room 999998 hacking lab",
+ roomID: 999998,
},
}
@@ -108,10 +108,10 @@ func TestBuildFullMap(t *testing.T) {
width int
height int
}{
- {"room 1 30x20", 1, 30, 20},
- {"room 1 20x10", 1, 20, 10},
- {"room 25 30x20", 25, 30, 20},
- {"min size 5x5", 1, 5, 5},
+ {"room 999991 30x20", 999991, 30, 20},
+ {"room 999991 20x10", 999991, 20, 10},
+ {"room 9999925 30x20", 9999925, 30, 20},
+ {"min size 5x5", 999991, 5, 5},
}
for _, tt := range tests {
diff --git a/internal/game/sys_hazard.go b/internal/game/sys_hazard.go
new file mode 100644
index 0000000..29bf65e
--- /dev/null
+++ b/internal/game/sys_hazard.go
@@ -0,0 +1,136 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+// HazardTick rolls environmental attacks against every player standing in a
+// hazardous room. It reuses the combat formulas exactly: the hazard "attacks"
+// and the player defends with their Defense level + equipment bonus selected by
+// the hazard's attack type. It runs independently of combat, so a player can be
+// hit by BOTH a mob and the room hazard in the same area. It deliberately never
+// touches movement state — a hazard hit does NOT interrupt walking.
+func (g *Game) HazardTick() {
+ if g.Hub == nil {
+ return
+ }
+ for _, sess := range g.Hub.AllSessions() {
+ p := sess.Player
+ if p == nil {
+ continue
+ }
+
+ room, err := g.World.LoadRoom(p.RoomID)
+ if err != nil || room.Hazard == "" {
+ p.HazardTimer = 0
+ continue
+ }
+ hz, err := g.World.LoadHazard(room.Hazard)
+ if err != nil || hz == nil {
+ p.HazardTimer = 0
+ continue
+ }
+
+ // A safespot shields the player from all hazard damage while hidden.
+ if g.safespotBlocksHazard(p) {
+ continue
+ }
+
+ // The right protective gear negates the hazard entirely.
+ if hz.RequiredItem != "" && g.hasEquipped(p, hz.RequiredItem) {
+ continue
+ }
+
+ speed := hz.Speed
+ if speed <= 0 {
+ speed = 5
+ }
+ p.HazardTimer++
+ if float64(p.HazardTimer) >= speed {
+ p.HazardTimer = 0
+ g.rollHazard(sess, p, hz)
+ }
+ }
+}
+
+func (g *Game) hasEquipped(p *player.Player, itemID string) bool {
+ for _, id := range p.Equipment {
+ if id == itemID {
+ return true
+ }
+ }
+ return false
+}
+
+func (g *Game) rollHazard(sess *net.Session, p *player.Player, hz *world.HazardDef) {
+ attackType := hz.AttackType
+ if attackType == "" {
+ attackType = "crush"
+ }
+
+ attRoll := combat.EffectiveRoll(hz.Attack, 0, hz.AttackBonus)
+
+ _, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle))
+ totals := g.playerEquipBonuses(p)
+ equipDef := combat.SelectBonus(attackType,
+ totals.StabDefense, totals.SlashDefense, totals.CrushDefense,
+ totals.ScienceDefense, totals.RangedDefense)
+ defRoll := combat.EffectiveRoll(
+ p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"),
+ defStyleBonus, equipDef)
+
+ if combat.HitCheck(attRoll, defRoll) {
+ g.applyHazardHit(sess, p, hz)
+ } else {
+ miss := hz.MissMessage
+ if miss == "" {
+ miss = fmt.Sprintf("You weather the %s.", hz.Name)
+ }
+ sess.WriteLine("\n" + g.colorize(sess, "miss", miss))
+ g.writePrompt(sess)
+ }
+}
+
+func (g *Game) applyHazardHit(sess *net.Session, p *player.Player, hz *world.HazardDef) {
+ maxHit := hz.MaxHit
+ if maxHit < 1 {
+ maxHit = 1
+ }
+ dmg := combat.RollDamage(maxHit)
+ dmg = g.damageAfterTechProtection(p, hz.AttackType, dmg)
+ if dmg < 0 {
+ dmg = 0
+ }
+
+ p.HP -= dmg
+ if p.HP < 0 {
+ p.HP = 0
+ }
+ p.StartRegen()
+ g.AccountStore.SaveCharacter(p)
+
+ msg := hz.HitMessage
+ switch {
+ case msg == "":
+ msg = fmt.Sprintf("The %s hits you for %d damage.", hz.Name, dmg)
+ case strings.Contains(msg, "%d"):
+ msg = fmt.Sprintf(msg, dmg)
+ }
+ hpSuffix := fmt.Sprintf(" %s [%d/%d]", g.hpBar(sess, p.HP, p.MaxHP()), p.HP, p.MaxHP())
+ sess.WriteLine("\n" + g.colorize(sess, "damage_taken", msg) + hpSuffix)
+
+ // NB: a hazard never clears MoveState, so it cannot interrupt a walk the way
+ // a mob hit ("Can't escape!") does.
+
+ if p.HP <= 0 {
+ g.killPlayer(sess, p, nil)
+ return
+ }
+ g.writePrompt(sess)
+}
diff --git a/internal/game/sys_labor.go b/internal/game/sys_labor.go
new file mode 100644
index 0000000..102c54e
--- /dev/null
+++ b/internal/game/sys_labor.go
@@ -0,0 +1,81 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+// taskWorkVerb returns the flavor verb for working on a task worksite. Designers
+// set `verb:` on the mob (e.g. "survey", "prospect", "build"); the default is
+// "work on". The player-facing command is always `work` (or `attack`).
+func taskWorkVerb(mob *world.MobInstance) string {
+ if mob != nil && mob.Verb != "" {
+ return mob.Verb
+ }
+ return "work on"
+}
+
+// progressBar renders a bar that fills as progress rises toward max — the
+// 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 + "]"
+}
+
+// writeTaskProgress prints the per-tick progress line for a task worksite,
+// mirroring the combat hit line but framed as labor instead of damage.
+func (g *Game) writeTaskProgress(sess *net.Session, p *player.Player, mob *world.MobInstance, dmg int, gains []xpGain) {
+ progress := mob.MaxHP - mob.HP
+ if progress < 0 {
+ progress = 0
+ }
+ pct := 0
+ if mob.MaxHP > 0 {
+ pct = progress * 100 / mob.MaxHP
+ }
+
+ noun := mob.ProgressNoun
+ if noun == "" {
+ noun = "work"
+ }
+
+ 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, ", ")+")")
+ }
+ sess.WriteLine(line)
+}
diff --git a/internal/game/sys_safespot.go b/internal/game/sys_safespot.go
index da999c0..44a8f9f 100644
--- a/internal/game/sys_safespot.go
+++ b/internal/game/sys_safespot.go
@@ -46,6 +46,13 @@ func blocksMob(maxBlockSize, mobSize string) bool {
return mob <= max
}
+// safespotBlocksHazard reports whether the player's active safespot shields them
+// from room hazard damage. Unlike mobs, hazard type does not matter: an active
+// safespot blocks ALL hazard damage, no safespot blocks none.
+func (g *Game) safespotBlocksHazard(p *player.Player) bool {
+ return g.isSafespotted(p.Name)
+}
+
func (g *Game) safespotBlocksMob(p *player.Player, mob *world.MobInstance) bool {
g.safespotMu.Lock()
ss, ok := g.safespotStates[p.Name]
diff --git a/internal/game/sys_technology.go b/internal/game/sys_technology.go
index bb48375..e673dcb 100644
--- a/internal/game/sys_technology.go
+++ b/internal/game/sys_technology.go
@@ -10,7 +10,7 @@ import (
)
type TechEffects struct {
- AttackPercent int
+ AccuracyPercent int
StrengthPercent int
DefensePercent int
RangedPercent int
@@ -39,9 +39,9 @@ var techByID map[string]*TechDef
func init() {
AllTechs = []TechDef{
- {ID: "clarity_1", Name: "Clarity", Level: 4, DrainRate: 0.05, Category: "attack", Group: "attack_tier", Effects: TechEffects{AttackPercent: 5}},
- {ID: "clarity_2", Name: "Enhanced Clarity", Level: 16, DrainRate: 0.10, Category: "attack", Group: "attack_tier", Effects: TechEffects{AttackPercent: 10}},
- {ID: "clarity_3", Name: "Superior Clarity", Level: 44, DrainRate: 0.15, Category: "attack", Group: "attack_tier", Effects: TechEffects{AttackPercent: 15}},
+ {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}},
@@ -68,7 +68,7 @@ func init() {
{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: "attack_tier", Effects: TechEffects{AttackPercent: 15, StrengthPercent: 15}},
+ {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}},
}
@@ -104,8 +104,8 @@ func TechByPrefixMatch(input string) []*TechDef {
func techEffectString(tech TechDef) string {
var parts []string
e := tech.Effects
- if e.AttackPercent > 0 {
- parts = append(parts, fmt.Sprintf("+%d%% Attack", e.AttackPercent))
+ if e.AccuracyPercent > 0 {
+ parts = append(parts, fmt.Sprintf("+%d%% Accuracy", e.AccuracyPercent))
}
if e.StrengthPercent > 0 {
parts = append(parts, fmt.Sprintf("+%d%% Strength", e.StrengthPercent))
@@ -163,8 +163,8 @@ func (g *Game) techLevelBonus(p *player.Player, stat string) int {
continue
}
switch stat {
- case "attack":
- totalPercent += def.Effects.AttackPercent
+ case "accuracy":
+ totalPercent += def.Effects.AccuracyPercent
case "strength":
totalPercent += def.Effects.StrengthPercent
case "defense":
@@ -182,8 +182,8 @@ func (g *Game) techLevelBonus(p *player.Player, stat string) int {
var baseLevel int
switch stat {
- case "attack":
- baseLevel = p.Level(player.Attack)
+ case "accuracy":
+ baseLevel = p.Level(player.Accuracy)
case "strength":
baseLevel = p.Level(player.Strength)
case "defense":
@@ -217,11 +217,17 @@ func (g *Game) tryRecharge(sess *net.Session, p *player.Player, input string) bo
}
func (g *Game) applyTechProtection(p *player.Player, mob *world.MobInstance, dmg int) int {
+ return g.damageAfterTechProtection(p, mob.AttackType, 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.
+func (g *Game) damageAfterTechProtection(p *player.Player, attackType string, dmg int) int {
if len(p.ActiveTechs) == 0 {
return dmg
}
- attackType := mob.AttackType
if attackType == "" {
attackType = "crush"
}