aboutsummaryrefslogtreecommitdiff
path: root/internal
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
parentd25638d98fe63efdeab570ef77e6a6a97f2d7a60 (diff)
downloadthehouseoficarus-9d4b799db4868bcab291b83599ff088763cd4ed6.tar.gz
feat: new type of non-violent 'combat': work
Diffstat (limited to 'internal')
-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
-rw-r--r--internal/net/server.go1
-rw-r--r--internal/object/object.go11
-rw-r--r--internal/player/player.go16
-rw-r--r--internal/world/hazard.go82
-rw-r--r--internal/world/mob.go23
-rw-r--r--internal/world/objects.go21
-rw-r--r--internal/world/room.go24
-rw-r--r--internal/world/world.go5
27 files changed, 1222 insertions, 168 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"
}
diff --git a/internal/net/server.go b/internal/net/server.go
index eaa8f17..b2cc3be 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -41,6 +41,7 @@ const (
StateShop
StateBank
StateHacking
+ StateDangerConfirm
)
type Session struct {
diff --git a/internal/object/object.go b/internal/object/object.go
index 12e5585..f7a0e7b 100644
--- a/internal/object/object.go
+++ b/internal/object/object.go
@@ -12,11 +12,13 @@ type UseInteraction struct {
type ObjectDef struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
+ Aliases []string `yaml:"aliases"`
Color string `yaml:"color"`
Hidden bool `yaml:"hidden"`
InRoomDescription string `yaml:"inroom_description"`
RemovalItem string `yaml:"removal_item"`
Description string `yaml:"description"`
+ Descriptions []ConditionalDesc `yaml:"descriptions,omitempty"`
UseInteractions []UseInteraction `yaml:"use_interactions"`
StealTable string `yaml:"steal_table"`
StealLevel int `yaml:"steal_level"`
@@ -30,6 +32,15 @@ type ObjectDef struct {
Safespot *SafespotConfig `yaml:"safespot,omitempty"`
}
+// ConditionalDesc is a description variant gated by a condition. They are
+// evaluated in order per-looker; the first whose condition passes (or has no
+// condition) wins. If a def has Descriptions but none match for a given player,
+// the object is treated as absent for that player.
+type ConditionalDesc struct {
+ Text string `yaml:"text"`
+ Condition *action.Condition `yaml:"condition"`
+}
+
type SafespotConfig struct {
Tier int `yaml:"tier"`
MaxBlockSize string `yaml:"max_block_size"`
diff --git a/internal/player/player.go b/internal/player/player.go
index ebba376..03ed7ac 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -10,7 +10,7 @@ import (
type SkillName string
const (
- Attack SkillName = "attack"
+ Accuracy SkillName = "accuracy"
Strength SkillName = "strength"
Defense SkillName = "defense"
Hitpoints SkillName = "hitpoints"
@@ -36,13 +36,13 @@ const (
)
var AllSkills = []SkillName{
- Attack, Strength, Defense, Hitpoints, Ranged, Science, Technology,
+ Accuracy, Strength, Defense, Hitpoints, Ranged, Science, Technology,
Fishing, Cooking, Woodcutting, Firemaking, Mining, Smithing, Crafting, Fletching,
Pharmacy, Thieving, Agility, Construction, Scavenging, Hacking, Assassin, Farming,
}
var SkillAbbr = map[SkillName]string{
- Attack: "atk",
+ Accuracy: "acc",
Strength: "str",
Defense: "def",
Hitpoints: "hp",
@@ -139,6 +139,7 @@ var OptionDefs = []OptionDef{
{"construct_all", OptBool, false, nil, "Auto-start constructing when only one product is possible"},
{"craft_all", OptBool, false, nil, "Auto-start crafting when only one product is possible"},
{"safespot_alert", OptString, "{196 bold}** Your safespot has been compromised! **{/}", nil, "Message shown when forced out of a safespot"},
+ {"danger_warning", OptBool, true, nil, "Confirm before entering a dangerous (hazardous) area"},
}
var optionByName map[string]*OptionDef
@@ -169,6 +170,7 @@ type Player struct {
Prompt string `yaml:"prompt"`
Options map[string]any `yaml:"-"`
Flags map[string]any `yaml:"flags"`
+ CutsceneRoom int `yaml:"cutscene_room,omitempty"`
RegenerateTick int
AmmoQty int `yaml:"ammo_qty,omitempty"`
Action *action.Action `yaml:"-"`
@@ -180,10 +182,14 @@ type Player struct {
MoveTicks int `yaml:"-"`
MoveDirection string `yaml:"-"`
MoveTarget int `yaml:"-"`
+ MovePendingFlags map[string]any `yaml:"-"`
+ MovePendingPlayerFlags map[string]any `yaml:"-"`
VisualTickCurrent int `yaml:"-"`
AutotriggerMod string `yaml:"-"`
QueuedTrigger string `yaml:"-"`
AttackTimer int `yaml:"-"`
+ HazardTimer int `yaml:"-"`
+ PendingDangerDir string `yaml:"-"`
Battery float64 `yaml:"battery"`
ActiveTechs map[string]bool `yaml:"-"`
QuickTech string `yaml:"quick_tech,omitempty"`
@@ -210,6 +216,8 @@ func (p *Player) ClearMoveState() {
p.MoveTicks = 0
p.MoveDirection = ""
p.MoveTarget = 0
+ p.MovePendingFlags = nil
+ p.MovePendingPlayerFlags = nil
}
func (p *Player) OptionBool(name string) bool {
@@ -358,7 +366,7 @@ func (p *Player) AddSkillXP(s SkillName, amount int) int {
func (p *Player) CombatLevel() int {
base := 0.25 * float64(p.Level(Defense)+p.Level(Hitpoints)+p.Level(Technology))
- att := float64(p.Level(Attack))
+ att := float64(p.Level(Accuracy))
str := float64(p.Level(Strength))
if float64(p.Level(Ranged))*1.5 > att+str {
diff --git a/internal/world/hazard.go b/internal/world/hazard.go
new file mode 100644
index 0000000..396343b
--- /dev/null
+++ b/internal/world/hazard.go
@@ -0,0 +1,82 @@
+package world
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "thehouseoficarus/internal/action"
+ "gopkg.in/yaml.v3"
+)
+
+// HazardDef describes an environmental threat attached to a room (a sandstorm,
+// solar radiation, falling rocks, etc.). It is a shared, ID-referenced
+// definition loaded from data/hazards/<id>.yaml, so one hazard can be reused
+// across a whole area and resolved by name for a future `examine` command.
+//
+// A hazard rolls an attack against everyone in the room every Speed ticks using
+// the SAME combat formulas as a mob: accuracy comes from Attack/AttackBonus, max
+// hit from MaxHit, and the player defends with their Defense level + equipment
+// bonus selected by AttackType (so existing armour and the Defense skill apply).
+// Tech protection (Kinetic Barrier / Projectile Screen / Neural Firewall) keys
+// off AttackType automatically.
+type HazardDef struct {
+ ID string `yaml:"id"`
+ Name string `yaml:"name"`
+ Description string `yaml:"description"`
+ AttackType string `yaml:"attack_type"` // stab/slash/crush/ranged/science
+ Attack int `yaml:"attack"`
+ AttackBonus int `yaml:"attack_bonus"`
+ MaxHit int `yaml:"max_hit"`
+ Speed float64 `yaml:"speed"` // ticks between hazard rolls
+ WarnMessage string `yaml:"warn_message"`
+ HitMessage string `yaml:"hit_message"` // accepts a single %d for damage
+ MissMessage string `yaml:"miss_message"`
+ RequiredItem string `yaml:"required_item"` // equipped item that negates the hazard
+}
+
+// LoadHazard returns the shared hazard definition for id, caching results.
+func (w *World) LoadHazard(id string) (*HazardDef, error) {
+ w.hazardMu.Lock()
+ if w.hazardPathIndex == nil {
+ w.hazardPathIndex = action.BuildPathIndex(filepath.Join(w.dataDir, "hazards"))
+ }
+ if def, ok := w.hazardDefs[id]; ok {
+ w.hazardMu.Unlock()
+ return def, nil
+ }
+ path, ok := w.hazardPathIndex[id]
+ w.hazardMu.Unlock()
+ if !ok {
+ return nil, fmt.Errorf("read hazard %s: no such hazard", id)
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read hazard %s: %w", id, err)
+ }
+ var def HazardDef
+ if err := yaml.Unmarshal(data, &def); err != nil {
+ return nil, fmt.Errorf("parse hazard %s: %w", id, err)
+ }
+ def.ID = id
+
+ w.hazardMu.Lock()
+ w.hazardDefs[id] = &def
+ w.hazardMu.Unlock()
+ return &def, nil
+}
+
+// HazardIndex returns the set of known hazard IDs (used by startup validation).
+func (w *World) HazardIndex() map[string]bool {
+ w.hazardMu.Lock()
+ defer w.hazardMu.Unlock()
+ if w.hazardPathIndex == nil {
+ w.hazardPathIndex = action.BuildPathIndex(filepath.Join(w.dataDir, "hazards"))
+ }
+ ids := make(map[string]bool, len(w.hazardPathIndex))
+ for id := range w.hazardPathIndex {
+ ids[id] = true
+ }
+ return ids
+}
diff --git a/internal/world/mob.go b/internal/world/mob.go
index ec7db36..4864e5c 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -58,11 +58,23 @@ type MobDef struct {
DamageWithout string `yaml:"damage_without"`
Size string `yaml:"size"`
+ // Kind discriminates a normal combat mob ("" or "combat") from a "task"
+ // worksite (build a solar panel, survey a rock, etc.). A task mob reuses the
+ // entire combat engine but its HP drains to 0 to COMPLETE the work; it never
+ // attacks back (the room hazard, if any, supplies the danger). The progress
+ // bar is displayed inverted (filling toward 100%).
+ Kind string `yaml:"kind"`
+ Verb string `yaml:"verb"` // flavor verb for messages (default "work")
+ ProgressNoun string `yaml:"progress_noun"` // flavor noun, e.g. "construction"
+ CompleteMessage string `yaml:"complete_message"` // shown on completion
+
Talk *action.TalkConfig `yaml:"talk,omitempty"`
}
func (d *MobDef) IsTalkable() bool { return d.Talk != nil }
+func (d *MobDef) IsTask() bool { return d.Kind == "task" }
+
type MobInstance struct {
InstanceID string
DefID string
@@ -109,11 +121,18 @@ type MobInstance struct {
DamageWithout string
Size string
+ Kind string
+ Verb string
+ ProgressNoun string
+ CompleteMessage string
+
TalkConfig *action.TalkConfig
}
func (m *MobInstance) IsTalkable() bool { return m.TalkConfig != nil }
+func (m *MobInstance) IsTask() bool { return m.Kind == "task" }
+
func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []int, wanderInterval float64) *MobInstance {
return &MobInstance{
InstanceID: instanceID,
@@ -153,6 +172,10 @@ func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []in
FinishingBlow: def.FinishingBlow,
DamageWithout: def.DamageWithout,
Size: def.Size,
+ Kind: def.Kind,
+ Verb: def.Verb,
+ ProgressNoun: def.ProgressNoun,
+ CompleteMessage: def.CompleteMessage,
TalkConfig: def.Talk,
}
}
diff --git a/internal/world/objects.go b/internal/world/objects.go
index bf4b592..a7e0605 100644
--- a/internal/world/objects.go
+++ b/internal/world/objects.go
@@ -14,6 +14,7 @@ type ObjState struct {
DepleteTimer float64
DefID string
Name string
+ Aliases []string
Index int
RoomID int
JustRespawned bool
@@ -160,12 +161,13 @@ func (w *World) FindObjInstances(roomID int, name string) []ObjState {
if st.RoomID != roomID {
continue
}
- if !wordMatchesObj(lower, st.DefID, st.Name) {
+ if !wordMatchesObj(lower, st.DefID, st.Name, st.Aliases) {
continue
}
out = append(out, ObjState{
DefID: st.DefID,
Name: st.Name,
+ Aliases: st.Aliases,
Index: st.Index,
RoomID: st.RoomID,
Depleted: st.Depleted,
@@ -184,10 +186,15 @@ func (w *World) FindObjInstances(roomID int, name string) []ObjState {
return out
}
-func wordMatchesObj(lower, defID, objName string) bool {
+func wordMatchesObj(lower, defID, objName string, aliases []string) bool {
if object.WordPrefixMatch(lower, objName) {
return true
}
+ for _, alias := range aliases {
+ if object.WordPrefixMatch(lower, alias) {
+ return true
+ }
+ }
inputWords := strings.Fields(strings.ToLower(lower))
if strings.HasPrefix(strings.ToLower(defID), lower) || strings.HasPrefix(lower, strings.ToLower(defID)) {
@@ -214,6 +221,16 @@ func (w *World) SetObjName(roomID int, defID string, name string) {
}
}
+func (w *World) SetObjAliases(roomID int, defID string, aliases []string) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ for _, st := range w.objStates {
+ if st.RoomID == roomID && st.DefID == defID {
+ st.Aliases = aliases
+ }
+ }
+}
+
func (w *World) SetObjWander(roomID int, defID string, rooms []int, interval float64) {
w.mu.Lock()
defer w.mu.Unlock()
diff --git a/internal/world/room.go b/internal/world/room.go
index 752ec41..2441c30 100644
--- a/internal/world/room.go
+++ b/internal/world/room.go
@@ -48,6 +48,8 @@ type ExitDef struct {
Room int `yaml:"room"`
Condition *action.Condition `yaml:"condition"`
BlockedMessage string `yaml:"blocked_message"`
+ SetFlags map[string]any `yaml:"set_flags"`
+ SetPlayerFlags map[string]any `yaml:"set_player_flags"`
}
func (e *ExitDef) UnmarshalYAML(value *yaml.Node) error {
@@ -67,11 +69,18 @@ type Room struct {
ID int `yaml:"id"`
Name string `yaml:"name"`
Description string `yaml:"description"`
+ Descriptions []RoomDesc `yaml:"descriptions,omitempty"`
Exits map[ExitDir]ExitDef `yaml:"exits"`
Objects []RoomObject `yaml:"objects"`
ItemSpawns []SpawnDef `yaml:"item_spawns"`
Mobs []RoomMob `yaml:"mobs"`
OnEnter []EnterStep `yaml:"on_enter"`
+ Hazard string `yaml:"hazard"`
+}
+
+type RoomDesc struct {
+ Text string `yaml:"text"`
+ Condition *action.Condition `yaml:"condition"`
}
type RoomMob struct {
@@ -94,8 +103,19 @@ func (rm *RoomMob) UnmarshalYAML(value *yaml.Node) error {
}
type EnterStep struct {
- Message string `yaml:"message"`
- Condition *action.Condition `yaml:"condition"`
+ Message string `yaml:"message"`
+ Condition *action.Condition `yaml:"condition"`
+ Delay int `yaml:"delay"`
+ SetFlags map[string]any `yaml:"set_flags"`
+ SetPlayerFlags map[string]any `yaml:"set_player_flags"`
+}
+
+// IsTimed reports whether the step carries cutscene semantics: a tick delay or
+// a flag mutation. A room whose qualifying on_enter steps are all non-timed is
+// printed synchronously; any timed step turns the sequence into a scheduled
+// cutscene.
+func (e EnterStep) IsTimed() bool {
+ return e.Delay > 0 || len(e.SetFlags) > 0 || len(e.SetPlayerFlags) > 0
}
type RoomObject struct {
diff --git a/internal/world/world.go b/internal/world/world.go
index b305c4b..8ed9a7c 100644
--- a/internal/world/world.go
+++ b/internal/world/world.go
@@ -19,6 +19,10 @@ type World struct {
seeded map[int]bool
objStates map[string]*ObjState
objMoves []ObjMove
+
+ hazardMu sync.Mutex
+ hazardPathIndex map[string]string
+ hazardDefs map[string]*HazardDef
}
func New(dataDir string) *World {
@@ -28,6 +32,7 @@ func New(dataDir string) *World {
groundItems: make(map[int][]*groundEntry),
seeded: make(map[int]bool),
objStates: make(map[string]*ObjState),
+ hazardDefs: make(map[string]*HazardDef),
}
}