From fee376102192dc6f24297a7b11324636ace3a07d Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 30 Jun 2026 19:27:37 -0400 Subject: feat: admin gui object CRUD is much nicer --- internal/admin/api_courses.go | 8 +- internal/admin/api_drops.go | 8 +- internal/admin/api_hazards.go | 8 +- internal/admin/api_items.go | 8 +- internal/admin/api_map.go | 31 +- internal/admin/api_mobs.go | 8 +- internal/admin/api_modules.go | 8 +- internal/admin/api_objects.go | 8 +- internal/admin/api_rooms.go | 16 +- internal/admin/api_search.go | 8 + internal/admin/api_techs.go | 8 +- internal/admin/server.go | 36 ++ internal/admin/static/admin.css | 68 ++- internal/admin/static/editor.js | 66 ++- internal/admin/static/map.js | 15 +- internal/admin/static/objecteditor.js | 997 ++++++++++++++++++++++++++++++++++ internal/admin/templates/login.html | 9 +- internal/admin/templates/map.html | 4 + internal/admin/templates/objects.html | 17 +- internal/admin/undo.go | 113 +++- internal/admin/yaml_util.go | 39 ++ internal/behavior/behavior.go | 3 - internal/behavior/types.go | 2 +- internal/game/act_gather.go | 58 +- internal/validate/checks.go | 8 + 25 files changed, 1431 insertions(+), 123 deletions(-) create mode 100644 internal/admin/static/objecteditor.js (limited to 'internal') diff --git a/internal/admin/api_courses.go b/internal/admin/api_courses.go index 63b356c..facb8f2 100644 --- a/internal/admin/api_courses.go +++ b/internal/admin/api_courses.go @@ -10,15 +10,15 @@ import ( func (s *AdminServer) handleCourses(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - ids, err := listYAMLFiles(s.dataDir, "courses") + tree, err := listYAMLTree(s.dataDir, "courses") if err != nil { writeJSON(w, map[string]any{"error": err.Error()}) return } - if ids == nil { - ids = []string{} + if tree == nil { + tree = &YAMLTree{Root: []string{}, Dirs: map[string][]string{}} } - writeJSON(w, ids) + writeJSON(w, tree) case http.MethodPost: var m map[string]any if err := readJSON(r, &m); err != nil { diff --git a/internal/admin/api_drops.go b/internal/admin/api_drops.go index 7645d39..0d14841 100644 --- a/internal/admin/api_drops.go +++ b/internal/admin/api_drops.go @@ -13,15 +13,15 @@ import ( func (s *AdminServer) handleDrops(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - ids, err := listYAMLFiles(s.dataDir, "drops") + tree, err := listYAMLTree(s.dataDir, "drops") if err != nil { writeJSON(w, map[string]any{"error": err.Error()}) return } - if ids == nil { - ids = []string{} + if tree == nil { + tree = &YAMLTree{Root: []string{}, Dirs: map[string][]string{}} } - writeJSON(w, ids) + writeJSON(w, tree) case http.MethodPost: s.createDrop(w, r) diff --git a/internal/admin/api_hazards.go b/internal/admin/api_hazards.go index c8c0282..405c9ab 100644 --- a/internal/admin/api_hazards.go +++ b/internal/admin/api_hazards.go @@ -11,15 +11,15 @@ import ( func (s *AdminServer) handleHazards(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - ids, err := listYAMLFiles(s.dataDir, "hazards") + tree, err := listYAMLTree(s.dataDir, "hazards") if err != nil { writeJSON(w, map[string]any{"error": err.Error()}) return } - if ids == nil { - ids = []string{} + if tree == nil { + tree = &YAMLTree{Root: []string{}, Dirs: map[string][]string{}} } - writeJSON(w, ids) + writeJSON(w, tree) case http.MethodPost: s.createHazard(w, r) diff --git a/internal/admin/api_items.go b/internal/admin/api_items.go index 3ab20ae..d5e707e 100644 --- a/internal/admin/api_items.go +++ b/internal/admin/api_items.go @@ -11,15 +11,15 @@ import ( func (s *AdminServer) handleItems(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - ids, err := listYAMLFiles(s.dataDir, "items") + tree, err := listYAMLTree(s.dataDir, "items") if err != nil { http.Error(w, `{"error":"failed to list items"}`, http.StatusInternalServerError) return } - if ids == nil { - ids = []string{} + if tree == nil { + tree = &YAMLTree{Root: []string{}, Dirs: map[string][]string{}} } - writeJSON(w, ids) + writeJSON(w, tree) case http.MethodPost: s.createItem(w, r) default: diff --git a/internal/admin/api_map.go b/internal/admin/api_map.go index 7298f1e..561c1e8 100644 --- a/internal/admin/api_map.go +++ b/internal/admin/api_map.go @@ -1,9 +1,11 @@ package admin import ( + "log" "net/http" "os" "path/filepath" + "sort" "strconv" "thehouseoficarus/internal/world" @@ -24,8 +26,7 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) { dir := r.URL.Query().Get("dir") seed := s.cfg.StartingRoom if dir != "" { - base := filepath.Join(s.dataDir, "rooms", dir) - if low, ok := findLowestRoom(base); ok { + if low, ok := findLowestLoadableRoom(s, dir); ok { seed = low } } @@ -33,6 +34,7 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) { g := world.BuildGrid(seed, func(id int) (*world.Room, bool) { room, err := s.world.LoadRoom(id) if err != nil { + log.Printf("map: BuildGrid failed to load room %d: %v", id, err) return nil, false } return room, true @@ -196,31 +198,32 @@ func linkKey(a, b int) string { return strconv.Itoa(b) + "-" + strconv.Itoa(a) } -func findLowestRoom(dir string) (int, bool) { - entries, err := os.ReadDir(dir) +func findLowestLoadableRoom(s *AdminServer, dir string) (int, bool) { + base := filepath.Join(s.dataDir, "rooms", dir) + entries, err := os.ReadDir(base) if err != nil { return 0, false } - lowest := 0 - found := false + var ids []int for _, e := range entries { - if e.IsDir() { + if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" { continue } name := e.Name() - if filepath.Ext(name) != ".yaml" { - continue - } id, err := strconv.Atoi(name[:len(name)-5]) if err != nil || id <= 0 { continue } - if !found || id < lowest { - lowest = id - found = true + ids = append(ids, id) + } + sort.Ints(ids) + for _, id := range ids { + if _, err := s.world.LoadRoom(id); err == nil { + return id, true } + log.Printf("map: seed candidate room %d in dir %s is not loadable, skipping", id, dir) } - return lowest, found + return 0, false } func findDisconnectedRooms(s *AdminServer, dir string, seed int, g world.RoomGrid) []map[string]any { diff --git a/internal/admin/api_mobs.go b/internal/admin/api_mobs.go index 5fdfbc8..da088d3 100644 --- a/internal/admin/api_mobs.go +++ b/internal/admin/api_mobs.go @@ -11,15 +11,15 @@ import ( func (s *AdminServer) handleMobs(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - ids, err := listYAMLFiles(s.dataDir, "mobs") + tree, err := listYAMLTree(s.dataDir, "mobs") if err != nil { writeJSON(w, map[string]any{"error": err.Error()}) return } - if ids == nil { - ids = []string{} + if tree == nil { + tree = &YAMLTree{Root: []string{}, Dirs: map[string][]string{}} } - writeJSON(w, ids) + writeJSON(w, tree) case http.MethodPost: s.createMob(w, r) diff --git a/internal/admin/api_modules.go b/internal/admin/api_modules.go index e676cd7..9fedccd 100644 --- a/internal/admin/api_modules.go +++ b/internal/admin/api_modules.go @@ -10,15 +10,15 @@ import ( func (s *AdminServer) handleModules(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - ids, err := listYAMLFiles(s.dataDir, "modules") + tree, err := listYAMLTree(s.dataDir, "modules") if err != nil { writeJSON(w, map[string]any{"error": err.Error()}) return } - if ids == nil { - ids = []string{} + if tree == nil { + tree = &YAMLTree{Root: []string{}, Dirs: map[string][]string{}} } - writeJSON(w, ids) + writeJSON(w, tree) case http.MethodPost: var m map[string]any if err := readJSON(r, &m); err != nil { diff --git a/internal/admin/api_objects.go b/internal/admin/api_objects.go index e4a2c60..01a0803 100644 --- a/internal/admin/api_objects.go +++ b/internal/admin/api_objects.go @@ -11,15 +11,15 @@ import ( func (s *AdminServer) handleObjects(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - ids, err := listYAMLFiles(s.dataDir, "objects") + tree, err := listYAMLTree(s.dataDir, "objects") if err != nil { http.Error(w, `{"error":"failed to list objects"}`, http.StatusInternalServerError) return } - if ids == nil { - ids = []string{} + if tree == nil { + tree = &YAMLTree{Root: []string{}, Dirs: map[string][]string{}} } - writeJSON(w, ids) + writeJSON(w, tree) case http.MethodPost: s.createObject(w, r) default: diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go index a477cc3..f615d9d 100644 --- a/internal/admin/api_rooms.go +++ b/internal/admin/api_rooms.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "os" "path/filepath" @@ -229,6 +230,7 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) { s.world.ClearRoomState(id) allIDs, _ := listRoomIDs(s.dataDir) + var extraFiles []ExtraFile var exitCleaned int for _, otherID := range allIDs { if otherID == id { @@ -250,7 +252,16 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) { if changed { otherPath, pathOk := s.world.GetRoomPath(otherID) if pathOk { - writeYAMLFile(otherPath, otherRoom) + oldExtra, _ := snapshotFile(otherPath) + newContent, writeErr := writeYAMLFile(otherPath, otherRoom) + if writeErr != nil { + continue + } + extraFiles = append(extraFiles, ExtraFile{ + FilePath: otherPath, + OldContent: oldExtra, + NewContent: newContent, + }) exitCleaned++ } } @@ -261,6 +272,7 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) { FilePath: path, OldContent: oldContent, IsDelete: true, + ExtraFiles: extraFiles, }) writeJSON(w, map[string]any{"ok": true}) @@ -352,6 +364,7 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { grid := world.BuildGrid(s.cfg.StartingRoom, func(id int) (*world.Room, bool) { room, err := s.world.LoadRoom(id) if err != nil { + log.Printf("link: BuildGrid failed to load room %d: %v", id, err) return nil, false } return room, true @@ -402,6 +415,7 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { testGrid := world.BuildGrid(s.cfg.StartingRoom, func(id int) (*world.Room, bool) { room, err := s.world.LoadRoom(id) if err != nil { + log.Printf("link: conflict test BuildGrid failed to load room %d: %v", id, err) return nil, false } roomCopy := *room diff --git a/internal/admin/api_search.go b/internal/admin/api_search.go index 3367a52..32ef292 100644 --- a/internal/admin/api_search.go +++ b/internal/admin/api_search.go @@ -29,6 +29,7 @@ func (s *AdminServer) handleSearch(w http.ResponseWriter, r *http.Request) { Mobs []searchResult `json:"mobs"` Objects []searchResult `json:"objects"` Hazards []searchResult `json:"hazards"` + Drops []searchResult `json:"drops"` } var resp searchResponse @@ -90,5 +91,12 @@ func (s *AdminServer) handleSearch(w http.ResponseWriter, r *http.Request) { } } + dropIDs, _ := listYAMLFiles(s.dataDir, "drops") + for _, id := range dropIDs { + if strings.Contains(strings.ToLower(id), q) { + resp.Drops = append(resp.Drops, searchResult{ID: id, Name: id}) + } + } + writeJSON(w, resp) } diff --git a/internal/admin/api_techs.go b/internal/admin/api_techs.go index 9b29647..bdb8295 100644 --- a/internal/admin/api_techs.go +++ b/internal/admin/api_techs.go @@ -10,15 +10,15 @@ import ( func (s *AdminServer) handleTechs(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - ids, err := listYAMLFiles(s.dataDir, "techs") + tree, err := listYAMLTree(s.dataDir, "techs") if err != nil { writeJSON(w, map[string]any{"error": err.Error()}) return } - if ids == nil { - ids = []string{} + if tree == nil { + tree = &YAMLTree{Root: []string{}, Dirs: map[string][]string{}} } - writeJSON(w, ids) + writeJSON(w, tree) case http.MethodPost: var m map[string]any if err := readJSON(r, &m); err != nil { diff --git a/internal/admin/server.go b/internal/admin/server.go index 823d7ba..df6d5d9 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -19,6 +19,8 @@ import ( "math/big" "net" "net/http" + "path/filepath" + "strconv" "strings" "time" @@ -400,6 +402,7 @@ func (s *AdminServer) doUndo(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"message": "Nothing to undo"}) return } + s.rebuildAfterUndo(change) writeJSON(w, map[string]any{"message": "Undid: " + change.Description}) } @@ -413,5 +416,38 @@ func (s *AdminServer) doRedo(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"message": "Nothing to redo"}) return } + s.rebuildAfterUndo(change) writeJSON(w, map[string]any{"message": "Redid: " + change.Description}) } + +func (s *AdminServer) rebuildAfterUndo(change *ChangeDesc) { + s.world.RebuildRoomIndex(s.dataDir) + s.world.ClearHazardCache() + + for _, p := range changeFiles(change) { + rel, err := filepath.Rel(filepath.Join(s.dataDir, "rooms"), p) + if err != nil { + continue + } + if rel == "." || rel == ".." || strings.HasPrefix(rel, "..") { + continue + } + name := filepath.Base(p) + if idStr := strings.TrimSuffix(name, ".yaml"); idStr != name { + if id, err := strconv.Atoi(idStr); err == nil && id > 0 { + s.world.ClearRoomState(id) + } + } + } +} + +func changeFiles(change *ChangeDesc) []string { + files := []string{change.FilePath} + if change.NewFilePath != "" { + files = append(files, change.NewFilePath) + } + for _, ef := range change.ExtraFiles { + files = append(files, ef.FilePath) + } + return files +} diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index 6a83664..6da338e 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -11,7 +11,7 @@ body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100 .nav .undo-bar span{color:#888;flex:1;text-align:right;margin-right:8px} .layout{display:flex;height:calc(100vh - 38px)} .main{flex:1;overflow:auto;position:relative;user-select:none;-webkit-user-select:none} -.panel{width:800px;background:var(--panel);border-left:1px solid var(--border);overflow-y:auto;padding:16px;flex-shrink:0;position:relative} +.panel{width:300px;background:var(--panel);border-left:1px solid var(--border);overflow-y:auto;padding:16px;flex-shrink:0;position:relative} .panel-resize-handle{position:absolute;left:-3px;top:0;bottom:0;width:6px;cursor:col-resize;z-index:5;background:transparent} .panel-resize-handle:hover{background:rgba(255,255,255,.06)} .panel h2{font-size:15px;margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid var(--border)} @@ -76,6 +76,14 @@ body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100 .editor-list .item:hover{background:rgba(15,52,96,.3)} .editor-list .item.active{background:var(--accent)} .editor-list h3{font-size:13px;margin-bottom:8px;color:#aaa} +.dir-group{margin-bottom:0} +.dir-header{display:flex;align-items:center;gap:4px;padding:4px 8px;font-size:11px;cursor:pointer;border-radius:3px;color:#888;margin-bottom:1px} +.dir-header:hover{background:rgba(255,255,255,.04)} +.dir-arrow{font-size:8px;width:10px;flex-shrink:0;color:#666;text-align:center} +.dir-name{flex:1;text-transform:capitalize} +.dir-count{font-size:9px;color:#555} +.dir-items{padding-left:10px} +.dir-items .item{font-size:11px} .editor-main pre{background:var(--input-bg);padding:12px;border-radius:4px;font-size:12px;overflow-x:auto;border:1px solid var(--border)} .form-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:4px 10px} .form-grid .form-group{margin-bottom:4px} @@ -144,3 +152,61 @@ g.ud-hover:hover text{font-weight:bold} .panel-tab-content .section-add-btn:hover{background:var(--hover)} .panel-tab-content .mini-label{font-size:9px;color:#888;display:block;margin-bottom:1px;margin-top:3px} .panel-tab-content .mini-input{width:60px!important;display:inline-block;margin-right:4px} +.obj-card{background:rgba(255,255,255,.02);border:1px solid var(--border);border-radius:5px;margin-bottom:10px;overflow:hidden;flex:1 1 calc(50% - 5px);min-width:340px;max-width:calc(50% - 5px)} +.obj-fields-wrap{display:flex;flex-wrap:wrap;gap:10px} +.obj-card-header{display:flex;align-items:center;gap:8px;padding:8px 12px;background:rgba(15,52,96,.3);cursor:pointer;user-select:none;font-size:13px;font-weight:bold} +.obj-card-header:hover{background:rgba(15,52,96,.5)} +.obj-card-arrow{font-size:10px;width:14px;color:#888} +.obj-card-label{flex:1;color:var(--text)} +.obj-card-remove{padding:2px 6px;font-size:10px;min-width:auto} +.obj-card-body{padding:10px 12px} +.obj-card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:6px 8px} +.obj-field{display:flex;flex-direction:column;gap:2px;font-size:11px} +.obj-field span{color:#888;font-size:9px;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.obj-field input,.obj-field textarea,.obj-field select{padding:4px 6px;font-size:11px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace;width:100%} +.obj-field textarea{resize:vertical;min-height:28px} +.obj-field.obj-wide{grid-column:1/-1} +.obj-field.obj-check{flex-direction:row;align-items:flex-end;gap:4px;padding-bottom:3px} +.obj-field.obj-check input{width:auto} +.obj-field.obj-check span{font-size:11px;color:var(--text)} +.obj-color-row{display:flex;align-items:center;gap:4px} +.obj-color-row .color-swatch{flex-shrink:0} +.obj-color-row input{width:38px!important;text-align:center;padding:4px 2px} +.obj-field.obj-narrow{flex:0 0 auto!important;min-width:auto} +.obj-field.obj-narrow input{width:44px} +.obj-field.obj-grow{flex:1 1 0} +.obj-inline-group{border:1px solid var(--border);border-radius:4px;padding:8px;margin-top:8px} +.obj-inline-label{font-size:10px;color:#888;text-transform:uppercase;letter-spacing:.5px;display:block;margin-bottom:4px} +.obj-subtable{margin-top:10px;border:1px solid var(--border);border-radius:4px;overflow:hidden} +.obj-subtable-header{font-size:10px;color:#888;text-transform:uppercase;letter-spacing:.5px;padding:6px 10px;background:rgba(255,255,255,.03);border-bottom:1px solid var(--border)} +.obj-sub-row{display:flex;gap:6px;align-items:flex-end;padding:6px 10px;border-bottom:1px solid rgba(255,255,255,.03)} +.obj-sub-row:last-child{border-bottom:none} +.obj-sub-row .obj-card-grid{flex:1;display:flex;gap:6px;flex-wrap:wrap} +.obj-sub-row .obj-card-grid .obj-field{margin-bottom:0} +.obj-sub-remove{align-self:flex-end;padding:2px 6px;font-size:10px;min-width:auto;background:var(--danger);color:#fff;border:none;border-radius:3px;cursor:pointer;margin-bottom:3px} +.obj-sub-add{display:block;width:100%;margin-top:4px;padding:4px 8px;font-size:11px;background:var(--accent);color:var(--text);border:1px solid #1a5a8e;border-radius:3px;cursor:pointer;font-family:monospace} +.obj-sub-add:hover{background:var(--hover)} +.add-section-bar{display:flex;gap:6px;align-items:center;margin-top:12px;padding:8px 0;border-top:1px solid var(--border);max-width:calc(50% - 5px)} +.add-section-bar select{padding:4px 8px;font-size:11px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace;flex:1} +.add-section-bar button{padding:4px 12px;font-size:11px} +.obj-kv-list{margin-top:4px} +.obj-kv-row{display:flex;gap:4px;align-items:center;margin-bottom:3px} +.obj-kv-row input{padding:3px 5px;font-size:10px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:2px;font-family:monospace;flex:1} +.obj-kv-row .obj-kv-key{flex:1} +.obj-kv-row .obj-kv-val{width:70px;flex:none} +.obj-core-row{display:flex;gap:6px;align-items:flex-end;margin-bottom:8px} +.obj-field-error input,.obj-field-error textarea,.obj-field-error select{border-color:var(--danger)!important;background:rgba(192,57,43,.08)} +.obj-search{position:relative!important} +.obj-search .search-results{position:absolute;top:100%;left:0;right:0;max-height:160px;overflow-y:auto;background:var(--panel);border:1px solid var(--border);border-radius:3px;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.4);margin-top:2px} +.obj-search .search-results .sr-item{padding:4px 8px;font-size:11px;cursor:pointer;color:var(--text);border-bottom:1px solid rgba(255,255,255,.03)} +.obj-search .search-results .sr-item:hover{background:var(--accent)} +.obj-search input.search-input{padding-right:24px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.3-4.3'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 6px center} +.obj-search input.search-input::placeholder{color:#666} +.obj-narrow.obj-search input{width:80px} +.obj-sub-row .obj-search input.search-input{min-width:80px} +.obj-sub-row .obj-search{flex:0 0 auto;min-width:0} +.obj-sub-row .obj-search.obj-grow{flex:1 1 0} +.obj-sub-row-line{display:flex;gap:6px;align-items:flex-end;flex-wrap:wrap} +.obj-sub-row-line+.obj-sub-row-line{margin-top:4px} +.obj-inline-compact .obj-card-grid{grid-template-columns:repeat(auto-fill,minmax(60px,1fr))!important} +.obj-inline-compact .obj-narrow input{width:50px} diff --git a/internal/admin/static/editor.js b/internal/admin/static/editor.js index d9ec36f..1ed0653 100644 --- a/internal/admin/static/editor.js +++ b/internal/admin/static/editor.js @@ -2,6 +2,8 @@ var editorType = ''; var editorFields = []; var currentID = null; var allIDs = []; +var treeData = { root: [], dirs: {} }; +var collapsedDirs = {}; function initEditor(type, fields) { editorType = type; @@ -13,7 +15,16 @@ function initEditor(type, fields) { function loadList() { API.get('/api/' + editorType).then(function(data) { - allIDs = data.ids || data || []; + if (Array.isArray(data)) { + treeData = { root: data, dirs: {} }; + allIDs = data.slice(); + } else { + treeData = { root: data.root || [], dirs: data.dirs || {} }; + allIDs = treeData.root.slice(); + Object.keys(treeData.dirs).sort().forEach(function(dir) { + Array.prototype.push.apply(allIDs, treeData.dirs[dir]); + }); + } renderList(''); }).catch(function(e) { notify('Failed to load list: ' + e.message, 'error'); @@ -22,11 +33,54 @@ function loadList() { function renderList(filter) { var el = $('#listEntries'); - var f = filter.toLowerCase(); - var filtered = allIDs.filter(function(id) { return !f || String(id).toLowerCase().indexOf(f) >= 0; }); - el.innerHTML = filtered.map(function(id) { - return '
' + esc(id) + '
'; - }).join(''); + var f = (filter || '').toLowerCase(); + + var html = ''; + var hasDirs = Object.keys(treeData.dirs).length > 0; + + if (treeData.root.length > 0 || !hasDirs) { + html += renderDirGroup(hasDirs ? 'Root' : '', treeData.root, f); + } + + if (hasDirs) { + Object.keys(treeData.dirs).sort().forEach(function(dir) { + html += renderDirGroup(dir, treeData.dirs[dir], f); + }); + } + + el.innerHTML = html; +} + +function renderDirGroup(name, ids, filter) { + if (ids.length === 0 && !filter) return ''; + var isFiltering = !!filter; + var displayIds = ids; + if (isFiltering) { + displayIds = ids.filter(function(id) { return String(id).toLowerCase().indexOf(filter) >= 0; }); + if (displayIds.length === 0) return ''; + } + var expanded = isFiltering ? true : (collapsedDirs[name] === false || (!name && collapsedDirs[''] === undefined)); + var displayName = name || editorType.charAt(0).toUpperCase() + editorType.slice(1); + var h = '
'; + if (name) { + h += '
'; + h += '' + (expanded ? '▼' : '▶') + ''; + h += '' + esc(displayName) + ''; + h += '' + (isFiltering ? displayIds.length + '/' + ids.length : ids.length) + ''; + h += '
'; + } + h += ''; + h += '
'; + return h; +} + +function toggleDir(name) { + collapsedDirs[name] = collapsedDirs[name] === false ? true : false; + renderList(''); } function filterList(val) { diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js index 85364d5..5bff9d7 100644 --- a/internal/admin/static/map.js +++ b/internal/admin/static/map.js @@ -543,7 +543,12 @@ function renderLocalTab(localObjs) { h += '
'; h += ''; h += ''; - h += ''; + h += '
'; + h += ''; + h += ''; + h += '
'; h += '
'; h += ''; h += ''; @@ -958,6 +963,7 @@ function updateLocalObject(i) { var nameEl = row.querySelector('.local-name'); var descEl = row.querySelector('.local-desc'); var aliasesEl = row.querySelector('.local-aliases'); + var hiddenEl = row.querySelector('.local-hidden'); localObjs[i].name = nameEl ? nameEl.value : ''; var descVal = descEl ? descEl.value : ''; if (descVal) { @@ -970,6 +976,7 @@ function updateLocalObject(i) { } else { localObjs[i].aliases = []; } + localObjs[i].hidden = hiddenEl ? hiddenEl.checked : false; var refObjs = objs.filter(function(o) { return !!o.id; }); setRoomObjects(localObjs.concat(refObjs)); } @@ -1159,7 +1166,10 @@ async function doSavePanel() { } function deleteRoom(id) { - if (!confirm('Delete room #' + id + '?')) return; + var cb = document.getElementById('confirmDelete'); + if (!cb || cb.checked) { + if (!confirm('Delete room #' + id + '?')) return; + } if (mapData && mapData.rooms) { var neighbors = []; if (mapData.links) { @@ -1567,6 +1577,7 @@ function initPanelResize() { var panel = $('#sidePanel'); if (!panel) return; panel.appendChild(handle); + panel.style.width = Math.min(800, window.innerWidth / 3) + 'px'; var startX, startW; handle.addEventListener('mousedown', function(e) { e.preventDefault(); diff --git a/internal/admin/static/objecteditor.js b/internal/admin/static/objecteditor.js new file mode 100644 index 0000000..3e944c4 --- /dev/null +++ b/internal/admin/static/objecteditor.js @@ -0,0 +1,997 @@ +var objectData = null; +var objectID = null; +var expandedCards = {}; + +var SECTIONS = [ + { + id: 'core', label: 'Core', always: true, + fields: [ + ['name', 'Name', 'text', 'copper rock'], + ['color', 'Color', 'color', 'B2'], + ['aliases', 'Aliases', 'text', 'rock, ore'], + ['hidden', 'Hidden', 'checkbox'], + ['inroom_description', 'In-Room Description', 'text', 'A copper rock juts from the ground.'], + ['description', 'Description', 'textarea', 'A vein of copper runs through this rock.'], + ['removal_item', 'Removal Item ID', 'search', 'e.g. ashes', '', null, 'items'], + ] + }, + { + id: 'steal', label: 'Steal', + detect: function(d) { return d.steal_table || d.steal_level || d.steal_speed || d.steal_xp || d.guard_mob; }, + fields: [ + ['steal_table', 'Table ID', 'text'], + ['steal_level', 'Level', 'number', '', 'narrow'], + ['steal_xp', 'XP', 'number', '', 'narrow'], + ['steal_speed', 'Speed', 'number', '', 'narrow'], + ['guard_mob', 'Guard Mob', 'text'], + ] + }, + { + id: 'gather', label: 'Gather', path: 'gather', + detect: function(d) { return d.gather; }, + fields: [ + ['skill', 'Skill', 'select', 'mining|woodcutting|fishing|crafting'], + ['bait', 'Bait Item', 'search', 'fishing_bait', '', function(d) { return d.skill === 'fishing'; }, 'items'], + ['gather_message', 'Gather Message', 'text', 'You swing your pickaxe at the rock...', 'wide'], + ['fail_message', 'Fail Message', 'text', 'You chip away but get nothing useful.', 'wide'], + ['depleted_message', 'Depleted Message', 'text', "You don't see any ore in this rock.", 'wide'], + ['exhausted_message', 'Exhausted Message', 'text', 'The tree comes crashing down!', 'wide', function(d) { return d.skill === 'woodcutting'; }], + ['respawn_broadcast', 'Respawn Broadcast', 'text', 'A glint of copper catches your eye from some {name}.', 'wide'], + ], + inline: [ + {label:'Success', path:'success', row:0, fields:[ + ['base', 'Base', 'number', '0.5', 'narrow'], ['per_level', 'Per Lvl', 'number', '0.01', 'narrow'], ['cap', 'Cap', 'number', '0.95', 'narrow'] + ]}, + {label:'Other', row:0, fields:[ + ['respawn_timer', 'Respawn', 'number', '50', 'narrow'], + ['deplete_timer', 'Deplete', 'number', '45', 'narrow', function(d) { return d.skill === 'woodcutting'; }], + ['nest_chance', 'Nest 1/n', 'number', '256', 'narrow', function(d) { return d.skill === 'woodcutting'; }], + ]}, + {label:'Tools', key:'tools', type:'tags'}, + ], + subtables: [ + {label:'Drops', key:'drops', fields:[ + ['item_id', 'Item ID', 'search', 'copper_ore', '', 'items'], ['table', 'Table', 'search', 'gem_table', '', 'drops'], + ['weight', 'Weight', 'number', '90', 'narrow'], ['level', 'Level', 'number', '1', 'narrow'], ['xp', 'XP', 'number', '17', 'narrow'], + ['quantity', 'Quantity', 'number', '1', 'narrow'], ['depletes', 'Depletes node', 'checkbox'], + ['message', 'Message', 'text', 'You manage to mine some copper ore.'], + ]} + ] + }, + { + id: 'use', label: 'Use (Station)', path: 'use', + detect: function(d) { return d.use; }, + fields: [ + ['message', 'Message', 'text'], + ['wait', 'Wait', 'number', '', 'narrow'], + ['skill', 'Skill', 'text'], + ['level', 'Level', 'number', '', 'narrow'], + ['xp', 'XP', 'number', '', 'narrow'], + ['fail_message', 'Fail Message', 'text'], + ], + inline: [ + {label:'Success', path:'success', fields:[ + ['base', 'Base', 'number', '', 'narrow'], ['per_level', 'Per Lvl', 'number', '', 'narrow'], ['cap', 'Cap', 'number', '', 'narrow'] + ]}, + {label:'Consume', key:'consume', type:'kv', valType:'number'}, + ], + subtables: [ + {label:'Reward', key:'reward', single:true, fields:[ + ['item_id', 'Item ID', 'search', '', '', 'items'], ['weight', 'Weight', 'number'], ['quantity', 'Quantity', 'number'], + ['message', 'Message', 'text'], + ]} + ] + }, + { + id: 'safespot', label: 'Safespot', path: 'safespot', + detect: function(d) { return d.safespot; }, + fields: [ + ['tier', 'Tier', 'number', '', 'narrow'], + ['max_block_size', 'Max Block Size', 'text'], + ['max_occupants', 'Max Occupants', 'number', '', 'narrow'], + ['unsafe_chance', 'Unsafe Chance', 'number', '', 'narrow'], + ['decay_ticks', 'Decay Ticks', 'number', '', 'narrow'], + ['decay_chance', 'Decay Chance', 'number', '', 'narrow'], + ['respawn_on_hide', 'Respawn on Hide', 'checkbox'], + ['respawn_ticks', 'Respawn Ticks', 'number', '', 'narrow'], + ], + subtables: [ + {label:'Levels', key:'levels', fields:[ + ['message', 'Message', 'text'], ['degrade_message', 'Degrade Message', 'text'] + ]} + ] + }, + { + id: 'use_interactions', label: 'Use Interactions', path: 'use_interactions', isArray: true, + detect: function(d) { return d.use_interactions && d.use_interactions.length; }, + fields: [ + ['item_id', 'Item ID', 'text'], + ['message', 'Message', 'text'], + ['condition', 'Condition', 'json'], + ['action', 'Action', 'json'], + ] + }, + { + id: 'talk', label: 'Talk', path: 'talk', + detect: function(d) { return d.talk && d.talk.nodes; }, + fields: [] + }, + { + id: 'on_look', label: 'On Look', path: 'on_look', + detect: function(d) { return d.on_look; }, + fields: [ + ['set_flags', 'Set Flags', 'json'], + ['set_player_flags', 'Set Player Flags', 'json'], + ['give_item', 'Give Item', 'text'], + ['take_item', 'Take Item', 'text'], + ['teleport', 'Teleport', 'number', '', 'narrow'], + ['heal', 'Heal', 'number', '', 'narrow'], + ['cost', 'Cost', 'number', '', 'narrow'], + ['assign_task', 'Assign Task', 'checkbox'], + ['skip_task', 'Skip Task', 'checkbox'], + ['extend_task', 'Extend Task', 'checkbox'], + ['reputation_cost', 'Reputation Cost', 'number', '', 'narrow'], + ['sawmill', 'Sawmill', 'checkbox'], + ['aps_node', 'APS Node', 'checkbox'], + ] + }, +]; + +function initObjectEditor() { + editorType = 'objects'; + editorFields = []; + loadList(); + if (window.location.hash) loadObject(window.location.hash.substring(1)); +} + +window.onTalkTreeChange = function(data) { + objectData.talk = data.talk; +}; + +function loadObject(id) { + objectID = id; + window.location.hash = id; + renderList(''); + API.get('/api/objects/' + encodeURIComponent(id)).then(function(data) { + objectData = data; + renderObjectEditor(id, data); + }).catch(function(e) { + $('#editorMain').innerHTML = '

Failed to load: ' + esc(e.message) + '

'; + }); +} + +function renderObjectEditor(id, data) { + var html = '

Object: ' + esc(id) + '

'; + html += '
'; + html += '
'; + + html += '
'; + + html += '
'; + + SECTIONS.forEach(function(sec) { + if (!sec.always && sec.detect && !sec.detect(data)) return; + html += renderCard(sec, data); + }); + + html += '
'; + + html += '
'; + html += ''; + html += ''; + html += '
'; + + html += '
'; + + html += ''; + + html += '
'; + html += ''; + html += ''; + html += ''; + html += '
'; + + $('#editorMain').innerHTML = html; + setupSearchFields(); + setTimeout(function() { + document.querySelectorAll('.color-field').forEach(function(el) { bindColorField(el); }); + }, 50); + + SECTIONS.forEach(function(sec) { + if (sec.id === 'talk' && data.talk && data.talk.nodes) { + renderTalkTree(data); + } + }); +} + +function cardPath(sec) { + return sec.path || ''; +} + +function getVal(data, sec, key) { + var p = cardPath(sec); + if (p === 'on_look' || sec.isArray) { + return data[p] && data[p][key] !== undefined ? data[p][key] : ''; + } + var fp = p ? p + '.' + key : key; + var v = getNested(data, fp); + return v === undefined || v === null ? '' : v; +} + +function fieldID(sec, key, idx) { + if (idx !== undefined && idx >= 0) return 'f_' + sec.id + '_' + idx + '_' + key.replace(/\./g, '_'); + return 'f_' + sec.id + '_' + key.replace(/\./g, '_'); +} + +function fieldIDSub(sec, subKey, idx, fieldKey) { + return 'f_' + sec.id + '_' + subKey + '_' + idx + '_' + fieldKey; +} + +function renderCard(sec, data) { + var collapsed = expandedCards[sec.id] === false; + var h = '
'; + h += '
'; + h += '' + (collapsed ? '▶' : '▼') + ''; + h += '' + sec.label + ''; + if (!sec.always) { + h += ''; + } + h += '
'; + if (!collapsed) { + h += '
'; + + if (sec.isArray) { + h += renderArraySection(sec, data); + } else if (sec.id === 'core') { + h += renderCoreCard(data); + } else { + h += '
'; + sec.fields.forEach(function(f) { + h += renderField(sec, f, data, -1); + }); + h += '
'; + + if (sec.inline) { + var rowGroups = {}; + var noRow = []; + sec.inline.forEach(function(il) { + if (il.row !== undefined) { + if (!rowGroups[il.row]) rowGroups[il.row] = []; + rowGroups[il.row].push(il); + } else { + noRow.push(il); + } + }); + Object.keys(rowGroups).sort().forEach(function(r) { + h += '
'; + rowGroups[r].forEach(function(il) { + if (il.type === 'tags') { + h += '
'; + h += renderTags(sec, il, data); + h += '
'; + } else if (il.type === 'kv') { + h += '
'; + h += renderKV(sec, il, data); + h += '
'; + } else { + h += '
'; + h += '' + esc(il.label) + ''; + h += '
'; + il.fields.forEach(function(f) { + h += renderField(sec, f, data, -1, il.path); + }); + h += '
'; + h += '
'; + } + }); + h += '
'; + }); + noRow.forEach(function(il) { + if (il.type === 'tags') { + h += renderTags(sec, il, data); + } else if (il.type === 'kv') { + h += renderKV(sec, il, data); + } else { + h += '
'; + h += '' + esc(il.label) + ''; + h += '
'; + il.fields.forEach(function(f) { + h += renderField(sec, f, data, -1, il.path); + }); + h += '
'; + h += '
'; + } + }); + } + + if (sec.subtables) { + sec.subtables.forEach(function(st) { + h += renderSubtable(sec, st, data); + }); + } + + if (sec.id === 'talk') { + h += '
'; + } + } + h += '
'; + } + h += '
'; + return h; +} + +function renderField(sec, f, data, idx, subPath, optStyle) { + var key = f[0], label = f[1], type = f[2], hint = f[3], wide = f[4], showIf = f[5], searchEntity = f[6]; + var fp = subPath ? subPath + '.' + key : key; + var fid = fieldID(sec, fp, idx); + var val; + + if (sec.isArray && idx >= 0) { + var arr = objectData[cardPath(sec)] || []; + var item = arr[idx] || {}; + val = item[key]; + } else if (subPath) { + var sectionRoot = objectData[cardPath(sec)] || {}; + var subObj = sectionRoot[subPath] || {}; + val = subObj[key]; + } else { + val = getVal(objectData, sec, key); + } + val = val === undefined || val === null ? '' : val; + + var hiddenStyle = ''; + if (showIf) { + var sectionData = objectData[cardPath(sec)] || {}; + if (!showIf(sectionData)) hiddenStyle = ' style="display:none"'; + } + + function wrap(cls, inner) { + var w = (wide === 'wide') ? ' obj-wide' : ''; + if (wide === 'narrow') w = ' obj-narrow'; + var s = optStyle ? ' style="' + optStyle + '"' : ''; + return ''; + } + + if (type === 'checkbox') { + return wrap('obj-field obj-check', ' ' + esc(label) + ''); + } + if (type === 'json') { + var jstr = typeof val === 'object' ? JSON.stringify(val) : String(val); + return wrap('obj-field', '' + esc(label) + ''); + } + if (type === 'textarea') { + var dstr = typeof val === 'object' ? (Array.isArray(val) ? val.map(function(v){return v.text||'';}).join('\n') : JSON.stringify(val)) : String(val); + return wrap('obj-field', '' + esc(label) + ''); + } + if (type === 'select') { + var opts = hint ? hint.split('|') : []; + var inner = '' + esc(label) + ''; + return wrap('obj-field', inner); + } + if (type === 'color') { + var swatch = ''; + return wrap('obj-field color-field', '' + esc(label) + '
' + swatch + '
'); + } + if (type === 'search') { + var entity = searchEntity || 'items'; + var ph = hint ? ' placeholder="' + esc(hint) + '"' : ' placeholder="Search ' + entity + '..."'; + return wrap('obj-field obj-search', '' + esc(label) + '
'); + } + var ph = hint ? ' placeholder="' + esc(hint) + '"' : ''; + return wrap('obj-field', '' + esc(label) + ''); +} + +function renderCoreCard(data) { + var sec = SECTIONS[0]; + var h = '
'; + h += renderField(sec, sec.fields[0], data, -1, null, 'flex:1'); // name + h += renderField(sec, sec.fields[2], data, -1, null, 'flex:2'); // aliases + h += renderField(sec, sec.fields[1], data, -1); // color + h += renderField(sec, sec.fields[3], data, -1); // hidden + h += '
'; + h += '
'; + h += renderField(sec, sec.fields[5], data, -1); // description + h += renderField(sec, sec.fields[4], data, -1); // in-room description + h += renderField(sec, sec.fields[6], data, -1); // removal item + h += '
'; + return h; +} + +function renderArraySection(sec, data) { + var arr = data[cardPath(sec)] || []; + var h = ''; + arr.forEach(function(item, idx) { + h += '
'; + h += '
'; + sec.fields.forEach(function(f) { + h += renderField(sec, f, data, idx); + }); + h += '
'; + h += ''; + h += '
'; + }); + h += ''; + return h; +} + +function renderSubtable(sec, st, data) { + var sectionRoot = objectData[cardPath(sec)] || {}; + var arr; + if (st.single) { + arr = sectionRoot[st.key] ? [sectionRoot[st.key]] : []; + } else { + arr = sectionRoot[st.key] || []; + } + var isDrops = sec.id === 'gather' && st.key === 'drops'; + var h = '
'; + h += '
' + esc(st.label) + '
'; + arr.forEach(function(item, idx) { + h += '
'; + if (isDrops) { + var isItemDrop = item._type !== undefined ? item._type !== 't' : !item.table; + var showField = isItemDrop ? 'item_id' : 'table'; + var hideField = isItemDrop ? 'table' : 'item_id'; + h += '
'; + h += ''; + st.fields.forEach(function(f) { + if (f[0] === 'message' || f[0] === hideField) return; + var fid = fieldIDSub(sec, st.key, idx, f[0]); + var val = item[f[0]]; + val = val === undefined || val === null ? '' : val; + var extraCls = ''; + if (f[4] === 'narrow') extraCls = ' obj-narrow'; + if (f[2] === 'search') { + var entity = f[5] || 'items'; + var ph = f[3] ? ' placeholder="' + escAttr(f[3]) + '"' : ' placeholder="Search ' + entity + '..."'; + h += ''; + } else if (f[2] === 'checkbox') { + h += ''; + } else { + h += ''; + } + }); + if (!st.single) { + h += ''; + } + h += '
'; + h += '
'; + var fidMsg = fieldIDSub(sec, st.key, idx, 'message'); + var msgVal = item.message !== undefined && item.message !== null ? item.message : ''; + var msgField = st.fields.find(function(f){return f[0]==='message';}); + h += ''; + h += '
'; + } else { + st.fields.forEach(function(f) { + var fid = fieldIDSub(sec, st.key, idx, f[0]); + var val = item[f[0]]; + val = val === undefined || val === null ? '' : val; + var extraCls = ''; + if (f[4] === 'narrow') extraCls = ' obj-narrow'; + else if (f[4] === 'grow') extraCls = ' obj-grow'; + if (f[2] === 'search') { + var entity = f[5] || 'items'; + var ph = f[3] ? ' placeholder="' + escAttr(f[3]) + '"' : ' placeholder="Search ' + entity + '..."'; + h += ''; + } else if (f[2] === 'checkbox') { + h += ''; + } else { + h += ''; + } + }); + if (!st.single) { + h += ''; + } + } + h += '
'; + }); + if (isDrops) { + h += '
'; + h += ''; + h += ''; + h += '
'; + } else if (!st.single || arr.length === 0) { + h += ''; + } + h += '
'; + return h; +} + +function renderTags(sec, il, data) { + var sectionRoot = objectData[cardPath(sec)] || {}; + var arr = sectionRoot[il.key] || []; + var str = Array.isArray(arr) ? arr.join(', ') : String(arr || ''); + var fid = fieldID(sec, il.key); + return ''; +} + +function renderKV(sec, il, data) { + var sectionRoot = objectData[cardPath(sec)] || {}; + var map = sectionRoot[il.key] || {}; + var h = '
'; + h += '' + esc(il.label) + ''; + h += '
'; + Object.keys(map).forEach(function(k, idx) { + h += '
'; + h += ''; + h += '
'; + }); + h += '
'; + h += ''; + h += '
'; + return h; +} + +function toggleCard(id) { + expandedCards[id] = expandedCards[id] === false ? true : false; + renderCurrent(); +} + +function removeSection(id) { + var sec = SECTIONS.find(function(s) { return s.id === id; }); + if (!sec) return; + if (sec.path) { + delete objectData[sec.path]; + } else if (id === 'steal') { + delete objectData.steal_table; + delete objectData.steal_level; + delete objectData.steal_xp; + delete objectData.steal_speed; + delete objectData.guard_mob; + } + renderCurrent(); +} + +function addSection() { + var sel = $('#addSectionSelect'); + var id = sel.value; + if (!id) return; + var sec = SECTIONS.find(function(s) { return s.id === id; }); + if (!sec) return; + + if (sec.path) { + if (sec.isArray) { + objectData[sec.path] = []; + } else if (sec.id === 'on_look') { + objectData[sec.path] = {}; + } else if (sec.id === 'gather') { + objectData[sec.path] = {skill:'', tools:[], success:{base:0, per_level:0, cap:1}, drops:[], gather_message:'', fail_message:'', respawn_timer:0, respawn_broadcast:'', deplete_timer:0, nest_chance:0}; + } else if (sec.id === 'use') { + objectData[sec.path] = {message:'', wait:1, consume:{}, reward:{}, fail_message:'', success:{base:0, per_level:0, cap:1}, skill:'', level:1, xp:0}; + } else if (sec.id === 'safespot') { + objectData[sec.path] = {tier:1, max_block_size:'', max_occupants:1, unsafe_chance:0, decay_ticks:0, decay_chance:0, respawn_on_hide:false, respawn_ticks:0, levels:[]}; + } else if (sec.id === 'talk') { + objectData[sec.path] = {nodes:{start:{message:'',options:[]}}}; + } else { + objectData[sec.path] = {}; + } + } + expandedCards[id] = true; + renderCurrent(); +} + +function addArrayItem(cardID) { + var sec = SECTIONS.find(function(s) { return s.id === cardID; }); + if (!sec || !sec.isArray) return; + var arr = objectData[sec.path] || []; + var empty = {}; + sec.fields.forEach(function(f) { + empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' || f[2] === 'search' ? 0 : ''); + }); + arr.push(empty); + objectData[sec.path] = arr; + renderCurrent(); +} + +function removeArrayItem(cardID, idx) { + var sec = SECTIONS.find(function(s) { return s.id === cardID; }); + if (!sec || !sec.isArray) return; + var arr = objectData[sec.path] || []; + arr.splice(idx, 1); + renderCurrent(); +} + +function addSubRow(cardID, subKey) { + var sec = SECTIONS.find(function(s) { return s.id === cardID; }); + if (!sec) return; + var sectionRoot = objectData[sec.path || ''] || objectData; + var arr = sectionRoot[subKey] || []; + var empty = {}; + var st = (sec.subtables || []).find(function(s) { return s.key === subKey; }); + if (st) { + st.fields.forEach(function(f) { + empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' || f[2] === 'search' ? 0 : ''); + }); + } + arr.push(empty); + sectionRoot[subKey] = arr; + renderCurrent(); +} + +function removeSubRow(cardID, subKey, idx) { + var sec = SECTIONS.find(function(s) { return s.id === cardID; }); + if (!sec) return; + var sectionRoot = objectData[sec.path || ''] || objectData; + var arr = sectionRoot[subKey] || []; + arr.splice(idx, 1); + renderCurrent(); +} + +function addKVRow(cardID, subKey) { + var el = document.querySelector('.obj-kv-list[data-sec="' + cardID + '"][data-key="' + subKey + '"]'); + if (!el) return; + var row = document.createElement('div'); + row.className = 'obj-kv-row'; + row.innerHTML = ''; + el.appendChild(row); +} + +function addDropRow(cardID, subKey, dropType) { + var sec = SECTIONS.find(function(s) { return s.id === cardID; }); + if (!sec) return; + var sectionRoot = objectData[sec.path || ''] || objectData; + var arr = sectionRoot[subKey] || []; + var empty = { item_id: '', table: '', weight: 0, level: 0, xp: 0, quantity: 0, depletes: false, message: '', _type: (dropType === 'table' ? 't' : 'i') }; + arr.push(empty); + sectionRoot[subKey] = arr; + renderCurrent(); +} + +function validateDropRow(idx) { + // No longer needed — drops are either item or table type, never both. +} + +function renderCurrent() { + if (!objectData || !objectID) return; + renderObjectEditor(objectID, objectData); +} + +function toggleGatherConditionals() { + var skillEl = document.getElementById('f_gather_skill'); + var skill = skillEl ? skillEl.value : ''; + var fishing = skill === 'fishing'; + var wood = skill === 'woodcutting'; + var els = ['bait', 'exhausted_message', 'deplete_timer', 'nest_chance']; + var vis = {bait: fishing, exhausted_message: wood, deplete_timer: wood, nest_chance: wood}; + els.forEach(function(key) { + var el = document.getElementById('f_gather_' + key); + if (el) el.closest('.obj-field').style.display = vis[key] ? '' : 'none'; + }); +} + +function setupSearchFields() { + document.querySelectorAll('.search-input').forEach(function(input) { + if (input._searchSetup) return; + input._searchSetup = true; + var entityType = input.getAttribute('data-search-type') || 'items'; + var results = input.parentElement.querySelector('.search-results'); + if (!results) return; + + results._targetInput = input; + + input.addEventListener('input', function() { + var val = input.value.trim(); + if (!val) { results.style.display = 'none'; results.innerHTML = ''; return; } + API.get('/api/search?q=' + encodeURIComponent(val)).then(function(data) { + var items = []; + if (entityType === 'items' && data.items) items = data.items; + if (entityType === 'objects' && data.objects) items = data.objects; + if (entityType === 'mobs' && data.mobs) items = data.mobs; + if (entityType === 'drops' && data.drops) items = data.drops; + if (items.length === 0) { + results.style.display = 'block'; + results.innerHTML = '
No results
'; + return; + } + results.style.display = 'block'; + results.innerHTML = items.map(function(r) { + return '
' + esc(r.id) + ' ' + esc(r.name || '') + '
'; + }).join(''); + results._items = items; + }).catch(function() { + results.style.display = 'none'; + }); + }); + + input.addEventListener('focus', function() { + if (input.value.trim()) { + input.dispatchEvent(new Event('input')); + } + }); + + input.addEventListener('blur', function() { + setTimeout(function() { results.style.display = 'none'; }, 200); + }); + }); + + document.addEventListener('click', function(e) { + if (!e.target.closest('.search-results') && !e.target.closest('.search-input')) { + document.querySelectorAll('.search-results').forEach(function(r) { r.style.display = 'none'; }); + } + }); +} + +function selectObjSearchResult(el) { + var results = el.parentElement; + var idx = Array.prototype.indexOf.call(results.children, el); + if (results._items && results._items[idx] && results._targetInput) { + results._targetInput.value = results._items[idx].id; + results._targetInput.dispatchEvent(new Event('input')); + results.style.display = 'none'; + results.innerHTML = ''; + } +} + +function validateFields() { + var errors = []; + document.querySelectorAll('.obj-field-error').forEach(function(field) { + field.classList.remove('obj-field-error'); + }); + + document.querySelectorAll('input[id][type="text"], input[id]:not([type])').forEach(function(input) { + var id = input.id; + if (!id) return; + var labelEl = input.closest('.obj-field'); + if (!labelEl) return; + + var fieldDiv = labelEl.closest('[data-card]'); + var cardID = fieldDiv ? fieldDiv.getAttribute('data-card') : ''; + var sec = SECTIONS.find(function(s) { return s.id === cardID; }); + if (!sec) return; + + var isSearch = input.classList.contains('search-input'); + var subKeyMatch = id.match(/^f_(\w+)_(\w+)_(\d+)_(\w+)$/); + var numberField = null; + + if (isSearch) { + return; + } + + if (subKeyMatch) { + var subKey = subKeyMatch[2]; + var st = (sec.subtables || []).find(function(s) { return s.key === subKey; }); + if (st) { + var fieldKey = subKeyMatch[4]; + numberField = st.fields.find(function(f) { return f[0] === fieldKey && f[2] === 'number'; }); + } + } else { + numberField = sec.fields.find(function(f) { + var fp = f[0]; + if (sec.path && sec.path !== 'on_look') fp = fp; + var fid = fieldID(sec, fp, -1); + return fid === id && f[2] === 'number'; + }); + if (!numberField && sec.inline) { + sec.inline.forEach(function(il) { + if (il.fields) { + var nf = il.fields.find(function(f) { + var fid = fieldID(sec, il.path + '.' + f[0], -1); + return fid === id && f[2] === 'number'; + }); + if (nf) numberField = nf; + } + }); + } + } + + if (numberField) { + var rawVal = input.value.trim(); + if (rawVal !== '' && !/^-?\d*\.?\d*$/.test(rawVal)) { + labelEl.classList.add('obj-field-error'); + input.focus(); + input.select(); + var labelText = (labelEl.querySelector('span') || {}).textContent || numberField[1] || ''; + errors.push('Invalid value in "' + esc(labelText) + '": "' + esc(rawVal) + '"'); + return; + } + } + }); + + if (errors.length > 0) { + notify(errors.join('; '), 'error'); + return false; + } + return true; +} + +function collectFieldValue(el, type) { + if (type === 'checkbox') return el.checked; + if (type === 'select') return el.value; + if (type === 'search') return el.value; + if (type === 'number') { var n = parseFloat(el.value); return isNaN(n) ? 0 : n; } + if (type === 'json') { + var raw = el.value.trim(); + if (!raw) return null; + try { return JSON.parse(raw); } catch(e) { return raw; } + } + if (type === 'textarea') return el.value; + return el.value; +} + +function saveObject() { + if (!validateFields()) return; + + var out = {}; + + SECTIONS.forEach(function(sec) { + if (sec.isArray) { + var arr = []; + var card = document.querySelector('.obj-card[data-card="' + sec.id + '"]'); + if (!card) return; + var rows = card.querySelectorAll('.obj-sub-row'); + rows.forEach(function(row, idx) { + var item = {}; + sec.fields.forEach(function(f) { + var fid = fieldID(sec, f[0], idx); + var el = document.getElementById(fid); + if (el) item[f[0]] = collectFieldValue(el, f[2]); + }); + if (Object.keys(item).length > 0) arr.push(item); + }); + if (arr.length > 0) out[sec.path] = arr; + return; + } + + if (sec.id === 'talk') { + if (objectData.talk) out.talk = objectData.talk; + return; + } + + var sectionObj = {}; + var hasValues = sec.always; + + sec.fields.forEach(function(f) { + var fid = fieldID(sec, f[0]); + var el = document.getElementById(fid); + if (!el) return; + var val = collectFieldValue(el, f[2]); + if (val !== '' && val !== false && val !== 0 && val !== null) hasValues = true; + sectionObj[f[0]] = val; + }); + + if (sec.subtables) { + sec.subtables.forEach(function(st) { + var items = []; + for (var i = 0; ; i++) { + var item = {}; + var found = false; + st.fields.forEach(function(f) { + var fid = fieldIDSub(sec, st.key, i, f[0]); + var el = document.getElementById(fid); + if (el) found = true; + if (el) item[f[0]] = collectFieldValue(el, f[2]); + }); + if (!found) break; + if (Object.keys(item).length > 0) items.push(item); + } + if (st.single) { + if (items.length > 0) { sectionObj[st.key] = items[0]; hasValues = true; } + } else { + if (items.length > 0) { sectionObj[st.key] = items; hasValues = true; } + } + }); + } + + if (sec.inline) { + sec.inline.forEach(function(il) { + if (il.type === 'tags') { + var fid = fieldID(sec, il.key); + var el = document.getElementById(fid); + if (el && el.value.trim()) { + sectionObj[il.key] = el.value.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; }); + hasValues = true; + } + } else if (il.type === 'kv') { + var kvEl = document.querySelector('.obj-kv-list[data-sec="' + sec.id + '"][data-key="' + il.key + '"]'); + if (kvEl) { + var map = {}; + kvEl.querySelectorAll('.obj-kv-row').forEach(function(row) { + var keyEl = row.querySelector('.obj-kv-key'); + var valEl = row.querySelector('.obj-kv-val'); + if (keyEl && keyEl.value.trim() && valEl) { + map[keyEl.value.trim()] = parseFloat(valEl.value) || 0; + } + }); + if (Object.keys(map).length > 0) { sectionObj[il.key] = map; hasValues = true; } + } + } else { + var ssec = {}; + var sHas = false; + il.fields.forEach(function(f) { + var fp = il.path + '.' + f[0]; + var fid = fieldID(sec, fp); + var el = document.getElementById(fid); + if (el) sHas = true; + if (el) ssec[f[0]] = collectFieldValue(el, f[2]); + }); + if (sHas) { sectionObj[il.path] = ssec; hasValues = true; } + } + }); + } + + if (sec.always) { + Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; }); + if (typeof out.aliases === 'string') { + out.aliases = out.aliases ? out.aliases.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; }) : []; + } + } else if (hasValues && sec.path) { + out[sec.path] = sectionObj; + } + }); + + out.id = objectID; + + API.put('/api/objects/' + encodeURIComponent(objectID), out).then(function() { + notify('Object ' + objectID + ' saved', 'success'); + updateUndoBar(); + }).catch(function(e) { notify('Save failed: ' + e.message, 'error'); }); +} + +function duplicateObject() { + var baseID = objectID + '_copy'; + var newID = baseID; + var counter = 2; + while (true) { + var exists = false; + for (var i = 0; i < allIDs.length; i++) { + if (allIDs[i] === newID) { exists = true; break; } + } + if (!exists) break; + newID = baseID + '_' + counter; + counter++; + } + + var copy = JSON.parse(JSON.stringify(objectData)); + delete copy._raw; + delete copy._path; + copy.id = newID; + + API.post('/api/objects', {id: newID}).then(function() { + return API.put('/api/objects/' + encodeURIComponent(newID), copy); + }).then(function() { + notify('Duplicated as ' + newID, 'success'); + updateUndoBar(); + loadList(); + loadObject(newID); + }).catch(function(e) { notify('Duplicate failed: ' + e.message, 'error'); }); +} + +function deleteObject() { + if (!confirm('Delete object "' + objectID + '"?')) return; + API.del('/api/objects/' + encodeURIComponent(objectID)).then(function() { + notify('Object ' + objectID + ' deleted', 'success'); + updateUndoBar(); + objectID = null; + objectData = null; + $('#editorMain').innerHTML = '

Deleted

'; + loadList(); + }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); }); +} + +// Re-exports for editor.js compatibility +var currentID = null; +Object.defineProperty(window, 'currentID', { get: function() { return objectID; }, set: function(v) { objectID = v; } }); + +function loadItem(id) { loadObject(id); } +function createNew() { + var name = prompt('Object ID:'); + if (!name) return; + API.post('/api/objects', {id: name}).then(function() { + notify('Object ' + name + ' created', 'success'); + updateUndoBar(); + loadList(); + loadObject(name); + }).catch(function(e) { notify('Create failed: ' + e.message, 'error'); }); +} diff --git a/internal/admin/templates/login.html b/internal/admin/templates/login.html index ffc59c6..52d1f13 100644 --- a/internal/admin/templates/login.html +++ b/internal/admin/templates/login.html @@ -8,7 +8,8 @@
-

THOI Admin Portal

+

The House of Icarus

+

Admin Portal

{{if .Error}}

{{.Error}}

{{end}}
@@ -23,6 +24,12 @@
+

+ Use the same credentials you use in-game. To enable admin access, set + admin: true + in your account YAML at + data/players/accounts/<account>.yaml. +

diff --git a/internal/admin/templates/map.html b/internal/admin/templates/map.html index 6fd6261..3bcbb30 100644 --- a/internal/admin/templates/map.html +++ b/internal/admin/templates/map.html @@ -17,6 +17,10 @@ Zoom: + diff --git a/internal/admin/templates/objects.html b/internal/admin/templates/objects.html index 91aca09..ac9b6be 100644 --- a/internal/admin/templates/objects.html +++ b/internal/admin/templates/objects.html @@ -10,20 +10,9 @@ + + {{end}} diff --git a/internal/admin/undo.go b/internal/admin/undo.go index bdd16e6..4921ae5 100644 --- a/internal/admin/undo.go +++ b/internal/admin/undo.go @@ -9,15 +9,22 @@ import ( "time" ) +type ExtraFile struct { + FilePath string `json:"file_path"` + OldContent []byte `json:"-"` + NewContent []byte `json:"-"` +} + type ChangeDesc struct { - Time string `json:"time"` - Description string `json:"description"` - FilePath string `json:"file_path"` - NewFilePath string `json:"new_file_path,omitempty"` - OldContent []byte `json:"old_content"` - NewContent []byte `json:"new_content"` - IsDelete bool `json:"is_delete"` - IsCreate bool `json:"is_create"` + Time string `json:"time"` + Description string `json:"description"` + FilePath string `json:"file_path"` + NewFilePath string `json:"new_file_path,omitempty"` + OldContent []byte `json:"old_content"` + NewContent []byte `json:"new_content"` + IsDelete bool `json:"is_delete"` + IsCreate bool `json:"is_create"` + ExtraFiles []ExtraFile `json:"extra_files,omitempty"` } type UndoInfo struct { @@ -77,8 +84,16 @@ func (us *UndoStack) Undo() *ChangeDesc { log.Printf("undo: failed to restore deleted file %s: %v", last.FilePath, err) return nil } + for _, ef := range last.ExtraFiles { + if err := os.WriteFile(ef.FilePath, ef.OldContent, 0644); err != nil { + log.Printf("undo: failed to restore extra file %s: %v", ef.FilePath, err) + } + } } else if last.IsCreate { os.Remove(last.FilePath) + for _, ef := range last.ExtraFiles { + os.Remove(ef.FilePath) + } } else { if last.NewFilePath != "" { if err := os.WriteFile(last.FilePath, last.OldContent, 0644); err != nil { @@ -91,6 +106,11 @@ func (us *UndoStack) Undo() *ChangeDesc { return nil } } + for _, ef := range last.ExtraFiles { + if err := os.WriteFile(ef.FilePath, ef.OldContent, 0644); err != nil { + log.Printf("undo: failed to restore extra file %s: %v", ef.FilePath, err) + } + } } us.redo = append(us.redo, last) @@ -122,8 +142,18 @@ func (us *UndoStack) Redo() *ChangeDesc { return nil } } + for _, ef := range last.ExtraFiles { + if err := os.WriteFile(ef.FilePath, ef.NewContent, 0644); err != nil { + log.Printf("redo: failed to create extra file %s: %v", ef.FilePath, err) + } + } } else if last.IsDelete { os.Remove(last.FilePath) + for _, ef := range last.ExtraFiles { + if err := os.WriteFile(ef.FilePath, ef.NewContent, 0644); err != nil { + log.Printf("redo: failed to write extra file %s: %v", ef.FilePath, err) + } + } } else { if last.NewFilePath != "" { if err := os.WriteFile(last.NewFilePath, last.NewContent, 0644); err != nil { @@ -136,6 +166,11 @@ func (us *UndoStack) Redo() *ChangeDesc { return nil } } + for _, ef := range last.ExtraFiles { + if err := os.WriteFile(ef.FilePath, ef.NewContent, 0644); err != nil { + log.Printf("redo: failed to write extra file %s: %v", ef.FilePath, err) + } + } } us.history = append(us.history, last) @@ -162,15 +197,21 @@ func (us *UndoStack) Info() UndoInfo { } func (us *UndoStack) save() { + type extraEntry struct { + FilePath string `json:"file_path"` + OldContent string `json:"old_content"` + NewContent string `json:"new_content"` + } type entry struct { - Time string `json:"time"` - Description string `json:"description"` - FilePath string `json:"file_path"` - NewFilePath string `json:"new_file_path,omitempty"` - OldContent string `json:"old_content"` - NewContent string `json:"new_content"` - IsDelete bool `json:"is_delete"` - IsCreate bool `json:"is_create"` + Time string `json:"time"` + Description string `json:"description"` + FilePath string `json:"file_path"` + NewFilePath string `json:"new_file_path,omitempty"` + OldContent string `json:"old_content"` + NewContent string `json:"new_content"` + IsDelete bool `json:"is_delete"` + IsCreate bool `json:"is_create"` + ExtraFiles []extraEntry `json:"extra_files,omitempty"` } type saveData struct { History []entry `json:"history"` @@ -179,6 +220,14 @@ func (us *UndoStack) save() { toEntries := func(changes []ChangeDesc) []entry { var entries []entry for _, c := range changes { + var extra []extraEntry + for _, ef := range c.ExtraFiles { + extra = append(extra, extraEntry{ + FilePath: ef.FilePath, + OldContent: string(ef.OldContent), + NewContent: string(ef.NewContent), + }) + } entries = append(entries, entry{ Time: c.Time, Description: c.Description, @@ -188,6 +237,7 @@ func (us *UndoStack) save() { NewContent: string(c.NewContent), IsDelete: c.IsDelete, IsCreate: c.IsCreate, + ExtraFiles: extra, }) } return entries @@ -209,15 +259,21 @@ func (us *UndoStack) load() { if err != nil { return } + type extraEntry struct { + FilePath string `json:"file_path"` + OldContent string `json:"old_content"` + NewContent string `json:"new_content"` + } type entry struct { - Time string `json:"time"` - Description string `json:"description"` - FilePath string `json:"file_path"` - NewFilePath string `json:"new_file_path,omitempty"` - OldContent string `json:"old_content"` - NewContent string `json:"new_content"` - IsDelete bool `json:"is_delete"` - IsCreate bool `json:"is_create"` + Time string `json:"time"` + Description string `json:"description"` + FilePath string `json:"file_path"` + NewFilePath string `json:"new_file_path,omitempty"` + OldContent string `json:"old_content"` + NewContent string `json:"new_content"` + IsDelete bool `json:"is_delete"` + IsCreate bool `json:"is_create"` + ExtraFiles []extraEntry `json:"extra_files,omitempty"` } var saveData struct { History []entry `json:"history"` @@ -229,6 +285,14 @@ func (us *UndoStack) load() { fromEntries := func(entries []entry) []ChangeDesc { var changes []ChangeDesc for _, e := range entries { + var extra []ExtraFile + for _, ef := range e.ExtraFiles { + extra = append(extra, ExtraFile{ + FilePath: ef.FilePath, + OldContent: []byte(ef.OldContent), + NewContent: []byte(ef.NewContent), + }) + } changes = append(changes, ChangeDesc{ Time: e.Time, Description: e.Description, @@ -238,6 +302,7 @@ func (us *UndoStack) load() { NewContent: []byte(e.NewContent), IsDelete: e.IsDelete, IsCreate: e.IsCreate, + ExtraFiles: extra, }) } return changes diff --git a/internal/admin/yaml_util.go b/internal/admin/yaml_util.go index b97a4f2..0bc2b4a 100644 --- a/internal/admin/yaml_util.go +++ b/internal/admin/yaml_util.go @@ -86,6 +86,45 @@ func listYAMLFiles(dataDir, subdir string) ([]string, error) { return ids, nil } +type YAMLTree struct { + Root []string `json:"root"` + Dirs map[string][]string `json:"dirs"` +} + +func listYAMLTree(dataDir, subdir string) (*YAMLTree, error) { + tree := &YAMLTree{Root: []string{}, Dirs: map[string][]string{}} + base := filepath.Join(dataDir, subdir) + + entries, err := os.ReadDir(base) + if err != nil { + return nil, err + } + for _, e := range entries { + if !e.IsDir() && filepath.Ext(e.Name()) == ".yaml" { + tree.Root = append(tree.Root, e.Name()[:len(e.Name())-5]) + } + } + for _, e := range entries { + if e.IsDir() { + subPath := filepath.Join(base, e.Name()) + subEntries, err := os.ReadDir(subPath) + if err != nil { + continue + } + var ids []string + for _, se := range subEntries { + if !se.IsDir() && filepath.Ext(se.Name()) == ".yaml" { + ids = append(ids, se.Name()[:len(se.Name())-5]) + } + } + if len(ids) > 0 { + tree.Dirs[e.Name()] = ids + } + } + } + return tree, nil +} + func listYAMLFilesDeep(dataDir, subdir string) ([]string, error) { var ids []string base := filepath.Join(dataDir, subdir) diff --git a/internal/behavior/behavior.go b/internal/behavior/behavior.go index e01d8a7..f64b4b2 100644 --- a/internal/behavior/behavior.go +++ b/internal/behavior/behavior.go @@ -2,9 +2,6 @@ package behavior type GatherConfig struct { Skill string `yaml:"skill"` - Level int `yaml:"level"` - XP int `yaml:"xp"` - BaseWait float64 `yaml:"base_wait"` Tools []string `yaml:"tools"` Bait string `yaml:"bait"` Success SuccessFormula `yaml:"success"` diff --git a/internal/behavior/types.go b/internal/behavior/types.go index 1b7ac1a..c061742 100644 --- a/internal/behavior/types.go +++ b/internal/behavior/types.go @@ -77,7 +77,7 @@ type GatherData struct { ObjDefID string InstanceKey string InstanceIdx int - EffectiveWait float64 + Wait float64 DepleteTimer bool Step int Verb string diff --git a/internal/game/act_gather.go b/internal/game/act_gather.go index 826b901..ab59de0 100644 --- a/internal/game/act_gather.go +++ b/internal/game/act_gather.go @@ -32,8 +32,20 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje } skillLevel := p.Level(player.SkillName(cfg.Skill)) - if cfg.Level > 0 && skillLevel < cfg.Level { - sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", cfg.Level, cfg.Skill)) + + eligible := filterDropsByLevel(cfg.Drops, skillLevel) + if len(eligible) == 0 { + minLevel := 0 + for _, d := range cfg.Drops { + if d.Level > 0 && (minLevel == 0 || d.Level < minLevel) { + minLevel = d.Level + } + } + if minLevel > 0 { + sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", minLevel, cfg.Skill)) + } else { + sess.WriteLine("You are not skilled enough to do that.") + } return } @@ -52,7 +64,7 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje return } - wait := cfg.BaseWait + var wait float64 var toolName string if len(cfg.Tools) > 0 { @@ -67,7 +79,7 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje return } toolName = name - wait = cfg.BaseWait - toolSpeed + wait = toolSpeed if wait < 1 { wait = 1 } @@ -97,12 +109,12 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje g.broadcastAction(sess, "%s starts %s the %s.", p.Name, verb, obj.Name) d := &behavior.GatherData{ - ObjDefID: obj.ID, - InstanceKey: g.World.ObjStateKey(st.RoomID, st.DefID, st.Index), - InstanceIdx: st.Index + 1, - EffectiveWait: wait, - Verb: verb, - ToolName: toolName, + ObjDefID: obj.ID, + InstanceKey: g.World.ObjStateKey(st.RoomID, st.DefID, st.Index), + InstanceIdx: st.Index + 1, + Wait: wait, + Verb: verb, + ToolName: toolName, } if cfg.DepleteTimer > 0 { d.DepleteTimer = true @@ -125,7 +137,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { } step := d.Step - wait := d.EffectiveWait + wait := d.Wait instanceKey := d.InstanceKey shared := d.DepleteTimer @@ -177,7 +189,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { } skillLevel := p.Level(player.SkillName(cfg.Skill)) - chance := behavior.SuccessChance(cfg.Success, skillLevel, cfg.Level) + chance := behavior.SuccessChance(cfg.Success, skillLevel, 0) if rand.Float64() < chance { eligible := filterDropsByLevel(cfg.Drops, skillLevel) @@ -203,9 +215,6 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty}) xp := drop.XP - if xp <= 0 { - xp = cfg.XP - } if xp > 0 { g.awardSkillXP(sess, p, player.SkillName(cfg.Skill), xp) } @@ -250,18 +259,22 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { st.DepleteTimer = float64(delay) } + depletionMsg := cfg.ExhaustedMessage + if depletionMsg == "" { + depletionMsg = fmt.Sprintf("The %s was depleted by %s!", p.Action.TargetName, p.Name) + } + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { - if other == sess { - continue - } op := other.Player if op == nil || op.Action == nil || op.Action.Type != behavior.TypeGather { continue } if gd, ok := op.Action.Data.(*behavior.GatherData); ok && gd.InstanceKey == instanceKey { - other.WriteLine(fmt.Sprintf("The %s was depleted by %s!", g.colorize(sess, "item", p.Action.TargetName), g.colorize(sess, "player_name", p.Name))) - g.cancelAction(op) - g.writePrompt(other) + other.WriteLine(depletionMsg) + if other != sess { + g.cancelAction(op) + g.writePrompt(other) + } } } @@ -298,9 +311,6 @@ func filterDropsByLevel(drops []behavior.DropEntry, level int) []behavior.DropEn eligible = append(eligible, d) } } - if len(eligible) == 0 { - return drops - } return eligible } diff --git a/internal/validate/checks.go b/internal/validate/checks.go index d835a53..50fd9a5 100644 --- a/internal/validate/checks.go +++ b/internal/validate/checks.go @@ -995,6 +995,14 @@ func validateTechs(s Source) []Issue { func validateGather(prefix string, cfg *behavior.GatherConfig, itemIDs map[string]bool, dropIDs map[string]bool) []Issue { var issues []Issue + if len(cfg.Tools) == 0 { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "config", + Message: fmt.Sprintf("%s: gather must have at least one tool", prefix), + }) + } + if cfg.Bait != "" && !itemIDs[cfg.Bait] { issues = append(issues, Issue{ Level: "ERROR", -- cgit v1.2.3