aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-21 02:26:57 -0400
committerhistoria <[not public]>2026-06-21 02:26:57 -0400
commite4bc2de6e6aa507044a6a75b4c1954974539a857 (patch)
tree608f22548bf2b6a1a4b02bedad05ec7f3a3aa3f2
parentc36c1dc0c65e65b4d6ee53df6bbcc3a860eb6e4b (diff)
downloadthehouseoficarus-e4bc2de6e6aa507044a6a75b4c1954974539a857.tar.gz
feat: combat health bar. color improvements.
-rw-r--r--config.yaml8
-rw-r--r--data/help/color.yaml7
-rw-r--r--data/help/prompt.yaml2
-rw-r--r--internal/config/config.go10
-rw-r--r--internal/game/action_agility.go4
-rw-r--r--internal/game/action_finishing_blow.go2
-rw-r--r--internal/game/cmd_attack.go47
-rw-r--r--internal/game/cmd_color.go10
-rw-r--r--internal/game/cmd_look.go14
-rw-r--r--internal/game/cmd_score.go2
-rw-r--r--internal/game/cmd_trigger_combat.go12
-rw-r--r--internal/game/core_production.go2
-rw-r--r--internal/game/sys_combat.go2
-rw-r--r--internal/game/tick.go8
-rw-r--r--internal/game/ui_combat.go59
-rw-r--r--internal/game/ui_help.go2
-rw-r--r--worldbuilding_guide/README.md26
-rw-r--r--worldbuilding_guide/behaviors.md3
-rw-r--r--worldbuilding_guide/construction.md2
-rw-r--r--worldbuilding_guide/courses.md119
-rw-r--r--worldbuilding_guide/hacking.md22
-rw-r--r--worldbuilding_guide/index.md2
22 files changed, 250 insertions, 115 deletions
diff --git a/config.yaml b/config.yaml
index 0bde479..ffe9d3b 100644
--- a/config.yaml
+++ b/config.yaml
@@ -8,12 +8,10 @@ colors:
direction: "75"
exit_direction: "75"
exit_name: "114"
- mob_name: "203"
- friendly_npc: "120"
- hostile_npc: "203"
+ protected_mob: "off"
+ mob: "off"
damage: "196"
- enemy_hp: "167"
- character_hp: "84"
+
xp: "222"
level_up: "226 bold"
item: "223"
diff --git a/data/help/color.yaml b/data/help/color.yaml
index 6082183..12fd4a5 100644
--- a/data/help/color.yaml
+++ b/data/help/color.yaml
@@ -55,12 +55,9 @@ description: |
direction Movement direction text
exit_direction Exit direction labels
exit_name Exit destination room names
- mob_name Mob names in combat
- friendly_npc Friendly NPC names
- hostile_npc Hostile mob names in room listings
+ protected_mob Protected mob names
+ mob Mob names (combat, room listings, etc.)
damage Damage numbers
- enemy_hp Enemy HP in combat
- character_hp Your HP in combat
xp XP gain messages
level_up Level-up announcements
item Item names (default)
diff --git a/data/help/prompt.yaml b/data/help/prompt.yaml
index f3c23f4..2b35f41 100644
--- a/data/help/prompt.yaml
+++ b/data/help/prompt.yaml
@@ -14,6 +14,8 @@ description: |
Variables:
%h Current HP
%H Maximum HP
+ %b Current battery level
+ %B Maximum battery level
%c Credits
%m Current mob HP (blank unless in combat)
%M Mob max HP (blank unless in combat)
diff --git a/internal/config/config.go b/internal/config/config.go
index cbf15c5..874d6a3 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -25,12 +25,12 @@ func DefaultColors() ColorsConfig {
"direction": "75",
"exit_direction": "75",
"exit_name": "114",
- "mob_name": "203",
- "friendly_npc": "120",
- "hostile_npc": "203",
+ "protected_mob": "off",
+ "mob": "off",
+ "damage_dealt": "33",
+ "damage_taken": "196",
"damage": "196",
- "enemy_hp": "167",
- "character_hp": "84",
+
"xp": "222",
"level_up": "226 bold",
"item": "223",
diff --git a/internal/game/action_agility.go b/internal/game/action_agility.go
index 43c9cfd..f916a8d 100644
--- a/internal/game/action_agility.go
+++ b/internal/game/action_agility.go
@@ -140,13 +140,13 @@ func (g *Game) obstacleFail(sess *net.Session, p *player.Player, data map[string
damage = failMin + rand.Intn(failMax-failMin+1)
}
- sess.WriteLine(g.colorize(sess, "damage", "You slip and fall!"))
+ sess.WriteLine(g.colorize(sess, "damage_taken", "You slip and fall!"))
p.HP -= damage
if p.HP < 1 {
p.HP = 1
}
- sess.WriteLine(g.colorize(sess, "damage",
+ sess.WriteLine(g.colorize(sess, "damage_taken",
fmt.Sprintf("You take %d damage. HP: %d/%d", damage, p.HP, p.MaxHP())))
g.AccountStore.SaveCharacter(p)
diff --git a/internal/game/action_finishing_blow.go b/internal/game/action_finishing_blow.go
index 674c2ef..070559d 100644
--- a/internal/game/action_finishing_blow.go
+++ b/internal/game/action_finishing_blow.go
@@ -72,7 +72,7 @@ func (g *Game) doFinishingBlow(sess *net.Session, p *player.Player, mob *world.M
if consumed {
p.RemoveItem(mob.FinishingBlow, 1)
}
- sess.WriteLine(fmt.Sprintf("\nYou use the %s on %s!", g.itemColorize(sess, fbDef, fbDef.Name), g.colorize(sess, "mob_name", mobDisplayName(mob, true))))
+ sess.WriteLine(fmt.Sprintf("\nYou use the %s on %s!", g.itemColorize(sess, fbDef, fbDef.Name), g.colorize(sess, "mob", mobDisplayName(mob, true))))
mob.HP = 0
g.endCombat(sess, p, mob)
}
diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go
index 89cde46..1ede21f 100644
--- a/internal/game/cmd_attack.go
+++ b/internal/game/cmd_attack.go
@@ -7,6 +7,7 @@ import (
"strings"
"thehouseoficarus/internal/action"
+ "thehouseoficarus/internal/color"
"thehouseoficarus/internal/combat"
"thehouseoficarus/internal/engine"
"thehouseoficarus/internal/net"
@@ -149,7 +150,7 @@ 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("\nYou attack %s!%s", g.colorize(sess, "mob_name", mobDisplayName(mob, true)), styleStr))
+ sess.WriteLine(fmt.Sprintf("\nYou attack %s!%s", g.colorize(sess, "mob", mobDisplayName(mob, true)), styleStr))
} else {
mod := GetMod(p.AutotriggerMod)
modName := p.AutotriggerMod
@@ -157,7 +158,7 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn
modName = mod.Name
}
sess.WriteLine(fmt.Sprintf("\nYou attack %s with %s!",
- g.colorize(sess, "mob_name", mobDisplayName(mob, true)),
+ g.colorize(sess, "mob", mobDisplayName(mob, true)),
g.colorize(sess, "science_mod", modName)))
}
p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name}
@@ -352,7 +353,7 @@ func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.Mo
fbName = fbDef.Name
}
sess.WriteLine(g.colorize(sess, "warning",
- fmt.Sprintf(" %s resists death! Use %s on it to finish it off.",
+ fmt.Sprintf("%s resists death! Use %s on it to finish it off.",
mobDisplayName(mob, false), fbName)))
}
@@ -373,13 +374,17 @@ func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.Mo
if !mob.Unique {
attacker = "The " + mob.Name
}
- w := len(fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg))
- if w2 := len(fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg)); w2 > w {
+ 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_name", mobName), g.colorize(sess, "damage", fmt.Sprint(dmg)))
- hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "enemy_hp", fmt.Sprint(mob.HP)), mob.MaxHP)
- line := fmt.Sprintf("%-*s %s", w, prefix, hpPart)
+ 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 {
@@ -396,7 +401,7 @@ func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.Mo
}
func (g *Game) applyPlayerMiss(sess *net.Session, p *player.Player, mob *world.MobInstance, weaponType object.WeaponType) {
- sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" You miss %s.", mobDisplayName(mob, true))))
+ sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("You miss %s.", mobDisplayName(mob, true))))
if weaponType == object.WeaponRanged {
p.AmmoQty--
@@ -487,13 +492,17 @@ func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobIn
attacker = "The " + mob.Name
}
mobName := mobDisplayName(mob, true)
- w := len(fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg))
- if w2 := len(fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg)); w2 > w {
+ w := len(fmt.Sprintf("%s hits you for %d damage.", attacker, 999))
+ if w2 := len(fmt.Sprintf("You hit %s for %d damage.", mobName, 999)); w2 > w {
w = w2
}
- prefix := fmt.Sprintf(" %s hits you for %s damage.", g.colorize(sess, "mob_name", attacker), g.colorize(sess, "damage", fmt.Sprint(dmg)))
- hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "character_hp", fmt.Sprint(p.HP)), p.MaxHP())
- sess.WriteLine(fmt.Sprintf("%-*s %s", w, prefix, hpPart))
+ prefix := fmt.Sprintf("%s hits you for %s damage.", g.colorize(sess, "mob", attacker), g.colorize(sess, "damage_taken", 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, p.HP, p.MaxHP()), p.HP, p.MaxHP())
+ sess.WriteLine(prefix + hpSuffix)
if p.MoveTicks > 0 {
p.ClearMoveState()
@@ -507,7 +516,7 @@ func (g *Game) applyMobMiss(sess *net.Session, mob *world.MobInstance) {
if !mob.Unique {
attacker = "The " + mob.Name
}
- sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" %s misses you.", attacker)))
+ sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("%s misses you.", attacker)))
}
func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) {
@@ -572,7 +581,7 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
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", dropper+" drops:"), coloredName))
}
if len(mob.Drops.Loot) > 0 {
@@ -594,9 +603,9 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
}
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", dropper+" drops:"), 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", dropper+" drops:"), coloredName))
}
}
}
@@ -684,7 +693,7 @@ func (g *Game) respawnMob(instanceID string) {
if p := sess.Player; p != nil && p.OptionBool("mob_spawn") {
mobLvl := mobCombatLevel(inst)
levelStr := g.levelColorize(sess, p.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl))
- sess.WriteLine(fmt.Sprintf("\n%s %s spawns in the area.", g.colorize(sess, "mob_name", mobDisplayName(inst, false)), levelStr))
+ sess.WriteLine(fmt.Sprintf("\n%s %s spawns in the area.", g.colorize(sess, "mob", mobDisplayName(inst, false)), levelStr))
}
}
}
diff --git a/internal/game/cmd_color.go b/internal/game/cmd_color.go
index 257c965..fe2fe62 100644
--- a/internal/game/cmd_color.go
+++ b/internal/game/cmd_color.go
@@ -139,12 +139,12 @@ var colorCategoryOrder = []string{
"direction",
"exit_direction",
"exit_name",
- "mob_name",
- "friendly_npc",
- "hostile_npc",
+ "protected_mob",
+ "mob",
+ "damage_dealt",
+ "damage_taken",
"damage",
- "enemy_hp",
- "character_hp",
+
"xp",
"level_up",
"item",
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index 505f7a6..936a6b0 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -81,7 +81,7 @@ 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]", g.colorize(sess, "enemy_hp", fmt.Sprint(m.HP)), m.MaxHP)
+ 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) {
@@ -98,13 +98,13 @@ func (g *Game) showRoomMobs(sess *net.Session, p *player.Player, room *world.Roo
if !m.Unique {
displayName = "A " + m.Name
}
- npcColor := "hostile_npc"
+ mobColor := "mob"
if m.Protected {
- npcColor = "friendly_npc"
+ mobColor = "protected_mob"
}
mobLevel := mobCombatLevel(m)
levelStr := g.levelColorize(sess, playerLevel, mobLevel, fmt.Sprintf("(level %d)", mobLevel))
- sess.WriteLine(fmt.Sprintf(" %s %s%s%s", g.colorize(sess, npcColor, displayName), levelStr, hp, desc))
+ sess.WriteLine(fmt.Sprintf(" %s %s%s%s", g.colorize(sess, mobColor, displayName), levelStr, hp, desc))
}
}
}
@@ -466,15 +466,15 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
}
}
if best != nil {
- npcColor := "hostile_npc"
+ mobColor := "mob"
if best.Protected {
- npcColor = "friendly_npc"
+ mobColor = "protected_mob"
}
mobLevel := mobCombatLevel(best)
levelStr := g.levelColorize(sess, p.CombatLevel(), mobLevel, fmt.Sprintf("(level %d)", mobLevel))
sess.WriteLines(
"",
- fmt.Sprintf("%s %s", g.colorize(sess, npcColor, best.Name), levelStr),
+ fmt.Sprintf("%s %s", g.colorize(sess, mobColor, best.Name), levelStr),
)
if best.IdleDescription != "" {
sess.WriteLine(fmt.Sprintf(" %s", best.IdleDescription))
diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go
index b49fd7f..523dcb9 100644
--- a/internal/game/cmd_score.go
+++ b/internal/game/cmd_score.go
@@ -20,7 +20,7 @@ func (g *Game) doScore(sess *net.Session) {
"",
fmt.Sprintf("Name: %s", g.colorize(sess, "player_name", p.Name)),
fmt.Sprintf("Combat Level: %s", color.Render(mode, color.Parse("230"), fmt.Sprint(p.CombatLevel()))),
- fmt.Sprintf("HP: %s/%s", g.colorize(sess, "character_hp", fmt.Sprint(p.HP)), color.Render(mode, color.Parse("230"), fmt.Sprint(p.MaxHP()))),
+ fmt.Sprintf("HP: %s/%s", color.Render(mode, color.Parse("84"), fmt.Sprint(p.HP)), color.Render(mode, color.Parse("230"), fmt.Sprint(p.MaxHP()))),
fmt.Sprintf("Battery: %s/%s", g.colorize(sess, "battery", fmt.Sprintf("%.1f", p.Battery)), color.Render(mode, color.Parse("230"), fmt.Sprintf("%.0f", p.MaxBattery()))),
fmt.Sprintf("Credits: %s", g.colorize(sess, "credits_pickup", fmt.Sprint(p.Credits))),
)
diff --git a/internal/game/cmd_trigger_combat.go b/internal/game/cmd_trigger_combat.go
index 444f80d..2086235 100644
--- a/internal/game/cmd_trigger_combat.go
+++ b/internal/game/cmd_trigger_combat.go
@@ -62,12 +62,12 @@ func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.Mob
g.AccountStore.SaveCharacter(p)
mobName := mobDisplayName(mob, true)
- prefix := fmt.Sprintf(" %s hits %s for %s damage.",
+ prefix := fmt.Sprintf("%s hits %s for %s damage.",
g.colorize(sess, "science_mod", mod.Name),
- g.colorize(sess, "mob_name", mobName),
- g.colorize(sess, "damage", fmt.Sprint(dmg)))
- hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "enemy_hp", fmt.Sprint(mob.HP)), mob.MaxHP)
- line := prefix + " " + hpPart
+ g.colorize(sess, "mob", mobName),
+ g.colorize(sess, "damage_dealt", fmt.Sprint(dmg)))
+ hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, mob.HP, mob.MaxHP), mob.HP, mob.MaxHP)
+ line := prefix + " " + hpSuffix
if p.OptionBool("xp_drops") && len(gains) > 0 {
var parts []string
for _, gain := range gains {
@@ -77,7 +77,7 @@ func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.Mob
}
sess.WriteLine(line)
} else {
- sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" %s fails to connect.", mod.Name)))
+ sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("%s fails to connect.", mod.Name)))
}
return true
}
diff --git a/internal/game/core_production.go b/internal/game/core_production.go
index 47a164c..b4101a9 100644
--- a/internal/game/core_production.go
+++ b/internal/game/core_production.go
@@ -286,7 +286,7 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
msg := g.resolveCraftMessage(sess, craft.FailMessage, info.FailMessage, craft, item.ID, nil, p.HasItem)
if msg != "" {
- sess.WriteLine(g.colorize(sess, "damage", msg))
+ sess.WriteLine(g.colorize(sess, "damage_taken", msg))
}
}
diff --git a/internal/game/sys_combat.go b/internal/game/sys_combat.go
index 702871c..81b2c83 100644
--- a/internal/game/sys_combat.go
+++ b/internal/game/sys_combat.go
@@ -105,7 +105,7 @@ func (g *Game) checkAggro(sess *net.Session) {
if !aggMob.Unique {
attacker = "The " + aggMob.Name
}
- sess.WriteLine(fmt.Sprintf("\n%s attacks you!", g.colorize(sess, "mob_name", attacker)))
+ sess.WriteLine(fmt.Sprintf("\n%s attacks you!", g.colorize(sess, "mob", attacker)))
g.startCombat(sess, p, aggMob)
return false
})
diff --git a/internal/game/tick.go b/internal/game/tick.go
index ec7eda9..1402b06 100644
--- a/internal/game/tick.go
+++ b/internal/game/tick.go
@@ -142,17 +142,17 @@ func (g *Game) WanderTick() {
if p.RoomID == m.fromRoom && p.OptionBool("mob_leave") {
if m.level > 0 {
levelStr := g.levelColorize(sess, p.CombatLevel(), m.level, fmt.Sprintf("(level %d)", m.level))
- sess.WriteLine(fmt.Sprintf("\n%s %s leaves.", g.colorize(sess, "mob_name", m.name), levelStr))
+ sess.WriteLine(fmt.Sprintf("\n%s %s leaves.", g.colorize(sess, "mob", m.name), levelStr))
} else {
- sess.WriteLine(fmt.Sprintf("\n%s moves away.", g.colorize(sess, "mob_name", m.name)))
+ sess.WriteLine(fmt.Sprintf("\n%s moves away.", g.colorize(sess, "mob", m.name)))
}
}
if p.RoomID == m.toRoom && p.OptionBool("mob_enter") {
if m.level > 0 {
levelStr := g.levelColorize(sess, p.CombatLevel(), m.level, fmt.Sprintf("(level %d)", m.level))
- sess.WriteLine(fmt.Sprintf("\n%s %s enters.", g.colorize(sess, "mob_name", m.name), levelStr))
+ sess.WriteLine(fmt.Sprintf("\n%s %s enters.", g.colorize(sess, "mob", m.name), levelStr))
} else {
- sess.WriteLine(fmt.Sprintf("\n%s drifts in.", g.colorize(sess, "mob_name", m.name)))
+ sess.WriteLine(fmt.Sprintf("\n%s drifts in.", g.colorize(sess, "mob", m.name)))
}
}
}
diff --git a/internal/game/ui_combat.go b/internal/game/ui_combat.go
new file mode 100644
index 0000000..56de7e8
--- /dev/null
+++ b/internal/game/ui_combat.go
@@ -0,0 +1,59 @@
+package game
+
+import (
+ "math"
+ "strings"
+
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/net"
+)
+
+func (g *Game) hpBar(sess *net.Session, current, max int) string {
+ if max <= 0 {
+ max = 1
+ }
+ if current < 0 {
+ current = 0
+ }
+ if current > max {
+ current = max
+ }
+
+ pct := float64(current) / float64(max) * 100.0
+ filled := int(math.Round(pct / 10.0))
+ if filled < 0 {
+ filled = 0
+ }
+ if filled > 10 {
+ filled = 10
+ }
+
+ mode := g.colorMode(sess)
+
+ var fgColor int
+ switch {
+ case pct >= 70:
+ fgColor = 82
+ case pct >= 40:
+ fgColor = 220
+ default:
+ fgColor = 160
+ }
+
+ fillSpec := color.ColorSpec{Fg: fgColor}
+ emptySpec := color.ColorSpec{Fg: 238, Dim: true}
+
+ fillChar := "█"
+ emptyChar := "░"
+ if sess.Player != nil && !sess.Player.OptionBool("unicode") {
+ fillChar = "#"
+ emptyChar = "."
+ }
+
+ var sb strings.Builder
+ sb.WriteString("[")
+ sb.WriteString(color.Render(mode, fillSpec, strings.Repeat(fillChar, filled)))
+ sb.WriteString(color.Render(mode, emptySpec, strings.Repeat(emptyChar, 10-filled)))
+ sb.WriteString("]")
+ return sb.String()
+}
diff --git a/internal/game/ui_help.go b/internal/game/ui_help.go
index 151d1ba..ee8ad97 100644
--- a/internal/game/ui_help.go
+++ b/internal/game/ui_help.go
@@ -70,7 +70,7 @@ var commandList = []cmdEntry{
{"steal", "Active", "Steal from mobs (Thieving)"},
{"stoke", "Active", "Add logs to a fire"},
{"style", "Instant", "Change combat style"},
- {"talk / speak / ask", "Active", "Talk to NPCs"},
+ {"talk / speak / ask", "Active", "Talk to mobs"},
{"tech", "Instant", "Toggle technology abilities"},
{"trigger", "Active", "Trigger a science module"},
{"unalias", "Instant", "Remove command shortcuts"},
diff --git a/worldbuilding_guide/README.md b/worldbuilding_guide/README.md
deleted file mode 100644
index 0bea82c..0000000
--- a/worldbuilding_guide/README.md
+++ /dev/null
@@ -1,26 +0,0 @@
-# World Building Guide
-
-The House of Icarus is data-driven. Everything — rooms, items, mobs, objects, behaviors, drop tables, and recipes — is defined in YAML files under `data/`. No code changes needed to build a world.
-
-## Files
-
-| Document | Covers |
-|----------|--------|
-| [Rooms](rooms.md) | Room definitions, exits (simple and conditional), spawns, placed objects and mobs, on_enter scripts |
-| [Items](items.md) | Item definitions, stats, equip slots, weapon types, tool types, firemaking, food/healing |
-| [Mobs](mobs.md) | Mob definitions, combat stats, drops, behavior, idle descriptions |
-| [Objects](objects.md) | Interactive objects, behaviors, hidden objects, in-room descriptions |
-| [Behaviors](behaviors.md) | Gather, use, talk behaviors with full YAML reference |
-| [Recipes](recipes.md) | Recipe system for cooking, crafting, smithing — station and item-on-item |
-| [Conditions](conditions.md) | All condition types: flag, player_flag, has_item, all_of, any_of, not |
-| [Drop Tables](drops.md) | Shared weighted drop tables for mob loot and search tables |
-| [State](state.md) | World flags vs player flags — global vs per-character state |
-| [Doors](doors.md) | Door examples with world flags and player flags |
-| [Quests](quests.md) | Complete multi-room quest example with talk behaviors |
-| [Wandering Objects](wandering_objects.md) | Objects that teleport between rooms on a timer |
-| [Wandering Mobs](wandering_mobs.md) | Mobs that wander through legal exits |
-| [Search](search.md) | The search command and searchable items |
-| [Hidden Objects](hidden_objects.md) | Hidden interactive objects |
-| [Bank](bank.md) | Bank system — deposit, withdraw, browse, bank booth placement |
-| [Construction](construction.md) | Construction skill — planks, furniture, houses, sawmill, Plank Make |
-| [Tips](tips.md) | Practical tips for building a world |
diff --git a/worldbuilding_guide/behaviors.md b/worldbuilding_guide/behaviors.md
index 1934f5b..08dc2d1 100644
--- a/worldbuilding_guide/behaviors.md
+++ b/worldbuilding_guide/behaviors.md
@@ -1,8 +1,7 @@
## Behaviors
Behaviors are **inline configs** placed directly inside object YAML (`gather:`, `talk:`,
-`use:`) or mob YAML (`talk:`). There is no separate `data/behaviors/`
-directory. Each section below shows the keys you can use under each behavior type.
+`use:`) 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/construction.md b/worldbuilding_guide/construction.md
index 63249b3..2c4b7ce 100644
--- a/worldbuilding_guide/construction.md
+++ b/worldbuilding_guide/construction.md
@@ -212,7 +212,7 @@ This enables automatic support through the unified production system (`advancePr
| `data/items/saw.yaml` | Saw tool |
| `data/items/planks.yaml` - `mahogany_planks.yaml` | Plank items (4 tiers) |
| `data/items/wooden_shelf.yaml` - `mahogany_table.yaml` | Furniture items (7 items) |
-| `data/recipes/construct_planks.yaml` - `construct_mahogany_table.yaml` | Construction recipes (11 recipes) |
+| Item YAMLs with `craft:` blocks (planks, furniture) | Construction recipes — inline on output item YAML |
| `data/mobs/estate_broker.yaml` | Estate broker mob (talk inline) |
| `data/mobs/sawmill_operator.yaml` | Sawmill operator mob (talk inline) |
| `data/rooms/16.yaml` | Construction Site (updated) |
diff --git a/worldbuilding_guide/courses.md b/worldbuilding_guide/courses.md
new file mode 100644
index 0000000..491e2bd
--- /dev/null
+++ b/worldbuilding_guide/courses.md
@@ -0,0 +1,119 @@
+## Agility Courses
+
+Agility courses are defined in `data/courses/<id>.yaml`. Each course defines a sequence of obstacle rooms, each with a specific verb the player must type to advance. Obstacle rooms themselves are standard room YAML files (typically in `data/rooms/agility/`).
+
+### Course YAML
+
+```yaml
+id: "vent_shaft"
+name: "Ventilation Shaft Course"
+required_level: 1
+start_room: 200
+completion_xp: 40
+obstacles:
+ - room_id: 201
+ verb: scramble
+ ticks_per_phase: 2
+ xp: 8
+ fail_damage: [1, 2]
+ messages:
+ - "You approach the corroded ventilation wall..."
+ - "You find footholds in the rusted panels and begin to climb..."
+ - "You scramble up the wall and haul yourself onto the ledge!"
+ - room_id: 202
+ verb: balance
+ ticks_per_phase: 2
+ xp: 8
+ fail_damage: [1, 2]
+ messages:
+ - "You step onto the narrow coolant pipe..."
+ - "Arms outstretched, you carefully place one foot in front of the other..."
+ - "You reach the other side of the pipe and step onto solid ground!"
+```
+
+### CourseConfig Fields
+
+| Field | Type | Description |
+| ---------------- | ------------- | ---------------------------------------------------- |
+| `id` | string | Unique course identifier |
+| `name` | string | Display name shown to players |
+| `required_level` | int | Minimum Agility level to attempt the course |
+| `start_room` | int | Hub room — teleported here on fail or lap completion |
+| `completion_xp` | int | Bonus XP awarded when a full lap is completed |
+| `obstacles` | []ObstacleDef | Ordered list of obstacles |
+
+### ObstacleDef Fields
+
+| Field | Type | Description |
+| ----------------- | -------- | --------------------------------------------------------------------------- |
+| `room_id` | int | Room ID for this obstacle |
+| `verb` | string | Command the player types to attempt it (e.g. `scramble`, `jump`, `climb`) |
+| `ticks_per_phase` | float64 | Ticks between each phase message (supports fractional via `engine.ToTicks`) |
+| `xp` | int | XP awarded for successfully completing this single obstacle |
+| `fail_damage` | [2]int | `[min, max]` damage on failure (HP clamped to minimum 1 — cannot kill) |
+| `messages` | []string | Exactly 3 strings for the 3-phase advancement system |
+
+### 3-Phase Message System
+
+Each obstacle advances through 3 phases, printing one message per phase:
+
+```
+Phase 0 (start): messages[0] printed immediately
+Phase 1 (middle): messages[1] printed — FAILURE CHECK happens here
+Phase 2 (end): messages[2] printed — XP awarded, teleport to next room
+```
+
+Failure only occurs at phase 1. Success chance is based on Agility level:
+- At required level: 70% success (30% fail)
+- Each level above reduces fail chance by 1% (down to minimum 5%)
+- Fail chance capped at 60%
+
+On failure, the player takes random damage in `[fail_damage[0], fail_damage[1]]`, is teleported back to `start_room`, and must restart the course.
+
+### Lap Counting
+
+Completing all obstacles in a course increments a lap counter stored as a player flag (`agility_laps_<course_id>`). The `completion_xp` bonus is awarded on the final obstacle only.
+
+### Obstacle Room YAML
+
+Each obstacle room should have an `on_enter` message telling the player which verb to use, and a `down` exit back to the course hub:
+
+```yaml
+id: 201
+name: "Ventilation Shaft - Corroded Wall"
+description: "A towering wall of corroded ventilation panels..."
+on_enter:
+ - message: "Type 'scramble' to climb the wall."
+exits:
+ down:
+ room: 200
+ blocked_message: ""
+```
+
+The hub room (e.g. room 200) links to the first obstacle of each course:
+
+```yaml
+id: 200
+name: "Agility Training Grounds"
+exits:
+ south: 17
+ north: 201 # Vent Shaft (level 1)
+ east: 210 # Rooftop (level 20)
+ west: 220 # Reactor (level 50)
+```
+
+### Supported Obstacle Verbs
+
+| Verb | Gerund (display) |
+| ---------- | ---------------- |
+| `scramble` | scrambling |
+| `jump` | jumping |
+| `swing` | swinging |
+| `balance` | balancing |
+| `climb` | climbing |
+| `crawl` | crawling |
+| `vault` | vaulting |
+| `leap` | leaping |
+| `slide` | sliding |
+
+Obstacle verbs are auto-registered at startup from all course YAML files. No changes to `cmd_registry.go` needed when adding new courses — any verb in a `data/courses/` file will work.
diff --git a/worldbuilding_guide/hacking.md b/worldbuilding_guide/hacking.md
index 6f9bf9a..8927b3e 100644
--- a/worldbuilding_guide/hacking.md
+++ b/worldbuilding_guide/hacking.md
@@ -46,26 +46,4 @@ type XPBonuser interface {
}
```
-### Agility Courses
-Agility courses are defined in `data/courses/<id>.yaml`. Each course defines a sequence of obstacle rooms with specific verbs.
-
-```yaml
-id: "vent_shaft"
-name: "Ventilation Shaft Course"
-required_level: 1
-start_room: 200
-completion_xp: 40
-obstacles:
- - room_id: 201
- verb: scramble
- ticks_per_phase: 2
- xp: 8
- fail_damage: [1, 2]
- messages:
- - "You approach the corroded ventilation wall..."
- - "You find footholds in the rusted panels and begin to climb..."
- - "You scramble up the wall and haul yourself onto the ledge!"
-```
-
-Each obstacle room should have an `on_enter` message telling the player which verb to use, and a `down` exit back to the course hub.
diff --git a/worldbuilding_guide/index.md b/worldbuilding_guide/index.md
index 83b3782..e79ee1b 100644
--- a/worldbuilding_guide/index.md
+++ b/worldbuilding_guide/index.md
@@ -7,7 +7,7 @@
| A mob (NPC, monster) | `data/mobs/<id>.yaml` |
| An interactive object (rock, lever, door) | `data/objects/<id>.yaml` |
| A shared drop table | `data/drops/<id>.yaml` |
-| A crafting recipe | `data/recipes/<id>.yaml` |
+| A crafting recipe | In the output item's YAML under a `craft:` block |
| A help topic | `data/help/<id>.yaml` |
| A science module | `data/modules/<id>.yaml` |
| An agility course | `data/courses/<id>.yaml` |