aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--AGENTS.md6
-rw-r--r--cmd/mud/main.go1
-rw-r--r--data/help/automap.yaml4
-rw-r--r--data/help/hide.yaml15
-rw-r--r--data/help/map.yaml6
-rw-r--r--data/help/map_height.yaml (renamed from data/help/mapheight.yaml)2
-rw-r--r--data/help/map_padding.yaml (renamed from data/help/mappadding.yaml)2
-rw-r--r--data/help/map_width.yaml (renamed from data/help/mapwidth.yaml)2
-rw-r--r--data/help/option.yaml2
-rw-r--r--data/help/safespot.yaml38
-rw-r--r--data/items/ammo/adamant_arrow.yaml1
-rw-r--r--data/items/ammo/bronze_arrow.yaml1
-rw-r--r--data/items/ammo/iron_arrow.yaml1
-rw-r--r--data/items/ammo/mithril_arrow.yaml1
-rw-r--r--data/items/ammo/rune_arrow.yaml1
-rw-r--r--data/items/ammo/steel_arrow.yaml1
-rw-r--r--data/objects/fence_post.yaml18
-rw-r--r--data/objects/rock_outcrop.yaml22
-rw-r--r--data/objects/simple_rock.yaml18
-rw-r--r--data/objects/tutorial_lever.yaml22
-rw-r--r--data/rooms/town/4.yaml10
-rw-r--r--internal/combat/formulas.go26
-rw-r--r--internal/game/action.go1
-rw-r--r--internal/game/action_state.go3
-rw-r--r--internal/game/cmd_attack.go88
-rw-r--r--internal/game/cmd_hide.go171
-rw-r--r--internal/game/cmd_look.go46
-rw-r--r--internal/game/cmd_map.go6
-rw-r--r--internal/game/cmd_move.go8
-rw-r--r--internal/game/cmd_option.go8
-rw-r--r--internal/game/cmd_registry.go4
-rw-r--r--internal/game/cmd_score.go2
-rw-r--r--internal/game/cmd_stats.go6
-rw-r--r--internal/game/core_login_account.go29
-rw-r--r--internal/game/game.go18
-rw-r--r--internal/game/sys_combat.go3
-rw-r--r--internal/game/sys_safespot.go330
-rw-r--r--internal/object/item.go1
-rw-r--r--internal/object/object.go29
-rw-r--r--internal/player/player.go8
-rw-r--r--internal/player/store.go4
-rw-r--r--internal/world/mob.go85
-rw-r--r--internal/world/objects.go4
-rw-r--r--worldbuilding_guide/behaviors.md2
-rw-r--r--worldbuilding_guide/hidden_objects.md12
-rw-r--r--worldbuilding_guide/mobs.md1
-rw-r--r--worldbuilding_guide/objects.md109
48 files changed, 1052 insertions, 127 deletions
diff --git a/.gitignore b/.gitignore
index 38c7192..1cb07df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,4 +7,5 @@ thoi
.npm/
.local/
.config/
+.opencode/
node_modules/
diff --git a/AGENTS.md b/AGENTS.md
index 00ae2cc..8b65314 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -405,9 +405,9 @@ Account-wide options set via `option <name> <value>`. Stored in account YAML, sh
| `depletion` | bool | false | Show depletion timers |
| `despawn` | bool | false | Show ground item despawn timers |
| `color` | string | "xterm256" | none/ansi/xterm256 — color output mode |
-| `mapwidth` | int | 30 | Map viewport width |
-| `mapheight` | int | 20 | Map viewport height |
-| `mappadding` | string | "none" | none/x/y/xy — blank-row stripping |
+| `map_width` | int | 30 | Map viewport width |
+| `map_height` | int | 20 | Map viewport height |
+| `map_padding` | string | "none" | none/x/y/xy — blank-row stripping |
| `automap` | bool | false | Auto-show map after moving |
| `queue_silently` | bool | true | Suppress queue messages |
| `room_desc_width` | int | 70 | Room desc line wrap width |
diff --git a/cmd/mud/main.go b/cmd/mud/main.go
index c51cc6e..e9d0aed 100644
--- a/cmd/mud/main.go
+++ b/cmd/mud/main.go
@@ -57,6 +57,7 @@ func main() {
g.SneakTick()
g.FarmTick()
g.BuffTick()
+ g.SafespotTick()
return true
})
diff --git a/data/help/automap.yaml b/data/help/automap.yaml
index 32b84cb..3cd1a2f 100644
--- a/data/help/automap.yaml
+++ b/data/help/automap.yaml
@@ -9,5 +9,5 @@ description: |
When enabled, the map command output is printed after each
room movement, so you always know where you are.
- Map size and padding are controlled by the mapwidth,
- mapheight, and mappadding options.
+ Map size and padding are controlled by the map_width,
+ map_height, and map_padding options.
diff --git a/data/help/hide.yaml b/data/help/hide.yaml
new file mode 100644
index 0000000..eb2d27a
--- /dev/null
+++ b/data/help/hide.yaml
@@ -0,0 +1,15 @@
+name: "hide"
+category: "Skills"
+description: |
+ Hide behind a safespot object to block melee attacks.
+
+ Usage: hide <object>
+ hide behind <object>
+ safespot <object>
+
+ Hiding takes 1 tick out of combat or 4 ticks while fighting. If a mob
+ hits you during repositioning in combat, the hide attempt fails.
+
+ Leave cover with: unhide (or unsafespot)
+
+ See also: help safespot, help combat
diff --git a/data/help/map.yaml b/data/help/map.yaml
index 3b4917a..7ca03a2 100644
--- a/data/help/map.yaml
+++ b/data/help/map.yaml
@@ -9,11 +9,11 @@ description: |
centered on your current location. You are shown as @.
Map size is controlled by:
- option mapwidth <num> (default 30)
- option mapheight <num> (default 20)
+ option map_width <num> (default 30)
+ option map_height <num> (default 20)
Padding/trimming is controlled by:
- option mappadding <mode>
+ option map_padding <mode>
none - strip blank lines + left-trim (default)
x - strip blank lines only
y - left-trim only
diff --git a/data/help/mapheight.yaml b/data/help/map_height.yaml
index ee3920b..32daf0a 100644
--- a/data/help/mapheight.yaml
+++ b/data/help/map_height.yaml
@@ -1,4 +1,4 @@
-name: "mapheight"
+name: "map_height"
category: "Options"
description: |
Height of the full map display in characters.
diff --git a/data/help/mappadding.yaml b/data/help/map_padding.yaml
index 201ef53..7772226 100644
--- a/data/help/mappadding.yaml
+++ b/data/help/map_padding.yaml
@@ -1,4 +1,4 @@
-name: "mappadding"
+name: "map_padding"
category: "Options"
description: |
Controls how the map command trims its output.
diff --git a/data/help/mapwidth.yaml b/data/help/map_width.yaml
index 7e372ef..3957bed 100644
--- a/data/help/mapwidth.yaml
+++ b/data/help/map_width.yaml
@@ -1,4 +1,4 @@
-name: "mapwidth"
+name: "map_width"
category: "Options"
description: |
Width of the full map display in characters.
diff --git a/data/help/option.yaml b/data/help/option.yaml
index 9fdcf7f..75b1794 100644
--- a/data/help/option.yaml
+++ b/data/help/option.yaml
@@ -15,7 +15,7 @@ description: |
Examples:
option tinymap off
option color ansi
- option mapwidth 50
+ option map_width 50
Use help <option> for details on a specific option.
Use 'prompt' to set a custom per-character prompt.
diff --git a/data/help/safespot.yaml b/data/help/safespot.yaml
new file mode 100644
index 0000000..76e1768
--- /dev/null
+++ b/data/help/safespot.yaml
@@ -0,0 +1,38 @@
+name: "safespot"
+category: "Skills"
+description: |
+ Use environmental cover to block melee attacks from mobs while
+ attacking with ranged or science weapons.
+
+ Usage: hide <object>
+ safespot <object>
+
+ Find a safespot: look <object>
+ Leave a safespot: unhide (or unsafespot)
+
+ While in a safespot, melee mobs cannot reach you. Ranged and science
+ mobs can still attack. Attacking with melee while in a safespot forces
+ you out of cover.
+
+ Safespots degrade over time while in use. Each degrade level shows a
+ different message. When fully degraded, the safespot is destroyed.
+ Some safespots respawn after a delay; others require you to re-hide
+ to reset them.
+
+ There is a small per-tick chance of being forced out of cover
+ (unsafe_chance). You can re-hide immediately if the safespot is
+ still intact.
+
+ Hiding out of combat takes 1 tick. Hiding while fighting takes 4
+ ticks — taking a hit during the repositioning aborts the attempt.
+
+ Your effective safespot tier is unlocked by completing quests and
+ combat achievements. Higher-tier safespots require quest flags like
+ "quest_animal_magnetism" or achievement flags like
+ "achieve_medium_combat".
+
+ You can customize the alert shown when you are forced out of a
+ safespot with: option safespot_alert <message>
+ Set to "none" to disable. Uses inline color tags.
+
+ See also: help hide, help combat, help option
diff --git a/data/items/ammo/adamant_arrow.yaml b/data/items/ammo/adamant_arrow.yaml
index cbed73f..72f9470 100644
--- a/data/items/ammo/adamant_arrow.yaml
+++ b/data/items/ammo/adamant_arrow.yaml
@@ -18,6 +18,7 @@ equip_slot: ammo
id: adamant_arrow
name: adamant arrow
stackable: true
+recoverable: true
stats:
ranged_strength: 31
value: 20
diff --git a/data/items/ammo/bronze_arrow.yaml b/data/items/ammo/bronze_arrow.yaml
index bc26583..a61823b 100644
--- a/data/items/ammo/bronze_arrow.yaml
+++ b/data/items/ammo/bronze_arrow.yaml
@@ -18,6 +18,7 @@ equip_slot: ammo
id: bronze_arrow
name: bronze arrow
stackable: true
+recoverable: true
stats:
ranged_strength: 7
value: 1
diff --git a/data/items/ammo/iron_arrow.yaml b/data/items/ammo/iron_arrow.yaml
index 4cd5bea..9e15967 100644
--- a/data/items/ammo/iron_arrow.yaml
+++ b/data/items/ammo/iron_arrow.yaml
@@ -18,6 +18,7 @@ equip_slot: ammo
id: iron_arrow
name: iron arrow
stackable: true
+recoverable: true
stats:
ranged_strength: 10
value: 2
diff --git a/data/items/ammo/mithril_arrow.yaml b/data/items/ammo/mithril_arrow.yaml
index 3f07c09..3eef148 100644
--- a/data/items/ammo/mithril_arrow.yaml
+++ b/data/items/ammo/mithril_arrow.yaml
@@ -18,6 +18,7 @@ equip_slot: ammo
id: mithril_arrow
name: mithril arrow
stackable: true
+recoverable: true
stats:
ranged_strength: 22
value: 10
diff --git a/data/items/ammo/rune_arrow.yaml b/data/items/ammo/rune_arrow.yaml
index 0b497ff..4876472 100644
--- a/data/items/ammo/rune_arrow.yaml
+++ b/data/items/ammo/rune_arrow.yaml
@@ -18,6 +18,7 @@ equip_slot: ammo
id: rune_arrow
name: rune arrow
stackable: true
+recoverable: true
stats:
ranged_strength: 49
value: 40
diff --git a/data/items/ammo/steel_arrow.yaml b/data/items/ammo/steel_arrow.yaml
index fb85c0d..916a2f3 100644
--- a/data/items/ammo/steel_arrow.yaml
+++ b/data/items/ammo/steel_arrow.yaml
@@ -18,6 +18,7 @@ equip_slot: ammo
id: steel_arrow
name: steel arrow
stackable: true
+recoverable: true
stats:
ranged_strength: 16
value: 5
diff --git a/data/objects/fence_post.yaml b/data/objects/fence_post.yaml
new file mode 100644
index 0000000..11f6160
--- /dev/null
+++ b/data/objects/fence_post.yaml
@@ -0,0 +1,18 @@
+id: fence_post
+name: fence post
+color: "137"
+hidden: true
+inroom_description: "A sturdy fence post juts from the ground."
+description: "A weathered wooden fence post, solid enough to crouch behind."
+safespot:
+ tier: 1
+ max_block_size: small
+ max_occupants: 1
+ unsafe_chance: 0.005
+ decay_ticks: 0
+ decay_chance: 0
+ respawn_on_hide: true
+ respawn_ticks: 0
+ levels:
+ - message: "You crouch behind the fence post, using it as cover."
+ degrade_message: ""
diff --git a/data/objects/rock_outcrop.yaml b/data/objects/rock_outcrop.yaml
new file mode 100644
index 0000000..57b71d8
--- /dev/null
+++ b/data/objects/rock_outcrop.yaml
@@ -0,0 +1,22 @@
+id: rock_outcrop
+name: rock outcrop
+color: "248"
+hidden: true
+inroom_description: "A jagged rock outcrop juts from the floor."
+description: "A large, jagged rock formation providing natural cover."
+safespot:
+ tier: 1
+ max_block_size: large
+ max_occupants: 3
+ unsafe_chance: 0.008
+ decay_ticks: 500
+ decay_chance: 0.01
+ respawn_on_hide: false
+ respawn_ticks: 300
+ levels:
+ - message: "The rock formation stands solid."
+ degrade_message: "The rock structure begins crumbling!"
+ - message: "The rock outcrop is cracked and worn."
+ degrade_message: "A few jagged bits are all that's left of the rock outcrop!"
+ - message: "Only a few jagged rocks remain."
+ degrade_message: "The rock outcrop crumbles to nothing!"
diff --git a/data/objects/simple_rock.yaml b/data/objects/simple_rock.yaml
new file mode 100644
index 0000000..4c03308
--- /dev/null
+++ b/data/objects/simple_rock.yaml
@@ -0,0 +1,18 @@
+id: simple_rock
+name: rock formation
+color: "248"
+hidden: true
+inroom_description: "A sturdy rock formation juts from the ground."
+description: "A solid rock formation, weathered but large enough to crouch behind."
+safespot:
+ tier: 1
+ max_block_size: small
+ max_occupants: 2
+ unsafe_chance: 0.002
+ decay_ticks: 0
+ decay_chance: 0
+ respawn_on_hide: true
+ respawn_ticks: 0
+ levels:
+ - message: "You crouch behind the rock formation, using it as cover."
+ degrade_message: ""
diff --git a/data/objects/tutorial_lever.yaml b/data/objects/tutorial_lever.yaml
new file mode 100644
index 0000000..894dd07
--- /dev/null
+++ b/data/objects/tutorial_lever.yaml
@@ -0,0 +1,22 @@
+id: tutorial_lever
+name: stone lever
+color: "250"
+hidden: true
+inroom_description: "A stone lever protrudes from the base of the rock formation."
+description: "A weathered stone lever set into a metal bracket. It looks like it can be pulled."
+use_interactions:
+ - condition:
+ player_flag: unlocked_rock_cover
+ value: 1
+ not: true
+ message: "You pull the stone lever down. A mechanism clicks into place somewhere in the glade."
+ action:
+ set_player_flags:
+ unlocked_rock_cover: 1
+ - condition:
+ player_flag: unlocked_rock_cover
+ value: 1
+ message: "You push the stone lever back up. The mechanism disengages with a soft clunk."
+ action:
+ set_player_flags:
+ unlocked_rock_cover: 0
diff --git a/data/rooms/town/4.yaml b/data/rooms/town/4.yaml
index 2c9f27f..5a8b036 100644
--- a/data/rooms/town/4.yaml
+++ b/data/rooms/town/4.yaml
@@ -1,6 +1,6 @@
id: 4
name: "Woodcutting Glade"
-description: "A sunny glade at the edge of the forest. A sturdy tree stands here, perfect for practicing woodcutting."
+description: "A sunny glade at the edge of the forest. A sturdy tree stands here, perfect for practicing woodcutting. A solid rock formation near the edge of the glade looks like it could provide cover."
map_symbol: "W"
exits:
east: 6
@@ -11,6 +11,8 @@ objects:
- id: tree
- id: tree
- id: tree
+ - id: simple_rock
+ - id: tutorial_lever
mobs:
- id: rat
- id: rat
@@ -27,3 +29,9 @@ item_spawns:
- id: lighter
quantity: 1
respawn_ticks: 120
+ - id: shortbow
+ quantity: 1
+ respawn_ticks: 60
+ - id: bronze_arrow
+ quantity: 100
+ respawn_ticks: 60
diff --git a/internal/combat/formulas.go b/internal/combat/formulas.go
index 963ee9c..f4e1ba2 100644
--- a/internal/combat/formulas.go
+++ b/internal/combat/formulas.go
@@ -2,12 +2,7 @@ package combat
import "math/rand"
-func AttackRoll(level int, styleBonus int, equipBonus int) int {
- effective := level + styleBonus + 8
- return effective * (equipBonus + 64)
-}
-
-func DefenseRoll(level int, styleBonus int, equipBonus int) int {
+func EffectiveRoll(level int, styleBonus int, equipBonus int) int {
effective := level + styleBonus + 8
return effective * (equipBonus + 64)
}
@@ -72,24 +67,7 @@ func RangedStyleBonus(style string) (ranged, defense int) {
}
}
-func SelectAttackBonus(attackType string, stab, slash, crush, science, ranged int) int {
- switch attackType {
- case "stab":
- return stab
- case "slash":
- return slash
- case "crush":
- return crush
- case "science":
- return science
- case "ranged":
- return ranged
- default:
- return crush
- }
-}
-
-func SelectDefenseBonus(attackType string, stab, slash, crush, science, ranged int) int {
+func SelectBonus(attackType string, stab, slash, crush, science, ranged int) int {
switch attackType {
case "stab":
return stab
diff --git a/internal/game/action.go b/internal/game/action.go
index d6ef1bc..9a07419 100644
--- a/internal/game/action.go
+++ b/internal/game/action.go
@@ -25,6 +25,7 @@ var verbAliases = map[string]string{
"ask": "talk",
"steal": "steal",
"thieve": "steal",
+ "hide": "safespot",
}
var verbSkill = map[string]string{
diff --git a/internal/game/action_state.go b/internal/game/action_state.go
index 0ba3767..ffce545 100644
--- a/internal/game/action_state.go
+++ b/internal/game/action_state.go
@@ -29,6 +29,7 @@ const (
ActionCuring ActionType = "curing"
ActionTraversing ActionType = "traversing"
ActionHacking ActionType = "hacking"
+ ActionHiding ActionType = "hiding"
)
type ActionState struct {
@@ -98,6 +99,8 @@ func (a *ActionState) Description() string {
return a.Verb + " across " + a.TargetName
case ActionHacking:
return "jacked into a " + a.TargetName
+ case ActionHiding:
+ return "positioning behind a " + a.TargetName
}
return ""
}
diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go
index 703b9ac..ce396e8 100644
--- a/internal/game/cmd_attack.go
+++ b/internal/game/cmd_attack.go
@@ -2,6 +2,7 @@ package game
import (
"fmt"
+ "math/rand"
"sort"
"strconv"
"strings"
@@ -248,6 +249,15 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI
attackType, weaponType, attRoll, defRoll, maxHit := g.calculatePlayerAttackRoll(p, mob)
+ if g.isSafespotted(p.Name) && attackType != "ranged" && attackType != "science" {
+ g.safespotMu.Lock()
+ ss, ok := g.safespotStates[p.Name]
+ g.safespotMu.Unlock()
+ if ok {
+ g.forceLeaveSafespot(sess, p, ss, "You leave your cover to close in for melee!")
+ }
+ }
+
if combat.HitCheck(attRoll, defRoll) {
g.applyPlayerHit(sess, p, mob, attackType, weaponType, maxHit)
} else {
@@ -277,25 +287,25 @@ func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstanc
rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle))
equipAttack := totals.RangedAttack
effectiveRanged := p.Level(player.Ranged) + g.techLevelBonus(p, "ranged") + g.buffLevelBonus(p, "ranged")
- attRoll = combat.AttackRoll(effectiveRanged, rangedBonus, equipAttack)
+ attRoll = combat.EffectiveRoll(effectiveRanged, rangedBonus, equipAttack)
- mobDefBonus := combat.SelectDefenseBonus(attackType,
+ mobDefBonus := combat.SelectBonus(attackType,
mob.StabDefense, mob.SlashDefense, mob.CrushDefense,
mob.ScienceDefense, mob.RangedDefense)
- defRoll = combat.DefenseRoll(mob.Defense, 0, mobDefBonus)
+ defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus)
maxHit = combat.MaxHit(effectiveRanged, rangedBonus, totals.RangedStrength)
} else {
attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle))
- equipAttack := combat.SelectAttackBonus(attackType,
+ 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.AttackRoll(effectiveAttack, attBonus, equipAttack)
+ attRoll = combat.EffectiveRoll(effectiveAttack, attBonus, equipAttack)
- mobDefBonus := combat.SelectDefenseBonus(attackType,
+ mobDefBonus := combat.SelectBonus(attackType,
mob.StabDefense, mob.SlashDefense, mob.CrushDefense,
mob.ScienceDefense, mob.RangedDefense)
- defRoll = combat.DefenseRoll(mob.Defense, 0, mobDefBonus)
+ defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus)
maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, totals.StrengthBonus)
}
return
@@ -322,12 +332,12 @@ func (g *Game) calculateUnarmedAttackRoll(p *player.Player, mob *world.MobInstan
attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle))
effectiveAttack := p.Level(player.Attack) + g.techLevelBonus(p, "attack") + g.buffLevelBonus(p, "attack")
- attRoll = combat.AttackRoll(effectiveAttack, attBonus, 0)
+ attRoll = combat.EffectiveRoll(effectiveAttack, attBonus, 0)
- mobDefBonus := combat.SelectDefenseBonus(attackType,
+ mobDefBonus := combat.SelectBonus(attackType,
mob.StabDefense, mob.SlashDefense, mob.CrushDefense,
mob.ScienceDefense, mob.RangedDefense)
- defRoll = combat.DefenseRoll(mob.Defense, 0, mobDefBonus)
+ defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus)
maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, 0)
return
}
@@ -361,12 +371,7 @@ func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.Mo
gains := g.awardCombatXP(sess, p, dmg, isRanged)
if isRanged {
- p.AmmoQty--
- if p.AmmoQty <= 0 {
- delete(p.Equipment, object.SlotAmmo)
- p.AmmoQty = 0
- }
- g.AccountStore.SaveCharacter(p)
+ g.consumeAmmo(sess, p)
}
mobName := mobDisplayName(mob, true)
@@ -404,18 +409,39 @@ func (g *Game) applyPlayerMiss(sess *net.Session, p *player.Player, mob *world.M
sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("You miss %s.", mobDisplayName(mob, true))))
if weaponType == object.WeaponRanged {
- p.AmmoQty--
+ g.consumeAmmo(sess, p)
if p.AmmoQty <= 0 {
- delete(p.Equipment, object.SlotAmmo)
- p.AmmoQty = 0
- g.AccountStore.SaveCharacter(p)
sess.WriteLine("You've run out of ammo!")
combat.LeaveCombat(p.Name)
}
}
}
+func (g *Game) consumeAmmo(sess *net.Session, p *player.Player) {
+ ammoID := p.Equipment[object.SlotAmmo]
+
+ p.AmmoQty--
+ if p.AmmoQty <= 0 {
+ delete(p.Equipment, object.SlotAmmo)
+ p.AmmoQty = 0
+ }
+
+ if ammoID != "" {
+ if def, err := g.ItemStore.Load(ammoID); err == nil && def.Recoverable {
+ if rand.Float64() < 0.80 {
+ g.World.AddGroundItem(p.RoomID, def.ID, 1)
+ }
+ }
+ }
+
+ g.AccountStore.SaveCharacter(p)
+}
+
func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) {
+ if g.isSafespotted(p.Name) && g.safespotBlocksMob(p, mob) {
+ return
+ }
+
attRoll, defRoll := g.calculateMobAttack(p, mob)
if combat.HitCheck(attRoll, defRoll) {
@@ -433,13 +459,13 @@ func (g *Game) calculateMobAttack(p *player.Player, mob *world.MobInstance) (att
mobAttackType = "crush"
}
- attRoll = combat.AttackRoll(mob.Attack, 0, mob.AttackBonus)
+ attRoll = combat.EffectiveRoll(mob.Attack, 0, mob.AttackBonus)
totals := g.playerEquipBonuses(p)
- equipDef := combat.SelectDefenseBonus(mobAttackType,
+ equipDef := combat.SelectBonus(mobAttackType,
totals.StabDefense, totals.SlashDefense, totals.CrushDefense,
totals.ScienceDefense, totals.RangedDefense)
- defRoll = combat.DefenseRoll(p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus, equipDef)
+ defRoll = combat.EffectiveRoll(p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus, equipDef)
return
}
@@ -504,6 +530,15 @@ func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobIn
hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, p.HP, p.MaxHP()), p.HP, p.MaxHP())
sess.WriteLine(prefix + hpSuffix)
+ g.safespotMu.Lock()
+ ss, hasSS := g.safespotStates[p.Name]
+ if hasSS && ss.HideCountdown > 0 {
+ delete(g.safespotStates, p.Name)
+ p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name}
+ sess.WriteLine(g.colorize(sess, "error", "You're hit while repositioning! You failed to hide."))
+ }
+ g.safespotMu.Unlock()
+
if p.MoveTicks > 0 {
p.ClearMoveState()
p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name}
@@ -542,6 +577,13 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
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
diff --git a/internal/game/cmd_hide.go b/internal/game/cmd_hide.go
new file mode 100644
index 0000000..9445949
--- /dev/null
+++ b/internal/game/cmd_hide.go
@@ -0,0 +1,171 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/world"
+)
+
+func (g *Game) executeHide(sess *net.Session, args []string, rawInput string) {
+ p := sess.Player
+
+ if len(args) == 0 {
+ sess.WriteLine("Hide behind what?")
+ return
+ }
+
+ // Skip optional "behind"
+ target := strings.Join(args, " ")
+ if args[0] == "behind" {
+ if len(args) < 2 {
+ sess.WriteLine("Hide behind what?")
+ return
+ }
+ target = strings.Join(args[1:], " ")
+ }
+
+ instances := g.World.FindObjInstances(p.RoomID, target)
+ if len(instances) == 0 {
+ sess.WriteLine("You don't see that here.")
+ return
+ }
+
+ if len(instances) > 1 && objectInstancesNeedDisambiguation(instances) {
+ sess.WriteLines(
+ "That's ambiguous, which one?",
+ formatObjInstanceChoices(instances),
+ )
+ return
+ }
+
+ objDef, err := g.ObjectStore.Load(instances[0].DefID)
+ if err != nil || !objDef.IsSafespot() {
+ sess.WriteLine("You can't hide behind that.")
+ return
+ }
+
+ cfg := objDef.Safespot
+ st := g.World.GetObjState(p.RoomID, objDef.ID, instances[0].Index)
+ if st == nil {
+ sess.WriteLine("You can't hide behind that.")
+ return
+ }
+
+ g.safespotMu.Lock()
+ existingSS, alreadyHiding := g.safespotStates[p.Name]
+ g.safespotMu.Unlock()
+
+ if alreadyHiding && existingSS.Active {
+ if existingSS.ObjectDefID == objDef.ID && existingSS.ObjectIndex == instances[0].Index {
+ if existingSS.HideCountdown > 0 {
+ sess.WriteLine("You're already moving into position there.")
+ } else {
+ sess.WriteLine("You're already hiding there.")
+ }
+ return
+ }
+ g.leaveSafespot(sess, p)
+ }
+
+ if g.safespotTier(p) < cfg.Tier {
+ sess.WriteLine("You can't figure out how to use this as effective cover.")
+ return
+ }
+
+ if cfg.MaxOccupants > 0 && len(st.SafespotOccupants) >= cfg.MaxOccupants {
+ sess.WriteLines(
+ "",
+ fmt.Sprintf("%s already hiding there!",
+ joinSafespotOccupants(st.SafespotOccupants)),
+ )
+ return
+ }
+
+ if st.SafespotLevel <= 0 {
+ if cfg.RespawnTicks > 0 {
+ sess.WriteLine(fmt.Sprintf("The %s has been destroyed and hasn't reformed yet.", objDef.Name))
+ } else {
+ sess.WriteLine(fmt.Sprintf("The %s has been completely destroyed.", objDef.Name))
+ }
+ return
+ }
+
+ inCombat := combat.GetCombat(p.Name) != nil
+ hideCountdown := 1
+ if inCombat {
+ hideCountdown = 4
+ }
+
+ g.safespotMu.Lock()
+ g.safespotStates[p.Name] = &SafespotState{
+ Active: true,
+ ObjectDefID: objDef.ID,
+ ObjectIndex: instances[0].Index,
+ RoomID: p.RoomID,
+ HideCountdown: hideCountdown,
+ }
+ g.safespotMu.Unlock()
+
+ p.ActionState = &ActionState{Type: ActionHiding, TargetName: objDef.Name}
+
+ if inCombat {
+ sess.WriteLine(fmt.Sprintf("\nYou try to get behind the %s for cover...", objDef.Name))
+ } else {
+ sess.WriteLine(fmt.Sprintf("You position yourself behind the %s.", objDef.Name))
+ }
+}
+
+func (g *Game) executeUnhide(sess *net.Session, args []string, rawInput string) {
+ p := sess.Player
+
+ g.safespotMu.Lock()
+ ss, ok := g.safespotStates[p.Name]
+ g.safespotMu.Unlock()
+
+ if !ok || !ss.Active {
+ sess.WriteLine("You're not hiding behind anything.")
+ return
+ }
+
+ if ss.HideCountdown > 0 {
+ g.safespotMu.Lock()
+ delete(g.safespotStates, p.Name)
+ g.safespotMu.Unlock()
+ p.ActionState = nil
+ sess.WriteLine("You stop trying to hide.")
+ return
+ }
+
+ g.leaveSafespot(sess, p)
+}
+
+func objectInstancesNeedDisambiguation(instances []world.ObjState) bool {
+ if len(instances) < 2 {
+ return false
+ }
+ first := instances[0].DefID
+ for i := 1; i < len(instances); i++ {
+ if instances[i].DefID != first {
+ return true
+ }
+ }
+ return false
+}
+
+func formatObjInstanceChoices(instances []world.ObjState) string {
+ var lines []string
+ for _, inst := range instances {
+ lines = append(lines, fmt.Sprintf(" %d.%s", inst.Index+1, inst.Name))
+ }
+ return strings.Join(lines, "\n")
+}
+
+func joinSafespotOccupants(names []string) string {
+ if len(names) == 1 {
+ return fmt.Sprintf("%s is", names[0])
+ }
+ return fmt.Sprintf("%s are", strings.Join(names, ", "))
+}
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index 936a6b0..4cc556a 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -124,7 +124,11 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world.
for _, objID := range order {
count := grouped[objID]
def, err := g.ObjectStore.Load(objID)
- if err != nil || def.Hidden {
+ if err != nil {
+ continue
+ }
+
+ if def.Hidden && (def.Safespot == nil || g.safespotTier(p) < def.Safespot.Tier) {
continue
}
@@ -132,6 +136,24 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world.
continue
}
+ if def.Hidden && def.Safespot != nil {
+ st := g.World.GetObjState(p.RoomID, objID, 0)
+ levelInfo := ""
+ if st != nil {
+ if st.SafespotLevel <= 0 && def.Safespot.RespawnTicks > 0 {
+ continue
+ }
+ if st.SafespotLevel <= 0 {
+ levelInfo = " (destroyed)"
+ } else if st.SafespotLevel < len(def.Safespot.Levels) {
+ levelInfo = " (degraded)"
+ }
+ }
+ sess.WriteLine(fmt.Sprintf(" %s that could provide cover.%s",
+ g.objColorize(sess, def, def.Name), levelInfo))
+ continue
+ }
+
type instInfo struct {
idx int
depleted bool
@@ -522,6 +544,28 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
sess.WriteLine(fmt.Sprintf(" %s", def.Description))
}
+ if def.Safespot != nil {
+ realSt := g.World.GetObjState(p.RoomID, st.DefID, st.Index)
+ if realSt != nil {
+ levelStr := "intact"
+ if realSt.SafespotLevel <= 0 {
+ levelStr = "destroyed"
+ } else if realSt.SafespotLevel < len(def.Safespot.Levels) {
+ levelStr = fmt.Sprintf("degraded (level %d/%d)", realSt.SafespotLevel, len(def.Safespot.Levels))
+ } else {
+ levelStr = fmt.Sprintf("intact (level %d/%d)", realSt.SafespotLevel, len(def.Safespot.Levels))
+ }
+ blockInfo := "anything"
+ if def.Safespot.MaxBlockSize != "" {
+ blockInfo = fmt.Sprintf("up to %s mobs", def.Safespot.MaxBlockSize)
+ }
+ sess.WriteLine(fmt.Sprintf(" Safespot: %s — blocks %s", levelStr, blockInfo))
+ if len(realSt.SafespotOccupants) > 0 {
+ sess.WriteLine(fmt.Sprintf(" Occupied by: %s", strings.Join(realSt.SafespotOccupants, ", ")))
+ }
+ }
+ }
+
if p.OptionBool("depletion") {
for _, ist := range instances {
if ist.Depleted {
diff --git a/internal/game/cmd_map.go b/internal/game/cmd_map.go
index be35a66..90f58ab 100644
--- a/internal/game/cmd_map.go
+++ b/internal/game/cmd_map.go
@@ -10,8 +10,8 @@ func (g *Game) executeMap(sess *net.Session, args []string, rawInput string) {
func (g *Game) doMap(sess *net.Session) {
p := sess.Player
- mapWidth := p.OptionInt("mapwidth")
- mapHeight := p.OptionInt("mapheight")
+ mapWidth := p.OptionInt("map_width")
+ mapHeight := p.OptionInt("map_height")
if mapWidth < 5 {
mapWidth = 5
}
@@ -21,7 +21,7 @@ func (g *Game) doMap(sess *net.Session) {
lines := buildFullMap(g, p.RoomID, mapWidth, mapHeight, mapGlyphsForPlayer(p.OptionBool("unicode")))
- mode := p.OptionString("mappadding")
+ mode := p.OptionString("map_padding")
if mode == "none" || mode == "x" {
lines = stripBlankRows(lines)
}
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index f921a81..a5f1690 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -117,6 +117,14 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) {
p.Action = nil
+ g.safespotMu.Lock()
+ if ss, ok := g.safespotStates[p.Name]; ok {
+ g.safespotMu.Unlock()
+ g.forceLeaveSafespot(sess, p, ss, "")
+ } else {
+ g.safespotMu.Unlock()
+ }
+
oldRoom := p.RoomID
p.RoomID = targetID
p.Stats.RecordRoomVisit(targetID)
diff --git a/internal/game/cmd_option.go b/internal/game/cmd_option.go
index 19256ba..ff7725c 100644
--- a/internal/game/cmd_option.go
+++ b/internal/game/cmd_option.go
@@ -26,6 +26,7 @@ func (g *Game) doOption(sess *net.Session, input string) {
}
for _, def := range player.OptionDefs {
val := formatOptionValue(p, &def)
+ val = truncateForTable(val)
valid := formatValidValues(&def)
table.Rows = append(table.Rows, []string{
color.Render(mode, color.Parse("75"), def.Name),
@@ -148,3 +149,10 @@ func parseOptionValue(def *player.OptionDef, input string) (any, bool) {
}
return nil, false
}
+
+func truncateForTable(s string) string {
+ if len(s) <= 10 {
+ return s
+ }
+ return s[:7] + "..."
+}
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index 4824684..381f34d 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -125,6 +125,10 @@ var commandRegistry = map[string]commandDef{
"syntax": {(*Game).executeVerbs, ClassInstant},
"commands": {(*Game).executeVerbs, ClassInstant},
"stop": {(*Game).executeStop, ClassInstant},
+ "hide": {(*Game).executeHide, ClassActive},
+ "safespot": {(*Game).executeHide, ClassActive},
+ "unhide": {(*Game).executeUnhide, ClassActive},
+ "unsafespot": {(*Game).executeUnhide, ClassActive},
}
func classifyCommand(cmd string) CommandClass {
diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go
index d0302c2..9f3c786 100644
--- a/internal/game/cmd_score.go
+++ b/internal/game/cmd_score.go
@@ -98,7 +98,7 @@ func (g *Game) doScore(sess *net.Session) {
creditsStr := g.colorize(sess, "credits_pickup", formatInt(p.Credits))
styleStr := color.Render(mode, color.Parse("75"), fmt.Sprintf("Style: %s", p.AttackStyle))
- invStr := fmt.Sprintf("Inv: %d/28 used", 28-p.FreeSlots())
+ invStr := fmt.Sprintf("Inv: %d/28", 28-p.FreeSlots())
row2 := padded("Credits: "+creditsStr, 20) + padded(styleStr, 24) + invStr
content(row2)
diff --git a/internal/game/cmd_stats.go b/internal/game/cmd_stats.go
index 333d535..9987a1d 100644
--- a/internal/game/cmd_stats.go
+++ b/internal/game/cmd_stats.go
@@ -60,14 +60,14 @@ func (g *Game) doStats(sess *net.Session) {
var attRoll, maxHitVal int
if weaponType == object.WeaponRanged {
rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle))
- attRoll = combat.AttackRoll(p.Level(player.Ranged), rangedBonus, totals.RangedAttack)
+ attRoll = combat.EffectiveRoll(p.Level(player.Ranged), rangedBonus, totals.RangedAttack)
maxHitVal = combat.MaxHit(p.Level(player.Ranged), rangedBonus, totals.RangedStrength)
} else {
attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle))
- equipAtt := combat.SelectAttackBonus(attackType,
+ equipAtt := combat.SelectBonus(attackType,
totals.StabAttack, totals.SlashAttack, totals.CrushAttack,
totals.ScienceAttack, totals.RangedAttack)
- attRoll = combat.AttackRoll(p.Level(player.Attack), attBonus, equipAtt)
+ attRoll = combat.EffectiveRoll(p.Level(player.Attack), attBonus, equipAtt)
maxHitVal = combat.MaxHit(p.Level(player.Strength), strBonus, totals.StrengthBonus)
}
diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go
index a2c1bd8..093ae71 100644
--- a/internal/game/core_login_account.go
+++ b/internal/game/core_login_account.go
@@ -5,6 +5,7 @@ import (
"os"
"strings"
+ "thehouseoficarus/internal/color"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
)
@@ -172,8 +173,20 @@ func (g *Game) handleColorChoice(sess *net.Session, input string) {
g.showMenu(sess)
}
+func menuColorMode(sess *net.Session) string {
+ if sess != nil && sess.Account != nil && sess.Account.Options != nil {
+ if v, ok := sess.Account.Options["color"]; ok {
+ if s, ok := v.(string); ok && s != "" {
+ return s
+ }
+ }
+ }
+ return "xterm256"
+}
+
func (g *Game) showMenu(sess *net.Session) {
sess.State = net.StateMenu
+ mode := menuColorMode(sess)
lines := []string{
"",
fmt.Sprintf("Welcome back to Gaia 04, %s.", sess.Account.Name),
@@ -181,18 +194,18 @@ func (g *Game) showMenu(sess *net.Session) {
}
if len(sess.Account.Characters) > 0 {
lines = append(lines,
- " (C)onnect character to THOI",
+ color.ExpandTags(mode, " {3}(C){/}{4}onnect character to THOI{/}"),
"",
- " (L)ist characters",
- " (R)ename character",
- " (D)elete character",
+ color.ExpandTags(mode, " {3}(L){/}{4}ist characters{/}"),
+ color.ExpandTags(mode, " {3}(R){/}{4}ename character{/}"),
+ color.ExpandTags(mode, " {3}(D){/}{4}elete character{/}"),
)
}
lines = append(lines,
- " (N)ew character",
- " (P)urge account",
- " (A)ccount rename",
- " (Q)uit",
+ color.ExpandTags(mode, " {3}(N){/}{4}ew character{/}"),
+ color.ExpandTags(mode, " {3}(P){/}{4}urge account{/}"),
+ color.ExpandTags(mode, " {3}(A){/}{4}ccount rename{/}"),
+ color.ExpandTags(mode, " {3}(Q){/}{4}uit{/}"),
"",
)
sess.WriteLines(lines...)
diff --git a/internal/game/game.go b/internal/game/game.go
index 8bf289b..d3bb391 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -57,6 +57,16 @@ type Game struct {
guardWatchTimers map[string]int
farmTickCounter int
hackingStates map[string]*hacking.Session
+ safespotMu sync.Mutex
+ safespotStates map[string]*SafespotState
+}
+
+type SafespotState struct {
+ Active bool
+ ObjectDefID string
+ ObjectIndex int
+ RoomID int
+ HideCountdown int
}
func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.ValidationConfig) *Game {
@@ -81,6 +91,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali
pendingDepletions: nil,
guardWatchTimers: make(map[string]int),
hackingStates: make(map[string]*hacking.Session),
+ safespotStates: make(map[string]*SafespotState),
}
g.CourseStore.LoadAll()
g.LoadMods()
@@ -98,6 +109,13 @@ func (g *Game) SetHub(hub *net.Hub) {
delete(g.loggedInChars, p.Name)
g.charsMu.Unlock()
delete(g.hackingStates, p.Name)
+ g.safespotMu.Lock()
+ if ss, ok := g.safespotStates[p.Name]; ok {
+ g.safespotMu.Unlock()
+ g.forceLeaveSafespot(sess, p, ss, "")
+ } else {
+ g.safespotMu.Unlock()
+ }
}
})
}
diff --git a/internal/game/sys_combat.go b/internal/game/sys_combat.go
index 81b2c83..91a07cf 100644
--- a/internal/game/sys_combat.go
+++ b/internal/game/sys_combat.go
@@ -90,6 +90,9 @@ func (g *Game) checkAggro(sess *net.Session) {
if playerLevel > mobLevel*2 {
continue
}
+ if g.isSafespotted(p.Name) && g.safespotBlocksMob(p, mob) {
+ continue
+ }
aggMob := mob
g.Ticks.Subscribe(engine.ToTicks(1), func() bool {
if combat.GetCombat(p.Name) != nil {
diff --git a/internal/game/sys_safespot.go b/internal/game/sys_safespot.go
new file mode 100644
index 0000000..ffb7948
--- /dev/null
+++ b/internal/game/sys_safespot.go
@@ -0,0 +1,330 @@
+package game
+
+import (
+ "fmt"
+ "math/rand"
+
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/engine"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/object"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+var sizeRank = map[string]int{
+ "small": 1,
+ "medium": 2,
+ "large": 3,
+ "massive": 4,
+}
+
+func (g *Game) isSafespotted(playerName string) bool {
+ g.safespotMu.Lock()
+ defer g.safespotMu.Unlock()
+ ss, ok := g.safespotStates[playerName]
+ return ok && ss.Active && ss.HideCountdown <= 0
+}
+
+func (g *Game) safespotTier(p *player.Player) int {
+ tier := 1
+ if getPlayerFlagInt(p, "unlocked_rock_cover") > 0 {
+ tier++
+ }
+ return tier
+}
+
+func blocksMob(maxBlockSize, mobSize string) bool {
+ if maxBlockSize == "" {
+ return true
+ }
+ max := sizeRank[maxBlockSize]
+ mob := sizeRank[mobSize]
+ if mob == 0 {
+ mob = sizeRank["medium"]
+ }
+ return mob <= max
+}
+
+func (g *Game) safespotBlocksMob(p *player.Player, mob *world.MobInstance) bool {
+ g.safespotMu.Lock()
+ ss, ok := g.safespotStates[p.Name]
+ g.safespotMu.Unlock()
+ if !ok || !ss.Active || ss.HideCountdown > 0 {
+ return false
+ }
+
+ objDef, err := g.ObjectStore.Load(ss.ObjectDefID)
+ if err != nil || objDef.Safespot == nil {
+ return false
+ }
+
+ if !blocksMob(objDef.Safespot.MaxBlockSize, mob.Size) {
+ return false
+ }
+
+ if mob.AttackType == "ranged" || mob.AttackType == "science" {
+ return false
+ }
+
+ return true
+}
+
+func (g *Game) sessionInRoom(name string, roomSessions []*net.Session) *net.Session {
+ for _, sess := range roomSessions {
+ if sess.Player != nil && sess.Player.Name == name {
+ return sess
+ }
+ }
+ return nil
+}
+
+func (g *Game) activateSafespot(sess *net.Session, p *player.Player, ss *SafespotState) {
+ objDef, err := g.ObjectStore.Load(ss.ObjectDefID)
+ if err != nil || objDef.Safespot == nil {
+ g.safespotMu.Lock()
+ delete(g.safespotStates, p.Name)
+ g.safespotMu.Unlock()
+ sess.WriteLine("The safespot no longer exists.")
+ return
+ }
+
+ cfg := objDef.Safespot
+ st := g.World.GetObjState(ss.RoomID, ss.ObjectDefID, ss.ObjectIndex)
+ if st == nil {
+ g.safespotMu.Lock()
+ delete(g.safespotStates, p.Name)
+ g.safespotMu.Unlock()
+ sess.WriteLine("The safespot no longer exists.")
+ return
+ }
+
+ if cfg.RespawnOnHide {
+ st.SafespotLevel = len(cfg.Levels)
+ st.SafespotTicksAtLevel = 0
+ } else if st.SafespotLevel <= 0 {
+ g.safespotMu.Lock()
+ delete(g.safespotStates, p.Name)
+ g.safespotMu.Unlock()
+ sess.WriteLine(fmt.Sprintf("The %s has been completely destroyed.", objDef.Name))
+ return
+ }
+
+ st.SafespotOccupants = append(st.SafespotOccupants, p.Name)
+ p.ActionState = nil
+
+ var msg string
+ levelIdx := st.SafespotLevel - 1
+ if levelIdx >= 0 && levelIdx < len(cfg.Levels) && cfg.Levels[levelIdx].Message != "" {
+ msg = cfg.Levels[levelIdx].Message
+ } else {
+ msg = fmt.Sprintf("You crouch behind the %s, using it as cover.", objDef.Name)
+ }
+ sess.WriteLine(g.colorize(sess, "combat_info", msg))
+}
+
+func (g *Game) safespotMaintenance(sess *net.Session, p *player.Player, ss *SafespotState) {
+ objDef, err := g.ObjectStore.Load(ss.ObjectDefID)
+ if err != nil || objDef.Safespot == nil {
+ g.forceLeaveSafespot(sess, p, ss, "The safespot no longer exists.")
+ return
+ }
+
+ cfg := objDef.Safespot
+ st := g.World.GetObjState(ss.RoomID, ss.ObjectDefID, ss.ObjectIndex)
+ if st == nil {
+ g.forceLeaveSafespot(sess, p, ss, "The safespot no longer exists.")
+ return
+ }
+
+ if st.SafespotLevel <= 0 {
+ if cfg.RespawnTicks > 0 {
+ return
+ }
+ g.forceLeaveSafespot(sess, p, ss, fmt.Sprintf("The %s has crumbled away!", objDef.Name))
+ return
+ }
+
+ if len(st.SafespotOccupants) == 0 {
+ return
+ }
+
+ if cfg.UnsafeChance > 0 && rand.Float64() < cfg.UnsafeChance {
+ alert := p.OptionString("safespot_alert")
+ g.forceLeaveSafespot(sess, p, ss, "You slip and are no longer covered!")
+ if alert != "" {
+ sess.WriteLine(color.ExpandTags(g.colorMode(sess), alert))
+ }
+ return
+ }
+
+ levelIdx := st.SafespotLevel - 1
+ degraded := false
+
+ if cfg.DecayChance > 0 && rand.Float64() < cfg.DecayChance {
+ degraded = true
+ }
+
+ if !degraded && cfg.DecayTicks > 0 {
+ st.SafespotTicksAtLevel++
+ if st.SafespotTicksAtLevel >= engine.ToTicks(cfg.DecayTicks) {
+ degraded = true
+ }
+ }
+
+ if degraded {
+ g.degradeSafespot(st, objDef, cfg, levelIdx)
+ }
+}
+
+func (g *Game) degradeSafespot(st *world.ObjState, objDef *object.ObjectDef, cfg *object.SafespotConfig, currentLevelIdx int) {
+ roomSessions := g.Hub.PlayersInRoom(st.RoomID)
+
+ if currentLevelIdx >= 0 && currentLevelIdx < len(cfg.Levels) {
+ msg := cfg.Levels[currentLevelIdx].DegradeMessage
+ if msg != "" {
+ for _, rs := range roomSessions {
+ rs.WriteLine("")
+ rs.WriteLine(color.ExpandTags(g.colorMode(rs), msg))
+ }
+ }
+ }
+
+ st.SafespotLevel--
+ st.SafespotTicksAtLevel = 0
+
+ if st.SafespotLevel <= 0 {
+ for _, name := range st.SafespotOccupants {
+ g.safespotMu.Lock()
+ delete(g.safespotStates, name)
+ g.safespotMu.Unlock()
+ if occSess := g.sessionInRoom(name, roomSessions); occSess != nil {
+ occSess.WriteLine(color.ExpandTags(g.colorMode(occSess),
+ fmt.Sprintf("The %s crumbles away!", objDef.Name)))
+ if occSess.Player != nil {
+ alert := occSess.Player.OptionString("safespot_alert")
+ if alert != "" {
+ occSess.WriteLine(color.ExpandTags(g.colorMode(occSess), alert))
+ }
+ }
+ g.writePrompt(occSess)
+ }
+ }
+ st.SafespotOccupants = nil
+ g.scheduleSafespotRespawn(st, objDef, cfg)
+ return
+ }
+
+ newLevelIdx := st.SafespotLevel - 1
+ if newLevelIdx >= 0 && newLevelIdx < len(cfg.Levels) && cfg.Levels[newLevelIdx].Message != "" {
+ msg := cfg.Levels[newLevelIdx].Message
+ for _, name := range st.SafespotOccupants {
+ if occSess := g.sessionInRoom(name, roomSessions); occSess != nil {
+ occSess.WriteLine("")
+ occSess.WriteLine(color.ExpandTags(g.colorMode(occSess), msg))
+ }
+ }
+ }
+}
+
+func (g *Game) scheduleSafespotRespawn(st *world.ObjState, objDef *object.ObjectDef, cfg *object.SafespotConfig) {
+ if cfg.RespawnTicks <= 0 {
+ return
+ }
+ roomID := st.RoomID
+ defID := objDef.ID
+ g.Ticks.Subscribe(engine.ToTicks(cfg.RespawnTicks), func() bool {
+ respawnSt := g.World.GetObjState(roomID, defID, 0)
+ if respawnSt == nil {
+ return false
+ }
+ respawnSt.SafespotLevel = len(cfg.Levels)
+ respawnSt.SafespotTicksAtLevel = 0
+ for _, sess := range g.Hub.PlayersInRoom(roomID) {
+ sess.WriteLine("")
+ sess.WriteLine(color.ExpandTags(g.colorMode(sess),
+ fmt.Sprintf("The %s reforms and looks stable again.", objDef.Name)))
+ }
+ return false
+ })
+}
+
+func (g *Game) leaveSafespot(sess *net.Session, p *player.Player) {
+ g.safespotMu.Lock()
+ ss, ok := g.safespotStates[p.Name]
+ if !ok {
+ g.safespotMu.Unlock()
+ return
+ }
+ delete(g.safespotStates, p.Name)
+ g.safespotMu.Unlock()
+
+ g.removeSafespotOccupant(p.Name, ss)
+
+ objDef, _ := g.ObjectStore.Load(ss.ObjectDefID)
+ objName := "cover"
+ if objDef != nil {
+ objName = objDef.Name
+ }
+ sess.WriteLine(fmt.Sprintf("You step out from behind the %s.", objName))
+ p.ActionState = nil
+}
+
+func (g *Game) forceLeaveSafespot(sess *net.Session, p *player.Player, ss *SafespotState, reason string) {
+ g.safespotMu.Lock()
+ delete(g.safespotStates, p.Name)
+ g.safespotMu.Unlock()
+
+ g.removeSafespotOccupant(p.Name, ss)
+
+ if reason != "" {
+ sess.WriteLine(reason)
+ }
+ p.ActionState = nil
+}
+
+func (g *Game) removeSafespotOccupant(playerName string, ss *SafespotState) {
+ st := g.World.GetObjState(ss.RoomID, ss.ObjectDefID, ss.ObjectIndex)
+ if st == nil {
+ return
+ }
+ for i, name := range st.SafespotOccupants {
+ if name == playerName {
+ st.SafespotOccupants = append(st.SafespotOccupants[:i], st.SafespotOccupants[i+1:]...)
+ return
+ }
+ }
+}
+
+func (g *Game) SafespotTick() {
+ if g.Hub == nil {
+ return
+ }
+ for _, sess := range g.Hub.AllSessions() {
+ p := sess.Player
+ if p == nil {
+ continue
+ }
+
+ g.safespotMu.Lock()
+ ss, ok := g.safespotStates[p.Name]
+ if !ok || !ss.Active {
+ g.safespotMu.Unlock()
+ continue
+ }
+
+ if ss.HideCountdown > 0 {
+ ss.HideCountdown--
+ if ss.HideCountdown > 0 {
+ g.safespotMu.Unlock()
+ continue
+ }
+ g.safespotMu.Unlock()
+ g.activateSafespot(sess, p, ss)
+ continue
+ }
+ g.safespotMu.Unlock()
+
+ g.safespotMaintenance(sess, p, ss)
+ }
+}
diff --git a/internal/object/item.go b/internal/object/item.go
index b0865d6..b90432d 100644
--- a/internal/object/item.go
+++ b/internal/object/item.go
@@ -91,6 +91,7 @@ type ItemDef struct {
PotionEffect string `yaml:"potion_effect"`
PotionBonus int `yaml:"potion_bonus"`
PotionDuration float64 `yaml:"potion_duration"`
+ Recoverable bool `yaml:"recoverable"`
}
type CraftList []CraftDef
diff --git a/internal/object/object.go b/internal/object/object.go
index 826a315..12e5585 100644
--- a/internal/object/object.go
+++ b/internal/object/object.go
@@ -24,15 +24,34 @@ type ObjectDef struct {
StealSpeed float64 `yaml:"steal_speed"`
GuardMob string `yaml:"guard_mob"`
- Gather *action.GatherConfig `yaml:"gather,omitempty"`
- Talk *action.TalkConfig `yaml:"talk,omitempty"`
- Use *action.UseConfig `yaml:"use,omitempty"`
+ Gather *action.GatherConfig `yaml:"gather,omitempty"`
+ Talk *action.TalkConfig `yaml:"talk,omitempty"`
+ Use *action.UseConfig `yaml:"use,omitempty"`
+ Safespot *SafespotConfig `yaml:"safespot,omitempty"`
+}
+
+type SafespotConfig struct {
+ Tier int `yaml:"tier"`
+ MaxBlockSize string `yaml:"max_block_size"`
+ MaxOccupants int `yaml:"max_occupants"`
+ UnsafeChance float64 `yaml:"unsafe_chance"`
+ DecayTicks float64 `yaml:"decay_ticks"`
+ DecayChance float64 `yaml:"decay_chance"`
+ RespawnOnHide bool `yaml:"respawn_on_hide"`
+ RespawnTicks float64 `yaml:"respawn_ticks"`
+ Levels []SafespotLevel `yaml:"levels"`
+}
+
+type SafespotLevel struct {
+ Message string `yaml:"message"`
+ DegradeMessage string `yaml:"degrade_message"`
}
func (d *ObjectDef) IsGatherable() bool { return d.Gather != nil }
func (d *ObjectDef) IsTalkable() bool { return d.Talk != nil }
func (d *ObjectDef) IsUsable() bool { return d.Use != nil }
-func (d *ObjectDef) IsInteractable() bool { return d.Gather != nil || d.Talk != nil || d.Use != nil }
+func (d *ObjectDef) IsSafespot() bool { return d.Safespot != nil }
+func (d *ObjectDef) IsInteractable() bool { return d.Gather != nil || d.Talk != nil || d.Use != nil || d.Safespot != nil }
func (d *ObjectDef) BehaviorType() string {
switch {
@@ -42,6 +61,8 @@ func (d *ObjectDef) BehaviorType() string {
return "talk"
case d.Use != nil:
return "use"
+ case d.Safespot != nil:
+ return "safespot"
}
return ""
}
diff --git a/internal/player/player.go b/internal/player/player.go
index 7953691..ac2c8ce 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -120,9 +120,9 @@ var OptionDefs = []OptionDef{
{"depletion", OptBool, false, nil, "Show depletion and despawn timers"},
{"despawn", OptBool, false, nil, "Show ground item despawn timers"},
{"color", OptString, "xterm256", []string{"none", "ansi", "xterm256"}, "Color output mode"},
- {"mapwidth", OptInt, 30, nil, "Map width for the map command"},
- {"mapheight", OptInt, 20, nil, "Map height for the map command"},
- {"mappadding", OptString, "none", []string{"none", "x", "y", "xy"}, "Map output padding mode"},
+ {"map_width", OptInt, 30, nil, "Map width for the map command"},
+ {"map_height", OptInt, 20, nil, "Map height for the map command"},
+ {"map_padding", OptString, "none", []string{"none", "x", "y", "xy"}, "Map output padding mode"},
{"automap", OptBool, false, nil, "Show map automatically after moving"},
{"queue_silently", OptBool, true, nil, "Suppress messages for queued tick actions"},
{"room_desc_width", OptInt, 70, nil, "Maximum width for room descriptions"},
@@ -137,6 +137,8 @@ var OptionDefs = []OptionDef{
{"smelt_all", OptBool, false, nil, "Auto-start smelting when only one product is possible"},
{"mix_all", OptBool, false, nil, "Auto-start mixing when only one product is possible"},
{"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 (set to \"none\" to disable)"},
}
var optionByName map[string]*OptionDef
diff --git a/internal/player/store.go b/internal/player/store.go
index c05b29d..04af594 100644
--- a/internal/player/store.go
+++ b/internal/player/store.go
@@ -89,10 +89,6 @@ func (s *AccountStore) LoadCharacter(name string) (*Player, error) {
if p.Skills == nil {
p.Skills = make(map[SkillName]int)
}
- if xp, ok := p.Skills["alchemy"]; ok {
- p.Skills[Pharmacy] = xp
- delete(p.Skills, "alchemy")
- }
if p.Equipment == nil {
p.Equipment = make(map[object.EquipSlot]string)
}
diff --git a/internal/world/mob.go b/internal/world/mob.go
index 9ca1a45..ec7db36 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -56,6 +56,7 @@ type MobDef struct {
AssassinLevel int `yaml:"assassin_level"`
FinishingBlow string `yaml:"finishing_blow"`
DamageWithout string `yaml:"damage_without"`
+ Size string `yaml:"size"`
Talk *action.TalkConfig `yaml:"talk,omitempty"`
}
@@ -106,12 +107,56 @@ type MobInstance struct {
AssassinLevel int
FinishingBlow string
DamageWithout string
+ Size string
TalkConfig *action.TalkConfig
}
func (m *MobInstance) IsTalkable() bool { return m.TalkConfig != nil }
+func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []int, wanderInterval float64) *MobInstance {
+ return &MobInstance{
+ InstanceID: instanceID,
+ DefID: def.ID,
+ Name: def.Name,
+ HP: def.HP,
+ MaxHP: def.HP,
+ Attack: def.Attack,
+ Strength: def.Strength,
+ Defense: def.Defense,
+ Ranged: def.Ranged,
+ Science: def.Science,
+ Speed: def.Speed,
+ Aggressive: def.Aggressive,
+ Protected: def.Protected,
+ Unique: def.Unique,
+ RespawnTicks: def.RespawnTicks,
+ RoomID: roomID,
+ HomeRoomID: roomID,
+ WanderRooms: wanderRooms,
+ WanderInterval: wanderInterval,
+ Drops: def.Drops,
+ AttackBonus: def.AttackBonus,
+ StrengthBonus: def.StrengthBonus,
+ AttackType: def.AttackType,
+ StabDefense: def.StabDefense,
+ SlashDefense: def.SlashDefense,
+ CrushDefense: def.CrushDefense,
+ ScienceDefense: def.ScienceDefense,
+ RangedDefense: def.RangedDefense,
+ Weakness: def.Weakness,
+ StealTable: def.StealTable,
+ StealLevel: def.StealLevel,
+ StealXP: def.StealXP,
+ StealSpeed: def.StealSpeed,
+ AssassinLevel: def.AssassinLevel,
+ FinishingBlow: def.FinishingBlow,
+ DamageWithout: def.DamageWithout,
+ Size: def.Size,
+ TalkConfig: def.Talk,
+ }
+}
+
const (
MatchNone = 0
MatchPrefix = 1
@@ -276,45 +321,7 @@ func (s *MobStore) SeedMobs(roomID int, entries []RoomMob) {
if _, exists := s.instances[instID]; exists {
continue
}
- inst := &MobInstance{
- InstanceID: instID,
- DefID: defID,
- Name: dw.def.Name,
- HP: dw.def.HP,
- MaxHP: dw.def.HP,
- Attack: dw.def.Attack,
- Strength: dw.def.Strength,
- Defense: dw.def.Defense,
- Ranged: dw.def.Ranged,
- Science: dw.def.Science,
- Speed: dw.def.Speed,
- Aggressive: dw.def.Aggressive,
- Protected: dw.def.Protected,
- Unique: dw.def.Unique,
- RespawnTicks: dw.def.RespawnTicks,
- WanderRooms: dw.rm.WanderRooms,
- WanderInterval: dw.rm.WanderInterval,
- RoomID: roomID,
- HomeRoomID: roomID,
- Drops: dw.def.Drops,
- AttackBonus: dw.def.AttackBonus,
- StrengthBonus: dw.def.StrengthBonus,
- AttackType: dw.def.AttackType,
- StabDefense: dw.def.StabDefense,
- SlashDefense: dw.def.SlashDefense,
- CrushDefense: dw.def.CrushDefense,
- ScienceDefense: dw.def.ScienceDefense,
- RangedDefense: dw.def.RangedDefense,
- Weakness: dw.def.Weakness,
- StealTable: dw.def.StealTable,
- StealLevel: dw.def.StealLevel,
- StealXP: dw.def.StealXP,
- StealSpeed: dw.def.StealSpeed,
- AssassinLevel: dw.def.AssassinLevel,
- FinishingBlow: dw.def.FinishingBlow,
- DamageWithout: dw.def.DamageWithout,
- TalkConfig: dw.def.Talk,
- }
+ inst := NewMobInstance(dw.def, instID, roomID, dw.rm.WanderRooms, dw.rm.WanderInterval)
inst.IdleDescription = pickIdleDescription(dw.def.IdleDescriptions)
s.instances[instID] = inst
}
diff --git a/internal/world/objects.go b/internal/world/objects.go
index ae2586a..bf4b592 100644
--- a/internal/world/objects.go
+++ b/internal/world/objects.go
@@ -23,6 +23,10 @@ type ObjState struct {
SharedMax float64
SharedTimer int
Quality float64
+
+ SafespotLevel int
+ SafespotTicksAtLevel int
+ SafespotOccupants []string
}
type ObjMove struct {
diff --git a/worldbuilding_guide/behaviors.md b/worldbuilding_guide/behaviors.md
index 08dc2d1..22e97fe 100644
--- a/worldbuilding_guide/behaviors.md
+++ b/worldbuilding_guide/behaviors.md
@@ -1,7 +1,7 @@
## Behaviors
Behaviors are **inline configs** placed directly inside object YAML (`gather:`, `talk:`,
-`use:`) or mob YAML (`talk:`). Each section below shows the keys you can use under each behavior type.
+`use:`, `safespot:`) or mob YAML (`talk:`). Each section below shows the keys you can use under each behavior type.
### Gather (mining, fishing, woodcutting)
diff --git a/worldbuilding_guide/hidden_objects.md b/worldbuilding_guide/hidden_objects.md
index 39030d8..24fd6bc 100644
--- a/worldbuilding_guide/hidden_objects.md
+++ b/worldbuilding_guide/hidden_objects.md
@@ -24,5 +24,17 @@ Room description hints at it:
description: "A dusty corridor. One of the wall stones looks slightly out of place."
```
+### Safespot Objects
+
+Safespot objects should always be `hidden: true`. They only become visible in `look` when the player's effective safespot tier meets the object's `safespot.tier` requirement. This creates a natural discovery mechanic: completing quests and achievements reveals new coverage opportunities.
+
+Players can discover safespots by:
+- Reading room descriptions for hints
+- Trying `hide <object_name>` (name matching works even for hidden objects)
+- Using `look <object>` once they know the name
+- Gaining tier through progression, which reveals safespots in `look`
+
+See [Safespots](objects.md#safespots) for the full YAML format.
+
---
diff --git a/worldbuilding_guide/mobs.md b/worldbuilding_guide/mobs.md
index 70afa6a..c19efff 100644
--- a/worldbuilding_guide/mobs.md
+++ b/worldbuilding_guide/mobs.md
@@ -71,6 +71,7 @@ ranged_defense: 3
| `attack_type` | Determines which player defense is used against this mob |
| `stab_defense` through `ranged_defense` | Per-type defense bonuses |
| `weakness` | Elemental weakness (solar/hydro/eco/bio) — for future Science combat |
+| `size` | Mob size for safespot blocking: small/medium/large/massive (default: medium) |
### Protected/Unique Mobs
diff --git a/worldbuilding_guide/objects.md b/worldbuilding_guide/objects.md
index 38eaeeb..65ab430 100644
--- a/worldbuilding_guide/objects.md
+++ b/worldbuilding_guide/objects.md
@@ -227,3 +227,112 @@ Use the `sneak` command to see guard watch state changes in real time.
---
+## Safespots
+
+Safespot objects provide cover that blocks melee attacks. Players use `hide <object>` to
+crouch behind them and attack with ranged or science weapons. Melee attacks force the player
+out of cover.
+
+```yaml
+id: rock_outcrop
+name: rock outcrop
+color: "248"
+hidden: true
+inroom_description: "A jagged rock outcrop juts from the floor."
+description: "A large, jagged rock formation providing natural cover."
+safespot:
+ tier: 2
+ max_block_size: large
+ max_occupants: 3
+ unsafe_chance: 0.008
+ decay_ticks: 500
+ decay_chance: 0.01
+ respawn_on_hide: false
+ respawn_ticks: 300
+ levels:
+ - message: "The rock formation stands solid."
+ degrade_message: "The rock structure begins crumbling!"
+ - message: "The rock outcrop is cracked and worn."
+ degrade_message: "A few jagged bits are all that's left of the rock outcrop!"
+ - message: "Only a few jagged rocks remain."
+ degrade_message: "The rock outcrop crumbles to nothing!"
+```
+
+Safespot objects should be `hidden: true` so they only appear in `look` when the player's
+effective safespot tier meets or exceeds the object's required tier. This creates a natural
+discovery mechanic: as players complete quests and achievements, they begin to notice cover
+they couldn't use before.
+
+### SafespotConfig Fields
+
+| Field | Type | Default | Description |
+|---|---|---|---|
+| `tier` | int | 0 | Required safespot tier to use this object |
+| `max_block_size` | string | `""` (all) | Largest mob size blocked: small/medium/large/massive |
+| `max_occupants` | int | 0 | Max players that can hide behind this object (0 = unlimited) |
+| `unsafe_chance` | float64 | 0 | Per-tick chance (0.0-1.0) to be forced out of cover |
+| `decay_ticks` | float64 | 0 | Guaranteed ticks before object degrades one level |
+| `decay_chance` | float64 | 0 | Per-tick random chance (0.0-1.0) to degrade one level |
+| `respawn_on_hide` | bool | false | If true, re-hiding resets the safespot to max level |
+| `respawn_ticks` | float64 | 0 | Ticks until a destroyed safespot respawns (0 = never) |
+| `levels` | []SafespotLevel | required | At least one level entry |
+
+### SafespotLevel Fields
+
+| Field | Type | Description |
+|---|---|---|
+| `message` | string | Shown to the player when the safespot is at this level |
+| `degrade_message` | string | Broadcast to the room when the safespot degrades FROM this level |
+
+Levels are ordered from highest to lowest. The first entry is the safespot's best state;
+the last entry is its weakest state before destruction. When the last level degrades,
+the safespot is destroyed.
+
+### Mob Size
+
+Mobs must have a `size` field for safespot blocking to work:
+
+```yaml
+# data/mobs/cow.yaml
+size: small
+
+# data/mobs/moss_giant.yaml
+size: large
+```
+
+Size ordering: `small` < `medium` < `large` < `massive`. Defaults to `medium` when absent.
+A safespot with `max_block_size: large` blocks mobs of size small, medium, and large,
+but not massive.
+
+### Progression
+
+Players gain safespot tier by completing quests and combat achievements, which set
+player flags via talk node actions:
+
+| Flag | Source | Tier bonus |
+|---|---|---|
+| `quest_animal_magnetism` | Quest completion | +1 |
+| `quest_dragon_slayer` | Quest completion | +1 |
+| `achieve_medium_combat` | Combat achievement | +1 |
+| `achieve_hard_combat` | Combat achievement | +2 |
+
+### Combat Integration
+
+- Melee mobs (attack_type: stab/slash/crush) cannot initiate aggro or land hits on
+ safespotted players if the mob's size is within the safespot's `max_block_size`.
+- Ranged and science mobs (attack_type: ranged/science) ignore safespots entirely.
+- If a safespotted player attacks with melee, they automatically leave cover.
+- Hiding in combat takes 4 ticks; if the mob hits during this time, the hide fails.
+- Hiding out of combat takes 1 tick.
+
+### Option: safespot_alert
+
+Players can customize the message shown when forced out of a safespot:
+
+ option safespot_alert "{196 bold}** DANGER **{/} Cover blown!"
+ option safespot_alert none
+
+Default: `"{196 bold}** Your safespot has been compromised! **{/}"`
+
+---
+