aboutsummaryrefslogtreecommitdiff
path: root/skill_plans/agility.md
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-19 18:33:43 -0400
committerhistoria <[not public]>2026-06-19 18:33:43 -0400
commit2bec24859af8f03c80a0a58e3240519942450167 (patch)
treedcb8baa5591fdeca51e179a63a08d46b014cb85c /skill_plans/agility.md
parent0ea4eec5dd2122c6704216971eb1249276297867 (diff)
downloadthehouseoficarus-2bec24859af8f03c80a0a58e3240519942450167.tar.gz
readme
Diffstat (limited to 'skill_plans/agility.md')
-rw-r--r--skill_plans/agility.md1814
1 files changed, 0 insertions, 1814 deletions
diff --git a/skill_plans/agility.md b/skill_plans/agility.md
deleted file mode 100644
index a7c9672..0000000
--- a/skill_plans/agility.md
+++ /dev/null
@@ -1,1814 +0,0 @@
-# Agility Skill Implementation Plan
-
-## 1. Overview
-
-Agility courses are sequences of special rooms where players type obstacle-specific commands (scramble, jump, swing, balance, climb, crawl, vault, leap, slide) to traverse obstacles for Agility XP. Each obstacle is a multi-tick action with sequenced flavor text messages. After the last obstacle in a course, the player receives a large course-completion XP bonus and is teleported back to the course hub. The game tracks per-character lap counts for each course using player flags.
-
-**Existing state:**
-- `Agility` skill already defined in `internal/player/player.go` (line 27) with abbreviation `"agl"` (line 58)
-- Agility level is already used by the `walk` command to cap multi-step movement distance (`cmd_walk.go:81`)
-- Graceful equipment and Cape of Agility already reference agility for movement speed
-- Room 17 ("Bonfire") is adjacent to the agility area and currently has description "This area is not yet accessible"
-- Highest existing room ID is 149; agility rooms will use 200+
-
-**Design goals:**
-- Data-driven via YAML course definitions — add new courses without code changes
-- Obstacle verbs are a fixed vocabulary (9 verbs) but which verb applies to which room is defined in course YAML
-- All obstacle verbs share a single handler `doObstacle()` with a single advance function `advanceObstacle()`
-- Fail mechanic: chance to fall based on level vs required level (take damage, teleport to course start)
-- Lap tracking via player flags, displayed on course completion
-
-## 2. Architecture
-
-### Course System Overview
-
-Each agility course is a sequence of rooms. Each room contains one obstacle. The player types the obstacle's verb (e.g. `scramble`) to start a multi-tick action. On completion, the player is moved to the next room. After the final obstacle, the player receives course completion XP and is teleported back to the course hub room.
-
-```
-Course Hub (room 200)
- ├── Vent Course Start (room 201) ──scramble──> room 202 ──balance──> room 203 ──jump──> room 204 ──crawl──> room 205 ──slide──> [completion: teleport to 200]
- ├── Rooftop Course Start (room 210) ──climb──> ... ──leap──> [completion: teleport to 200]
- └── Reactor Course Start (room 220) ──climb──> ... ──leap──> [completion: teleport to 200]
-```
-
-### Course Definition (YAML)
-
-Courses are defined in `data/courses/<id>.yaml`. A new `CourseStore` loads them on first access (same pattern as `action.Store`). On load, it builds two lookup maps:
-
-1. **`roomToObstacle`** — `map[int]*ObstacleInfo` — given a room ID, returns the obstacle info (course ID, obstacle index, verb, messages, ticks, XP, fail chance, next room)
-2. **`obstacleVerbs`** — `map[string]bool` — the set of all verbs used across all courses (for `classifyCommand`)
-
-These maps are built once when the first course is loaded and rebuilt if courses are reloaded.
-
-### ObstacleInfo Struct
-
-```go
-type ObstacleInfo struct {
- CourseID string
- CourseName string
- ObstacleIndex int
- TotalObstacles int
- Verb string
- Messages []string // sequenced messages, one per phase
- TicksPerPhase float64 // ticks between messages
- ObstacleXP int // XP awarded on obstacle completion
- CompletionXP int // only set on last obstacle (course completion bonus)
- FailChance float64 // base fail rate (0.0 - 1.0) before level adjustment
- FailDamage [2]int // [min, max] damage on fail
- NextRoom int // room to move to on success (0 on last obstacle = teleport to start)
- StartRoom int // course hub room (for fail teleport and completion teleport)
- RequiredLevel int // course required agility level
-}
-```
-
-### Command Flow
-
-```
-Player types "scramble"
- → classifyCommand("scramble") checks obstacleVerbs set → ClassActive
- → queued as active command
- → executeCommand routes to doObstacle()
- → doObstacle() looks up roomToObstacle[player.RoomID]
- → validates verb matches obstacle's verb
- → validates agility level >= required
- → checks not in combat
- → calls startObstacle()
- → sets Action with type "obstacle", phases in Data map
- → AdvanceActions calls advanceObstacle() each tick
- → phase 0: first message
- → phase 1: second message + fail check
- → phase 2: completion message, move to next room, award XP
- → if last obstacle: award completion XP, increment lap counter, teleport to start
-```
-
-### State Tracking
-
-- **ActionType**: `ActionTraversing` (new constant = `"traversing"`)
-- **Action.Type**: `"obstacle"`
-- **Action.Data map keys**:
- - `"course_id"` (string): course identifier
- - `"phase"` (int): current message phase (0, 1, 2)
- - `"obstacle_index"` (int): index in course sequence
- - `"next_room"` (int): room to move to on completion
- - `"start_room"` (int): course start for fail teleport
- - `"obstacle_xp"` (int): XP for this obstacle
- - `"completion_xp"` (int): bonus XP if last obstacle (0 otherwise)
- - `"fail_chance"` (float64): adjusted fail probability
- - `"fail_damage_min"` (int): min damage on fail
- - `"fail_damage_max"` (int): max damage on fail
- - `"messages"` ([]any): message strings for each phase
- - `"ticks_per_phase"` (float64): ticks between phases
- - `"total_obstacles"` (int): total obstacles in course
- - `"required_level"` (int): course required level
-
-## 3. Commands
-
-### Obstacle Verbs (all Active)
-
-| Verb | Description |
-|------|-------------|
-| `scramble` | Scramble up a wall or surface |
-| `jump` | Jump across a gap |
-| `swing` | Swing on a cable, chain, or rope |
-| `balance` | Walk across a narrow beam or pipe |
-| `climb` | Climb a wall, ladder, or scaffolding |
-| `crawl` | Crawl through a tight space |
-| `vault` | Vault over a railing or barrier |
-| `leap` | Make a running leap across a chasm |
-| `slide` | Slide down a chute or surface |
-
-All verbs are classified as `ClassActive` and routed to `doObstacle()`. If the player's current room is not an obstacle room for that verb, the command is rejected with "You can't do that here."
-
-## 4. Course Definition System
-
-### YAML Format: `data/courses/<id>.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!"
- # ... etc
-```
-
-### Go Struct: `CourseConfig`
-
-```go
-type CourseConfig struct {
- ID string `yaml:"id"`
- Name string `yaml:"name"`
- RequiredLevel int `yaml:"required_level"`
- StartRoom int `yaml:"start_room"`
- CompletionXP int `yaml:"completion_xp"`
- Obstacles []ObstacleDef `yaml:"obstacles"`
-}
-
-type ObstacleDef struct {
- RoomID int `yaml:"room_id"`
- Verb string `yaml:"verb"`
- TicksPerPhase float64 `yaml:"ticks_per_phase"`
- XP int `yaml:"xp"`
- FailDamage [2]int `yaml:"fail_damage"`
- Messages []string `yaml:"messages"`
-}
-```
-
-### CourseStore
-
-```go
-type CourseStore struct {
- dataDir string
- mu sync.Mutex
- courses map[string]*CourseConfig
- roomToObstacle map[int]*ObstacleInfo
- obstacleVerbs map[string]bool
- loaded bool
-}
-```
-
-Located in `internal/game/course.go`. The store is initialized in `Game.New()` alongside the other stores. On first access (or on `loadAll()`), it reads all YAML files from `data/courses/`, parses them, and builds the lookup maps.
-
-`obstacleVerbs` is a package-level variable (not on the struct) so `classifyCommand()` can access it without a receiver:
-
-```go
-var obstacleVerbs = map[string]bool{}
-
-func classifyCommand(cmd string) CommandClass {
- // ... existing cases ...
- if obstacleVerbs[cmd] {
- return ClassActive
- }
- return ClassUnknown
-}
-```
-
-The `CourseStore.loadAll()` method populates this package-level map after loading courses.
-
-## 5. New Files to Create
-
-### Go Files
-
-| File | Purpose |
-|------|---------|
-| `internal/game/course.go` | `CourseStore` struct, YAML loading, `CourseConfig`/`ObstacleDef` structs, `ObstacleInfo` struct, lookup map building, `obstacleVerbs` package var |
-| `internal/game/cmd_agility.go` | `doObstacle()` command handler — validates room, verb, level; calls `startObstacle()` |
-| `internal/game/action_agility.go` | `startObstacle()` and `advanceObstacle()` — multi-phase obstacle action lifecycle |
-
-### YAML Data Files
-
-| File | Purpose |
-|------|---------|
-| `data/courses/vent_shaft.yaml` | Ventilation Shaft Course definition (Level 1) |
-| `data/courses/rooftop.yaml` | Rooftop Course definition (Level 20) |
-| `data/courses/reactor.yaml` | Reactor Course definition (Level 50) |
-| `data/rooms/200.yaml` | Agility Training Grounds (course hub) |
-| `data/rooms/201.yaml` | Vent Shaft: Corroded Wall |
-| `data/rooms/202.yaml` | Vent Shaft: Coolant Pipe Walkway |
-| `data/rooms/203.yaml` | Vent Shaft: Shaft Gap |
-| `data/rooms/204.yaml` | Vent Shaft: Narrow Vent |
-| `data/rooms/205.yaml` | Vent Shaft: Emergency Chute |
-| `data/rooms/210.yaml` | Rooftop: Hab Block Wall |
-| `data/rooms/211.yaml` | Rooftop: Building Gap |
-| `data/rooms/212.yaml` | Rooftop: Cable Array |
-| `data/rooms/213.yaml` | Rooftop: Narrow Beam |
-| `data/rooms/214.yaml` | Rooftop: Maintenance Railing |
-| `data/rooms/215.yaml` | Rooftop: Rooftop Edge |
-| `data/rooms/220.yaml` | Reactor: Scaffolding |
-| `data/rooms/221.yaml` | Reactor: Coolant Chain |
-| `data/rooms/222.yaml` | Reactor: Steam Pipe |
-| `data/rooms/223.yaml` | Reactor: Platform Gap |
-| `data/rooms/224.yaml` | Reactor: Service Conduit |
-| `data/rooms/225.yaml` | Reactor: Radiation Barrier |
-| `data/rooms/226.yaml` | Reactor: Reactor Chasm |
-| `data/help/agility.yaml` | Help topic for Agility |
-
-## 6. Code Changes to Existing Files
-
-### `internal/game/game.go`
-
-**1. Add CourseStore to Game struct** (after `RecipeStore` field, line ~43):
-```go
-type Game struct {
- // ... existing fields ...
- RecipeStore *action.RecipeStore
- CourseStore *CourseStore // NEW
- // ...
-}
-```
-
-**2. Initialize CourseStore in `New()`** (after `RecipeStore` init, line ~67):
-```go
-func New(dataDir string, colorConfig *config.ColorsConfig) *Game {
- g := &Game{
- // ... existing ...
- RecipeStore: action.NewRecipeStore(dataDir),
- CourseStore: NewCourseStore(dataDir), // NEW
- // ...
- }
- g.CourseStore.LoadAll() // populates obstacleVerbs package var
- return g
-}
-```
-
-**3. Update `classifyCommand()`** (at line ~155, before the `return ClassUnknown`):
-```go
-func classifyCommand(cmd string) CommandClass {
- // ... existing switch ...
- if _, ok := verbAliases[cmd]; ok {
- return ClassActive
- }
- if obstacleVerbs[cmd] { // NEW
- return ClassActive // NEW
- } // NEW
- return ClassUnknown
-}
-```
-
-**4. Update `executeCommand()`** (add new case before `default`, around line ~427):
-```go
- case "walk":
- g.doWalk(sess, args)
- return
- // NEW: obstacle verbs handled dynamically
- default:
- if obstacleVerbs[cmd] {
- g.doObstacle(sess, cmd)
- return
- }
- if a, ok := verbAliases[cmd]; ok {
- // ... existing verbAliases handling ...
-```
-
-The obstacle verb check must come before the existing `verbAliases` check in the `default` case. Restructure the `default` block:
-
-```go
- default:
- if obstacleVerbs[cmd] {
- g.doObstacle(sess, cmd)
- return
- }
- if a, ok := verbAliases[cmd]; ok {
- g.CancelAction(p)
- switch a {
- case "gather", "toggle":
- // ... existing ...
- case "talk":
- // ... existing ...
- }
- } else {
- sess.WriteLine("Unknown command.")
- }
-```
-
-### `internal/game/action_state.go`
-
-**Add `ActionTraversing` constant** (after `ActionEating`, line ~24):
-```go
-const (
- // ... existing ...
- ActionEating ActionType = "eating"
- ActionTraversing ActionType = "traversing" // NEW
-)
-```
-
-**Add description case in `Description()`** (after `ActionEating` case, line ~75):
-```go
- case ActionTraversing:
- return a.Verb + " across " + a.TargetName
-```
-
-### `internal/game/action.go`
-
-**Add `"obstacle"` to `AdvanceActions()`** (in the switch at line ~225):
-```go
- switch p.Action.Type {
- case "gather":
- g.advanceGather(sess, p)
- case "use":
- g.advanceUse(sess, p)
- case "burn":
- g.advanceBurn(sess, p)
- case "stoke":
- g.advanceStoke(sess, p)
- case "search":
- g.advanceSearch(sess, p)
- case "obstacle": // NEW
- g.advanceObstacle(sess, p) // NEW
- default:
- if productionActionTypes[p.Action.Type] {
- g.advanceProduction(sess, p)
- }
- }
-```
-
-**Add `ActionTraversing` to ProcessQueuedCommands stale-clear exclusion list** (line ~472):
-```go
- case ActionGathering, ActionCombating, ActionUsing, ActionTalking,
- ActionToggling, ActionBurning, ActionStoking, ActionResting, ActionWalking, ActionProducing,
- ActionTraversing: // NEW
-```
-
-### `data/rooms/17.yaml`
-
-Add a north exit to the agility training grounds:
-```yaml
-id: 17
-name: "Bonfire"
-description: "A ring of stones surrounds a bed of ash. A few unlit logs wait nearby. A narrow passage leads north toward what sounds like echoing footsteps and clanging metal."
-exits:
- east: 18
- west: 16
- north: 200
-```
-
-### `cmd/mud/main.go`
-
-No changes needed. The `AdvanceActions()` call already handles the new `"obstacle"` action type via the existing tick subscriber.
-
-## 7. Courses
-
-### Course 1: Ventilation Shaft Course (Level 1)
-
-**Theme:** Players crawl through the asteroid's ventilation infrastructure. Rusted panels, leaking coolant pipes, dark shafts.
-
-**`data/courses/vent_shaft.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!"
- - room_id: 203
- verb: jump
- ticks_per_phase: 2
- xp: 7
- fail_damage: [1, 2]
- messages:
- - "You peer across the dark gap in the shaft floor..."
- - "You take a few steps back, then sprint toward the edge..."
- - "You leap across the gap and land safely on the other side!"
- - room_id: 204
- verb: crawl
- ticks_per_phase: 3
- xp: 7
- fail_damage: [1, 1]
- messages:
- - "You drop to your hands and knees at the narrow vent opening..."
- - "You squeeze through the tight passage, metal scraping against your back..."
- - "You emerge from the vent and stand up, brushing dust from your clothes!"
- - room_id: 205
- verb: slide
- ticks_per_phase: 2
- xp: 10
- fail_damage: [1, 2]
- messages:
- - "You sit at the top of the emergency chute..."
- - "You push off and accelerate down the smooth metal surface..."
- - "You shoot out the bottom of the chute and land on your feet!"
-```
-
-### Course 2: Rooftop Course (Level 20)
-
-**Theme:** Players traverse the rooftops of the hab blocks. Cables, narrow beams, gaps between buildings.
-
-**`data/courses/rooftop.yaml`:**
-```yaml
-id: "rooftop"
-name: "Rooftop Course"
-required_level: 20
-start_room: 200
-completion_xp: 120
-obstacles:
- - room_id: 210
- verb: climb
- ticks_per_phase: 2
- xp: 15
- fail_damage: [2, 4]
- messages:
- - "You grab the rough permacrete wall and begin to climb..."
- - "Your fingers find cracks and ledges as you pull yourself higher..."
- - "You heave yourself over the edge and onto the rooftop!"
- - room_id: 211
- verb: jump
- ticks_per_phase: 2
- xp: 22
- fail_damage: [2, 4]
- messages:
- - "You eye the gap between this building and the next..."
- - "You sprint toward the edge, boots pounding on the rooftop..."
- - "You launch yourself across and roll to a stop on the other roof!"
- - room_id: 212
- verb: swing
- ticks_per_phase: 3
- xp: 20
- fail_damage: [2, 5]
- messages:
- - "You grab the dangling power cable with both hands..."
- - "You kick off the ledge and swing out over the street far below..."
- - "You release at the peak of the arc and land on the opposite platform!"
- - room_id: 213
- verb: balance
- ticks_per_phase: 3
- xp: 18
- fail_damage: [2, 4]
- messages:
- - "You step onto the narrow structural beam spanning the alley..."
- - "The beam sways slightly as you inch forward, arms out for balance..."
- - "You reach the far side and step gratefully onto solid rooftop!"
- - room_id: 214
- verb: vault
- ticks_per_phase: 2
- xp: 15
- fail_damage: [2, 3]
- messages:
- - "You run toward the maintenance railing at full speed..."
- - "You plant one hand on the rail and swing your legs over..."
- - "You clear the railing and land in a crouch on the other side!"
- - room_id: 215
- verb: leap
- ticks_per_phase: 2
- xp: 20
- fail_damage: [3, 5]
- messages:
- - "You stare at the final gap — the widest yet..."
- - "You take a deep breath, charge forward, and throw yourself into the air..."
- - "You barely catch the far ledge, pull yourself up, and stand triumphant!"
-```
-
-### Course 3: Reactor Course (Level 50)
-
-**Theme:** Players navigate the hazardous environment around the asteroid's reactor core. Scaffolding, chains, steam, radiation barriers.
-
-**`data/courses/reactor.yaml`:**
-```yaml
-id: "reactor"
-name: "Reactor Course"
-required_level: 50
-start_room: 200
-completion_xp: 350
-obstacles:
- - room_id: 220
- verb: climb
- ticks_per_phase: 3
- xp: 40
- fail_damage: [3, 6]
- messages:
- - "You grip the reactor scaffolding and begin your ascent..."
- - "The metal groans under your weight as you climb higher, heat radiating from below..."
- - "You pull yourself onto the upper platform, the reactor humming beneath you!"
- - room_id: 221
- verb: swing
- ticks_per_phase: 3
- xp: 45
- fail_damage: [3, 7]
- messages:
- - "You seize the heavy coolant chain dangling above the reactor pit..."
- - "You swing out over the glowing core, heat blasting your face..."
- - "You release and land hard on the maintenance gantry, chain clanging behind you!"
- - room_id: 222
- verb: balance
- ticks_per_phase: 3
- xp: 50
- fail_damage: [4, 7]
- messages:
- - "You step onto the massive steam pipe spanning the reactor chamber..."
- - "Steam jets hiss from valves on either side as you shuffle along the pipe..."
- - "You reach the junction platform and hop off the pipe with relief!"
- - room_id: 223
- verb: jump
- ticks_per_phase: 2
- xp: 45
- fail_damage: [3, 6]
- messages:
- - "A section of the reactor platform is missing, leaving a gaping void..."
- - "You back up, sprint, and leap with everything you've got..."
- - "You slam into the far platform and roll to safety!"
- - room_id: 224
- verb: crawl
- ticks_per_phase: 3
- xp: 40
- fail_damage: [3, 5]
- messages:
- - "You squeeze into the narrow service conduit, radiation warnings plastered on every surface..."
- - "You drag yourself through on your elbows, sparks showering from damaged wiring above..."
- - "You tumble out the far end and gulp down clean air!"
- - room_id: 225
- verb: vault
- ticks_per_phase: 2
- xp: 50
- fail_damage: [4, 7]
- messages:
- - "A radiation containment barrier blocks the path, humming with energy..."
- - "You time the pulse cycle, sprint at the barrier, and throw yourself over it..."
- - "You clear the barrier and land on the other side, heart pounding!"
- - room_id: 226
- verb: leap
- ticks_per_phase: 3
- xp: 55
- fail_damage: [4, 8]
- messages:
- - "The final obstacle: a massive chasm over the reactor coolant pool..."
- - "You sprint along the narrow runway, the abyss yawning below..."
- - "You launch into the void, arms windmilling, and crash onto the far platform!"
-```
-
-## 8. Obstacle Action Lifecycle
-
-### `startObstacle()` in `internal/game/action_agility.go`
-
-```go
-func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *ObstacleInfo) {
- failChance := g.calcFailChance(p, info)
-
- msgs := make([]any, len(info.Messages))
- for i, m := range info.Messages {
- msgs[i] = m
- }
-
- p.Action = &action.Action{
- Type: "obstacle",
- TargetID: info.CourseID,
- TargetName: info.CourseName,
- WaitLeft: 0,
- Data: map[string]any{
- "course_id": info.CourseID,
- "phase": 0,
- "obstacle_index": info.ObstacleIndex,
- "total_obstacles": info.TotalObstacles,
- "next_room": info.NextRoom,
- "start_room": info.StartRoom,
- "obstacle_xp": info.ObstacleXP,
- "completion_xp": info.CompletionXP,
- "fail_chance": failChance,
- "fail_damage_min": info.FailDamage[0],
- "fail_damage_max": info.FailDamage[1],
- "messages": msgs,
- "ticks_per_phase": info.TicksPerPhase,
- "required_level": info.RequiredLevel,
- },
- }
-
- p.ActionState = &ActionState{
- Type: ActionTraversing,
- Verb: info.Verb + "ing",
- TargetName: info.CourseName,
- }
-}
-```
-
-### `advanceObstacle()` in `internal/game/action_agility.go`
-
-```go
-func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) {
- data := p.Action.Data
- phase := data["phase"].(int)
- messages := data["messages"].([]any)
- ticksPerPhase := data["ticks_per_phase"].(float64)
- courseID := data["course_id"].(string)
-
- if phase >= len(messages) {
- g.CancelAction(p)
- return
- }
-
- msg := messages[phase].(string)
- sess.WriteLine(g.colorize(sess, "agility", msg))
-
- if phase == 1 {
- failChance := data["fail_chance"].(float64)
- if rand.Float64() < failChance {
- g.obstacleFail(sess, p, data)
- return
- }
- }
-
- nextPhase := phase + 1
-
- if nextPhase >= len(messages) {
- obstacleXP := data["obstacle_xp"].(int)
- completionXP := data["completion_xp"].(int)
- nextRoom := data["next_room"].(int)
- startRoom := data["start_room"].(int)
- obstacleIndex := data["obstacle_index"].(int)
- totalObstacles := data["total_obstacles"].(int)
-
- if obstacleXP > 0 {
- if newLevel := p.AddSkillXP(player.Agility, obstacleXP); newLevel > 0 {
- sess.WriteLine(g.colorize(sess, "level_up",
- fmt.Sprintf("*** You are now level %d agility! ***", newLevel)))
- }
- if p.OptionBool("xp_drops") {
- sess.WriteLine(g.colorize(sess, "xp",
- fmt.Sprintf("(+%dxp %s)", obstacleXP, player.SkillAbbr[player.Agility])))
- }
- }
-
- isLastObstacle := obstacleIndex == totalObstacles-1
-
- if isLastObstacle {
- if completionXP > 0 {
- if newLevel := p.AddSkillXP(player.Agility, completionXP); newLevel > 0 {
- sess.WriteLine(g.colorize(sess, "level_up",
- fmt.Sprintf("*** You are now level %d agility! ***", newLevel)))
- }
- if p.OptionBool("xp_drops") {
- sess.WriteLine(g.colorize(sess, "xp",
- fmt.Sprintf("(+%dxp %s course bonus)", completionXP, player.SkillAbbr[player.Agility])))
- }
- }
-
- lapKey := "agility_laps_" + courseID
- laps := g.getPlayerFlagInt(p, lapKey) + 1
- g.setPlayerFlag(p, lapKey, laps)
-
- courseName := p.Action.TargetName
- sess.WriteLine(g.colorize(sess, "agility",
- fmt.Sprintf("Course complete! %s lap %d finished.", courseName, laps)))
-
- g.CancelAction(p)
- g.teleportPlayer(sess, p, startRoom)
- } else {
- g.CancelAction(p)
- g.teleportPlayer(sess, p, nextRoom)
- }
-
- g.AccountStore.SaveCharacter(p)
- return
- }
-
- data["phase"] = nextPhase
- p.Action.WaitLeft = engine.ToTicks(ticksPerPhase)
-}
-```
-
-### `teleportPlayer()` helper
-
-This function moves the player to a room without using normal movement mechanics (no movement delay, no flee check). It mirrors the structure of `completeMove()` but is instant:
-
-```go
-func (g *Game) teleportPlayer(sess *net.Session, p *player.Player, targetRoomID int) {
- oldRoom := p.RoomID
- p.RoomID = targetRoomID
-
- g.World.SeedGroundItems(p.RoomID)
- g.seedRoomMobs(p.RoomID)
- g.seedRoomObjects(p.RoomID)
-
- if g.Hub != nil {
- for _, other := range g.Hub.PlayersInRoom(oldRoom) {
- if other != sess {
- other.WriteLine(fmt.Sprintf("\n%s disappears.", p.Name))
- }
- }
- g.Hub.EnterRoom(sess, targetRoomID)
- for _, other := range g.Hub.PlayersInRoom(targetRoomID) {
- if other != sess {
- other.WriteLine(fmt.Sprintf("\n%s arrives.", p.Name))
- }
- }
- }
-
- if p.OptionBool("description") {
- g.doLook(sess)
- } else {
- targetRoom, _ := g.World.LoadRoom(targetRoomID)
- if targetRoom != nil {
- sess.WriteLine(g.colorize(sess, "room_name", targetRoom.Name))
- }
- }
-
- g.RunEnterSteps(sess, targetRoomID)
-}
-```
-
-**Note:** Check if a `teleportPlayer` helper already exists. The talk action system's `teleport` node action (in `action_talk.go` or similar) likely already implements this. If so, reuse it. If not, add it to `cmd_agility.go` or `action_agility.go`.
-
-### `obstacleFail()` helper
-
-```go
-func (g *Game) obstacleFail(sess *net.Session, p *player.Player, data map[string]any) {
- startRoom := data["start_room"].(int)
- failMin := data["fail_damage_min"].(int)
- failMax := data["fail_damage_max"].(int)
-
- damage := failMin
- if failMax > failMin {
- damage = failMin + rand.Intn(failMax-failMin+1)
- }
-
- sess.WriteLine(g.colorize(sess, "damage", "You slip and fall!"))
-
- p.HP -= damage
- if p.HP < 1 {
- p.HP = 1
- }
- sess.WriteLine(g.colorize(sess, "damage", fmt.Sprintf("You take %d damage. HP: %d/%d", damage, p.HP, p.MaxHP())))
-
- g.AccountStore.SaveCharacter(p)
- g.CancelAction(p)
- g.teleportPlayer(sess, p, startRoom)
-}
-```
-
-## 9. Fail Mechanics
-
-### Fail Chance Calculation
-
-`calcFailChance()` in `internal/game/action_agility.go`:
-
-```go
-func (g *Game) calcFailChance(p *player.Player, info *ObstacleInfo) float64 {
- level := p.Level(player.Agility)
- required := info.RequiredLevel
- chance := 0.30 - float64(level-required)*0.01
- if chance < 0.05 {
- chance = 0.05
- }
- if chance > 0.60 {
- chance = 0.60
- }
- return chance
-}
-```
-
-**Formula:** `failChance = max(0.05, min(0.60, 0.30 - (level - required) * 0.01))`
-
-| Level vs Required | Fail Chance |
-|--------------------|-------------|
-| At required level | 30% |
-| +5 levels | 25% |
-| +10 levels | 20% |
-| +20 levels | 10% |
-| +25 levels | 5% (minimum) |
-| Below required | Up to 60% (capped) |
-
-**On fail:**
-- Player takes `fail_damage[0]` to `fail_damage[1]` HP damage (random in range, inclusive)
-- HP cannot go below 1 (fail never kills)
-- Player is teleported to the course `start_room` (hub room 200)
-- Message: "You slip and fall!"
-- Course progress resets (player must start from obstacle 1 again)
-- No XP awarded for the failed obstacle
-
-**Fail check timing:** The fail check occurs when phase 1 completes (the second message). This means the player sees the first two messages, then either fails or proceeds to the completion message.
-
-## 10. Lap Tracking
-
-### Player Flags
-
-Lap counts are stored as player flags (per-character, saved to character YAML):
-
-- `agility_laps_vent_shaft` — number of completed Ventilation Shaft laps
-- `agility_laps_rooftop` — number of completed Rooftop Course laps
-- `agility_laps_reactor` — number of completed Reactor Course laps
-
-Flag key format: `agility_laps_<course_id>`
-
-### Helper Functions
-
-Player flags in this codebase use `map[string]any`. We need helpers to read/write integer flags:
-
-```go
-func (g *Game) getPlayerFlagInt(p *player.Player, key string) int {
- if p.Flags == nil {
- return 0
- }
- val, ok := p.Flags[key]
- if !ok {
- return 0
- }
- switch v := val.(type) {
- case int:
- return v
- case int64:
- return int(v)
- case float64:
- return int(v)
- }
- return 0
-}
-
-func (g *Game) setPlayerFlag(p *player.Player, key string, val any) {
- if p.Flags == nil {
- p.Flags = make(map[string]any)
- }
- p.Flags[key] = val
-}
-```
-
-**Note:** Check if similar helpers already exist in the codebase (e.g., in `action_talk.go` where `set_player_flags` is handled). Reuse them if so.
-
-### Completion Message
-
-On completing the last obstacle:
-```
-You shoot out the bottom of the chute and land on your feet!
-(+10xp agl)
-(+40xp agl course bonus)
-Course complete! Ventilation Shaft Course lap 47 finished.
-```
-
-## 11. Course Detection
-
-### How `doObstacle()` Works
-
-```go
-func (g *Game) doObstacle(sess *net.Session, verb string) {
- p := sess.Player.(*player.Player)
-
- if combat.GetCombat(p.Name) != nil {
- sess.WriteLine("You can't do that during combat!")
- return
- }
-
- info := g.CourseStore.GetObstacle(p.RoomID)
- if info == nil || info.Verb != verb {
- sess.WriteLine("You can't do that here.")
- return
- }
-
- agilityLevel := p.Level(player.Agility)
- if agilityLevel < info.RequiredLevel {
- sess.WriteLine(fmt.Sprintf("You need level %d agility to attempt this course.", info.RequiredLevel))
- return
- }
-
- if p.Action != nil {
- g.CancelAction(p)
- }
- g.CancelBackgroundAction(p)
-
- g.startObstacle(sess, p, info)
-}
-```
-
-### `CourseStore.GetObstacle()`
-
-```go
-func (cs *CourseStore) GetObstacle(roomID int) *ObstacleInfo {
- cs.mu.Lock()
- defer cs.mu.Unlock()
- if !cs.loaded {
- cs.loadAllLocked()
- }
- return cs.roomToObstacle[roomID]
-}
-```
-
-### Map Building in `loadAllLocked()`
-
-```go
-func (cs *CourseStore) loadAllLocked() {
- cs.loaded = true
- cs.roomToObstacle = make(map[int]*ObstacleInfo)
- localVerbs := make(map[string]bool)
-
- pattern := filepath.Join(cs.dataDir, "courses", "*.yaml")
- files, err := filepath.Glob(pattern)
- if err != nil {
- return
- }
-
- for _, f := range files {
- data, err := os.ReadFile(f)
- if err != nil {
- continue
- }
- var cfg CourseConfig
- if err := yaml.Unmarshal(data, &cfg); err != nil {
- continue
- }
- cs.courses[cfg.ID] = &cfg
-
- totalObstacles := len(cfg.Obstacles)
- for i, obs := range cfg.Obstacles {
- nextRoom := 0
- if i < totalObstacles-1 {
- nextRoom = cfg.Obstacles[i+1].RoomID
- }
-
- completionXP := 0
- if i == totalObstacles-1 {
- completionXP = cfg.CompletionXP
- }
-
- info := &ObstacleInfo{
- CourseID: cfg.ID,
- CourseName: cfg.Name,
- ObstacleIndex: i,
- TotalObstacles: totalObstacles,
- Verb: obs.Verb,
- Messages: obs.Messages,
- TicksPerPhase: obs.TicksPerPhase,
- ObstacleXP: obs.XP,
- CompletionXP: completionXP,
- FailDamage: obs.FailDamage,
- NextRoom: nextRoom,
- StartRoom: cfg.StartRoom,
- RequiredLevel: cfg.RequiredLevel,
- }
- cs.roomToObstacle[obs.RoomID] = info
- localVerbs[obs.Verb] = true
- }
- }
-
- obstacleVerbs = localVerbs
-}
-```
-
-### Sequence Enforcement
-
-The course system does NOT enforce that players complete obstacles in order via explicit state tracking. Instead, it relies on room topology: the only way to reach room 202 is by completing the obstacle in room 201. If a player somehow ends up in room 203 without completing room 202's obstacle (e.g., via teleport or `walk`), they can still type the obstacle command and proceed. This is acceptable — the XP is balanced per-obstacle, and the completion bonus only fires on the last obstacle.
-
-Each obstacle room has a "down" exit back to the hub room (room 200) so players can bail out at any time. Walking backwards through the course is prevented by not having standard exits between obstacle rooms in the reverse direction.
-
-## 12. XP Table
-
-### Ventilation Shaft Course (Level 1)
-
-| # | Obstacle | Verb | XP | Ticks |
-|---|----------|------|----|-------|
-| 1 | Corroded Wall | scramble | 8 | 2/phase |
-| 2 | Coolant Pipe Walkway | balance | 8 | 2/phase |
-| 3 | Shaft Gap | jump | 7 | 2/phase |
-| 4 | Narrow Vent | crawl | 7 | 3/phase |
-| 5 | Emergency Chute | slide | 10 | 2/phase |
-| | **Completion bonus** | | **40** | |
-| | **Total per lap** | | **80** | |
-
-Time per lap: ~33 ticks (~20 seconds at 600ms ticks)
-
-### Rooftop Course (Level 20)
-
-| # | Obstacle | Verb | XP | Ticks |
-|---|----------|------|----|-------|
-| 1 | Hab Block Wall | climb | 15 | 2/phase |
-| 2 | Building Gap | jump | 22 | 2/phase |
-| 3 | Cable Array | swing | 20 | 3/phase |
-| 4 | Narrow Beam | balance | 18 | 3/phase |
-| 5 | Maintenance Railing | vault | 15 | 2/phase |
-| 6 | Rooftop Edge | leap | 20 | 2/phase |
-| | **Completion bonus** | | **120** | |
-| | **Total per lap** | | **230** | |
-
-Time per lap: ~42 ticks (~25 seconds at 600ms ticks)
-
-### Reactor Course (Level 50)
-
-| # | Obstacle | Verb | XP | Ticks |
-|---|----------|------|----|-------|
-| 1 | Reactor Scaffolding | climb | 40 | 3/phase |
-| 2 | Coolant Chain | swing | 45 | 3/phase |
-| 3 | Steam Pipe | balance | 50 | 3/phase |
-| 4 | Platform Gap | jump | 45 | 2/phase |
-| 5 | Service Conduit | crawl | 40 | 3/phase |
-| 6 | Radiation Barrier | vault | 50 | 2/phase |
-| 7 | Reactor Chasm | leap | 55 | 3/phase |
-| | **Completion bonus** | | **350** | |
-| | **Total per lap** | | **675** | |
-
-Time per lap: ~57 ticks (~34 seconds at 600ms ticks)
-
-### XP/Hour Estimates (no fails)
-
-| Course | XP/Lap | Laps/Hr (est) | XP/Hr |
-|--------|--------|---------------|-------|
-| Ventilation Shaft | 80 | ~160 | ~12,800 |
-| Rooftop | 230 | ~130 | ~29,900 |
-| Reactor | 675 | ~95 | ~64,125 |
-
-## 13. Room YAML
-
-### Hub Room
-
-**`data/rooms/200.yaml`:**
-```yaml
-id: 200
-name: "Agility Training Grounds"
-description: "A cavernous space beneath the asteroid's surface, repurposed as a training facility. Scaffolding, pipes, and platforms fill the chamber. Signs point to three courses of increasing difficulty: {33}Ventilation Shaft{/} (beginner), {214}Rooftop{/} (intermediate), and {196}Reactor{/} (advanced)."
-exits:
- south: 17
- north: 201
- east: 210
- west: 220
-```
-
-### Ventilation Shaft Course Rooms
-
-**`data/rooms/201.yaml`:**
-```yaml
-id: 201
-name: "Ventilation Shaft - Corroded Wall"
-description: "A towering wall of corroded ventilation panels rises before you. Rust-eaten handholds and buckled seams offer a treacherous path upward. The air smells of old metal and recycled atmosphere."
-on_enter:
- - message: "Type 'scramble' to climb the wall."
-exits:
- down:
- room: 200
- blocked_message: ""
-```
-
-**`data/rooms/202.yaml`:**
-```yaml
-id: 202
-name: "Ventilation Shaft - Coolant Pipe Walkway"
-description: "A narrow coolant pipe stretches across a dark chasm. Condensation drips from its surface, making it slick. Far below, you can hear the distant hum of machinery."
-on_enter:
- - message: "Type 'balance' to cross the pipe."
-exits:
- down:
- room: 200
- blocked_message: ""
-```
-
-**`data/rooms/203.yaml`:**
-```yaml
-id: 203
-name: "Ventilation Shaft - Shaft Gap"
-description: "The ventilation shaft floor is missing here — a jagged gap drops into darkness. The far side is just barely within jumping distance. Exposed wiring sparks intermittently below."
-on_enter:
- - message: "Type 'jump' to leap across the gap."
-exits:
- down:
- room: 200
- blocked_message: ""
-```
-
-**`data/rooms/204.yaml`:**
-```yaml
-id: 204
-name: "Ventilation Shaft - Narrow Vent"
-description: "The passage narrows dramatically here, becoming a tight rectangular vent barely wide enough to fit through. Scratches on the metal walls suggest others have squeezed through before you."
-on_enter:
- - message: "Type 'crawl' to squeeze through the vent."
-exits:
- down:
- room: 200
- blocked_message: ""
-```
-
-**`data/rooms/205.yaml`:**
-```yaml
-id: 205
-name: "Ventilation Shaft - Emergency Chute"
-description: "A smooth metal chute angles steeply downward, polished by countless slides. An old emergency evacuation sign hangs crookedly on the wall. This is the final obstacle — the chute leads back to the training grounds."
-on_enter:
- - message: "Type 'slide' to descend the chute."
-exits:
- down:
- room: 200
- blocked_message: ""
-```
-
-### Rooftop Course Rooms
-
-**`data/rooms/210.yaml`:**
-```yaml
-id: 210
-name: "Rooftop Course - Hab Block Wall"
-description: "The exterior wall of Hab Block 7 rises three stories above the street. Rough permacrete and maintenance handholds provide a challenging climb. The city spreads out below, neon signs flickering in the perpetual twilight."
-on_enter:
- - message: "Type 'climb' to scale the wall."
-exits:
- down:
- room: 200
- blocked_message: ""
-```
-
-**`data/rooms/211.yaml`:**
-```yaml
-id: 211
-name: "Rooftop Course - Building Gap"
-description: "You stand on the edge of Hab Block 7's roof. Across a three-meter gap, the roof of Hab Block 8 awaits. The street below is a dizzying drop. A few old bootprints mark the takeoff point."
-on_enter:
- - message: "Type 'jump' to leap to the next building."
-exits:
- down:
- room: 200
- blocked_message: ""
-```
-
-**`data/rooms/212.yaml`:**
-```yaml
-id: 212
-name: "Rooftop Course - Cable Array"
-description: "A tangle of power cables and data lines stretches between two antenna towers. One thick cable hangs low enough to grab. The gap below drops to a dark alleyway between hab blocks."
-on_enter:
- - message: "Type 'swing' to cross on the cable."
-exits:
- down:
- room: 200
- blocked_message: ""
-```
-
-**`data/rooms/213.yaml`:**
-```yaml
-id: 213
-name: "Rooftop Course - Narrow Beam"
-description: "A structural I-beam extends across the gap between two buildings, no wider than your foot. It sways slightly in the recycled air currents. Someone has scratched tally marks into the near end."
-on_enter:
- - message: "Type 'balance' to cross the beam."
-exits:
- down:
- room: 200
- blocked_message: ""
-```
-
-**`data/rooms/214.yaml`:**
-```yaml
-id: 214
-name: "Rooftop Course - Maintenance Railing"
-description: "A high maintenance railing blocks the path forward, topped with sensor equipment and warning labels. It's too high to step over but the right technique could clear it. Beyond the railing, the course continues."
-on_enter:
- - message: "Type 'vault' to clear the railing."
-exits:
- down:
- room: 200
- blocked_message: ""
-```
-
-**`data/rooms/215.yaml`:**
-```yaml
-id: 215
-name: "Rooftop Course - Rooftop Edge"
-description: "The final jump. The gap here is wider than any before — a full four meters of empty air between you and the landing platform. Far below, the streets of the asteroid colony pulse with dim light. This is the last obstacle."
-on_enter:
- - message: "Type 'leap' to make the final jump."
-exits:
- down:
- room: 200
- blocked_message: ""
-```
-
-### Reactor Course Rooms
-
-**`data/rooms/220.yaml`:**
-```yaml
-id: 220
-name: "Reactor Course - Scaffolding"
-description: "Massive metal scaffolding surrounds the outer reactor housing. The structure vibrates with the reactor's pulse. Heat radiates from every surface, and warning klaxons sound periodically in the distance."
-on_enter:
- - message: "Type 'climb' to ascend the scaffolding."
-exits:
- down:
- room: 200
- blocked_message: ""
-```
-
-**`data/rooms/221.yaml`:**
-```yaml
-id: 221
-name: "Reactor Course - Coolant Chain"
-description: "A heavy chain hangs from an overhead crane, suspended above the reactor cooling pit. The pit glows with an eerie blue-green light. The chain is your only way across — the gantry ahead is the landing zone."
-on_enter:
- - message: "Type 'swing' to cross on the chain."
-exits:
- down:
- room: 200
- blocked_message: ""
-```
-
-**`data/rooms/222.yaml`:**
-```yaml
-id: 222
-name: "Reactor Course - Steam Pipe"
-description: "An enormous steam pipe, two meters in diameter, stretches across the reactor chamber. Steam vents periodically blast from pressure valves along its length. The pipe's surface is warm but not scalding — yet."
-on_enter:
- - message: "Type 'balance' to traverse the pipe."
-exits:
- down:
- room: 200
- blocked_message: ""
-```
-
-**`data/rooms/223.yaml`:**
-```yaml
-id: 223
-name: "Reactor Course - Platform Gap"
-description: "A section of the reactor maintenance platform has collapsed into the void below. Emergency barriers block the edges, but someone has moved them aside here. The gap is intimidating but clearable."
-on_enter:
- - message: "Type 'jump' to clear the gap."
-exits:
- down:
- room: 200
- blocked_message: ""
-```
-
-**`data/rooms/224.yaml`:**
-```yaml
-id: 224
-name: "Reactor Course - Service Conduit"
-description: "A narrow service conduit leads through the reactor shielding. Radiation warning symbols are painted on every surface. Damaged wiring hangs from the ceiling, sparking occasionally. It's the only way forward."
-on_enter:
- - message: "Type 'crawl' to enter the conduit."
-exits:
- down:
- room: 200
- blocked_message: ""
-```
-
-**`data/rooms/225.yaml`:**
-```yaml
-id: 225
-name: "Reactor Course - Radiation Barrier"
-description: "A containment barrier hums with energy, its surface shimmering with a faint purple glow. It pulses on and off in a regular cycle. Beyond it, the final stretch of the course is visible."
-on_enter:
- - message: "Type 'vault' to clear the barrier."
-exits:
- down:
- room: 200
- blocked_message: ""
-```
-
-**`data/rooms/226.yaml`:**
-```yaml
-id: 226
-name: "Reactor Course - Reactor Chasm"
-description: "The final obstacle. A massive chasm separates you from the exit platform, the reactor's coolant pool churning far below in shades of luminous green. A narrow runway of grating leads to the edge. This is the longest leap on the course."
-on_enter:
- - message: "Type 'leap' to make the final jump."
-exits:
- down:
- room: 200
- blocked_message: ""
-```
-
-## 14. Help File
-
-**`data/help/agility.yaml`:**
-```yaml
-id: agility
-title: "Agility"
-body: |
- Agility is trained by completing obstacle courses. Each course is a sequence
- of rooms with obstacles that you traverse using special commands.
-
- COMMANDS
- scramble, jump, swing, balance, climb, crawl, vault, leap, slide
- Each obstacle room tells you which command to use. Type it to begin
- the obstacle. After a few ticks of sequenced messages, you'll either
- succeed and move to the next obstacle, or slip and fall.
-
- COURSES
- Ventilation Shaft Level 1 - 5 obstacles, 80 XP/lap
- Rooftop Level 20 - 6 obstacles, 230 XP/lap
- Reactor Level 50 - 7 obstacles, 675 XP/lap
-
- FAILING
- Each obstacle has a chance to fail. If you fail, you take minor damage
- and are teleported back to the Agility Training Grounds. Fail chance
- decreases as your agility level increases above the course requirement.
-
- LAP TRACKING
- The game tracks how many laps you've completed on each course. Your
- lap count is displayed when you finish a course.
-
- TIPS
- - The "down" exit in any obstacle room returns you to the Training
- Grounds without penalty (but no XP either).
- - Agility level also determines how many steps you can queue with the
- "walk" command.
- - Graceful equipment and Cape of Agility reduce movement speed.
-related:
- - skills
- - walk
-```
-
-## 15. Full Go Implementation
-
-### `internal/game/course.go`
-
-```go
-package game
-
-import (
- "os"
- "path/filepath"
- "sync"
-
- "gopkg.in/yaml.v3"
-)
-
-type ObstacleDef struct {
- RoomID int `yaml:"room_id"`
- Verb string `yaml:"verb"`
- TicksPerPhase float64 `yaml:"ticks_per_phase"`
- XP int `yaml:"xp"`
- FailDamage [2]int `yaml:"fail_damage"`
- Messages []string `yaml:"messages"`
-}
-
-type CourseConfig struct {
- ID string `yaml:"id"`
- Name string `yaml:"name"`
- RequiredLevel int `yaml:"required_level"`
- StartRoom int `yaml:"start_room"`
- CompletionXP int `yaml:"completion_xp"`
- Obstacles []ObstacleDef `yaml:"obstacles"`
-}
-
-type ObstacleInfo struct {
- CourseID string
- CourseName string
- ObstacleIndex int
- TotalObstacles int
- Verb string
- Messages []string
- TicksPerPhase float64
- ObstacleXP int
- CompletionXP int
- FailDamage [2]int
- NextRoom int
- StartRoom int
- RequiredLevel int
-}
-
-var obstacleVerbs = map[string]bool{}
-
-type CourseStore struct {
- dataDir string
- mu sync.Mutex
- courses map[string]*CourseConfig
- roomToObstacle map[int]*ObstacleInfo
- loaded bool
-}
-
-func NewCourseStore(dataDir string) *CourseStore {
- return &CourseStore{
- dataDir: dataDir,
- courses: make(map[string]*CourseConfig),
- }
-}
-
-func (cs *CourseStore) LoadAll() {
- cs.mu.Lock()
- defer cs.mu.Unlock()
- cs.loadAllLocked()
-}
-
-func (cs *CourseStore) GetObstacle(roomID int) *ObstacleInfo {
- cs.mu.Lock()
- defer cs.mu.Unlock()
- if !cs.loaded {
- cs.loadAllLocked()
- }
- return cs.roomToObstacle[roomID]
-}
-
-func (cs *CourseStore) loadAllLocked() {
- cs.loaded = true
- cs.roomToObstacle = make(map[int]*ObstacleInfo)
- localVerbs := make(map[string]bool)
-
- pattern := filepath.Join(cs.dataDir, "courses", "*.yaml")
- files, _ := filepath.Glob(pattern)
-
- for _, f := range files {
- data, err := os.ReadFile(f)
- if err != nil {
- continue
- }
- var cfg CourseConfig
- if err := yaml.Unmarshal(data, &cfg); err != nil {
- continue
- }
- cs.courses[cfg.ID] = &cfg
-
- totalObstacles := len(cfg.Obstacles)
- for i, obs := range cfg.Obstacles {
- nextRoom := 0
- if i < totalObstacles-1 {
- nextRoom = cfg.Obstacles[i+1].RoomID
- }
-
- completionXP := 0
- if i == totalObstacles-1 {
- completionXP = cfg.CompletionXP
- }
-
- info := &ObstacleInfo{
- CourseID: cfg.ID,
- CourseName: cfg.Name,
- ObstacleIndex: i,
- TotalObstacles: totalObstacles,
- Verb: obs.Verb,
- Messages: obs.Messages,
- TicksPerPhase: obs.TicksPerPhase,
- ObstacleXP: obs.XP,
- CompletionXP: completionXP,
- FailDamage: obs.FailDamage,
- NextRoom: nextRoom,
- StartRoom: cfg.StartRoom,
- RequiredLevel: cfg.RequiredLevel,
- }
- cs.roomToObstacle[obs.RoomID] = info
- localVerbs[obs.Verb] = true
- }
- }
-
- obstacleVerbs = localVerbs
-}
-```
-
-### `internal/game/cmd_agility.go`
-
-```go
-package game
-
-import (
- "fmt"
-
- "thehouseoficarus/internal/combat"
- "thehouseoficarus/internal/net"
- "thehouseoficarus/internal/player"
-)
-
-func (g *Game) doObstacle(sess *net.Session, verb string) {
- p := sess.Player.(*player.Player)
-
- if combat.GetCombat(p.Name) != nil {
- sess.WriteLine("You can't do that during combat!")
- return
- }
-
- info := g.CourseStore.GetObstacle(p.RoomID)
- if info == nil || info.Verb != verb {
- sess.WriteLine("You can't do that here.")
- return
- }
-
- agilityLevel := p.Level(player.Agility)
- if agilityLevel < info.RequiredLevel {
- sess.WriteLine(fmt.Sprintf("You need level %d agility to attempt this course.", info.RequiredLevel))
- return
- }
-
- if p.Action != nil {
- g.CancelAction(p)
- }
- g.CancelBackgroundAction(p)
-
- g.startObstacle(sess, p, info)
-}
-```
-
-### `internal/game/action_agility.go`
-
-```go
-package game
-
-import (
- "fmt"
- "math/rand"
-
- "thehouseoficarus/internal/action"
- "thehouseoficarus/internal/engine"
- "thehouseoficarus/internal/net"
- "thehouseoficarus/internal/player"
-)
-
-func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *ObstacleInfo) {
- failChance := g.calcFailChance(p, info)
-
- msgs := make([]any, len(info.Messages))
- for i, m := range info.Messages {
- msgs[i] = m
- }
-
- p.Action = &action.Action{
- Type: "obstacle",
- TargetID: info.CourseID,
- TargetName: info.CourseName,
- WaitLeft: 0,
- Data: map[string]any{
- "course_id": info.CourseID,
- "phase": 0,
- "obstacle_index": info.ObstacleIndex,
- "total_obstacles": info.TotalObstacles,
- "next_room": info.NextRoom,
- "start_room": info.StartRoom,
- "obstacle_xp": info.ObstacleXP,
- "completion_xp": info.CompletionXP,
- "fail_chance": failChance,
- "fail_damage_min": info.FailDamage[0],
- "fail_damage_max": info.FailDamage[1],
- "messages": msgs,
- "ticks_per_phase": info.TicksPerPhase,
- "required_level": info.RequiredLevel,
- },
- }
-
- p.ActionState = &ActionState{
- Type: ActionTraversing,
- Verb: info.Verb + "ing",
- TargetName: info.CourseName,
- }
-}
-
-func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) {
- data := p.Action.Data
- phase := data["phase"].(int)
- messages := data["messages"].([]any)
- ticksPerPhase := data["ticks_per_phase"].(float64)
-
- if phase >= len(messages) {
- g.CancelAction(p)
- return
- }
-
- msg := messages[phase].(string)
- sess.WriteLine(g.colorize(sess, "agility", msg))
-
- if phase == 1 {
- failChance := data["fail_chance"].(float64)
- if rand.Float64() < failChance {
- g.obstacleFail(sess, p, data)
- return
- }
- }
-
- nextPhase := phase + 1
-
- if nextPhase >= len(messages) {
- obstacleXP := data["obstacle_xp"].(int)
- completionXP := data["completion_xp"].(int)
- nextRoom := data["next_room"].(int)
- startRoom := data["start_room"].(int)
- obstacleIndex := data["obstacle_index"].(int)
- totalObstacles := data["total_obstacles"].(int)
- courseID := data["course_id"].(string)
-
- if obstacleXP > 0 {
- if newLevel := p.AddSkillXP(player.Agility, obstacleXP); newLevel > 0 {
- sess.WriteLine(g.colorize(sess, "level_up",
- fmt.Sprintf("*** You are now level %d agility! ***", newLevel)))
- }
- if p.OptionBool("xp_drops") {
- sess.WriteLine(g.colorize(sess, "xp",
- fmt.Sprintf("(+%dxp %s)", obstacleXP, player.SkillAbbr[player.Agility])))
- }
- }
-
- isLastObstacle := obstacleIndex == totalObstacles-1
-
- if isLastObstacle {
- if completionXP > 0 {
- if newLevel := p.AddSkillXP(player.Agility, completionXP); newLevel > 0 {
- sess.WriteLine(g.colorize(sess, "level_up",
- fmt.Sprintf("*** You are now level %d agility! ***", newLevel)))
- }
- if p.OptionBool("xp_drops") {
- sess.WriteLine(g.colorize(sess, "xp",
- fmt.Sprintf("(+%dxp %s course bonus)", completionXP, player.SkillAbbr[player.Agility])))
- }
- }
-
- lapKey := "agility_laps_" + courseID
- laps := g.getPlayerFlagInt(p, lapKey) + 1
- g.setPlayerFlag(p, lapKey, laps)
-
- courseName := p.Action.TargetName
- sess.WriteLine(g.colorize(sess, "agility",
- fmt.Sprintf("Course complete! %s lap %d finished.", courseName, laps)))
-
- g.CancelAction(p)
- g.teleportPlayer(sess, p, startRoom)
- } else {
- g.CancelAction(p)
- g.teleportPlayer(sess, p, nextRoom)
- }
-
- g.AccountStore.SaveCharacter(p)
- return
- }
-
- data["phase"] = nextPhase
- p.Action.WaitLeft = engine.ToTicks(ticksPerPhase)
-}
-
-func (g *Game) obstacleFail(sess *net.Session, p *player.Player, data map[string]any) {
- startRoom := data["start_room"].(int)
- failMin := data["fail_damage_min"].(int)
- failMax := data["fail_damage_max"].(int)
-
- damage := failMin
- if failMax > failMin {
- damage = failMin + rand.Intn(failMax-failMin+1)
- }
-
- sess.WriteLine(g.colorize(sess, "damage", "You slip and fall!"))
-
- p.HP -= damage
- if p.HP < 1 {
- p.HP = 1
- }
- sess.WriteLine(g.colorize(sess, "damage",
- fmt.Sprintf("You take %d damage. HP: %d/%d", damage, p.HP, p.MaxHP())))
-
- g.AccountStore.SaveCharacter(p)
- g.CancelAction(p)
- g.teleportPlayer(sess, p, startRoom)
-}
-
-func (g *Game) calcFailChance(p *player.Player, info *ObstacleInfo) float64 {
- level := p.Level(player.Agility)
- required := info.RequiredLevel
- chance := 0.30 - float64(level-required)*0.01
- if chance < 0.05 {
- chance = 0.05
- }
- if chance > 0.60 {
- chance = 0.60
- }
- return chance
-}
-
-func (g *Game) getPlayerFlagInt(p *player.Player, key string) int {
- if p.Flags == nil {
- return 0
- }
- val, ok := p.Flags[key]
- if !ok {
- return 0
- }
- switch v := val.(type) {
- case int:
- return v
- case int64:
- return int(v)
- case float64:
- return int(v)
- }
- return 0
-}
-
-func (g *Game) setPlayerFlag(p *player.Player, key string, val any) {
- if p.Flags == nil {
- p.Flags = make(map[string]any)
- }
- p.Flags[key] = val
-}
-
-func (g *Game) teleportPlayer(sess *net.Session, p *player.Player, targetRoomID int) {
- oldRoom := p.RoomID
- p.RoomID = targetRoomID
-
- g.World.SeedGroundItems(p.RoomID)
- g.seedRoomMobs(p.RoomID)
- g.seedRoomObjects(p.RoomID)
-
- if g.Hub != nil {
- for _, other := range g.Hub.PlayersInRoom(oldRoom) {
- if other != sess {
- other.WriteLine(fmt.Sprintf("\n%s disappears.", p.Name))
- }
- }
- g.Hub.EnterRoom(sess, targetRoomID)
- for _, other := range g.Hub.PlayersInRoom(targetRoomID) {
- if other != sess {
- other.WriteLine(fmt.Sprintf("\n%s arrives.", p.Name))
- }
- }
- }
-
- if p.OptionBool("description") {
- g.doLook(sess)
- } else {
- targetRoom, _ := g.World.LoadRoom(targetRoomID)
- if targetRoom != nil {
- sess.WriteLine(g.colorize(sess, "room_name", targetRoom.Name))
- }
- }
-
- g.RunEnterSteps(sess, targetRoomID)
-}
-```
-
-## 16. Implementation Checklist
-
-1. [ ] Create `data/courses/` directory
-2. [ ] Create `data/courses/vent_shaft.yaml`
-3. [ ] Create `data/courses/rooftop.yaml`
-4. [ ] Create `data/courses/reactor.yaml`
-5. [ ] Create all room YAML files (200-226, 18 rooms total)
-6. [ ] Update `data/rooms/17.yaml` to add north exit to 200
-7. [ ] Create `data/help/agility.yaml`
-8. [ ] Create `internal/game/course.go`
-9. [ ] Create `internal/game/cmd_agility.go`
-10. [ ] Create `internal/game/action_agility.go`
-11. [ ] Update `internal/game/game.go`: add `CourseStore` field, init in `New()`, update `classifyCommand()`, update `executeCommand()` default case
-12. [ ] Update `internal/game/action_state.go`: add `ActionTraversing`, add `Description()` case
-13. [ ] Update `internal/game/action.go`: add `"obstacle"` case in `AdvanceActions()`, add `ActionTraversing` to stale-clear exclusion
-14. [ ] Check if `teleportPlayer` already exists (search for teleport in `action_talk.go`) — reuse or create
-15. [ ] Check if `getPlayerFlagInt`/`setPlayerFlag` helpers already exist — reuse or create
-16. [ ] Run `make vet` and `make test`
-17. [ ] Test in-game: complete each course, verify XP, verify lap tracking, verify fail mechanic
-
-## 17. Edge Cases and Notes
-
-- **Player disconnects mid-obstacle:** Action is cleared on disconnect (standard behavior). Player stays in the obstacle room. On reconnect they can type the obstacle verb again or use "down" to leave.
-- **Player types wrong verb:** "You can't do that here." — each room only accepts its specific verb.
-- **Player types obstacle verb outside a course room:** "You can't do that here."
-- **Player is in combat:** "You can't do that during combat!" — checked before anything else.
-- **Player dies on fail:** HP cannot go below 1. Fail never kills.
-- **Multiple players on same obstacle:** Each player's action is independent. No conflict resolution needed (unlike gathering/combat).
-- **Walking to an obstacle room via `walk <room_number>`:** Works fine. The player can walk to any obstacle room and attempt it. This doesn't break anything — individual obstacle XP is small, and the completion bonus only fires on the last obstacle.
-- **BFS pathfinding:** Obstacle rooms have a "down" exit to room 200, so BFS can find them. However, there are no forward exits between obstacle rooms (movement is done via teleport on obstacle completion), so BFS cannot path *through* the course. This is intentional.
-- **Map display:** Obstacle rooms will appear on the map connected to room 200 via "down" exits. This is fine — they'll cluster around the hub.
-- **Color target:** The plan uses `"agility"` as a color target for obstacle messages. If this target doesn't exist in the color config, it will fall back to default. Add it to the color config if desired, or use an existing target like `"broadcast"`.
-- **`obstacleVerbs` race condition:** The package-level `obstacleVerbs` map is written once during `CourseStore.LoadAll()` (called from `Game.New()` before any sessions exist) and then only read. No mutex needed for reads.
-- **`FailDamage` YAML parsing:** The `[2]int` type for `fail_damage` works with YAML arrays like `[1, 2]`. Go's `yaml.v3` handles this correctly for fixed-size arrays.
-- **Verb conjugation for ActionState:** The `Verb` field is set to e.g. `"scrambling"` (verb + "ing"). This is a naive conjugation. For verbs like "slide" it produces "slideing" which is wrong. To handle this, add a small helper or hardcode the gerund forms:
- ```go
- var verbGerund = map[string]string{
- "scramble": "scrambling",
- "jump": "jumping",
- "swing": "swinging",
- "balance": "balancing",
- "climb": "climbing",
- "crawl": "crawling",
- "vault": "vaulting",
- "leap": "leaping",
- "slide": "sliding",
- }
- ```
- Use this in `startObstacle()` instead of naive `verb + "ing"`.