From 74803657d587b1f93b739c1ee25bb8d62df95413 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 7 Jul 2026 21:22:39 -0400 Subject: fix: agility course creation fixed and improved in admin web gui --- internal/admin/api_room_courses.go | 247 ++++++++++++++----------------- internal/admin/api_rooms.go | 78 +++++++++- internal/admin/static/admin.css | 2 +- internal/admin/static/map.js | 292 +++++++++++++++++++++++++++++++------ internal/behavior/types.go | 1 + internal/game/act_agility.go | 104 +++++++------ internal/game/core_course.go | 75 ++++++++++ internal/game/core_course_test.go | 4 +- internal/game/game.go | 1 + 9 files changed, 566 insertions(+), 238 deletions(-) (limited to 'internal') diff --git a/internal/admin/api_room_courses.go b/internal/admin/api_room_courses.go index 740a102..e4cf849 100644 --- a/internal/admin/api_room_courses.go +++ b/internal/admin/api_room_courses.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "gopkg.in/yaml.v3" @@ -229,8 +230,13 @@ func (s *AdminServer) handleCourseByID(w http.ResponseWriter, r *http.Request) { NewContent: newContent, IsCreate: isCreate, }) + warnings := s.reconcileCourseExits(body) s.reloadCoursesIfWired() - writeJSON(w, map[string]any{"ok": true, "id": id}) + resp := map[string]any{"ok": true, "id": id} + if len(warnings) > 0 { + resp["warnings"] = warnings + } + writeJSON(w, resp) case http.MethodDelete: oldContent, err := snapshotFile(path) @@ -366,6 +372,33 @@ func (s *AdminServer) handleRoomAttachCourse(w http.ResponseWriter, r *http.Requ } m["obstacles"] = anyList + // --- course map exit management --- + // Set exit_dir on the previous obstacle. + // Append-only: the new obstacle is last so no new->next link is needed. + if insertAt > 0 { + prevIdx := insertAt - 1 + prevRoom := obstacleRoomID(newList[prevIdx]) + if prevRoom > 0 { + dir := strings.TrimSpace(strings.ToLower(body.Direction)) + if dir == "auto" || dir == "" { + dir = "north" + } + if !isHorizontalDir(dir) { + writeJSON(w, map[string]any{"error": "invalid direction: " + dir}) + return + } + + prevObstacle := newList[prevIdx] + prevObstacle["exit_dir"] = dir + newList[prevIdx] = prevObstacle + } + } + anyList = make([]any, len(newList)) + for i, o := range newList { + anyList[i] = o + } + m["obstacles"] = anyList + newContent, err := writeMapAsYAML(path, m) if err != nil { writeJSON(w, map[string]any{"error": "write error: " + err.Error()}) @@ -378,55 +411,18 @@ func (s *AdminServer) handleRoomAttachCourse(w http.ResponseWriter, r *http.Requ NewContent: newContent, }) - // --- course map exit management --- - // Create always-blocked exits so the map BFS lays the course rooms in sequence. - var prevRoom, nextRoom int - if insertAt > 0 { - prevRoom = obstacleRoomID(newList[insertAt-1]) - } - if insertAt < len(newList)-1 { - nextRoom = obstacleRoomID(newList[insertAt+1]) - } - - if prevRoom > 0 { - pm, _, _, perr := s.loadRoomMapByID(prevRoom) - if perr == nil { - // Delete the old blocked link from prevRoom to nextRoom (now bypassed). - if nextRoom > 0 { - oldDir := findCourseExitDirToTarget(pm, nextRoom) - if oldDir != "" { - removeExit(pm, oldDir) - } - } - dir := ensureValidDirection(body.Direction, pm) - setCourseBlockedExit(pm, dir, roomID) - s.saveRoomMapByID(prevRoom, pm, fmt.Sprintf("blocked exit %d->%d for course %s", prevRoom, roomID, body.CourseID)) - } - } - if nextRoom > 0 { - cm, _, _, cerr := s.loadRoomMapByID(roomID) - if cerr == nil { - dir := autoPickDirection(cm) - setCourseBlockedExit(cm, dir, nextRoom) - s.saveRoomMapByID(roomID, cm, fmt.Sprintf("blocked exit %d->%d for course %s", roomID, nextRoom, body.CourseID)) - } - } - s.reloadCoursesIfWired() writeJSON(w, map[string]any{"ok": true, "course_id": body.CourseID, "obstacle_index": insertAt, "total_obstacles": len(newList)}) } // Detach a room from its course; delete the course file if it becomes empty -func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Request, roomID int) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } +// detachRoomFromCourse removes roomID from the course it belongs to, if any. +// Returns the course ID that was modified, or "" if none match. +func (s *AdminServer) detachRoomFromCourse(roomID int) (courseID string, deleted bool) { ids, err := s.listCourseIDs() if err != nil { - writeJSON(w, map[string]any{"error": err.Error()}) - return + return "", false } for _, id := range ids { m, _, path, err := s.loadCourseMap(id) @@ -440,44 +436,8 @@ func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Requ } oldContent, _ := snapshotFile(path) - var prevRoom, nextRoom int if idx > 0 { - prevRoom = obstacleRoomID(obstacles[idx-1]) - } - if idx < len(obstacles)-1 { - nextRoom = obstacleRoomID(obstacles[idx+1]) - } - - // --- course map exit cleanup --- - var relinkDir string - if prevRoom > 0 { - pm, _, _, perr := s.loadRoomMapByID(prevRoom) - if perr == nil { - relinkDir = findCourseExitDirToTarget(pm, roomID) - if relinkDir != "" { - removeExit(pm, relinkDir) - s.saveRoomMapByID(prevRoom, pm, fmt.Sprintf("remove blocked exit to %d (course %s)", roomID, id)) - } - } - } - if nextRoom > 0 { - cm, _, _, cerr := s.loadRoomMapByID(roomID) - if cerr == nil { - d := findCourseExitDirToTarget(cm, nextRoom) - if d != "" { - removeExit(cm, d) - s.saveRoomMapByID(roomID, cm, fmt.Sprintf("remove blocked exit to %d (course %s)", nextRoom, id)) - } - } - } - // Relink prevRoom -> nextRoom - if prevRoom > 0 && nextRoom > 0 { - pm, _, _, perr := s.loadRoomMapByID(prevRoom) - if perr == nil { - dir := ensureValidDirection(relinkDir, pm) - setCourseBlockedExit(pm, dir, nextRoom) - s.saveRoomMapByID(prevRoom, pm, fmt.Sprintf("relink blocked exit %d->%d (course %s)", prevRoom, nextRoom, id)) - } + delete(obstacles[idx-1], "exit_dir") } remaining := make([]map[string]any, 0, len(obstacles)-1) @@ -485,10 +445,7 @@ func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Requ remaining = append(remaining, obstacles[idx+1:]...) if len(remaining) == 0 { - if err := os.Remove(path); err != nil { - writeJSON(w, map[string]any{"error": "delete error: " + err.Error()}) - return - } + os.Remove(path) s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("delete course %s (last obstacle removed)", id), FilePath: path, @@ -496,8 +453,7 @@ func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Requ IsDelete: true, }) s.reloadCoursesIfWired() - writeJSON(w, map[string]any{"ok": true, "course_id": id, "deleted": true}) - return + return id, true } anyList := make([]any, len(remaining)) @@ -507,8 +463,7 @@ func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Requ m["obstacles"] = anyList newContent, err := writeMapAsYAML(path, m) if err != nil { - writeJSON(w, map[string]any{"error": "write error: " + err.Error()}) - return + return "", false } s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("detach room %d from course %s", roomID, id), @@ -517,13 +472,23 @@ func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Requ NewContent: newContent, }) s.reloadCoursesIfWired() - writeJSON(w, map[string]any{"ok": true, "course_id": id, "obstacle_index": idx, "total_obstacles": len(remaining)}) - return + return id, false } - writeJSON(w, map[string]any{"error": "room is not part of any course"}) + return "", false } -// --- hidden exit management on room files ------------------------------- +func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Request, roomID int) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + courseID, deleted := s.detachRoomFromCourse(roomID) + if courseID == "" && !deleted { + writeJSON(w, map[string]any{"error": "room is not part of any course"}) + return + } + writeJSON(w, map[string]any{"ok": true, "course_id": courseID, "deleted": deleted}) +} // loadRoomMapByID reads a room YAML file as a generic map. func (s *AdminServer) loadRoomMapByID(roomID int) (map[string]any, []byte, string, error) { @@ -562,25 +527,6 @@ func (s *AdminServer) saveRoomMapByID(roomID int, m map[string]any, desc string) return nil } -// roomExitsMap returns the exits sub-map from a room map, initializing it -// as an empty map if absent. -func roomExitsMap(m map[string]any) map[string]any { - if ex, ok := m["exits"].(map[string]any); ok { - return ex - } - ex := make(map[string]any) - m["exits"] = ex - return ex -} - -// setCourseBlockedExit adds or replaces an always-blocked exit in direction dir -// targeting targetRoomID. Always-blocked exits are blocked to players but -// render on the map; used to lay out course rooms in visual sequence. -func setCourseBlockedExit(m map[string]any, dir string, targetRoomID int) { - ex := roomExitsMap(m) - ex[dir] = map[string]any{"room": targetRoomID, "always_blocked": true} -} - // removeExit deletes an exit by direction. Returns true if it existed. func removeExit(m map[string]any, dir string) bool { ex, ok := m["exits"].(map[string]any) @@ -619,45 +565,66 @@ func exitIsAlwaysBlocked(v any) bool { return false } -// findCourseExitDirToTarget returns the direction of an always-blocked exit from m -// targeting targetRoomID, or "" if none. -func findCourseExitDirToTarget(m map[string]any, targetRoomID int) string { - ex, ok := m["exits"].(map[string]any) - if !ok { - return "" - } - for dir, v := range ex { - if exitIsAlwaysBlocked(v) && exitTargetRoom(v) == targetRoomID { - return dir + + +// isHorizontalDir returns whether d is one of the 8 horizontal directions. +func isHorizontalDir(d string) bool { + for _, h := range horizontalExitDirs { + if d == h { + return true } } - return "" + return false } -// autoPickDirection returns the first horizontal direction that has no exit -// on room map m. -func autoPickDirection(m map[string]any) string { + + +// reconcileExitOnRoom validates that roomID has an exit in dir. +// If expectedTarget > 0, also checks the exit points to that room. +// Returns an error string or empty string on success. +func (s *AdminServer) validateExitDir(roomID int, dir string, expectedTarget int) string { + m, _, _, err := s.loadRoomMapByID(roomID) + if err != nil { + return "cannot load room " + strconv.Itoa(roomID) + } ex, _ := m["exits"].(map[string]any) - for _, d := range horizontalExitDirs { - if _, occupied := ex[d]; !occupied { - return d + + existing := ex[dir] + if existing == nil { + return "room " + strconv.Itoa(roomID) + " has no exit in direction " + dir + } + if expectedTarget > 0 { + target := exitTargetRoom(existing) + if target != expectedTarget { + return "direction " + dir + " on room " + strconv.Itoa(roomID) + " points to room " + strconv.Itoa(target) + ", expected " + strconv.Itoa(expectedTarget) } } - return "north" + return "" } -// ensureValidDirection returns dir if it's a valid horizontal direction, -// otherwise auto-picks one from m. -func ensureValidDirection(dir string, m map[string]any) string { - dir = strings.TrimSpace(strings.ToLower(dir)) - for _, d := range horizontalExitDirs { - if d == dir { - ex, _ := m["exits"].(map[string]any) - if _, occupied := ex[dir]; !occupied { - return dir - } - break +// reconcileCourseExits validates every obstacle's exit_dir against the actual +// exits on the corresponding room. Returns warnings for any mismatches found. +func (s *AdminServer) reconcileCourseExits(m map[string]any) []string { + var warnings []string + obstacles := asMapList(m["obstacles"]) + + for i, obs := range obstacles { + roomID := obstacleRoomID(obs) + dir, _ := obs["exit_dir"].(string) + if dir == "" { + continue + } + + var nextRoom int + if i < len(obstacles)-1 { + nextRoom = obstacleRoomID(obstacles[i+1]) + } + + errMsg := s.validateExitDir(roomID, dir, nextRoom) + if errMsg != "" { + warnings = append(warnings, errMsg) } } - return autoPickDirection(m) + + return warnings } \ No newline at end of file diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go index 49a6601..0925f8b 100644 --- a/internal/admin/api_rooms.go +++ b/internal/admin/api_rooms.go @@ -48,6 +48,8 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) { var linkFrom *int var linkDir *string var linkOneWay bool + var linkHidden bool + var linkBlocked bool if v, ok := m["link_from"]; ok { if f, ok := v.(float64); ok { lf := int(f) @@ -68,6 +70,18 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) { } delete(m, "link_oneway") } + if v, ok := m["link_hidden"]; ok { + if b, ok := v.(bool); ok { + linkHidden = b + } + delete(m, "link_hidden") + } + if v, ok := m["link_blocked"]; ok { + if b, ok := v.(bool); ok { + linkBlocked = b + } + delete(m, "link_blocked") + } fromID := 0 if linkFrom != nil { @@ -80,6 +94,46 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) { } m["id"] = id + if linkFrom != nil && linkDir != nil && *linkDir != "" { + dir := world.ExitDir(strings.ToLower(*linkDir)) + opp, validDir := world.OppositeExit[dir] + if validDir { + conflicts := 0 + world.BuildGrid(s.cfg.StartingRoom, func(rid int) (*world.Room, bool) { + if rid == id { + ex := make(map[world.ExitDir]world.ExitDef) + if !linkOneWay { + ex[opp] = world.ExitDef{Room: *linkFrom} + } + return &world.Room{Exits: ex}, true + } + r, err := s.world.LoadRoom(rid) + if err != nil { + return nil, false + } + if rid == *linkFrom { + rc := *r + rc.Exits = make(map[world.ExitDir]world.ExitDef, len(r.Exits)+1) + for k, v := range r.Exits { + rc.Exits[k] = v + } + rc.Exits[dir] = world.ExitDef{Room: id} + return &rc, true + } + return r, true + }, nil, nil, func(gc world.GridConflict) { + conflicts++ + }) + if conflicts > 0 { + writeJSON(w, map[string]any{"error": fmt.Sprintf("Add Next Step: that grid spot is already occupied — room would cause map overlap")}) + return + } + } else { + writeJSON(w, map[string]any{"error": fmt.Sprintf("invalid direction: %s", *linkDir)}) + return + } + } + exits, _ := m["exits"].(map[string]any) if exits == nil { exits = make(map[string]any) @@ -100,7 +154,7 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) { if srcRoom.Exits == nil { srcRoom.Exits = make(map[world.ExitDir]world.ExitDef) } - srcRoom.Exits[dir] = world.ExitDef{Room: id} + srcRoom.Exits[dir] = world.ExitDef{Room: id, Hidden: linkHidden, AlwaysBlocked: linkBlocked} srcPath, srcOk := s.world.GetRoomPath(*linkFrom) if srcOk { oldContent, _ := snapshotFile(srcPath) @@ -247,6 +301,8 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) { return } + courseID, _ := s.detachRoomFromCourse(id) + if err := os.Remove(path); err != nil { writeJSON(w, map[string]any{"error": fmt.Sprintf("delete room: %v", err)}) return @@ -301,7 +357,11 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) { ExtraFiles: extraFiles, }) - writeJSON(w, map[string]any{"ok": true}) + resp := map[string]any{"ok": true} + if courseID != "" { + resp["course_cleaned"] = courseID + } + writeJSON(w, resp) default: http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) @@ -424,10 +484,12 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: var body struct { - From int `json:"from"` - To int `json:"to"` - Dir string `json:"dir"` - OneWay bool `json:"oneway"` + From int `json:"from"` + To int `json:"to"` + Dir string `json:"dir"` + OneWay bool `json:"oneway"` + Hidden bool `json:"hidden"` + AlwaysBlocked bool `json:"always_blocked"` } if err := readJSON(r, &body); err != nil || body.From <= 0 || body.To <= 0 { writeJSONError(w, "invalid body, need from and to", http.StatusBadRequest) @@ -555,13 +617,13 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { if roomA.Exits == nil { roomA.Exits = make(map[world.ExitDir]world.ExitDef) } - roomA.Exits[dir] = world.ExitDef{Room: body.To} + roomA.Exits[dir] = world.ExitDef{Room: body.To, Hidden: body.Hidden, AlwaysBlocked: body.AlwaysBlocked} if !body.OneWay { if roomB.Exits == nil { roomB.Exits = make(map[world.ExitDir]world.ExitDef) } - roomB.Exits[oppDir] = world.ExitDef{Room: body.From} + roomB.Exits[oppDir] = world.ExitDef{Room: body.From, Hidden: body.Hidden, AlwaysBlocked: body.AlwaysBlocked} } pathA, okA2 := s.world.GetRoomPath(body.From) diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index d878db5..b710491 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -55,7 +55,7 @@ body{font-family:monospace;background:var(--bg);color:var(--text);display:flex;f .link-mode-badge.oneway{background:rgba(80,50,20,0.92);color:#fca;border:1px solid #a64} #mapSvg:focus{outline:none} #mapSvg,#mapSvg *{user-select:none;-webkit-user-select:none;-moz-user-select:none} -.notification{position:fixed;bottom:16px;right:16px;background:var(--panel);border:1px solid var(--border);padding:10px 16px;border-radius:4px;font-size:12px;z-index:100;animation:fadeIn .2s} +.notification{position:fixed;bottom:16px;left:16px;background:var(--panel);border:1px solid var(--border);padding:10px 16px;border-radius:4px;font-size:12px;z-index:100;animation:fadeIn .2s} .notification.error{border-color:var(--danger)} .notification.success{border-color:var(--success)} @keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}} diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js index 9974b2c..7cd4430 100644 --- a/internal/admin/static/map.js +++ b/internal/admin/static/map.js @@ -591,6 +591,7 @@ function loadRoomCourseData(id) { API.get('/api/rooms/' + id + '/course').then(function(c) { currentRoomData.course = c; _coursePhases = null; + _courseFailShown = null; if (currentPanelTab === 7 && selectedRoom === id) { var content = document.querySelector('.panel-tab-content'); if (content) content.innerHTML = renderCourseTab(); @@ -618,6 +619,8 @@ function renderCourseTab() { var prevRoom = idx > 0 ? (course.obstacles && course.obstacles[idx-1] && course.obstacles[idx-1].room_id) : 0; var nextRoom = idx < total - 1 ? (course.obstacles && course.obstacles[idx+1] && course.obstacles[idx+1].room_id) : 0; var roomID = currentRoomData.room.id; + var isLast = idx === total - 1; + var exitDir = obstacle.exit_dir || ''; // Header: Step N, Room #X (This Obstacle) h += '
This room is not part of any course.
'; - h += 'Room deleted
'; @@ -1934,23 +2132,31 @@ function deleteRoom(id) { }).catch(function(e) { notify('Delete failed: ' + extractError(e), 'error'); }); } -function createRoom(fromID, dir, oneway) { - API.get('/api/next-room-id?from=' + fromID).then(function(r) { +function createRoomPromise(fromID, dir, oneway, hidden, blocked) { + return API.get('/api/next-room-id?from=' + fromID).then(function(r) { var newID = r.id; var name = 'Room #' + newID; var body = { name: name, link_from: fromID, link_dir: dir }; if (oneway) body.link_oneway = true; + if (hidden) body.link_hidden = true; + if (blocked) body.link_blocked = true; var srcRoom = roomMap[fromID]; if (srcRoom && srcRoom.color) body.color = srcRoom.color; - API.post('/api/rooms', body).then(function(resp) { + return API.post('/api/rooms', body).then(function(resp) { var createdId = (resp && resp.room && resp.room.id) || newID; - notify('Room #' + createdId + ' created', 'success'); - updateUndoBar(); - loadMap().then(function() { selectRoom(createdId); }); - }).catch(function(e) { notify('Create failed: ' + extractError(e), 'error'); }); + return { id: createdId }; + }); }); } +function createRoom(fromID, dir, oneway) { + createRoomPromise(fromID, dir, oneway, false, false).then(function(res) { + notify('Room #' + res.id + ' created', 'success'); + updateUndoBar(); + loadMap().then(function() { selectRoom(res.id); }); + }).catch(function(e) { notify('Create failed: ' + extractError(e), 'error'); }); +} + function showRoomContextMenu(x, y, id) { var overlay = document.createElement('div'); overlay.className = 'cm-overlay'; diff --git a/internal/behavior/types.go b/internal/behavior/types.go index 03876e5..c43a0d9 100644 --- a/internal/behavior/types.go +++ b/internal/behavior/types.go @@ -143,6 +143,7 @@ type ObstacleData struct { TotalObstacles int NextRoom int StartRoom int + OnFailRoom int ObstacleXP int CompletionXP int // FailChance is the resolved failure probability for this attempt. diff --git a/internal/game/act_agility.go b/internal/game/act_agility.go index 18dbf4a..a243b44 100644 --- a/internal/game/act_agility.go +++ b/internal/game/act_agility.go @@ -44,6 +44,7 @@ func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *Obstacle TotalObstacles: info.TotalObstacles, NextRoom: info.NextRoom, StartRoom: info.StartRoom, + OnFailRoom: info.OnFailRoom, ObstacleXP: info.ObstacleXP, CompletionXP: info.CompletionXP, FailChance: failPtr, @@ -62,6 +63,11 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { phase := d.Phase phases := d.Phases + if len(phases) == 0 { + g.completeObstacle(sess, p, d) + return + } + if phase >= len(phases) { g.cancelAction(p) return @@ -84,58 +90,61 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { nextPhase := phase + 1 if nextPhase >= len(phases) { - obstacleXP := d.ObstacleXP - completionXP := d.CompletionXP - nextRoom := d.NextRoom - startRoom := d.StartRoom - obstacleIndex := d.ObstacleIndex - totalObstacles := d.TotalObstacles - courseID := d.CourseID - - if obstacleXP > 0 { - g.awardSkillXP(sess, p, player.Agility, obstacleXP) - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", - fmt.Sprintf("(+%dxp %s)", obstacleXP, player.SkillAbbr[player.Agility]))) - } + g.completeObstacle(sess, p, d) + return + } + + d.Phase = nextPhase + p.Action.WaitLeft = engine.ToTicks(phases[nextPhase].Delay) +} + +func (g *Game) completeObstacle(sess *net.Session, p *player.Player, d *behavior.ObstacleData) { + obstacleXP := d.ObstacleXP + completionXP := d.CompletionXP + nextRoom := d.NextRoom + obstacleIndex := d.ObstacleIndex + totalObstacles := d.TotalObstacles + courseID := d.CourseID + + isLastObstacle := obstacleIndex == totalObstacles-1 + + if obstacleXP > 0 { + g.awardSkillXP(sess, p, player.Agility, obstacleXP) + if !isLastObstacle && 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 { - g.awardSkillXP(sess, p, player.Agility, completionXP) - 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 := getPlayerFlagInt(p, lapKey) + 1 - g.setPlayerFlag(p, lapKey, laps) - - courseName := p.Action.TargetName - sess.WriteLine(g.colorize(sess, "broadcast", - 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) + if isLastObstacle { + if completionXP > 0 { + g.awardSkillXP(sess, p, player.Agility, completionXP) } - g.AccountStore.SaveCharacter(p) - return + lapKey := "agility_laps_" + courseID + laps := getPlayerFlagInt(p, lapKey) + 1 + g.setPlayerFlag(p, lapKey, laps) + + courseName := p.Action.TargetName + msg := fmt.Sprintf("You've completed %d lap%s of %s", laps, pluralLap(laps), courseName) + combinedXP := obstacleXP + completionXP + if p.OptionBool("xp_drops") && combinedXP > 0 { + msg += fmt.Sprintf(" (+%d%s)", combinedXP, player.SkillAbbr[player.Agility]) + } + sess.WriteLine(g.colorize(sess, "broadcast", msg)) + + g.cancelAction(p) + g.teleportPlayer(sess, p, nextRoom) + } else { + g.cancelAction(p) + g.teleportPlayer(sess, p, nextRoom) } - d.Phase = nextPhase - p.Action.WaitLeft = engine.ToTicks(phases[nextPhase].Delay) + g.AccountStore.SaveCharacter(p) } func (g *Game) obstacleFail(sess *net.Session, p *player.Player, d *behavior.ObstacleData) { - startRoom := d.StartRoom + onFailRoom := d.OnFailRoom failMin := d.FailDamageMin failMax := d.FailDamageMax @@ -155,7 +164,14 @@ func (g *Game) obstacleFail(sess *net.Session, p *player.Player, d *behavior.Obs g.AccountStore.SaveCharacter(p) g.cancelAction(p) - g.teleportPlayer(sess, p, startRoom) + g.teleportPlayer(sess, p, onFailRoom) +} + +func pluralLap(n int) string { + if n == 1 { + return "" + } + return "s" } // calcFailChance returns the failure probability for an obstacle attempt. diff --git a/internal/game/core_course.go b/internal/game/core_course.go index 54894fd..031eff5 100644 --- a/internal/game/core_course.go +++ b/internal/game/core_course.go @@ -1,11 +1,14 @@ package game import ( + "log" + "os" "path/filepath" "sync" "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/world" ) // ObstaclePhase is a single phase of an obstacle's advancement sequence. @@ -31,6 +34,8 @@ type ObstacleDef struct { TicksPerPhase float64 `yaml:"ticks_per_phase"` Messages []string `yaml:"messages"` Phases []ObstaclePhase `yaml:"phases"` + ExitDir string `yaml:"exit_dir"` + OnFailRoom int `yaml:"on_fail,omitempty"` } type CourseConfig struct { @@ -62,7 +67,9 @@ type ObstacleInfo struct { FailDamage [2]int NextRoom int StartRoom int + OnFailRoom int RequiredLevel int + ExitDir string } var obstacleVerbs = map[string]bool{} @@ -85,6 +92,7 @@ type CourseStore struct { courses map[string]*CourseConfig roomToObstacle map[int]*ObstacleInfo loaded bool + world *world.World } func NewCourseStore(dataDir string) *CourseStore { @@ -94,6 +102,10 @@ func NewCourseStore(dataDir string) *CourseStore { } } +func (cs *CourseStore) SetWorld(w *world.World) { + cs.world = w +} + func (cs *CourseStore) LoadAll() { cs.mu.Lock() defer cs.mu.Unlock() @@ -160,6 +172,52 @@ func resolvePhases(obs ObstacleDef) []PhaseInfo { return phases } +func (cs *CourseStore) resolveExitTarget(roomID int, dir string) int { + if cs.world == nil || dir == "" { + return 0 + } + path, ok := cs.world.GetRoomPath(roomID) + if !ok { + return 0 + } + data, err := os.ReadFile(path) + if err != nil { + return 0 + } + var m map[string]any + if err := yaml.Unmarshal(data, &m); err != nil { + return 0 + } + exits, _ := m["exits"].(map[string]any) + if exits == nil { + return 0 + } + v, ok := exits[dir] + if !ok { + return 0 + } + switch x := v.(type) { + case int: + return x + case int64: + return int(x) + case float64: + return int(x) + case map[string]any: + if r, ok := x["room"]; ok { + switch y := r.(type) { + case int: + return y + case int64: + return int(y) + case float64: + return int(y) + } + } + } + return 0 +} + func (cs *CourseStore) loadAllLocked() { cs.loaded = true cs.roomToObstacle = make(map[int]*ObstacleInfo) @@ -178,6 +236,16 @@ func (cs *CourseStore) loadAllLocked() { nextRoom := 0 if i < totalObstacles-1 { nextRoom = cfg.Obstacles[i+1].RoomID + } else if obs.ExitDir != "" { + resolved := cs.resolveExitTarget(obs.RoomID, obs.ExitDir) + if resolved != 0 { + nextRoom = resolved + } else { + log.Printf("[WARNING] Course %q: last obstacle room %d has exit_dir=%q but no exit in that direction; falling back to start_room %d", cfg.ID, obs.RoomID, obs.ExitDir, cfg.StartRoom) + nextRoom = cfg.StartRoom + } + } else { + nextRoom = cfg.StartRoom } completionXP := 0 @@ -185,6 +253,11 @@ func (cs *CourseStore) loadAllLocked() { completionXP = cfg.CompletionXP } + onFailRoom := obs.OnFailRoom + if onFailRoom == 0 { + onFailRoom = cfg.StartRoom + } + info := &ObstacleInfo{ CourseID: cfg.ID, CourseName: cfg.Name, @@ -198,7 +271,9 @@ func (cs *CourseStore) loadAllLocked() { FailDamage: obs.FailDamage, NextRoom: nextRoom, StartRoom: cfg.StartRoom, + OnFailRoom: onFailRoom, RequiredLevel: cfg.RequiredLevel, + ExitDir: obs.ExitDir, } cs.roomToObstacle[obs.RoomID] = info localVerbs[obs.Verb] = true diff --git a/internal/game/core_course_test.go b/internal/game/core_course_test.go index 3d9fdc2..64ec8ad 100644 --- a/internal/game/core_course_test.go +++ b/internal/game/core_course_test.go @@ -130,8 +130,8 @@ obstacles: if o2.CompletionXP != 50 { t.Errorf("last obstacle should carry completion_xp, got %d", o2.CompletionXP) } - if o2.NextRoom != 0 { - t.Errorf("last obstacle should have next_room 0, got %d", o2.NextRoom) + if o2.NextRoom != 100 { + t.Errorf("last obstacle with no exit_dir should fall back to start_room 100, got %d", o2.NextRoom) } if cs.GetObstacle(999) != nil { diff --git a/internal/game/game.go b/internal/game/game.go index b9fb0af..f34e649 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -101,6 +101,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali hackingStates: make(map[string]*hacking.Session), enterSeqs: make(map[string]*enterSeq), } + g.CourseStore.SetWorld(g.World) g.CourseStore.LoadAll() g.LoadMods() g.LoadTechs() -- cgit v1.2.3