aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--data/.admin_history.json1
-rw-r--r--data/techs/amplifier_1.yaml1
-rw-r--r--data/techs/amplifier_2.yaml1
-rw-r--r--data/techs/amplifier_3.yaml1
-rw-r--r--data/techs/clarity_1.yaml1
-rw-r--r--data/techs/clarity_2.yaml1
-rw-r--r--data/techs/clarity_3.yaml1
-rw-r--r--data/techs/focus_1.yaml1
-rw-r--r--data/techs/focus_2.yaml1
-rw-r--r--data/techs/focus_3.yaml1
-rw-r--r--data/techs/fortify.yaml1
-rw-r--r--data/techs/overclock.yaml1
-rw-r--r--data/techs/preserve.yaml1
-rw-r--r--data/techs/protect_melee.yaml1
-rw-r--r--data/techs/protect_ranged.yaml1
-rw-r--r--data/techs/protect_science.yaml1
-rw-r--r--data/techs/rapid_heal.yaml1
-rw-r--r--data/techs/regen.yaml1
-rw-r--r--data/techs/retribution.yaml1
-rw-r--r--data/techs/shield_1.yaml1
-rw-r--r--data/techs/shield_2.yaml1
-rw-r--r--data/techs/shield_3.yaml1
-rw-r--r--data/techs/targeting_1.yaml1
-rw-r--r--data/techs/targeting_2.yaml1
-rw-r--r--data/techs/targeting_3.yaml1
-rw-r--r--internal/admin/api_techs.go30
-rw-r--r--internal/admin/static/admin.css1
-rw-r--r--internal/admin/static/techeditor.js220
-rw-r--r--internal/game/core_validate.go1
-rw-r--r--internal/game/sys_technology.go1
-rw-r--r--internal/validate/checks.go7
-rw-r--r--internal/validate/validate.go6
32 files changed, 231 insertions, 60 deletions
diff --git a/data/.admin_history.json b/data/.admin_history.json
deleted file mode 100644
index 1bafd70..0000000
--- a/data/.admin_history.json
+++ /dev/null
@@ -1 +0,0 @@
-{"history":[{"time":"2026-07-14T01:27:45-04:00","description":"delete course test (last obstacle removed)","file_path":"data/courses/test.yaml","old_content":"completion_xp: 0\nname: test\nobstacles:\n - exit_dir: southwest\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1009\n verb: climb\n xp: 0\nrequired_level: 1\nstart_room: 1007\n","new_content":"","is_delete":true,"is_create":false},{"time":"2026-07-14T01:27:45-04:00","description":"delete 3 rooms (cleaned 2 exits)","file_path":"data/rooms/intro/1008.yaml","old_content":"block_transport: false\ncolor: D3\ndescription: []\nexits:\n east:\n room: 1009\n south:\n always_blocked: false\n blocked_message: \"\"\n condition: null\n hidden: false\n room: 1007\nhazard: \"\"\nid: 1008\nitem_spawns: []\nmobs: []\nname: 'Room #1008'\nobjects: []\non_enter:\n - condition:\n all_of: []\n steps: []\n - steps: []\n - steps: []\non_exit:\n - steps: []\n - steps: []\n","new_content":"","is_delete":true,"is_create":false,"extra_files":[{"file_path":"data/rooms/intro/1007.yaml","old_content":"block_transport: false\ncolor: D3\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit: []\n","new_content":"id: 1007\nname: 'Room #1007'\ncolor: D3\ndescription: []\nexits:\n east:\n room: 1010\n south:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n"},{"file_path":"data/rooms/intro/1010.yaml","old_content":"block_transport: false\ncolor: D3\nexits:\n south:\n room: 1009\nhazard: \"\"\nid: 1010\nname: 'Room #1011'\n","new_content":"id: 1010\nname: 'Room #1011'\ncolor: D3\ndescription: []\nexits: {}\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n"},{"file_path":"data/rooms/intro/1011.yaml","old_content":"color: D3\nexits:\n south:\n room: 1009\nid: 1011\nname: 'Room #1011'\n","new_content":"","is_delete":true},{"file_path":"data/rooms/intro/1009.yaml","old_content":"color: D3\nexits:\n south:\n room: 1009\nid: 1009\nname: 'Room #1011'\n","new_content":"","is_delete":true}]},{"time":"2026-07-14T01:27:52-04:00","description":"delete room 1010 (cleaned 1 exits)","file_path":"data/rooms/intro/1010.yaml","old_content":"id: 1010\nname: 'Room #1011'\ncolor: D3\ndescription: []\nexits: {}\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n","new_content":"","is_delete":true,"is_create":false,"extra_files":[{"file_path":"data/rooms/intro/1007.yaml","old_content":"id: 1007\nname: 'Room #1007'\ncolor: D3\ndescription: []\nexits:\n east:\n room: 1010\n south:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n","new_content":"id: 1007\nname: 'Room #1007'\ncolor: D3\ndescription: []\nexits:\n south:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n"}]},{"time":"2026-07-14T01:27:58-04:00","description":"create room 1008","file_path":"data/rooms/intro/1008.yaml","old_content":"","new_content":"color: D3\nexits:\n west:\n room: 1007\nid: 1008\nname: 'Room #1008'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/1007.yaml","old_content":"id: 1007\nname: 'Room #1007'\ncolor: D3\ndescription: []\nexits:\n south:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n","new_content":"id: 1007\nname: 'Room #1007'\ncolor: D3\ndescription: []\nexits:\n east:\n room: 1008\n south:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n"}]},{"time":"2026-07-14T01:27:59-04:00","description":"create room 1009","file_path":"data/rooms/intro/1009.yaml","old_content":"","new_content":"color: D3\nexits:\n south:\n room: 1008\nid: 1009\nname: 'Room #1009'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/1008.yaml","old_content":"color: D3\nexits:\n west:\n room: 1007\nid: 1008\nname: 'Room #1008'\n","new_content":"id: 1008\nname: 'Room #1008'\ncolor: D3\ndescription: []\nexits:\n north:\n room: 1009\n west:\n room: 1007\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n"}]},{"time":"2026-07-14T01:27:59-04:00","description":"create room 1010","file_path":"data/rooms/intro/1010.yaml","old_content":"","new_content":"color: D3\nexits:\n east:\n room: 1009\nid: 1010\nname: 'Room #1010'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/1009.yaml","old_content":"color: D3\nexits:\n south:\n room: 1008\nid: 1009\nname: 'Room #1009'\n","new_content":"id: 1009\nname: 'Room #1009'\ncolor: D3\ndescription: []\nexits:\n south:\n room: 1008\n west:\n room: 1010\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n"}]},{"time":"2026-07-14T01:28:00-04:00","description":"create room 1011","file_path":"data/rooms/intro/1011.yaml","old_content":"","new_content":"color: D3\nexits:\n south:\n room: 1010\nid: 1011\nname: 'Room #1011'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/1010.yaml","old_content":"color: D3\nexits:\n east:\n room: 1009\nid: 1010\nname: 'Room #1010'\n","new_content":"id: 1010\nname: 'Room #1010'\ncolor: D3\ndescription: []\nexits:\n east:\n room: 1009\n north:\n room: 1011\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n"}]},{"time":"2026-07-14T01:28:00-04:00","description":"create room 1012","file_path":"data/rooms/intro/1012.yaml","old_content":"","new_content":"color: D3\nexits:\n west:\n room: 1011\nid: 1012\nname: 'Room #1012'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/1011.yaml","old_content":"color: D3\nexits:\n south:\n room: 1010\nid: 1011\nname: 'Room #1011'\n","new_content":"id: 1011\nname: 'Room #1011'\ncolor: D3\ndescription: []\nexits:\n east:\n room: 1012\n south:\n room: 1010\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n"}]},{"time":"2026-07-14T01:28:08-04:00","description":"swap room 1008 with 1009 (north)","file_path":"data/rooms/intro/1008.yaml","old_content":"id: 1008\nname: 'Room #1008'\ncolor: D3\ndescription: []\nexits:\n north:\n room: 1009\n west:\n room: 1007\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n","new_content":"block_transport: false\ncolor: D3\ndescription: []\nexits:\n north:\n room: 1009\n west:\n room: 1007\nhazard: \"\"\nid: 1008\nitem_spawns: []\nmobs: []\nname: 'Room #1009'\nobjects: []\n","is_delete":false,"is_create":false,"extra_files":[{"file_path":"data/rooms/intro/1009.yaml","old_content":"id: 1009\nname: 'Room #1009'\ncolor: D3\ndescription: []\nexits:\n south:\n room: 1008\n west:\n room: 1010\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n","new_content":"block_transport: false\ncolor: D3\ndescription: []\nexits:\n south:\n room: 1008\n west:\n room: 1010\nhazard: \"\"\nid: 1009\nitem_spawns: []\nmobs: []\nname: 'Room #1008'\nobjects: []\n"}]},{"time":"2026-07-14T01:28:15-04:00","description":"swap room 1009 with 1008 (south)","file_path":"data/rooms/intro/1009.yaml","old_content":"block_transport: false\ncolor: D3\ndescription: []\nexits:\n south:\n room: 1008\n west:\n room: 1010\nhazard: \"\"\nid: 1009\nitem_spawns: []\nmobs: []\nname: 'Room #1008'\nobjects: []\n","new_content":"block_transport: false\ncolor: D3\ndescription: []\nexits:\n south:\n room: 1008\n west:\n room: 1010\nhazard: \"\"\nid: 1009\nitem_spawns: []\nmobs: []\nname: 'Room #1009'\nobjects: []\n","is_delete":false,"is_create":false,"extra_files":[{"file_path":"data/rooms/intro/1008.yaml","old_content":"block_transport: false\ncolor: D3\ndescription: []\nexits:\n north:\n room: 1009\n west:\n room: 1007\nhazard: \"\"\nid: 1008\nitem_spawns: []\nmobs: []\nname: 'Room #1009'\nobjects: []\n","new_content":"block_transport: false\ncolor: D3\ndescription: []\nexits:\n north:\n room: 1009\n west:\n room: 1007\nhazard: \"\"\nid: 1008\nitem_spawns: []\nmobs: []\nname: 'Room #1008'\nobjects: []\n"}]},{"time":"2026-07-14T01:28:39-04:00","description":"update room 1010","file_path":"data/rooms/intro/1010.yaml","old_content":"id: 1010\nname: 'Room #1010'\ncolor: D3\ndescription: []\nexits:\n east:\n room: 1009\n north:\n room: 1011\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n","new_content":"block_transport: false\ncolor: D3\ndescription: []\nexits:\n east:\n room: 1009\n north:\n room: 1011\nhazard: \"\"\nid: 1010\nitem_spawns: []\nmobs: []\nname: 'Room #1009'\nobjects: []\n","is_delete":false,"is_create":false}],"redo":null} \ No newline at end of file
diff --git a/data/techs/amplifier_1.yaml b/data/techs/amplifier_1.yaml
index 6b47c9d..6de139d 100644
--- a/data/techs/amplifier_1.yaml
+++ b/data/techs/amplifier_1.yaml
@@ -1,7 +1,6 @@
name: Power Amplifier
level: 7
drain_rate: 0.05
-category: strength
group: strength_tier
effects:
strength_percent: 5
diff --git a/data/techs/amplifier_2.yaml b/data/techs/amplifier_2.yaml
index 3a8950a..df362d4 100644
--- a/data/techs/amplifier_2.yaml
+++ b/data/techs/amplifier_2.yaml
@@ -1,7 +1,6 @@
name: Enhanced Amplifier
level: 23
drain_rate: 0.10
-category: strength
group: strength_tier
effects:
strength_percent: 10
diff --git a/data/techs/amplifier_3.yaml b/data/techs/amplifier_3.yaml
index ff72433..c255c3a 100644
--- a/data/techs/amplifier_3.yaml
+++ b/data/techs/amplifier_3.yaml
@@ -1,7 +1,6 @@
name: Superior Amplifier
level: 49
drain_rate: 0.15
-category: strength
group: strength_tier
effects:
strength_percent: 15
diff --git a/data/techs/clarity_1.yaml b/data/techs/clarity_1.yaml
index 47afa33..7e44f2a 100644
--- a/data/techs/clarity_1.yaml
+++ b/data/techs/clarity_1.yaml
@@ -1,7 +1,6 @@
name: Clarity
level: 4
drain_rate: 0.05
-category: accuracy
group: accuracy_tier
effects:
accuracy_percent: 5
diff --git a/data/techs/clarity_2.yaml b/data/techs/clarity_2.yaml
index 9db17ed..d67b245 100644
--- a/data/techs/clarity_2.yaml
+++ b/data/techs/clarity_2.yaml
@@ -1,7 +1,6 @@
name: Enhanced Clarity
level: 16
drain_rate: 0.10
-category: accuracy
group: accuracy_tier
effects:
accuracy_percent: 10
diff --git a/data/techs/clarity_3.yaml b/data/techs/clarity_3.yaml
index 6ab54f7..69c04c5 100644
--- a/data/techs/clarity_3.yaml
+++ b/data/techs/clarity_3.yaml
@@ -1,7 +1,6 @@
name: Superior Clarity
level: 44
drain_rate: 0.15
-category: accuracy
group: accuracy_tier
effects:
accuracy_percent: 15
diff --git a/data/techs/focus_1.yaml b/data/techs/focus_1.yaml
index 07139c8..d509bc8 100644
--- a/data/techs/focus_1.yaml
+++ b/data/techs/focus_1.yaml
@@ -1,7 +1,6 @@
name: Neural Focus
level: 9
drain_rate: 0.05
-category: science
group: science_tier
effects:
science_percent: 5
diff --git a/data/techs/focus_2.yaml b/data/techs/focus_2.yaml
index cbda53a..6513e36 100644
--- a/data/techs/focus_2.yaml
+++ b/data/techs/focus_2.yaml
@@ -1,7 +1,6 @@
name: Enhanced Focus
level: 27
drain_rate: 0.10
-category: science
group: science_tier
effects:
science_percent: 10
diff --git a/data/techs/focus_3.yaml b/data/techs/focus_3.yaml
index 3f777bf..53f27e1 100644
--- a/data/techs/focus_3.yaml
+++ b/data/techs/focus_3.yaml
@@ -1,7 +1,6 @@
name: Superior Focus
level: 55
drain_rate: 0.15
-category: science
group: science_tier
effects:
science_percent: 15
diff --git a/data/techs/fortify.yaml b/data/techs/fortify.yaml
index c7a3fcf..e3d0bc2 100644
--- a/data/techs/fortify.yaml
+++ b/data/techs/fortify.yaml
@@ -1,7 +1,6 @@
name: Fortify
level: 65
drain_rate: 0.25
-category: combo
group: defense_tier
effects:
defense_percent: 15
diff --git a/data/techs/overclock.yaml b/data/techs/overclock.yaml
index 853365f..28982c8 100644
--- a/data/techs/overclock.yaml
+++ b/data/techs/overclock.yaml
@@ -1,7 +1,6 @@
name: Overclock
level: 60
drain_rate: 0.25
-category: combo
group: accuracy_tier
effects:
accuracy_percent: 15
diff --git a/data/techs/preserve.yaml b/data/techs/preserve.yaml
index eec5e88..610354f 100644
--- a/data/techs/preserve.yaml
+++ b/data/techs/preserve.yaml
@@ -1,7 +1,6 @@
name: Power Saver
level: 55
drain_rate: 0.05
-category: utility
group: ""
effects:
preserve_drain: 0.2
diff --git a/data/techs/protect_melee.yaml b/data/techs/protect_melee.yaml
index 730afcd..d4af9bc 100644
--- a/data/techs/protect_melee.yaml
+++ b/data/techs/protect_melee.yaml
@@ -1,7 +1,6 @@
name: Kinetic Barrier
level: 37
drain_rate: 0.20
-category: protection
group: protection
effects:
protect_melee: true
diff --git a/data/techs/protect_ranged.yaml b/data/techs/protect_ranged.yaml
index 2557af8..a3fb740 100644
--- a/data/techs/protect_ranged.yaml
+++ b/data/techs/protect_ranged.yaml
@@ -1,7 +1,6 @@
name: Projectile Screen
level: 40
drain_rate: 0.20
-category: protection
group: protection
effects:
protect_ranged: true
diff --git a/data/techs/protect_science.yaml b/data/techs/protect_science.yaml
index 8dc4e76..028123c 100644
--- a/data/techs/protect_science.yaml
+++ b/data/techs/protect_science.yaml
@@ -1,7 +1,6 @@
name: Neural Firewall
level: 43
drain_rate: 0.20
-category: protection
group: protection
effects:
protect_science: true
diff --git a/data/techs/rapid_heal.yaml b/data/techs/rapid_heal.yaml
index fe36c19..ed30096 100644
--- a/data/techs/rapid_heal.yaml
+++ b/data/techs/rapid_heal.yaml
@@ -1,7 +1,6 @@
name: Rapid Repair
level: 31
drain_rate: 0.15
-category: utility
group: regen_tier
effects:
hp_regen_multi: 4.0
diff --git a/data/techs/regen.yaml b/data/techs/regen.yaml
index 94855b4..8aad259 100644
--- a/data/techs/regen.yaml
+++ b/data/techs/regen.yaml
@@ -1,7 +1,6 @@
name: Nano Repair
level: 22
drain_rate: 0.10
-category: utility
group: regen_tier
effects:
hp_regen_multi: 2.0
diff --git a/data/techs/retribution.yaml b/data/techs/retribution.yaml
index a6350ee..8b3a771 100644
--- a/data/techs/retribution.yaml
+++ b/data/techs/retribution.yaml
@@ -1,7 +1,6 @@
name: Dead Man's Switch
level: 46
drain_rate: 0.10
-category: utility
group: ""
effects:
retribution_pct: 0.25
diff --git a/data/techs/shield_1.yaml b/data/techs/shield_1.yaml
index 0a445c2..b1dff79 100644
--- a/data/techs/shield_1.yaml
+++ b/data/techs/shield_1.yaml
@@ -1,7 +1,6 @@
name: Energy Shield
level: 10
drain_rate: 0.05
-category: defense
group: defense_tier
effects:
defense_percent: 5
diff --git a/data/techs/shield_2.yaml b/data/techs/shield_2.yaml
index bb55150..3eae4ec 100644
--- a/data/techs/shield_2.yaml
+++ b/data/techs/shield_2.yaml
@@ -1,7 +1,6 @@
name: Enhanced Shield
level: 28
drain_rate: 0.10
-category: defense
group: defense_tier
effects:
defense_percent: 10
diff --git a/data/techs/shield_3.yaml b/data/techs/shield_3.yaml
index da1358f..63b9237 100644
--- a/data/techs/shield_3.yaml
+++ b/data/techs/shield_3.yaml
@@ -1,7 +1,6 @@
name: Superior Shield
level: 52
drain_rate: 0.15
-category: defense
group: defense_tier
effects:
defense_percent: 15
diff --git a/data/techs/targeting_1.yaml b/data/techs/targeting_1.yaml
index 13a3cdb..2687aab 100644
--- a/data/techs/targeting_1.yaml
+++ b/data/techs/targeting_1.yaml
@@ -1,7 +1,6 @@
name: Targeting System
level: 8
drain_rate: 0.05
-category: ranged
group: ranged_tier
effects:
ranged_percent: 5
diff --git a/data/techs/targeting_2.yaml b/data/techs/targeting_2.yaml
index 55eaf5e..50d602d 100644
--- a/data/techs/targeting_2.yaml
+++ b/data/techs/targeting_2.yaml
@@ -1,7 +1,6 @@
name: Enhanced Targeting
level: 22
drain_rate: 0.10
-category: ranged
group: ranged_tier
effects:
ranged_percent: 10
diff --git a/data/techs/targeting_3.yaml b/data/techs/targeting_3.yaml
index 35017be..1272956 100644
--- a/data/techs/targeting_3.yaml
+++ b/data/techs/targeting_3.yaml
@@ -1,7 +1,6 @@
name: Superior Targeting
level: 46
drain_rate: 0.15
-category: ranged
group: ranged_tier
effects:
ranged_percent: 15
diff --git a/internal/admin/api_techs.go b/internal/admin/api_techs.go
index 0d3e201..293b1db 100644
--- a/internal/admin/api_techs.go
+++ b/internal/admin/api_techs.go
@@ -7,6 +7,14 @@ import (
"strings"
)
+func (s *AdminServer) findTechFile(id string) ([]byte, string, error) {
+ data, path, err := readYAMLFile(s.dataDir, "techs", id)
+ if err == nil {
+ return data, path, nil
+ }
+ return findYAMLFileInSubdirs(s.dataDir, "techs", id)
+}
+
func (s *AdminServer) handleTechs(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
@@ -31,11 +39,11 @@ func (s *AdminServer) handleTechs(w http.ResponseWriter, r *http.Request) {
return
}
delete(m, "id")
- path := filepath.Join(s.dataDir, "techs", id+".yaml")
- if _, err := os.Stat(path); err == nil {
+ if _, _, err := s.findTechFile(id); err == nil {
writeJSON(w, map[string]any{"error": "tech already exists"})
return
}
+ path := filepath.Join(s.dataDir, "techs", id+".yaml")
newContent, err := writeMapAsYAML(path, m)
if err != nil {
writeJSON(w, map[string]any{"error": "write error: " + err.Error()})
@@ -62,7 +70,7 @@ func (s *AdminServer) handleTechByID(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
- data, path, err := findYAMLFileInSubdirs(s.dataDir, "techs", id)
+ data, path, err := s.findTechFile(id)
if err != nil {
writeJSON(w, map[string]any{"error": "not found: " + id})
return
@@ -87,10 +95,17 @@ func (s *AdminServer) handleTechByID(w http.ResponseWriter, r *http.Request) {
delete(body, "_path")
delete(body, "_raw")
- path := filepath.Join(s.dataDir, "techs", id+".yaml")
+ _, path, findErr := s.findTechFile(id)
+ isCreate := findErr != nil
+ if isCreate {
+ path = filepath.Join(s.dataDir, "techs", id+".yaml")
+ }
+
var oldContent []byte
- if existing, err := snapshotFile(path); err == nil {
- oldContent = existing
+ if !isCreate {
+ if existing, err := snapshotFile(path); err == nil {
+ oldContent = existing
+ }
}
newContent, err := writeMapAsYAML(path, body)
@@ -99,7 +114,6 @@ func (s *AdminServer) handleTechByID(w http.ResponseWriter, r *http.Request) {
return
}
- isCreate := oldContent == nil
desc := "Updated tech " + id
if isCreate {
desc = "Created tech " + id
@@ -114,7 +128,7 @@ func (s *AdminServer) handleTechByID(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"ok": true, "id": id})
case http.MethodDelete:
- _, path, err := findYAMLFileInSubdirs(s.dataDir, "techs", id)
+ _, path, err := s.findTechFile(id)
if err != nil {
writeJSON(w, map[string]any{"error": "not found: " + id})
return
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index 3d54e54..8106773 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -146,6 +146,7 @@ input.search-input{padding-right:24px;background-image:url("data:image/svg+xml,%
.talk-orphans{margin-top:12px;padding-top:8px;border-top:1px dashed var(--border)}
.talk-orphans-label{font-size:10px;color:#f39c12;text-transform:uppercase;letter-spacing:.3px;margin-bottom:2px}
.talk-orphans-hint{font-size:9px;color:#888;margin-bottom:6px;font-style:italic}
+.tech-info-hint{font-size:10px;color:#888;margin-bottom:8px;font-style:italic}
.tabs{display:flex;gap:2px;margin-bottom:12px;border-bottom:1px solid var(--border);padding-bottom:0}
.tab{padding:6px 14px;font-size:12px;background:transparent;color:#aaa;border:none;border-bottom:2px solid transparent;cursor:pointer;font-family:monospace}
.tab:hover{color:var(--text)}
diff --git a/internal/admin/static/techeditor.js b/internal/admin/static/techeditor.js
index 020ba11..27dcbd8 100644
--- a/internal/admin/static/techeditor.js
+++ b/internal/admin/static/techeditor.js
@@ -10,8 +10,7 @@ var TECH_SECTIONS = [
['name', 'Name', 'text', 'Protect from Melee'],
['level', 'Level', 'number', '37', 'narrow'],
['drain_rate', 'Drain Rate', 'number', '0.5', 'narrow'],
- ['category', 'Category', 'select', '|accuracy|combo|defense|protection|ranged|science|strength|utility'],
- ['group', 'Group', 'select', '|accuracy_tier|defense_tier|protection|ranged_tier|regen_tier|science_tier|strength_tier'],
+ ['group', 'Group', 'text', 'accuracy_tier'],
]
},
{
@@ -26,9 +25,6 @@ var TECH_SECTIONS = [
['hp_regen_multi', 'HP Regen Multi', 'number', '2', 'narrow'],
['preserve_drain', 'Preserve Drain', 'number', '1', 'narrow'],
['retribution_pct', 'Retribution %', 'number', '25', 'narrow'],
- ['protect_melee', 'Protect Melee', 'checkbox'],
- ['protect_ranged', 'Protect Ranged', 'checkbox'],
- ['protect_science', 'Protect Science', 'checkbox'],
]
}
];
@@ -118,18 +114,26 @@ function renderCard(sec, data) {
h += '<button class="btn btn-sm obj-card-expand" onclick="event.preventDefault();toggleCardWidth(\'' + sec.id + '\')" title="' + (isFull ? 'Half width' : 'Full width') + '">' + (isFull ? '\u2190' : '\u2192') + '</button>';
h += '</summary>';
h += '<div class="obj-card-body">';
- h += '<div class="obj-card-grid">';
- sec.fields.forEach(function(f) {
- h += renderField(sec, f, data, -1);
- });
- h += '</div>';
+
+ if (sec.id === 'core') {
+ h += renderTechCoreCard(data);
+ } else if (sec.id === 'effects') {
+ h += renderTechEffectsCard(data);
+ } else {
+ h += '<div class="obj-card-grid">';
+ sec.fields.forEach(function(f) {
+ h += renderField(sec, f, data, -1);
+ });
+ h += '</div>';
+ }
+
h += '</div>';
h += '</details>';
return h;
}
-function renderField(sec, f, data, idx) {
- return ced_renderField(sec, f, data, idx, null, null, cardPath, fieldID, getVal);
+function renderField(sec, f, data, idx, subPath, optStyle) {
+ return ced_renderField(sec, f, data, idx, subPath, optStyle, cardPath, fieldID, getVal);
}
function toggleCardWidth(id) {
@@ -137,9 +141,191 @@ function toggleCardWidth(id) {
renderCurrent();
}
+// ---- Custom Core card ----
+
+function renderTechCoreCard(data) {
+ var sec = TECH_SECTIONS.find(function(s) { return s.id === 'core'; });
+ var fields = sec.fields;
+ var h = '';
+
+ h += '<div class="obj-core-row">';
+ h += renderField(sec, fields[0], data, -1, null, 'flex:1');
+ h += renderField(sec, fields[1], data, -1);
+ h += renderField(sec, fields[2], data, -1);
+ h += '</div>';
+
+ h += '<div class="obj-core-row">';
+
+ var groupExists = data && data.group !== undefined;
+ if (groupExists) {
+ h += '<div style="display:flex;align-items:center;gap:4px">';
+ h += renderField(sec, fields[3], data, -1);
+ h += '<button class="btn btn-sm btn-danger" style="padding:2px 6px;font-size:10px;min-width:auto;margin-top:14px" onclick="removeTechGroup()">X</button>';
+ h += '</div>';
+ } else {
+ h += '<div style="display:flex;align-items:center;margin-top:14px">';
+ h += '<button class="btn btn-sm obj-sub-add" onclick="addTechGroup()">+ Add Group</button>';
+ h += '</div>';
+ }
+
+ h += '</div>';
+
+ h += '<div style="font-size:10px;color:#888;margin-bottom:4px">Only one tech per Group can be active at a time.</div>';
+ h += '<div style="font-size:10px;color:#888;margin-bottom:8px">Drain rate is applied per tick.</div>';
+
+ return h;
+}
+
+function addTechGroup() {
+ ced_syncDOMToData(techData);
+ techData.group = '';
+ renderTechEditor(techID, techData);
+}
+
+function removeTechGroup() {
+ ced_syncDOMToData(techData);
+ delete techData.group;
+ renderTechEditor(techID, techData);
+}
+
+// ---- Custom Effects card ----
+
+function renderTechEffectsCard(data) {
+ var sec = TECH_SECTIONS.find(function(s) { return s.id === 'effects'; });
+ var eff = data.effects || {};
+ var h = '';
+ var hasActive = false;
+
+ var protectType = null;
+ if (eff.protect_melee) protectType = 'melee';
+ else if (eff.protect_ranged) protectType = 'ranged';
+ else if (eff.protect_science) protectType = 'science';
+
+ if (protectType) {
+ hasActive = true;
+ var protectIdx = protectType === 'melee' ? 0 : (protectType === 'ranged' ? 1 : 2);
+ h += '<div class="obj-inline-group">';
+ h += '<div style="display:flex;align-items:flex-start;gap:6px">';
+ h += '<div style="flex:1">';
+ h += '<div class="obj-field">';
+ h += '<span>Protection</span>';
+ h += '<div class="seg-pill seg-pill-3 seg-idx-' + protectIdx + '" style="margin-top:4px" id="tech-protect-pill">';
+ ['melee', 'ranged', 'science'].forEach(function(t, i) {
+ h += '<button type="button" class="seg-opt' + (i === protectIdx ? ' seg-active' : '') + '" data-v="' + t + '" onclick="techProtectPillSelect(\'' + t + '\',this)">' + t + '</button>';
+ });
+ h += '</div>';
+ h += '</div>';
+ h += '</div>';
+ h += '<button class="btn btn-sm btn-danger" style="padding:2px 6px;font-size:10px;min-width:auto;margin-top:2px" onclick="removeTechProtection()">X</button>';
+ h += '</div>';
+ h += '</div>';
+ }
+
+ sec.fields.forEach(function(f) {
+ var key = f[0], label = f[1];
+ var val = eff[key];
+ if (val === undefined || val === null) return;
+ if (f[2] === 'checkbox' && !val) return;
+ if (val === '' || val === 0) return;
+
+ hasActive = true;
+ h += '<div class="obj-inline-group">';
+ h += '<div style="display:flex;align-items:flex-start;gap:6px">';
+ h += '<div style="flex:1">';
+ h += renderField(sec, f, data, -1);
+ h += '</div>';
+ h += '<button class="btn btn-sm btn-danger" style="padding:2px 6px;font-size:10px;min-width:auto;margin-top:2px" onclick="removeTechEffect(\'' + key + '\')">X</button>';
+ h += '</div>';
+ h += '</div>';
+ });
+
+ var inactiveFields = [];
+ sec.fields.forEach(function(f) {
+ var key = f[0];
+ var val = eff[key];
+ var isSet = val !== undefined && val !== null;
+ if (f[2] === 'checkbox' && !val) isSet = false;
+ if (val === '' || val === 0) isSet = false;
+ if (!isSet) inactiveFields.push(f);
+ });
+
+ if (inactiveFields.length > 0 || !protectType) {
+ if (hasActive) h += '<div style="margin-top:8px"></div>';
+ h += '<div class="obj-card-grid">';
+ inactiveFields.forEach(function(f) {
+ var key = f[0], label = f[1], type = f[2];
+ var dflt = type === 'checkbox' ? true : (parseFloat(f[3]) || 0);
+ h += '<button class="btn btn-sm obj-sub-add" style="margin-bottom:4px" onclick="addTechEffect(\'' + key + '\',' + dflt + ')">+ ' + esc(label) + '</button>';
+ });
+
+ if (!protectType) {
+ h += '<button class="btn btn-sm obj-sub-add" style="margin-bottom:4px" onclick="addTechProtection()">+ Protection</button>';
+ }
+
+ h += '</div>';
+ }
+
+ return h;
+}
+
+function addTechEffect(key, dflt) {
+ ced_syncDOMToData(techData);
+ if (!techData.effects) techData.effects = {};
+ techData.effects[key] = dflt;
+ renderTechEditor(techID, techData);
+}
+
+function removeTechEffect(key) {
+ ced_syncDOMToData(techData);
+ if (techData.effects) delete techData.effects[key];
+ renderTechEditor(techID, techData);
+}
+
+function addTechProtection() {
+ ced_syncDOMToData(techData);
+ if (!techData.effects) techData.effects = {};
+ techData.effects.protect_melee = true;
+ techData.effects.damage_reduction = 0.4;
+ renderTechEditor(techID, techData);
+}
+
+function removeTechProtection() {
+ ced_syncDOMToData(techData);
+ if (techData.effects) {
+ delete techData.effects.protect_melee;
+ delete techData.effects.protect_ranged;
+ delete techData.effects.protect_science;
+ }
+ renderTechEditor(techID, techData);
+}
+
+function techProtectPillSelect(val, btn) {
+ ced_syncDOMToData(techData);
+ if (!techData.effects) techData.effects = {};
+ delete techData.effects.protect_melee;
+ delete techData.effects.protect_ranged;
+ delete techData.effects.protect_science;
+ techData.effects['protect_' + val] = true;
+
+ var pill = document.getElementById('tech-protect-pill');
+ if (pill) {
+ var opts = pill.querySelectorAll('.seg-opt');
+ var idx = -1;
+ opts.forEach(function(o, i) {
+ if (o.getAttribute('data-v') === val) { o.classList.add('seg-active'); idx = i; }
+ else o.classList.remove('seg-active');
+ });
+ pill.className = pill.className.replace(/seg-idx-\d/g, 'seg-idx-' + idx);
+ }
+}
+
+// ---- Save / Delete / Duplicate ----
+
function saveTech() {
if (!ced_validateFields(TECH_SECTIONS, fieldID, null)) return;
+ ced_syncDOMToData(techData);
+
var out = {};
TECH_SECTIONS.forEach(function(sec) {
var sectionObj = {};
@@ -167,8 +353,18 @@ function saveTech() {
}
});
+ if (techData.effects) {
+ if (techData.effects.protect_melee || techData.effects.protect_ranged || techData.effects.protect_science) {
+ if (!out.effects) out.effects = {};
+ if (techData.effects.protect_melee) out.effects.protect_melee = true;
+ if (techData.effects.protect_ranged) out.effects.protect_ranged = true;
+ if (techData.effects.protect_science) out.effects.protect_science = true;
+ }
+ }
+
ced_cleanOutput(out);
out.id = techID;
+ if (out.effects && Object.keys(out.effects).length === 0) delete out.effects;
API.put('/api/techs/' + encodeURIComponent(techID), out).then(function(resp) {
notify('Tech ' + techID + ' saved', 'success');
diff --git a/internal/game/core_validate.go b/internal/game/core_validate.go
index 4a2e5b9..0e302c0 100644
--- a/internal/game/core_validate.go
+++ b/internal/game/core_validate.go
@@ -32,7 +32,6 @@ func (g *Game) validationSource() validate.Source {
techViews = append(techViews, validate.TechView{
ID: t.ID,
Name: t.Name,
- Category: t.Category,
DrainRate: t.DrainRate,
})
techIDs[t.ID] = true
diff --git a/internal/game/sys_technology.go b/internal/game/sys_technology.go
index c7214af..e285ba8 100644
--- a/internal/game/sys_technology.go
+++ b/internal/game/sys_technology.go
@@ -33,7 +33,6 @@ type TechDef struct {
Name string `yaml:"name"`
Level int `yaml:"level"`
DrainRate float64 `yaml:"drain_rate"`
- Category string `yaml:"category"`
Group string `yaml:"group"`
Effects TechEffects `yaml:"effects"`
}
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index 7ac6fa1..527b558 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -1068,13 +1068,6 @@ func validateTechs(s Source) []Issue {
Message: fmt.Sprintf("Tech %q: has no name", def.ID),
})
}
- if def.Category != "" && !knownTechCategories[def.Category] {
- issues = append(issues, Issue{
- Level: "WARN",
- Type: "reference",
- Message: fmt.Sprintf("Tech %q: unknown category %q", def.ID, def.Category),
- })
- }
if def.DrainRate < 0 {
issues = append(issues, Issue{
Level: "ERROR",
diff --git a/internal/validate/validate.go b/internal/validate/validate.go
index 234651e..735c9ef 100644
--- a/internal/validate/validate.go
+++ b/internal/validate/validate.go
@@ -36,7 +36,6 @@ type CourseView struct {
type TechView struct {
ID string
Name string
- Category string
DrainRate float64
}
@@ -151,11 +150,6 @@ func LogIssues(issues []Issue) {
}
}
-var knownTechCategories = map[string]bool{
- "accuracy": true, "strength": true, "defense": true, "ranged": true,
- "science": true, "protection": true, "utility": true, "combo": true,
-}
-
// reservedTechIDs are tech IDs whose semantics are coupled to code logic and
// must exist in YAML (damage protection mapping + death retribution).
var reservedTechIDs = map[string]bool{