aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-05 17:39:06 -0400
committerhistoria <[not public]>2026-10-05 17:39:06 -0400
commitec703a39738fefb60817f5659f76f8e683f8d301 (patch)
tree00d1242d9f94bc2fec48331d8c02909be6b37f7a
parent516fd29da423432ef6dfded21a984f1d15dcab1a (diff)
downloadthehouseoficarus-ec703a39738fefb60817f5659f76f8e683f8d301.tar.gz
fix: admin undo/redo failure, video poker cabinet ids, room ref behavior
-rw-r--r--FIXES.md25
-rw-r--r--internal/admin/api_rooms.go19
-rw-r--r--internal/admin/api_triggers.go6
-rw-r--r--internal/admin/correct_audit_fixes_test.go24
-rw-r--r--internal/admin/server.go8
-rw-r--r--internal/admin/undo.go17
-rw-r--r--internal/casino/config_validate.go6
-rw-r--r--internal/casino/correct_audit_fixes_test.go11
-rw-r--r--internal/game/cmd_registry.go2
-rw-r--r--internal/net/conn.go6
-rw-r--r--internal/net/server.go5
-rw-r--r--internal/validate/checks.go11
-rw-r--r--internal/validate/correct_audit_fixes_test.go16
-rw-r--r--internal/world/correct_audit_fixes_test.go18
-rw-r--r--internal/world/room.go7
15 files changed, 169 insertions, 12 deletions
diff --git a/FIXES.md b/FIXES.md
index 80dc7ae..d40ed3d 100644
--- a/FIXES.md
+++ b/FIXES.md
@@ -57,7 +57,7 @@ Nothing was staged or committed. The audit artifacts (`AUDIT.md`, `reports/`) ar
### Tooling
31. **`make lint` gate restored** โ€” fixed the 15 reported issues (error-string casing, doc-comment form, unused params, ineffassign) without weakening `.golangci.yml`.
-## Tranche 2 (this pass)
+## Tranche 2
- **Security**: `restoreAccountSecret` no longer fails open on a non-mapping body (the admin raw editor can no longer blank `password_hash`); `validEntityID` rejects control characters and leading dots.
- **Info disclosure**: video-poker bank/wager errors are mapped instead of printing raw `os`/YAML errors and server paths.
@@ -69,18 +69,27 @@ Nothing was staged or committed. The audit artifacts (`AUDIT.md`, `reports/`) ar
- **Net**: `Conn` now requires `SetReadDeadline`, so every transport gets an idle read deadline (removed the concrete-type switch); hub room buckets are pruned and `RekeyRooms` merges instead of clobbering.
- Tests added: `TestRestoreAccountSecretRefusesNonMappingBody`, `TestWalkYAMLDirIncludesYml`, `TestValidateTalkConfigRequiresStartNode`, `TestPickupCreditsHonorsRequestedQuantity`, `TestMachineLeavePaysBankedFeature`, `TestCrapsShooterChangeResetsHand`.
+## Tranche 3 (this pass)
+
+- **Undo**: a failed undo/redo is now reported distinctly from an empty history (`UndoStack.LastError()`), so the admin UI no longer shows "Nothing to undo" for a real failure. Test: `TestUndoFailureIsReportedDistinctly`.
+- **Quit**: `quit`/`logout` are `ClassInstant`, so a queued active command can no longer silently replace or delay a quit.
+- **World**: a reference-style room object entry (`id:` only) that carries behavior fields is rejected at load instead of silently dropping the behavior. Test: `TestRoomObjectReferenceRejectsBehaviorFields`.
+- **Validation**: gather drops with `weight <= 0` (never selectable) are warned about. Test: `TestValidateGatherWarnsOnWeightlessDrop`.
+- **Admin**: trigger POST now serializes through `triggerMapToYAML` like PUT (no unknown-key divergence); `resolve-duplicate` verifies the path is genuinely a duplicate-ID room file before deleting/renaming it.
+- **Net**: the shutdown flush gives each session a bounded slice instead of letting the first stalled writer starve the rest; the telnet echo CRLF refreshes its write deadline so it is not dropped on an idle connection.
+- **Casino**: video-poker cabinet IDs are claimed in the addressable namespace, so an ID colliding with a machine variant/game name is rejected instead of producing an unreachable cabinet. Test: `TestVideoPokerCabinetIDCollisionIsRejected`.
+
## Still open
-- **Undo failure reporting** (`Undo()`/`Redo()` still return nil on failure โ†’ "Nothing to undo") and an explicit snapshot-existence flag.
-- **`quit` queue/busy interaction**; **"how many" batches vs units**; **production `FirstCraft()` vs multi-recipe availability**.
-- **Room object reference entries silently drop behavior fields**; **craft `type`/`subtype` validator rule**; **safespot validation**; **drop weight validation**.
+- **Explicit undo snapshot-existence flag** (the current fix safely refuses an intentionally empty before-image).
+- **Production `FirstCraft()` vs multi-recipe availability** (5 of 6 `arrow_shaft` recipes unreachable); **"how many" batches vs units**.
+- **Craft `type`/`subtype` validator rule**; **safespot validation** (needs the size vocabulary moved to a shared package); **drop weight in mob loot/steal readers**.
- **Persistence** `Recover` mid-game misuse; journal versioning.
-- **Casino**: craps Fire/ATS deferral, plinko RTP wording, video-poker cabinet ID collisions.
-- **Admin**: trigger POST/PUT divergence, `resolve-duplicate` precondition, symlink handling.
-- **Net**: telnet echo CRLF deadline, shutdown flush starvation.
+- **Casino**: craps Fire/ATS settlement deferral; plinko RTP wording.
+- **Admin**: symlink handling in the raw file API.
- **Structural refactors** from `AUDIT.md` ยง3โ€“4.
- **Test infrastructure**: `make test-race`/CI, harness owner-goroutine leak, master-tick harness option, and coverage for steal/gather/burn/talk and the WebSocket transport.
## Suggested next step
-Tranche 3: the remaining admin/persistence/casino P2s above, then the structural refactors. Keep `make lint`, `go test ./...`, and `go test -race` green.
+Tranche 4: the production multi-recipe fix, the remaining validation gaps, the persistence hardening, and then the structural refactors. Keep `make lint`, `go test ./...`, and `go test -race` green.
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index 586bfff..76db231 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -1315,6 +1315,25 @@ func (s *AdminServer) handleResolveDuplicate(w http.ResponseWriter, r *http.Requ
failJSON(w, "invalid path")
return
}
+ // Only a real duplicate-ID file may be resolved here. Trusting the client
+ // path would let this endpoint delete a live indexed room with none of the
+ // normal delete semantics (exit cleanup, casino eviction, undo).
+ stem := strings.TrimSuffix(filepath.Base(absPath), ".yaml")
+ isDuplicate := false
+ for _, d := range behavior.CheckDuplicateIDs(roomsDir) {
+ if d.ID != stem {
+ continue
+ }
+ for _, p := range d.Paths {
+ if pa, err := filepath.Abs(p); err == nil && pa == absPath {
+ isDuplicate = true
+ }
+ }
+ }
+ if !isDuplicate {
+ failJSON(w, "path is not a duplicate-ID room file")
+ return
+ }
switch body.Action {
case "delete":
diff --git a/internal/admin/api_triggers.go b/internal/admin/api_triggers.go
index 0dd086c..ff3e7d6 100644
--- a/internal/admin/api_triggers.go
+++ b/internal/admin/api_triggers.go
@@ -56,8 +56,12 @@ func (s *AdminServer) handleTriggers(w http.ResponseWriter, r *http.Request) {
return
}
- newContent, err := writeMapAsYAML(path, m)
+ newContent, err := triggerMapToYAML(m)
if err != nil {
+ failJSON(w, "serialize error: "+err.Error())
+ return
+ }
+ if err := persistence.WriteFile(path, newContent, 0644); err != nil {
failJSON(w, "write error: "+err.Error())
return
}
diff --git a/internal/admin/correct_audit_fixes_test.go b/internal/admin/correct_audit_fixes_test.go
index 60073f7..d97732d 100644
--- a/internal/admin/correct_audit_fixes_test.go
+++ b/internal/admin/correct_audit_fixes_test.go
@@ -49,3 +49,27 @@ func TestRestoreAccountSecretRefusesNonMappingBody(t *testing.T) {
t.Fatalf("password hash not preserved: %s", out)
}
}
+
+// Regression: a failed undo returned a nil change exactly like an empty
+// history, so the UI reported "Nothing to undo" for a real failure.
+func TestUndoFailureIsReportedDistinctly(t *testing.T) {
+ dir := t.TempDir()
+ us := NewUndoStack(dir)
+ us.Push(ChangeDesc{
+ Description: "edit with a missing before-image",
+ FilePath: filepath.Join(dir, "rooms", "1.yaml"),
+ OldContent: nil,
+ NewContent: []byte("id: 1\n"),
+ })
+ if ch := us.Undo(); ch != nil {
+ t.Fatal("undo with a missing before-image unexpectedly succeeded")
+ }
+ if err := us.LastError(); err == nil {
+ t.Fatal("LastError = nil after a failed undo; the UI would report 'Nothing to undo'")
+ }
+ // A genuinely empty history is a clean nil change and nil error.
+ us2 := NewUndoStack(t.TempDir())
+ if ch := us2.Undo(); ch != nil || us2.LastError() != nil {
+ t.Fatal("empty history must be a clean nil change and nil error")
+ }
+}
diff --git a/internal/admin/server.go b/internal/admin/server.go
index 8fcba77..800cfa1 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -908,6 +908,10 @@ func (s *AdminServer) doUndo(w http.ResponseWriter, r *http.Request) {
}
change := s.undoStack.Undo()
if change == nil {
+ if err := s.undoStack.LastError(); err != nil {
+ writeJSONError(w, "undo failed: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
writeJSON(w, map[string]any{"message": "Nothing to undo"})
return
}
@@ -927,6 +931,10 @@ func (s *AdminServer) doRedo(w http.ResponseWriter, r *http.Request) {
}
change := s.undoStack.Redo()
if change == nil {
+ if err := s.undoStack.LastError(); err != nil {
+ writeJSONError(w, "redo failed: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
writeJSON(w, map[string]any{"message": "Nothing to redo"})
return
}
diff --git a/internal/admin/undo.go b/internal/admin/undo.go
index 5683f13..c3231e3 100644
--- a/internal/admin/undo.go
+++ b/internal/admin/undo.go
@@ -85,6 +85,10 @@ type UndoStack struct {
// writes those files directly, so it must refuse the same files the raw
// file editor refuses. Optional; nil disables the refusal.
playerFileOnlineCheck func(relPath string) bool
+ // lastErr records the most recent Undo/Redo failure so the HTTP handler
+ // can distinguish a genuine failure from an empty history (both return a
+ // nil change). Owner-task access only.
+ lastErr error
}
func NewUndoStack(dataDir string) *UndoStack {
@@ -179,8 +183,10 @@ func (us *UndoStack) Undo() *ChangeDesc {
if err := us.applyUndo(last); err != nil {
log.Printf("undo: failed %s: %v", last.ShortDesc(), err)
+ us.lastErr = err
return nil
}
+ us.lastErr = nil
// Runtime migration moves with the file images: undo applies the
// inverse room-ID map so players, Hub membership, and casino room keys
// return to the old location together.
@@ -209,8 +215,10 @@ func (us *UndoStack) Redo() *ChangeDesc {
if err := us.applyRedo(last); err != nil {
log.Printf("redo: failed %s: %v", last.ShortDesc(), err)
+ us.lastErr = err
return nil
}
+ us.lastErr = nil
// Redo applies the forward room-ID map through the runtime migration.
if len(last.IDMap) > 0 && us.migrateRoomIDs != nil {
forward := make(map[int]int, len(last.IDMap))
@@ -803,6 +811,15 @@ func (us *UndoStack) Clear() {
defer us.mu.Unlock()
us.history = nil
us.redo = nil
+ us.lastErr = nil
os.Remove(us.filePath)
log.Printf("undo: cleared history")
}
+
+// LastError returns the error from the most recent failed Undo/Redo, or nil.
+// A nil change plus a nil LastError means the history/redo stack was empty.
+func (us *UndoStack) LastError() error {
+ us.mu.Lock()
+ defer us.mu.Unlock()
+ return us.lastErr
+}
diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go
index 74dc0a4..41d5b83 100644
--- a/internal/casino/config_validate.go
+++ b/internal/casino/config_validate.go
@@ -359,8 +359,12 @@ func ValidateRoomConfigs(tables []TableConfig, machines []MachineConfig) error {
for _, c := range tables {
check("table", c.ID, c.Validate())
if c.Game == GameVideoPoker {
+ // Cabinets are addressed by their unique ID (not the game name), so
+ // claim the ID as an address. Skipping the claim let a cabinet ID
+ // collide with a machine variant/game name and become unreachable.
+ checkAddressable(strings.ToLower(c.ID), c.ID)
continue
- } // Cabinets are addressed by unique ID, not game name.
+ }
checkAddressable(strings.ToLower(string(c.Game)), c.ID)
}
// A machine addresses by its variant when one is declared; a bare game
diff --git a/internal/casino/correct_audit_fixes_test.go b/internal/casino/correct_audit_fixes_test.go
index e5e3bd3..dc33c47 100644
--- a/internal/casino/correct_audit_fixes_test.go
+++ b/internal/casino/correct_audit_fixes_test.go
@@ -21,3 +21,14 @@ func TestCrapsShooterChangeResetsHand(t *testing.T) {
t.Fatalf("new shooter inherited hand: point=%d rolled=%v made=%v seen=%v", tb.point, tb.rolled, tb.made, tb.seen)
}
}
+
+// Regression: a video-poker cabinet addressed by an ID that collides with a
+// machine variant/game name validated but was unreachable (the machine won the
+// address lookup).
+func TestVideoPokerCabinetIDCollisionIsRejected(t *testing.T) {
+ machines := []MachineConfig{{ID: "m1", Game: GameSlots, Variant: "x"}}
+ tables := []TableConfig{{ID: "x", Game: GameVideoPoker}}
+ if err := ValidateRoomConfigs(tables, machines); err == nil {
+ t.Fatal("cabinet ID colliding with a machine variant was accepted; the cabinet would be unreachable")
+ }
+}
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index d10ba0e..2defb9e 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -122,7 +122,7 @@ var commandRegistry = map[string]commandDef{
"i": {(*Game).executeInventory, ClassInstant},
"inv": {(*Game).executeInventory, ClassInstant},
"inventory": {(*Game).executeInventory, ClassInstant},
- "quit": {(*Game).executeQuit, ClassActive},
+ "quit": {(*Game).executeQuit, ClassInstant},
"description": {(*Game).executeDescription, ClassInstant},
"desc": {(*Game).executeDescription, ClassInstant},
"option": {(*Game).executeOption, ClassInstant},
diff --git a/internal/net/conn.go b/internal/net/conn.go
index a4be83a..d8c867e 100644
--- a/internal/net/conn.go
+++ b/internal/net/conn.go
@@ -79,7 +79,11 @@ func (t *tcpConn) ReadMessage() (string, error) {
text = stripIAC(text)
text = truncateLine(text, maxLineLength)
if t.echoOff.Load() {
- t.conn.Write([]byte("\r\n"))
+ // Refresh the write deadline: the writer goroutine may have left a
+ // stale deadline in the past, which would silently drop this echo on
+ // an idle connection.
+ _ = t.conn.SetWriteDeadline(time.Now().Add(writeDeadline))
+ _, _ = t.conn.Write([]byte("\r\n"))
}
return text, nil
}
diff --git a/internal/net/server.go b/internal/net/server.go
index 71e34c8..8e23a1f 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -808,6 +808,11 @@ func (s *Server) Shutdown() {
if remaining <= 0 {
break
}
+ // Cap each wait so one stalled writer cannot consume the whole
+ // budget and starve every later session's flush.
+ if remaining > 200*time.Millisecond {
+ remaining = 200 * time.Millisecond
+ }
select {
case <-sess.writeDone:
case <-time.After(remaining):
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index d3fa111..2030225 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -1624,6 +1624,17 @@ func validateGather(prefix string, cfg *behavior.GatherConfig, itemIDs map[strin
prefix, label, len(cfg.Tools)),
})
}
+ if d.Weight <= 0 {
+ label := d.ItemID
+ if label == "" {
+ label = "<table:" + d.Table + ">"
+ }
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "semantic",
+ Message: fmt.Sprintf("%s: drop %q has weight %d; it can never be selected", prefix, label, d.Weight),
+ })
+ }
}
return issues
diff --git a/internal/validate/correct_audit_fixes_test.go b/internal/validate/correct_audit_fixes_test.go
index 8ed6f52..f11dd3f 100644
--- a/internal/validate/correct_audit_fixes_test.go
+++ b/internal/validate/correct_audit_fixes_test.go
@@ -31,3 +31,19 @@ func TestValidateTalkConfigRequiresStartNode(t *testing.T) {
}
}
}
+
+// Regression: a gather drop with weight <= 0 can never be selected (the
+// weighted roll has zero total); it must be flagged.
+func TestValidateGatherWarnsOnWeightlessDrop(t *testing.T) {
+ cfg := &behavior.GatherConfig{Drops: []behavior.DropEntry{{ItemID: "ore", Weight: 0}}}
+ issues := validateGather("test", cfg, map[string]bool{"ore": true}, map[string]bool{})
+ found := false
+ for _, iss := range issues {
+ if strings.Contains(iss.Message, "weight") {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("weightless drop not warned: %+v", issues)
+ }
+}
diff --git a/internal/world/correct_audit_fixes_test.go b/internal/world/correct_audit_fixes_test.go
new file mode 100644
index 0000000..0ecb87a
--- /dev/null
+++ b/internal/world/correct_audit_fixes_test.go
@@ -0,0 +1,18 @@
+package world
+
+import (
+ "testing"
+
+ "gopkg.in/yaml.v3"
+)
+
+// Regression: a reference-style room object entry (id only) that also carries
+// behavior fields silently lost them at runtime, because the loader always
+// uses the object file. It must now be rejected at load time.
+func TestRoomObjectReferenceRejectsBehaviorFields(t *testing.T) {
+ src := "id: 1\nname: Test\nobjects:\n - id: chest\n on_use:\n - steps:\n - message: hi\n"
+ var r Room
+ if err := yaml.Unmarshal([]byte(src), &r); err == nil {
+ t.Fatal("reference object entry with on_use was accepted; the behavior would be silently lost")
+ }
+}
diff --git a/internal/world/room.go b/internal/world/room.go
index e200f32..9b9bef7 100644
--- a/internal/world/room.go
+++ b/internal/world/room.go
@@ -171,6 +171,13 @@ func (ro *RoomObject) UnmarshalYAML(value *yaml.Node) error {
ro.Local = &def
return nil
}
+ // A reference entry (no name/description) that also carries behavior
+ // fields silently loses them at runtime, because the loader always uses
+ // the object file. Reject it instead of discarding authored behavior.
+ if def.RemovalItem != "" || len(def.OnUse) > 0 || def.Steal != nil ||
+ def.Gather != nil || def.Talk != nil || def.Safespot != nil {
+ return fmt.Errorf("room object entry %q cannot carry behavior (on_use/gather/talk/steal/removal_item/safespot); define it in objects/%s.yaml or give the entry a name", ref.ID, ref.ID)
+ }
ro.ID = ref.ID
ro.HiddenOverride = ref.Hidden
return nil