aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 17:48:26 -0400
committerhistoria <[not public]>2026-09-18 17:48:26 -0400
commit8fe9e3c3fef0049df82281a62afe35dda516dfe2 (patch)
treedd269f2c0710f334e19f00a191260ebc46284a90 /internal
parent194164a77eb4f2479b94c19e6f0e1278ab608781 (diff)
downloadthehouseoficarus-8fe9e3c3fef0049df82281a62afe35dda516dfe2.tar.gz
fix: close combat ghost, admin traversal, login throttle, journal double-pay, and quality/economy gaps
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/api_entity_path_test.go49
-rw-r--r--internal/admin/api_modules.go4
-rw-r--r--internal/admin/api_rooms.go70
-rw-r--r--internal/admin/api_rooms_bulk_delete.go3
-rw-r--r--internal/admin/api_techs.go4
-rw-r--r--internal/admin/api_triggers.go6
-rw-r--r--internal/admin/server.go79
-rw-r--r--internal/admin/undo.go41
-rw-r--r--internal/casino/baccarat.go48
-rw-r--r--internal/casino/baccarat_test.go32
-rw-r--r--internal/casino/manager.go14
-rw-r--r--internal/engine/tick.go11
-rw-r--r--internal/engine/tick_values_test.go20
-rw-r--r--internal/game/act_burn.go5
-rw-r--r--internal/game/act_clean.go12
-rw-r--r--internal/game/act_effects.go2
-rw-r--r--internal/game/act_gather.go2
-rw-r--r--internal/game/casino.go13
-rw-r--r--internal/game/cmd_consume.go5
-rw-r--r--internal/game/cmd_drop.go16
-rw-r--r--internal/game/cmd_get.go54
-rw-r--r--internal/game/cmd_quit.go4
-rw-r--r--internal/game/cmd_trigger_utility.go2
-rw-r--r--internal/game/cmd_wear.go7
-rw-r--r--internal/game/combat_attack.go17
-rw-r--r--internal/game/combat_mob.go4
-rw-r--r--internal/game/core_login_account.go10
-rw-r--r--internal/game/core_production.go7
-rw-r--r--internal/game/core_validate.go18
-rw-r--r--internal/game/economy.go74
-rw-r--r--internal/game/game.go19
-rw-r--r--internal/game/hardening_disconnect_combat_test.go31
-rw-r--r--internal/game/login_throttle.go114
-rw-r--r--internal/game/login_throttle_test.go35
-rw-r--r--internal/game/session_registry.go14
-rw-r--r--internal/game/sys_triggers.go7
-rw-r--r--internal/game/tick.go5
-rw-r--r--internal/net/conn.go28
-rw-r--r--internal/net/server.go87
-rw-r--r--internal/net/web.go6
-rw-r--r--internal/net/wsconn.go8
-rw-r--r--internal/persistence/atomic.go12
-rw-r--r--internal/player/password.go2
-rw-r--r--internal/player/store.go8
-rw-r--r--internal/validate/checks.go64
-rw-r--r--internal/validate/shipped_data_test.go2
-rw-r--r--internal/validate/validate.go37
-rw-r--r--internal/world/ground.go87
-rw-r--r--internal/world/ground_quality_test.go43
-rw-r--r--internal/world/world.go1
50 files changed, 1118 insertions, 125 deletions
diff --git a/internal/admin/api_entity_path_test.go b/internal/admin/api_entity_path_test.go
index 27ecbe8..75fed7a 100644
--- a/internal/admin/api_entity_path_test.go
+++ b/internal/admin/api_entity_path_test.go
@@ -62,6 +62,55 @@ func TestEntityAPICannotEscapeData(t *testing.T) {
}
}
+// TestTechModuleCreateRejectsTraversalID: POST /api/techs and /api/modules
+// once joined an unvalidated id into a filesystem path, allowing an
+// arbitrary write outside the data directory (and a destructive undo).
+func TestTechModuleCreateRejectsTraversalID(t *testing.T) {
+ root := t.TempDir()
+ dir := filepath.Join(root, "data")
+ for _, sub := range []string{"techs", "modules", "players/accounts"} {
+ if err := os.MkdirAll(filepath.Join(dir, sub), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ }
+ accounts := player.NewAccountStore(dir)
+ if err := accounts.SaveAccount(&player.Account{Name: "auditor", Admin: true}); err != nil {
+ t.Fatal(err)
+ }
+ s, err := NewServer(config.Default(), false, accounts, nil, nil, nil, nil, dir,
+ nil, nil, nil, nil, filepath.Join(dir, "config.yaml"),
+ func(fn func()) bool { fn(); return true })
+ if err != nil {
+ t.Fatal(err)
+ }
+ cookieRec := httptest.NewRecorder()
+ s.setAuthCookie(cookieRec, "auditor")
+ cookie := cookieRec.Result().Cookies()[0]
+
+ post := func(path, body string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
+ req.AddCookie(cookie)
+ rec := httptest.NewRecorder()
+ s.ServeHTTP(rec, req)
+ return rec
+ }
+
+ for _, tc := range []struct{ path, body string }{
+ {"/api/techs", `{"id":"../../outside_tech","name":"x"}`},
+ {"/api/modules", `{"id":"../../outside_mod","name":"x"}`},
+ } {
+ if rec := post(tc.path, tc.body); !strings.Contains(rec.Body.String(), "invalid id") {
+ t.Fatalf("%s traversal id not rejected: status %d body %s", tc.path, rec.Code, rec.Body.String())
+ }
+ }
+ if _, err := os.Stat(filepath.Join(root, "outside_tech.yaml")); !os.IsNotExist(err) {
+ t.Fatal("tech create escaped the data directory")
+ }
+ if _, err := os.Stat(filepath.Join(root, "outside_mod.yaml")); !os.IsNotExist(err) {
+ t.Fatal("module create escaped the data directory")
+ }
+}
+
// TestEntityRenameRejectsDuplicateIDInOtherDirectory: entity IDs are global
// across the type's subdirectory tree (the runtime path indexes resolve by
// filename anywhere under data/<subdir>), so renaming into an ID that
diff --git a/internal/admin/api_modules.go b/internal/admin/api_modules.go
index 1d8c25b..8356b2d 100644
--- a/internal/admin/api_modules.go
+++ b/internal/admin/api_modules.go
@@ -30,6 +30,10 @@ func (s *AdminServer) handleModules(w http.ResponseWriter, r *http.Request) {
failJSON(w, "missing id")
return
}
+ if !validEntityID(id) {
+ failJSON(w, "invalid id")
+ return
+ }
delete(m, "id")
path := filepath.Join(s.dataDir, "modules", id+".yaml")
if _, err := os.Stat(path); err == nil {
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index d84449b..7f317cc 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -343,6 +343,11 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) {
s.world.RebuildRoomIndex(s.dataDir)
s.world.ClearRoomState(id)
+ // Drop the room's live mob instances too, or they keep wandering and
+ // are returned by MobsInRoom for a room that no longer exists.
+ if s.mobStore != nil {
+ s.mobStore.RemoveMobsInRoom(id)
+ }
// Drop any cached in-room games (tables/machines) and refund their
// outstanding wagers — same eviction semantics as bulk delete.
if s.evictRoom != nil {
@@ -490,6 +495,11 @@ func (s *AdminServer) handleRenameRoomDir(w http.ResponseWriter, r *http.Request
failJSON(w, "rename failed: "+err.Error())
return
}
+ // Undo history stores absolute paths: re-prefix them so a later undo
+ // does not recreate the old directory and write a duplicate room file.
+ if s.undoStack != nil {
+ s.undoStack.RewritePaths(oldPath, newPath)
+ }
s.world.RebuildRoomIndex(s.dataDir)
writeJSON(w, map[string]any{"ok": true, "new_dir": newRel})
}
@@ -674,39 +684,63 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) {
writeJSONError(w, "room path not found", http.StatusInternalServerError)
return
}
-
- oldA, _ := snapshotFile(pathA)
- newA, err := writeYAMLFile(pathA, roomA)
+ newA, err := marshalYAML(roomA)
if err != nil {
- writeJSONError(w, "write A: "+err.Error(), http.StatusInternalServerError)
+ writeJSONError(w, "serialize A: "+err.Error(), http.StatusInternalServerError)
return
}
- s.undoStack.Push(ChangeDesc{
- Description: fmt.Sprintf("link %d %s %d", body.From, dir, body.To),
- FilePath: pathA,
- OldContent: oldA,
- NewContent: newA,
- })
+ oldA, _ := snapshotFile(pathA)
+ pathB := ""
+ var oldB, newB []byte
if !body.OneWay {
- pathB, okB2 := s.world.GetRoomPath(body.To)
+ var okB2 bool
+ pathB, okB2 = s.world.GetRoomPath(body.To)
if !okB2 {
writeJSONError(w, "room B path not found", http.StatusInternalServerError)
return
}
- oldB, _ := snapshotFile(pathB)
- newB, err := writeYAMLFile(pathB, roomB)
+ newB, err = marshalYAML(roomB)
if err != nil {
+ writeJSONError(w, "serialize B: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ oldB, _ = snapshotFile(pathB)
+ }
+
+ // Both sides are resolved and serialized before any write, and both
+ // are applied in one journaled transaction so a failure cannot leave
+ // an asymmetric link, and one Undo reverses the whole action.
+ txn := persistence.NewTransaction(filepath.Join(s.dataDir, ".journal"))
+ if err := txn.Replace(pathA, newA, 0644); err != nil {
+ writeJSONError(w, "write A: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if !body.OneWay {
+ if err := txn.Replace(pathB, newB, 0644); err != nil {
writeJSONError(w, "write B: "+err.Error(), http.StatusInternalServerError)
return
}
- s.undoStack.Push(ChangeDesc{
- Description: fmt.Sprintf("link %d %s %d (reverse)", body.To, oppDir, body.From),
- FilePath: pathB,
- OldContent: oldB,
- NewContent: newB,
+ }
+ if err := txn.Commit(); err != nil {
+ writeJSONError(w, "commit link: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ desc := ChangeDesc{
+ Description: fmt.Sprintf("link %d %s %d", body.From, dir, body.To),
+ FilePath: pathA,
+ OldContent: oldA,
+ NewContent: newA,
+ }
+ if !body.OneWay {
+ desc.ExtraFiles = append(desc.ExtraFiles, ExtraFile{
+ FilePath: pathB,
+ OldContent: oldB,
+ NewContent: newB,
})
}
+ s.undoStack.Push(desc)
writeJSON(w, map[string]any{"ok": true})
case http.MethodDelete:
diff --git a/internal/admin/api_rooms_bulk_delete.go b/internal/admin/api_rooms_bulk_delete.go
index 6017eed..bba1879 100644
--- a/internal/admin/api_rooms_bulk_delete.go
+++ b/internal/admin/api_rooms_bulk_delete.go
@@ -100,6 +100,9 @@ func (s *AdminServer) handleBulkDelete(w http.ResponseWriter, r *http.Request) {
s.world.RebuildRoomIndex(s.dataDir)
for _, snap := range snapshots {
s.world.ClearRoomState(snap.id)
+ if s.mobStore != nil {
+ s.mobStore.RemoveMobsInRoom(snap.id)
+ }
if s.evictRoom != nil {
s.evictRoom(snap.id)
}
diff --git a/internal/admin/api_techs.go b/internal/admin/api_techs.go
index 91eb929..9e742e1 100644
--- a/internal/admin/api_techs.go
+++ b/internal/admin/api_techs.go
@@ -38,6 +38,10 @@ func (s *AdminServer) handleTechs(w http.ResponseWriter, r *http.Request) {
failJSON(w, "missing id")
return
}
+ if !validEntityID(id) {
+ failJSON(w, "invalid id")
+ return
+ }
delete(m, "id")
if _, _, err := s.findTechFile(id); err == nil {
failJSON(w, "tech already exists")
diff --git a/internal/admin/api_triggers.go b/internal/admin/api_triggers.go
index 08e2855..299ca10 100644
--- a/internal/admin/api_triggers.go
+++ b/internal/admin/api_triggers.go
@@ -152,6 +152,9 @@ func (s *AdminServer) handleTriggerByID(w http.ResponseWriter, r *http.Request)
FilePath: newPath,
OldContent: oldContent,
NewContent: triggerData,
+ // A PUT to a missing trigger creates it; undo must delete the
+ // file rather than overwrite it with the nil before-image.
+ IsCreate: findErr != nil,
}
if findErr == nil && newSubdir != oldDir {
// A kind change moves the file between subdirectories. Record the
@@ -184,6 +187,9 @@ func (s *AdminServer) handleTriggerByID(w http.ResponseWriter, r *http.Request)
Description: "Delete trigger " + id,
FilePath: path,
OldContent: oldContent,
+ // Redo of a delete must delete again, not write the nil
+ // new-content image over the file.
+ IsDelete: true,
})
s.rebuildTriggerIndex()
writeJSON(w, map[string]any{"ok": true})
diff --git a/internal/admin/server.go b/internal/admin/server.go
index 98400c2..05586ef 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -473,6 +473,7 @@ type loginLimiter struct {
type loginAttempts struct {
failures int
+ inflight int
blockedTill time.Time
lastSeen time.Time
}
@@ -481,6 +482,66 @@ func newLoginLimiter() *loginLimiter {
return &loginLimiter{entries: make(map[string]loginAttempts), now: time.Now}
}
+// begin atomically checks the cooldown and reserves an in-flight slot for
+// ip. Reserving at admission time closes the TOCTOU where a concurrent burst
+// all passes allow() before any of them records a failure: the number of
+// simultaneous verifications for one address is bounded by the same
+// threshold. Every successful begin must be paired with finish.
+func (l *loginLimiter) begin(ip string) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ now := l.now()
+ e, ok := l.entries[ip]
+ if !ok {
+ if !l.makeRoomLocked(now) {
+ // The tracked map is full of cooling-down addresses; do not
+ // reserve (preserves the existing bounded-map behavior).
+ return true
+ }
+ e = loginAttempts{}
+ }
+ if now.Before(e.blockedTill) {
+ return false
+ }
+ if e.failures+e.inflight >= adminLoginMaxFailures {
+ return false
+ }
+ e.inflight++
+ e.lastSeen = now
+ l.entries[ip] = e
+ return true
+}
+
+// finish releases the in-flight slot reserved by begin and records the
+// outcome: success clears the address, failure advances the cooldown.
+func (l *loginLimiter) finish(ip string, ok bool) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ now := l.now()
+ e, tracked := l.entries[ip]
+ if !tracked {
+ return
+ }
+ if e.inflight > 0 {
+ e.inflight--
+ }
+ e.lastSeen = now
+ if ok {
+ delete(l.entries, ip)
+ return
+ }
+ if now.Before(e.blockedTill) {
+ l.entries[ip] = e
+ return
+ }
+ e.failures++
+ if e.failures >= adminLoginMaxFailures {
+ e.blockedTill = now.Add(adminLoginCooldown)
+ e.failures = 0
+ }
+ l.entries[ip] = e
+}
+
// allow reports whether ip may attempt a login now.
func (l *loginLimiter) allow(ip string) bool {
l.mu.Lock()
@@ -540,7 +601,7 @@ func (l *loginLimiter) makeRoomLocked(now time.Time) bool {
return true
}
for ip, e := range l.entries {
- if e.failures == 0 && !now.Before(e.blockedTill) {
+ if e.failures == 0 && e.inflight == 0 && !now.Before(e.blockedTill) {
delete(l.entries, ip)
}
}
@@ -616,10 +677,12 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) {
// to make password guessing expensive, so it must not cost a parse.
limiter := s.limitLogins()
ip := clientIP(r.RemoteAddr)
- if !limiter.allow(ip) {
+ if !limiter.begin(ip) {
http.Error(w, "too many failed login attempts; try again later", http.StatusTooManyRequests)
return
}
+ loginOK := false
+ defer func() { limiter.finish(ip, loginOK) }()
// /login is registered before authMiddleware, so bound and buffer its body
// here before r.FormValue can parse it: without the cap a multipart POST
@@ -646,20 +709,17 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) {
// primitive ("account=../../rooms/1"). Every other account-name entry
// point validates first.
if err := player.ValidName(account); err != nil {
- limiter.fail(ip)
s.renderPage(w, r, "login.html", map[string]any{"Error": "Invalid account name or password."})
return
}
acc, err := s.accountStore.LoadAccount(account)
if err != nil || !player.CheckPassword(password, acc.PasswordHash) {
- limiter.fail(ip)
s.renderPage(w, r, "login.html", map[string]any{"Error": "Invalid account name or password."})
return
}
if !acc.Admin {
- limiter.fail(ip)
s.renderPage(w, r, "login.html", map[string]any{"Error": "Account is not authorized as admin."})
return
}
@@ -693,7 +753,7 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
s.setAuthCookie(w, account)
- limiter.succeed(ip)
+ loginOK = true
http.Redirect(w, r, "/", http.StatusFound)
}
@@ -898,6 +958,13 @@ func (s *AdminServer) handleEntityDirs(entityType string) http.HandlerFunc {
failJSON(w, err.Error())
return
}
+ // Undo history stores absolute paths; re-prefix them so a later
+ // undo does not recreate the old entity directory.
+ if s.undoStack != nil {
+ oldPrefix := filepath.Join(s.dataDir, entityType, body.Dir)
+ newPrefix := filepath.Join(s.dataDir, entityType, body.NewName)
+ s.undoStack.RewritePaths(oldPrefix, newPrefix)
+ }
writeJSON(w, map[string]any{"ok": true, "name": body.NewName})
default:
failJSON(w, "unknown action")
diff --git a/internal/admin/undo.go b/internal/admin/undo.go
index 24ce9c1..c2ea61d 100644
--- a/internal/admin/undo.go
+++ b/internal/admin/undo.go
@@ -115,6 +115,47 @@ func (us *UndoStack) SetPlayerFileOnlineCheck(fn func(relPath string) bool) {
us.playerFileOnlineCheck = fn
}
+// RewritePaths re-prefixes every stored history path under oldPrefix so a
+// directory rename does not leave stale absolute paths behind. Without this
+// a later undo recreates the old directory and writes a duplicate file while
+// the live file stays in the new directory. newPrefix may be empty to leave
+// paths untouched.
+func (us *UndoStack) RewritePaths(oldPrefix, newPrefix string) {
+ if oldPrefix == "" || newPrefix == "" || oldPrefix == newPrefix {
+ return
+ }
+ us.mu.Lock()
+ defer us.mu.Unlock()
+ sep := string(os.PathSeparator)
+ rewrite := func(p string) string {
+ if p == "" {
+ return p
+ }
+ if p == oldPrefix {
+ return newPrefix
+ }
+ if strings.HasPrefix(p, oldPrefix+sep) {
+ return filepath.Join(newPrefix, strings.TrimPrefix(p, oldPrefix+sep))
+ }
+ return p
+ }
+ rewriteDesc := func(c *ChangeDesc) {
+ c.FilePath = rewrite(c.FilePath)
+ c.NewFilePath = rewrite(c.NewFilePath)
+ for i := range c.ExtraFiles {
+ c.ExtraFiles[i].FilePath = rewrite(c.ExtraFiles[i].FilePath)
+ c.ExtraFiles[i].NewFilePath = rewrite(c.ExtraFiles[i].NewFilePath)
+ }
+ }
+ for i := range us.history {
+ rewriteDesc(&us.history[i])
+ }
+ for i := range us.redo {
+ rewriteDesc(&us.redo[i])
+ }
+ us.save()
+}
+
func (us *UndoStack) Push(desc ChangeDesc) {
us.mu.Lock()
defer us.mu.Unlock()
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
index c9a50a1..164d0ff 100644
--- a/internal/casino/baccarat.go
+++ b/internal/casino/baccarat.go
@@ -200,24 +200,28 @@ func (t *baccaratTable) deal() []Event {
if !p.HasBet {
continue
}
- result, payout := settleBaccaratBet(p.lastSpot, p.Wager.Total, playerTotal, bankerTotal, t.Rules)
- payoutFailed := false
- if payout > 0 && p.Wallet != nil {
+ result, payout, settleErr := settleBaccaratBet(p.lastSpot, p.Wager.Total, playerTotal, bankerTotal, t.Rules)
+ // A payout-overflow error means the win could not be computed; treat
+ // it like an unpersisted payout so the win is never announced as paid
+ // (blackjack handles the same case identically).
+ payoutFailed := settleErr != nil
+ if !payoutFailed && payout > 0 && p.Wallet != nil {
if err := p.Wallet.PayStakeCover(payout, []string{p.stakeID}); err != nil {
// The payout could not be made durable; recovery will
// re-apply the owed amount from the open journal record.
// Do not announce the win as paid and leave the stakes
// open for recovery to settle.
payoutFailed = true
- publicResult, _ := baccaratResultMessages(p.Name, result, payout)
- settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage())
- if result == "win" {
- settlement.Color = "casino_big_win"
- }
- events = append(events, settlement)
}
}
- if !payoutFailed {
+ if payoutFailed {
+ publicResult, _ := baccaratResultMessages(p.Name, result, payout)
+ settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage())
+ if result == "win" {
+ settlement.Color = "casino_big_win"
+ }
+ events = append(events, settlement)
+ } else {
publicResult, privateResult := baccaratResultMessages(p.Name, result, payout)
settlement := t.playerAction(p.Name, publicResult, privateResult)
if result == "win" {
@@ -360,15 +364,18 @@ func bankerDraws(bankerTotal, playerThird int) bool {
// winnings) for one wager. Player and banker bets push on a tie. Winnings
// round down to whole credits, so the banker commission effectively rounds
// up: never bet less than one commission unit.
-func settleBaccaratBet(spot string, bet int, playerTotal, bankerTotal int, rules BaccaratConfig) (string, int) {
+func settleBaccaratBet(spot string, bet int, playerTotal, bankerTotal int, rules BaccaratConfig) (string, int, error) {
switch {
case playerTotal > bankerTotal:
if spot == baccaratSpotPlayer {
- return "win", bet * 2
+ return "win", bet * 2, nil
}
case bankerTotal > playerTotal:
if spot == baccaratSpotBanker {
- bankerWin, _ := MulPayout(bet, rules.BankerPayout)
+ bankerWin, err := MulPayout(bet, rules.BankerPayout)
+ if err != nil {
+ return "win", bet + bankerWin, err
+ }
// The banker commission rounds UP, not down: when the wager is
// too small for a whole credit of commission, a winning banker
// bet returns only the stake. MulPayout's positive-stake floor
@@ -377,18 +384,21 @@ func settleBaccaratBet(spot string, bet int, playerTotal, bankerTotal int, rules
// push), which the "never bet less than one commission unit"
// rule exists to prevent.
if float64(bet)*rules.BankerPayout < 1 {
- return "push", bet
+ return "push", bet, nil
}
- return "win", bet + bankerWin
+ return "win", bet + bankerWin, nil
}
default:
if spot == baccaratSpotTie {
- tieWin, _ := MulPayout(bet, rules.TiePayout)
- return "win", bet + tieWin
+ tieWin, err := MulPayout(bet, rules.TiePayout)
+ if err != nil {
+ return "win", bet + tieWin, err
+ }
+ return "win", bet + tieWin, nil
}
- return "push", bet
+ return "push", bet, nil
}
- return "loss", 0
+ return "loss", 0, nil
}
func baccaratOutcomeMessage(playerTotal, bankerTotal int) string {
diff --git a/internal/casino/baccarat_test.go b/internal/casino/baccarat_test.go
index 39941eb..41f5acf 100644
--- a/internal/casino/baccarat_test.go
+++ b/internal/casino/baccarat_test.go
@@ -99,29 +99,29 @@ func TestBaccaratTableauBankerThirdCardRules(t *testing.T) {
func TestBaccaratSettlement(t *testing.T) {
rules := (BaccaratConfig{}).Normalize()
- if result, payout := settleBaccaratBet(baccaratSpotPlayer, 100, 6, 4, rules); result != "win" || payout != 200 {
- t.Fatalf("player win: result=%s payout=%d, want win/200", result, payout)
+ if result, payout, err := settleBaccaratBet(baccaratSpotPlayer, 100, 6, 4, rules); err != nil || result != "win" || payout != 200 {
+ t.Fatalf("player win: result=%s payout=%d err=%v, want win/200", result, payout, err)
}
- if result, payout := settleBaccaratBet(baccaratSpotBanker, 100, 6, 4, rules); result != "loss" || payout != 0 {
- t.Fatalf("banker bet on player win: result=%s payout=%d, want loss/0", result, payout)
+ if result, payout, err := settleBaccaratBet(baccaratSpotBanker, 100, 6, 4, rules); err != nil || result != "loss" || payout != 0 {
+ t.Fatalf("banker bet on player win: result=%s payout=%d err=%v, want loss/0", result, payout, err)
}
- if result, payout := settleBaccaratBet(baccaratSpotBanker, 100, 4, 6, rules); result != "win" || payout != 195 {
- t.Fatalf("banker win: result=%s payout=%d, want win/195 (5%% commission)", result, payout)
+ if result, payout, err := settleBaccaratBet(baccaratSpotBanker, 100, 4, 6, rules); err != nil || result != "win" || payout != 195 {
+ t.Fatalf("banker win: result=%s payout=%d err=%v, want win/195 (5%% commission)", result, payout, err)
}
- if result, payout := settleBaccaratBet(baccaratSpotBanker, 1, 4, 6, rules); result != "push" || payout != 1 {
- t.Fatalf("banker win on bet 1: result=%s payout=%d, want push/1 (a sub-credit commission rounds up, returning only the stake)", result, payout)
+ if result, payout, err := settleBaccaratBet(baccaratSpotBanker, 1, 4, 6, rules); err != nil || result != "push" || payout != 1 {
+ t.Fatalf("banker win on bet 1: result=%s payout=%d err=%v, want push/1 (a sub-credit commission rounds up, returning only the stake)", result, payout, err)
}
- if result, payout := settleBaccaratBet(baccaratSpotTie, 100, 7, 7, rules); result != "win" || payout != 900 {
- t.Fatalf("tie win: result=%s payout=%d, want win/900 (8:1)", result, payout)
+ if result, payout, err := settleBaccaratBet(baccaratSpotTie, 100, 7, 7, rules); err != nil || result != "win" || payout != 900 {
+ t.Fatalf("tie win: result=%s payout=%d err=%v, want win/900 (8:1)", result, payout, err)
}
- if result, payout := settleBaccaratBet(baccaratSpotPlayer, 100, 7, 7, rules); result != "push" || payout != 100 {
- t.Fatalf("player bet on tie: result=%s payout=%d, want push/100", result, payout)
+ if result, payout, err := settleBaccaratBet(baccaratSpotPlayer, 100, 7, 7, rules); err != nil || result != "push" || payout != 100 {
+ t.Fatalf("player bet on tie: result=%s payout=%d err=%v, want push/100", result, payout, err)
}
- if result, payout := settleBaccaratBet(baccaratSpotBanker, 100, 7, 7, rules); result != "push" || payout != 100 {
- t.Fatalf("banker bet on tie: result=%s payout=%d, want push/100", result, payout)
+ if result, payout, err := settleBaccaratBet(baccaratSpotBanker, 100, 7, 7, rules); err != nil || result != "push" || payout != 100 {
+ t.Fatalf("banker bet on tie: result=%s payout=%d err=%v, want push/100", result, payout, err)
}
- if result, payout := settleBaccaratBet(baccaratSpotTie, 100, 6, 4, rules); result != "loss" || payout != 0 {
- t.Fatalf("tie bet on player win: result=%s payout=%d, want loss/0", result, payout)
+ if result, payout, err := settleBaccaratBet(baccaratSpotTie, 100, 6, 4, rules); err != nil || result != "loss" || payout != 0 {
+ t.Fatalf("tie bet on player win: result=%s payout=%d err=%v, want loss/0", result, payout, err)
}
}
diff --git a/internal/casino/manager.go b/internal/casino/manager.go
index 8b87147..b1281b2 100644
--- a/internal/casino/manager.go
+++ b/internal/casino/manager.go
@@ -409,20 +409,28 @@ func (m *Manager) RekeyRooms(idMap map[int]int) {
// refunded through each game's refundAll before the entries drop.
func (m *Manager) EvictRoom(roomID int) []Event {
prefix := fmt.Sprintf("%d:", roomID)
- var events []Event
+ // Snapshot the games under the manager lock, then refund OUTSIDE it:
+ // refundAll appends to the money journal and saves character files, and
+ // holding m.mu across that I/O blocks every casino command and Tick.
+ type evictable interface{ refundAll() []Event }
+ var evicted []evictable
m.mu.Lock()
for key, table := range m.tables {
if strings.HasPrefix(key, prefix) {
- events = append(events, table.refundAll()...)
+ evicted = append(evicted, table)
delete(m.tables, key)
}
}
for key, machine := range m.machines {
if strings.HasPrefix(key, prefix) {
- events = append(events, machine.refundAll()...)
+ evicted = append(evicted, machine)
delete(m.machines, key)
}
}
m.mu.Unlock()
+ var events []Event
+ for _, game := range evicted {
+ events = append(events, game.refundAll()...)
+ }
return events
}
diff --git a/internal/engine/tick.go b/internal/engine/tick.go
index 68dd54f..8c64cdd 100644
--- a/internal/engine/tick.go
+++ b/internal/engine/tick.go
@@ -4,6 +4,7 @@ package engine
import (
"math/rand"
+ "reflect"
"sort"
"sync"
"time"
@@ -171,6 +172,16 @@ func ValuesEqual(a, b any) bool {
if aok && bok {
return ai == bi
}
+ // == panics when both operands share an uncomparable dynamic type (a
+ // YAML list or map used as a flag value). Fall back to DeepEqual so a
+ // tick system cannot panic on authored content.
+ ta, tb := reflect.TypeOf(a), reflect.TypeOf(b)
+ if ta != tb {
+ return false
+ }
+ if ta != nil && !ta.Comparable() {
+ return reflect.DeepEqual(a, b)
+ }
return a == b
}
diff --git a/internal/engine/tick_values_test.go b/internal/engine/tick_values_test.go
new file mode 100644
index 0000000..4c704bb
--- /dev/null
+++ b/internal/engine/tick_values_test.go
@@ -0,0 +1,20 @@
+package engine
+
+import "testing"
+
+// TestValuesEqualUncomparableDoesNotPanic: a YAML list/map flag value must not
+// panic a tick system when compared (== panics on uncomparable dynamic types).
+func TestValuesEqualUncomparableDoesNotPanic(t *testing.T) {
+ if !ValuesEqual([]any{1, 2}, []any{1, 2}) {
+ t.Fatal("equal slices should compare equal")
+ }
+ if ValuesEqual([]any{1, 2}, []any{1, 3}) {
+ t.Fatal("different slices should not compare equal")
+ }
+ if !ValuesEqual(map[string]any{"x": 1}, map[string]any{"x": 1}) {
+ t.Fatal("equal maps should compare equal")
+ }
+ if ValuesEqual(map[string]any{"x": 1}, map[string]any{"x": 2}) {
+ t.Fatal("different maps should not compare equal")
+ }
+}
diff --git a/internal/game/act_burn.go b/internal/game/act_burn.go
index 84f7f14..a823b19 100644
--- a/internal/game/act_burn.go
+++ b/internal/game/act_burn.go
@@ -366,6 +366,11 @@ func (g *Game) consumeFireTool(sess *net.Session, p *player.Player, toolItemID s
g.saveCharacter(p)
return true
}
+ // The tool is carried/equipped but has no charge record (a stripped
+ // or depleted max_quality tool). Refuse instead of falling through
+ // to the stackable check and burning for free forever.
+ sess.WriteLine("The lighter is out of butane!")
+ return false
}
if def != nil && def.Stackable {
diff --git a/internal/game/act_clean.go b/internal/game/act_clean.go
index 3525475..4efe293 100644
--- a/internal/game/act_clean.go
+++ b/internal/game/act_clean.go
@@ -56,7 +56,17 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) {
}
craft := matchItem.FirstCraft()
- consumeID := craft.Ingredients[0].Items[0]
+ // Resolve the exact alternative the availability check selected instead
+ // of assuming the first listed item: an ingredient-less or
+ // alternative-item cleaning recipe would otherwise panic or consume the
+ // wrong stack while still awarding XP.
+ plan := planIngredients(craft, p.CountItem)
+ if len(plan) == 0 {
+ sess.WriteLine("You've finished cleaning herbs.")
+ g.cancelBgAction(p)
+ return
+ }
+ consumeID := plan[0].id
for i := 0; i < 28; i++ {
slot := p.InvSlot(i)
diff --git a/internal/game/act_effects.go b/internal/game/act_effects.go
index e51deab..e175c8f 100644
--- a/internal/game/act_effects.go
+++ b/internal/game/act_effects.go
@@ -292,7 +292,7 @@ func (g *Game) giveTriggerDrop(sess *net.Session, p *player.Player, drop *behavi
return
}
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty})
+ p.SetInvSlot(freeSlot, g.newInventorySlot(drop.ItemID, qty))
sess.WriteLine(fmt.Sprintf("You receive %s.", g.itemColorize(sess, lootDef, name)))
g.saveCharacter(p)
}
diff --git a/internal/game/act_gather.go b/internal/game/act_gather.go
index 08ffbb1..3bef8a0 100644
--- a/internal/game/act_gather.go
+++ b/internal/game/act_gather.go
@@ -432,7 +432,7 @@ func (g *Game) checkBirdNest(sess *net.Session, p *player.Player, cfg *behavior.
return
}
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: birdsNestItemID, Quantity: 1})
+ p.SetInvSlot(freeSlot, g.newInventorySlot(birdsNestItemID, 1))
g.saveCharacter(p)
sess.WriteLine(fmt.Sprintf("A %s falls out of the tree!", nestName))
}
diff --git a/internal/game/casino.go b/internal/game/casino.go
index dcf4273..3e382e9 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -1,6 +1,7 @@
package game
import (
+ "errors"
"fmt"
"os"
"strconv"
@@ -11,6 +12,7 @@ import (
"thehouseoficarus/internal/casino"
"thehouseoficarus/internal/color"
"thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/persistence"
"thehouseoficarus/internal/player"
)
@@ -54,6 +56,17 @@ func (w casinoWallet) Wager(amount int) (casino.Wager, error) {
}
w.p.Credits -= credits
if err := w.g.AccountStore.SaveCharacter(w.p); err != nil {
+ if errors.Is(err, persistence.ErrDurabilityUncertain) {
+ // The character file was replaced but the directory sync failed:
+ // the debit may be durable. Keep the in-memory debit and the
+ // journal record OPEN instead of voiding it, so recovery compares
+ // checkpoints against whatever file survived and either voids or
+ // refunds consistently. Proceeding is safe: the record stays open
+ // (refundable) until the round resolves.
+ fmt.Fprintf(os.Stderr, "casino wager: %s save durability uncertain: %v — record kept open\n", w.p.Name, err)
+ w.g.Economy.registerStake(rec)
+ return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: rec.ID}, nil
+ }
// Revert the in-memory mutation: the play must not proceed on a
// stake that could not be made durable.
if chips > 0 {
diff --git a/internal/game/cmd_consume.go b/internal/game/cmd_consume.go
index 2e05620..f12332e 100644
--- a/internal/game/cmd_consume.go
+++ b/internal/game/cmd_consume.go
@@ -279,5 +279,10 @@ func (g *Game) applyPotion(sess *net.Session, p *player.Player, itemID string, d
p.ConsumeCooldown = 3
+ // Persist the consumption and its effects: without this a crash before
+ // the next save replays the potion (duplicating the item) while losing
+ // the HP/battery/buff change.
+ g.saveCharacter(p)
+
g.broadcastAction(sess, "%s drinks a %s.", p.Name, def.Name)
}
diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go
index 2e27f91..278a57d 100644
--- a/internal/game/cmd_drop.go
+++ b/internal/game/cmd_drop.go
@@ -152,12 +152,13 @@ func (g *Game) doDrop(sess *net.Session, input string) {
if take > remaining {
take = remaining
}
+ quality, maxQuality := slot.Quality, slot.MaxQuality
if slot.Quantity > take {
slot.Quantity -= take
} else {
p.SetInvSlot(match.Slot, nil)
}
- g.World.AddGroundItem(p.RoomID, itemID, take)
+ g.World.AddGroundItemQ(p.RoomID, itemID, take, quality, maxQuality)
remaining -= take
}
dropped := qty - remaining
@@ -179,13 +180,20 @@ func (g *Game) doDrop(sess *net.Session, input string) {
if slot == nil || slot.ItemID != itemID || slot.Quantity <= 0 {
continue
}
- take := 1
+ // Drop as much of the requested quantity as this slot holds, not
+ // just one: a non-stackable slot holding several units must honour
+ // "drop 3 <item>".
+ take := slot.Quantity
+ if take > remaining {
+ take = remaining
+ }
+ quality, maxQuality := slot.Quality, slot.MaxQuality
if slot.Quantity > take {
slot.Quantity -= take
} else {
p.SetInvSlot(match.Slot, nil)
}
- g.World.AddGroundItem(p.RoomID, itemID, take)
+ g.World.AddGroundItemQ(p.RoomID, itemID, take, quality, maxQuality)
remaining -= take
}
@@ -236,7 +244,7 @@ func (g *Game) handleDropAll(sess *net.Session, input string) {
if slot == nil || slot.Quantity <= 0 {
continue
}
- g.World.AddGroundItem(p.RoomID, slot.ItemID, slot.Quantity)
+ g.World.AddGroundItemQ(p.RoomID, slot.ItemID, slot.Quantity, slot.Quality, slot.MaxQuality)
def, _ := g.ItemStore.Load(slot.ItemID)
name := slot.ItemID
if def != nil {
diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go
index a2ad137..33f55de 100644
--- a/internal/game/cmd_get.go
+++ b/internal/game/cmd_get.go
@@ -75,7 +75,7 @@ func (g *Game) doGet(sess *net.Session, input string) {
// doGetAllNamed). See TestPickupMergesIntoFullInventory.
if def != nil && def.Stackable {
- quality, maxQuality := g.pickedStackProps(itemID)
+ quality, maxQuality := g.groundStackProps(p, itemID)
for i := 0; i < 28; i++ {
slot := p.InvSlot(i)
if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality {
@@ -96,11 +96,12 @@ func (g *Game) doGet(sess *net.Session, input string) {
sess.WriteLine("Your inventory is full.")
return
}
- if _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, qty, p.Name); !ok {
+ removed, q, mq, ok := g.World.RemoveReservedItemQ(p.RoomID, itemID, qty, p.Name)
+ if !ok {
sess.WriteLine("That's not yours!")
return
}
- p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, qty))
+ p.SetInvSlot(freeSlot, g.groundSlot(itemID, removed, q, mq))
g.saveCharacter(p)
if qty == 1 {
sess.WriteLine(fmt.Sprintf("You pick up a %s.", g.itemColorize(sess, def, def.Name)))
@@ -116,10 +117,11 @@ func (g *Game) doGet(sess *net.Session, input string) {
if fs == -1 {
break
}
- if _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, 1, p.Name); !ok {
+ _, q, mq, ok := g.World.RemoveReservedItemQ(p.RoomID, itemID, 1, p.Name)
+ if !ok {
break
}
- p.SetInvSlot(fs, g.newInventorySlot(itemID, 1))
+ p.SetInvSlot(fs, g.groundSlot(itemID, 1, q, mq))
picked++
}
@@ -195,7 +197,7 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) {
coloredName := g.itemColorize(sess, def, name)
if def != nil && def.Stackable {
- quality, maxQuality := g.pickedStackProps(itemID)
+ quality, maxQuality := g.groundStackProps(p, itemID)
for i := 0; i < 28; i++ {
slot := p.InvSlot(i)
if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality {
@@ -216,12 +218,12 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) {
sess.WriteLine("Your inventory is full.")
return
}
- removed, ok := g.World.RemoveReservedItem(p.RoomID, itemID, wanted, p.Name)
+ removed, q, mq, ok := g.World.RemoveReservedItemQ(p.RoomID, itemID, wanted, p.Name)
if !ok {
sess.WriteLine("That's not yours!")
return
}
- p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, removed))
+ p.SetInvSlot(freeSlot, g.groundSlot(itemID, removed, q, mq))
g.saveCharacter(p)
if removed == 1 {
sess.WriteLine(fmt.Sprintf("You pick up a %s.", coloredName))
@@ -237,11 +239,11 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) {
if fs == -1 {
break
}
- _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, 1, p.Name)
+ _, q, mq, ok := g.World.RemoveReservedItemQ(p.RoomID, itemID, 1, p.Name)
if !ok {
break
}
- p.SetInvSlot(fs, g.newInventorySlot(itemID, 1))
+ p.SetInvSlot(fs, g.groundSlot(itemID, 1, q, mq))
picked++
}
@@ -303,7 +305,7 @@ func (g *Game) doGetAll(sess *net.Session) {
for qty > 0 {
if def != nil && def.Stackable {
stacked := false
- quality, maxQuality := g.pickedStackProps(itemID)
+ quality, maxQuality := g.groundStackProps(p, itemID)
for i := 0; i < 28; i++ {
slot := p.InvSlot(i)
if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality {
@@ -335,11 +337,11 @@ func (g *Game) doGetAll(sess *net.Session) {
g.saveCharacter(p)
return
}
- _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, qty, p.Name)
+ removed, q, mq, ok := g.World.RemoveReservedItemQ(p.RoomID, itemID, qty, p.Name)
if !ok {
break
}
- p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, qty))
+ p.SetInvSlot(freeSlot, g.groundSlot(itemID, removed, q, mq))
name := itemID
if def != nil {
name = def.Name
@@ -361,11 +363,11 @@ func (g *Game) doGetAll(sess *net.Session) {
}
take := 1
- _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, take, p.Name)
+ _, q, mq, ok := g.World.RemoveReservedItemQ(p.RoomID, itemID, take, p.Name)
if !ok {
break
}
- p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, take))
+ p.SetInvSlot(freeSlot, g.groundSlot(itemID, take, q, mq))
name := itemID
if def != nil {
name = def.Name
@@ -433,6 +435,28 @@ func (g *Game) pickedStackProps(itemID string) (quality, maxQuality int) {
return stackProps(def)
}
+// groundStackProps is pickedStackProps for a ground pickup: it prefers the
+// quality carried by the ground entry so a partially-used item is not
+// re-created at full quality (and a pickup only merges into a slot with the
+// same properties).
+func (g *Game) groundStackProps(p *player.Player, itemID string) (quality, maxQuality int) {
+ if q, mq, ok := g.World.AccessibleGroundQuality(p.RoomID, itemID, p.Name); ok && mq > 0 {
+ return q, mq
+ }
+ return g.pickedStackProps(itemID)
+}
+
+// groundSlot builds an inventory slot for a ground pickup, preserving the
+// quality carried by the ground entry. A ground entry with no quality record
+// (loot/trigger/spawn drops) falls back to the item's full quality, matching
+// newInventorySlot.
+func (g *Game) groundSlot(itemID string, qty, quality, maxQuality int) *player.InventorySlot {
+ if maxQuality > 0 {
+ return &player.InventorySlot{ItemID: itemID, Quantity: qty, Quality: quality, MaxQuality: maxQuality}
+ }
+ return g.newInventorySlot(itemID, qty)
+}
+
func (g *Game) collectMatches(input string, each func(func(itemID string, slot int) bool)) []itemMatch {
var matches []itemMatch
each(func(itemID string, slot int) bool {
diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go
index 249df80..0a8b8ca 100644
--- a/internal/game/cmd_quit.go
+++ b/internal/game/cmd_quit.go
@@ -97,7 +97,9 @@ func (g *Game) beginQuitCountdown(sess *net.Session) {
// Forfeit any committed casino wager and free the seat only
// now — immediately before the disconnect. Settling games only
// pay seated bettors, so this forfeits a mid-round stake.
- g.emitCasinoEvents(g.Casino.MarkDisconnected(p.Name))
+ if g.Casino != nil {
+ g.emitCasinoEvents(g.Casino.MarkDisconnected(p.Name))
+ }
g.saveCharacter(p)
g.charsMu.Lock()
delete(g.loggedInChars, p.Name)
diff --git a/internal/game/cmd_trigger_utility.go b/internal/game/cmd_trigger_utility.go
index 75512d1..481cd40 100644
--- a/internal/game/cmd_trigger_utility.go
+++ b/internal/game/cmd_trigger_utility.go
@@ -194,7 +194,7 @@ func (g *Game) triggerEmGrab(sess *net.Session, p *player.Player, mod *ModDef, t
if freeSlot < 0 {
break
}
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: matchedID, Quantity: 1})
+ p.SetInvSlot(freeSlot, g.newInventorySlot(matchedID, 1))
placed++
}
if placed < taken {
diff --git a/internal/game/cmd_wear.go b/internal/game/cmd_wear.go
index 5a293de..e459386 100644
--- a/internal/game/cmd_wear.go
+++ b/internal/game/cmd_wear.go
@@ -141,6 +141,13 @@ func (g *Game) displacedEquipSlot(p *player.Player, itemID string) *player.Inven
slot.MaxQuality = def.MaxQuality
}
delete(p.EquipQuality, itemID)
+ return slot
+ }
+ // No charge record: keep a max_quality item quality-bearing but empty so
+ // consumeFireTool refuses it instead of treating it as an infinite
+ // firesteel. Stamping full quality here would refill the item.
+ if def, _ := g.ItemStore.Load(itemID); def != nil && def.MaxQuality > 0 {
+ slot.MaxQuality = def.MaxQuality
}
return slot
}
diff --git a/internal/game/combat_attack.go b/internal/game/combat_attack.go
index 80994e1..1e53510 100644
--- a/internal/game/combat_attack.go
+++ b/internal/game/combat_attack.go
@@ -194,6 +194,10 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn
}
g.SubscribeTicks(1, func() bool {
+ if !g.sessionRegistered(sess, p.Name) {
+ g.Combat.Leave(p.Name)
+ return false
+ }
cs := g.Combat.Get(p.Name)
if cs == nil || !cs.Active {
return false
@@ -256,6 +260,10 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn
if !isTask {
g.SubscribeTicks(engine.ToTicks(mob.Speed), func() bool {
+ if !g.sessionRegistered(sess, p.Name) {
+ g.Combat.Leave(p.Name)
+ return false
+ }
cs := g.Combat.Get(p.Name)
if cs == nil || !cs.Active {
return false
@@ -354,6 +362,15 @@ func (g *Game) playerAttackUnarmed(sess *net.Session, p *player.Player, mob *wor
return
}
+ // Unarmed is a melee crush attack, so it must leave cover exactly like
+ // every other melee attack: otherwise a safespotted player can punch a
+ // melee-only mob that cannot retaliate.
+ if g.isSafespotted(p.Name) {
+ if ss, ok := g.safespot.Get(p.Name); ok {
+ g.forceLeaveSafespot(sess, p, &ss, "You leave your cover to close in for melee!")
+ }
+ }
+
_, _, attRoll, defRoll, maxHit := g.calculateUnarmedAttackRoll(p, mob)
if combat.HitCheck(attRoll, defRoll) {
diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go
index ace02b5..21aaf4c 100644
--- a/internal/game/combat_mob.go
+++ b/internal/game/combat_mob.go
@@ -426,6 +426,10 @@ func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRan
{string(player.Defense), quarter},
{string(player.Hitpoints), quarter},
}
+ default:
+ // A legacy or hand-edited save may omit attack_style; default to
+ // accurate so melee combat never silently grants zero XP.
+ gains = []xpGain{{string(player.Accuracy), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}}
}
}
diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go
index a5106ea..e9fa67f 100644
--- a/internal/game/core_login_account.go
+++ b/internal/game/core_login_account.go
@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"strings"
+ "time"
"thehouseoficarus/internal/color"
"thehouseoficarus/internal/net"
@@ -156,6 +157,8 @@ func (g *Game) handlePasswordOffOwner(sess *net.Session, input string) {
})
return
}
+ throttle := g.ensureLoginThrottle()
+ key := loginThrottleKey(name, sess.RemoteAddr())
auth := passwordAuth{}
acc, err := g.AccountStore.LoadAccount(name)
switch {
@@ -163,6 +166,7 @@ func (g *Game) handlePasswordOffOwner(sess *net.Session, input string) {
auth.loadErr = true
case player.CheckPassword(input, acc.PasswordHash):
auth.ok, auth.stored = true, acc.PasswordHash
+ throttle.clear(key)
// Transparent upgrade: legacy single-pass SHA-256 hashes rehash to
// Argon2id after a successful verification. A failed rehash keeps
// the legacy hash and retries on the next login.
@@ -173,6 +177,12 @@ func (g *Game) handlePasswordOffOwner(sess *net.Session, input string) {
log.Printf("login: password rehash for %s: %v", acc.Name, hashErr)
}
}
+ default:
+ // Wrong password: apply an escalating per-(account, IP) delay so a
+ // brute-force attempt costs real wall-clock time. This is the
+ // connection's own goroutine, never the owner, so the world is not
+ // stalled by an attacker.
+ time.Sleep(throttle.delayFor(key))
}
g.RunOnOwner(func() {
if g.isShuttingDown() {
diff --git a/internal/game/core_production.go b/internal/game/core_production.go
index 34ad8a9..afffa7c 100644
--- a/internal/game/core_production.go
+++ b/internal/game/core_production.go
@@ -93,6 +93,13 @@ var productionTypes = map[string]productionTypeInfo{
SuccessMessage: "You mix a %n.",
EndMessage: "You've finished mixing.",
},
+ "cleaning": {
+ ActionType: behavior.TypeClean,
+ DisplayVerb: "cleaning",
+ StartMessage: "You start cleaning %i1.",
+ SuccessMessage: "You clean the %n.",
+ EndMessage: "You've finished cleaning.",
+ },
"construction": {
ActionType: behavior.TypeConstruct,
DisplayVerb: "constructing",
diff --git a/internal/game/core_validate.go b/internal/game/core_validate.go
index b6cb3b9..a858933 100644
--- a/internal/game/core_validate.go
+++ b/internal/game/core_validate.go
@@ -16,13 +16,18 @@ func (g *Game) validationSource() validate.Source {
courseViews := make([]validate.CourseView, 0, len(courses))
for id, cfg := range courses {
rooms := make([]int, 0, len(cfg.Obstacles))
+ failRooms := make([]int, 0, len(cfg.Obstacles))
for _, obs := range cfg.Obstacles {
rooms = append(rooms, obs.RoomID)
+ if obs.OnFailRoom > 0 {
+ failRooms = append(failRooms, obs.OnFailRoom)
+ }
}
courseViews = append(courseViews, validate.CourseView{
ID: id,
StartRoom: cfg.StartRoom,
ObstacleRooms: rooms,
+ OnFailRooms: failRooms,
})
}
@@ -39,6 +44,7 @@ func (g *Game) validationSource() validate.Source {
moduleElements := make(map[string]bool)
moduleDestinations := make(map[string]int)
+ transportModules := make(map[string]bool)
for _, m := range AllMods {
if m.Element != "" {
moduleElements[m.Element] = true
@@ -46,6 +52,16 @@ func (g *Game) validationSource() validate.Source {
if m.Destination > 0 {
moduleDestinations[m.ID] = m.Destination
}
+ if m.Category == ModTransport {
+ transportModules[m.ID] = true
+ }
+ }
+
+ knownFieldTargets := []validate.KnownFieldTarget{
+ {SubDir: "techs", Label: "tech", NewDef: func() any { return new(TechDef) }},
+ {SubDir: "modules", Label: "module", NewDef: func() any { return new(ModDef) }},
+ {SubDir: "courses", Label: "course", NewDef: func() any { return new(CourseConfig) }},
+ {SubDir: "help", Label: "help topic", NewDef: func() any { return new(helpRaw) }},
}
return validate.Source{
@@ -61,5 +77,7 @@ func (g *Game) validationSource() validate.Source {
IgnoreUnreachable: g.ValidationConfig.IgnoreUnreachable,
ModuleElements: moduleElements,
ModuleDestinations: moduleDestinations,
+ TransportModules: transportModules,
+ KnownFieldTargets: knownFieldTargets,
}
}
diff --git a/internal/game/economy.go b/internal/game/economy.go
index 0b9e92f..9853671 100644
--- a/internal/game/economy.go
+++ b/internal/game/economy.go
@@ -184,15 +184,39 @@ func (j *economyJournal) append(rec economyRecord) error {
return fmt.Errorf("open economy journal: %w", err)
}
defer f.Close()
+ // Record the pre-write size so a failed write/sync rolls the record back.
+ // "append failed" must mean "no record bytes remain": the wallet's
+ // journal-unavailable fallback commits the payout through the character
+ // file only, and a later recovery must not see the same payout record as
+ // still owed (that double-pays). It also keeps a short write from merging
+ // into the next append as a malformed mid-file line.
+ startSize := int64(-1)
+ if fi, statErr := f.Stat(); statErr == nil {
+ startSize = fi.Size()
+ }
+ rollback := func(cause error) error {
+ if startSize >= 0 {
+ if terr := f.Truncate(startSize); terr != nil {
+ fmt.Fprintf(os.Stderr, "economy journal: rollback truncate after %v: %v\n", cause, terr)
+ } else {
+ _ = f.Sync()
+ }
+ }
+ return cause
+ }
if _, err := f.Write(append(data, '\n')); err != nil {
- return fmt.Errorf("append economy journal: %w", err)
+ return rollback(fmt.Errorf("append economy journal: %w", err))
}
if err := f.Sync(); err != nil {
- return fmt.Errorf("sync economy journal: %w", err)
+ return rollback(fmt.Errorf("sync economy journal: %w", err))
}
if !j.dirSynced {
if err := syncDir(filepath.Dir(j.path)); err != nil {
- return fmt.Errorf("sync economy journal dir: %w", err)
+ // The record bytes are in the file but the directory entry is not
+ // guaranteed durable. Roll the record back so the caller's failure
+ // is truthful, and leave dirSynced false so a later append retries
+ // the directory sync.
+ return rollback(fmt.Errorf("sync economy journal dir: %w", err))
}
j.dirSynced = true
}
@@ -1043,6 +1067,10 @@ func (g *Game) recoverEconomy() {
return evidence, settlement
}
compensated := 0
+ // skipCompact is set when a recovery-time append that a later run depends
+ // on could not be made durable; compaction must then be skipped so the
+ // evidence it would erase is preserved.
+ skipCompact := false
// Phase 1 — payouts, in append order. Records with identical
// checkpoints are decided as one group: k sibling records all journaled
@@ -1097,6 +1125,19 @@ func (g *Game) recoverEconomy() {
// settled; a save failure (e.g. the credit cap) leaves the unpaid
// remainder open for operator inspection — a liability is never erased.
reapplyGroup := func(group []*economyRecord, p *player.Player, credit int) {
+ // A mixed tracked/untracked group can contain a record already settled
+ // earlier in this pass; paying it again would create credits. Filter
+ // to the still-unsettled members.
+ active := make([]*economyRecord, 0, len(group))
+ for _, rec := range group {
+ if !settled[rec.ID] {
+ active = append(active, rec)
+ }
+ }
+ group = active
+ if len(group) == 0 {
+ return
+ }
paid := 0
capBlocked := false
for m := 0; m < len(group); m++ {
@@ -1130,7 +1171,13 @@ func (g *Game) recoverEconomy() {
// and settles the remainder as "already durable").
reanchorOwedPayout(other, p.Credits, other.Credit)
settled[other.ID] = true // handled for the rest of this run
- _ = g.Economy.append(*other)
+ if err := g.Economy.appendRetry(*other, 3); err != nil {
+ // The re-anchor is the evidence the next run needs to avoid
+ // silently settling this liability. If it is not durable,
+ // skip compaction so the settled sibling evidence survives.
+ fmt.Fprintf(os.Stderr, "economy recovery: re-anchor append %s failed: %v — skipping compaction this run\n", other.ID, err)
+ skipCompact = true
+ }
continue
}
// A TRANSIENT save failure blocked the FIRST re-application
@@ -1323,11 +1370,28 @@ func (g *Game) recoverEconomy() {
g.Economy.settle(rec)
continue
}
+ // The chips were returned in kind and persisted; clear the
+ // record's chip obligation durably so a later session refund
+ // (RefundOutstanding) cannot return the same chips again. The
+ // record stays open for the credit side.
+ if chipsOwed {
+ rec.Chips = 0
+ rec.ChipsBefore = chipsNow
+ rec.ChipsAfter = chipsNow
+ if err := g.Economy.appendRetry(*rec, 3); err != nil {
+ fmt.Fprintf(os.Stderr, "economy recovery: chip-obligation clear %s failed: %v\n", rec.ID, err)
+ skipCompact = true
+ }
+ }
fmt.Fprintf(os.Stderr, "economy recovery: ambiguous wager %s for %s (credits %d, want before %d or after %d) — kept open for operator inspection\n",
rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter)
}
}
- g.Economy.compact()
+ if skipCompact {
+ fmt.Fprintln(os.Stderr, "economy recovery: compaction skipped to preserve payout evidence")
+ } else {
+ g.Economy.compact()
+ }
// Final evidence prune: the phases above settled or voided records, so
// evidence IDs that no longer correspond to an open record are stale
// (a settled stake's resolution evidence, an applied payout's entry).
diff --git a/internal/game/game.go b/internal/game/game.go
index 5ad1033..af2ff66 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -110,6 +110,12 @@ type Game struct {
// Economy is the durable money journal; see economy.go.
Economy *economyJournal
+ // loginThrottle applies an escalating per-(account, IP) delay to failed
+ // interactive password attempts. Lazily created so Game literals built
+ // directly in tests work without it.
+ loginThrottleOnce sync.Once
+ loginThrottle *loginThrottle
+
// owner serializes all live-state mutations; see owner.go.
owner *owner
ownerStop sync.Once
@@ -197,6 +203,13 @@ func (g *Game) SetHub(hub *net.Hub) {
return
}
if p := sess.Player; p != nil {
+ // The session is leaving the world: end any live fight and
+ // cancel a running logout countdown first, so neither keeps
+ // acting on a detached session (ghost combat that saves over
+ // a reconnected character, or a duplicate logout that drops
+ // the reconnected session's registry entry).
+ g.stopCombat(p.Name)
+ g.cancelRest(p.Name)
g.restoreGodPlayer(p)
g.persistLockedOnDisconnect(p)
g.saveCharacter(p)
@@ -443,6 +456,12 @@ func (g *Game) HandleSession(sess *net.Session, input string) {
g.handleDangerConfirm(sess, input)
case net.StateUndigConfirm:
g.handleUndigConfirm(sess, input)
+ default:
+ // A declared state with no handler would otherwise silently swallow
+ // every line. Re-prompt and log so a future menu flow cannot strand
+ // the player on a dead input state.
+ log.Printf("unhandled session state %v", sess.State())
+ sess.WriteLine("Something went wrong; please try again.")
}
}
diff --git a/internal/game/hardening_disconnect_combat_test.go b/internal/game/hardening_disconnect_combat_test.go
new file mode 100644
index 0000000..7bd6708
--- /dev/null
+++ b/internal/game/hardening_disconnect_combat_test.go
@@ -0,0 +1,31 @@
+package game
+
+import "testing"
+
+// TestDisconnectHardRemoveEndsCombat pins the P0 fix: a session hard-removed
+// while a fight is live must have that fight ended. Otherwise the combat tick
+// subscribers (keyed by character name) keep attacking and saving a detached
+// Player, which duplicates items/credits on death and can permanently lock a
+// finishing_blow mob.
+func TestDisconnectHardRemoveEndsCombat(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ sess, _ := h.connect("alice")
+ p := sess.Player
+
+ h.g.RunOnOwner(func() {
+ sess.StartDisconnect(1)
+ h.g.Combat.Enter(p.Name, "skeleton")
+ for i := 0; i < disconnectCombatGraceTicks+5 && len(h.g.Hub.AllSessions()) > 0; i++ {
+ h.g.DisconnectTick()
+ }
+ })
+
+ if len(h.g.Hub.AllSessions()) != 0 {
+ t.Fatal("session was not hard-removed")
+ }
+ if h.g.Combat.Get(p.Name) != nil {
+ t.Fatal("combat survived the hard remove; a ghost fight would keep saving the detached character")
+ }
+}
diff --git a/internal/game/login_throttle.go b/internal/game/login_throttle.go
new file mode 100644
index 0000000..b819188
--- /dev/null
+++ b/internal/game/login_throttle.go
@@ -0,0 +1,114 @@
+package game
+
+import (
+ "sync"
+ "time"
+)
+
+// Interactive login throttling. The admin HTTP /login route has its own
+// limiter; the telnet/TLS/websocket login is the primary credential surface
+// and needs one too. The policy is an escalating delay keyed on
+// (account, client IP): a hard lockout is deliberately avoided because
+// account existence is discoverable and a lockout would be a targeted
+// griefing tool. The delay runs on the connection goroutine, never the
+// owner, so a brute-force attempt stalls only its own connection.
+
+const (
+ // loginThrottleMaxDelay caps the escalation so a legitimate player who
+ // fat-fingers a password a few times is inconvenienced, not locked out.
+ loginThrottleMaxDelay = 4 * time.Second
+ // loginThrottleMaxKeys bounds the tracked keys so a source that cycles
+ // account names or addresses cannot grow server memory without bound.
+ loginThrottleMaxKeys = 4096
+ // loginThrottleEntryTTL is how long an untouched entry is retained.
+ loginThrottleEntryTTL = 10 * time.Minute
+)
+
+type loginThrottleEntry struct {
+ failures int
+ lastSeen time.Time
+}
+
+type loginThrottle struct {
+ mu sync.Mutex
+ entries map[string]loginThrottleEntry
+}
+
+func newLoginThrottle() *loginThrottle {
+ return &loginThrottle{entries: make(map[string]loginThrottleEntry)}
+}
+
+// ensureLoginThrottle returns the game's login throttle, creating it on first
+// use. The connection goroutine calls this; sync.Once makes the lazy init
+// race-free for Game literals that did not go through New.
+func (g *Game) ensureLoginThrottle() *loginThrottle {
+ g.loginThrottleOnce.Do(func() {
+ g.loginThrottle = newLoginThrottle()
+ })
+ return g.loginThrottle
+}
+
+// loginThrottleKey combines the attempted account and the client address.
+// Either may be empty (synthetic connections).
+func loginThrottleKey(account, ip string) string {
+ return account + "\x00" + ip
+}
+
+// delayFor records a failed attempt and returns the escalating delay the
+// caller should wait before responding: 0, 250ms, 500ms, 1s, 2s, 4s, 4s...
+func (t *loginThrottle) delayFor(key string) time.Duration {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ now := time.Now()
+ if len(t.entries) >= loginThrottleMaxKeys {
+ t.evictLocked(now)
+ }
+ e := t.entries[key]
+ e.failures++
+ e.lastSeen = now
+ t.entries[key] = e
+ if e.failures < 2 {
+ // The first wrong password is free: a single typo must not cost a
+ // legitimate player any time.
+ return 0
+ }
+ shift := e.failures - 2
+ if shift > 4 { // 250ms << 4 == loginThrottleMaxDelay
+ shift = 4
+ }
+ d := time.Duration(250*int64(1<<uint(shift))) * time.Millisecond
+ if d > loginThrottleMaxDelay {
+ d = loginThrottleMaxDelay
+ }
+ return d
+}
+
+// clear forgets a key after a successful login.
+func (t *loginThrottle) clear(key string) {
+ t.mu.Lock()
+ delete(t.entries, key)
+ t.mu.Unlock()
+}
+
+// evictLocked drops expired entries, then the oldest entry if still at the
+// cap. Caller holds mu.
+func (t *loginThrottle) evictLocked(now time.Time) {
+ for k, e := range t.entries {
+ if now.Sub(e.lastSeen) > loginThrottleEntryTTL {
+ delete(t.entries, k)
+ }
+ }
+ if len(t.entries) < loginThrottleMaxKeys {
+ return
+ }
+ oldestKey := ""
+ var oldest time.Time
+ for k, e := range t.entries {
+ if oldestKey == "" || e.lastSeen.Before(oldest) {
+ oldestKey, oldest = k, e.lastSeen
+ }
+ }
+ if oldestKey != "" {
+ delete(t.entries, oldestKey)
+ }
+}
diff --git a/internal/game/login_throttle_test.go b/internal/game/login_throttle_test.go
new file mode 100644
index 0000000..b2565c4
--- /dev/null
+++ b/internal/game/login_throttle_test.go
@@ -0,0 +1,35 @@
+package game
+
+import (
+ "testing"
+ "time"
+)
+
+// TestLoginThrottleEscalatesAndResets pins the telnet login throttle: the
+// first wrong password is free, then the delay escalates to a cap, and a
+// successful login clears the state.
+func TestLoginThrottleEscalatesAndResets(t *testing.T) {
+ th := newLoginThrottle()
+ key := loginThrottleKey("alice", "203.0.113.7")
+
+ if d := th.delayFor(key); d != 0 {
+ t.Fatalf("first failure delay = %v, want 0", d)
+ }
+ if d := th.delayFor(key); d != 250*time.Millisecond {
+ t.Fatalf("second failure delay = %v, want 250ms", d)
+ }
+ if d := th.delayFor(key); d != 500*time.Millisecond {
+ t.Fatalf("third failure delay = %v, want 500ms", d)
+ }
+ for i := 0; i < 10; i++ {
+ th.delayFor(key)
+ }
+ if d := th.delayFor(key); d != loginThrottleMaxDelay {
+ t.Fatalf("capped delay = %v, want %v", d, loginThrottleMaxDelay)
+ }
+
+ th.clear(key)
+ if d := th.delayFor(key); d != 0 {
+ t.Fatalf("delay after clear = %v, want 0", d)
+ }
+}
diff --git a/internal/game/session_registry.go b/internal/game/session_registry.go
index c4f5d67..cf4e8df 100644
--- a/internal/game/session_registry.go
+++ b/internal/game/session_registry.go
@@ -23,3 +23,17 @@ func (g *Game) detachSession(name string) {
g.queue.ClearPlayer(name)
}
}
+
+// sessionRegistered reports whether sess is still the live session for name.
+// Tick subscribers that can outlive a disconnect/re-login must consult this
+// before acting on a session that is no longer in the world (a detached
+// combat or logout subscriber would otherwise keep saving a ghost).
+func (g *Game) sessionRegistered(sess *net.Session, name string) bool {
+ if sess == nil || sess.Player == nil {
+ return false
+ }
+ g.charsMu.Lock()
+ current := g.loggedInChars[name]
+ g.charsMu.Unlock()
+ return current == sess
+}
diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go
index 8bba630..a6feec2 100644
--- a/internal/game/sys_triggers.go
+++ b/internal/game/sys_triggers.go
@@ -2,6 +2,7 @@ package game
import (
"fmt"
+ "log"
"os"
"path/filepath"
"sort"
@@ -572,7 +573,11 @@ func (g *Game) loadGlobalTriggers(dataDir string) error {
}
var t behavior.Trigger
if err := yamlUnmarshal(data, &t); err != nil {
- return fmt.Errorf("trigger %q: %w", full, err)
+ // Log and continue: aborting the walk would silently leave
+ // every trigger after this file unindexed. The validator also
+ // reports the bad file, so a reload/restart surfaces it.
+ log.Printf("trigger load %q: %v (skipped)", full, err)
+ continue
}
stem := strings.TrimSuffix(entry.Name(), ext)
if t.OnPlayerFlag != "" {
diff --git a/internal/game/tick.go b/internal/game/tick.go
index ef8089a..21489ca 100644
--- a/internal/game/tick.go
+++ b/internal/game/tick.go
@@ -50,6 +50,11 @@ func (g *Game) DisconnectTick() {
}
if sess.TickDisconnectCountdown() {
delete(g.disconnectDeferrals, sess)
+ // The fight must not outlive the session: the combat tick
+ // subscribers are keyed by name and would otherwise keep
+ // attacking and saving for a character no longer in the world.
+ g.stopCombat(p.Name)
+ g.cancelRest(p.Name)
g.restoreGodPlayer(p)
g.saveCharacter(p)
g.Hub.HardRemove(sess)
diff --git a/internal/net/conn.go b/internal/net/conn.go
index 42b7c41..4aa4db4 100644
--- a/internal/net/conn.go
+++ b/internal/net/conn.go
@@ -11,6 +11,7 @@ import (
"strings"
"sync/atomic"
"time"
+ "unicode/utf8"
)
type Conn interface {
@@ -72,6 +73,7 @@ func (t *tcpConn) ReadMessage() (string, error) {
}
text := strings.TrimRight(string(line), "\r\n")
text = stripIAC(text)
+ text = truncateLine(text, maxLineLength)
if t.echoOff.Load() {
t.conn.Write([]byte("\r\n"))
}
@@ -82,6 +84,32 @@ func (t *tcpConn) Write(b []byte) (int, error) {
return t.conn.Write(b)
}
+// RemoteAddr reports the peer address for the login throttle.
+func (t *tcpConn) RemoteAddr() string {
+ if t.conn == nil {
+ return ""
+ }
+ return t.conn.RemoteAddr().String()
+}
+
+// truncateLine cuts s to at most max bytes without splitting a UTF-8 rune.
+// Both the telnet and websocket paths bound line length, and a byte-boundary
+// cut would otherwise emit an invalid trailing rune to the game.
+func truncateLine(s string, max int) string {
+ if max <= 0 || len(s) <= max {
+ return s
+ }
+ s = s[:max]
+ for len(s) > 0 {
+ r, size := utf8.DecodeLastRuneInString(s)
+ if r != utf8.RuneError || size > 1 {
+ break
+ }
+ s = s[:len(s)-1]
+ }
+ return s
+}
+
func (t *tcpConn) SetWriteDeadline(ts time.Time) error {
return t.conn.SetWriteDeadline(ts)
}
diff --git a/internal/net/server.go b/internal/net/server.go
index f56aebb..c818eef 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -141,6 +141,16 @@ func (sess *Session) AuthName() string {
return ""
}
+// RemoteAddr returns the client's network address when the transport can
+// report one, or "" for synthetic/test connections. It is immutable for the
+// life of the connection, so it is safe to read off the owner goroutine.
+func (sess *Session) RemoteAddr() string {
+ if rc, ok := sess.Conn.(interface{ RemoteAddr() string }); ok {
+ return rc.RemoteAddr()
+ }
+ return ""
+}
+
// sessionSeq is the source of Session.seq values. It is incremented under the
// adding Hub's mutex, so no extra atomicity is needed for correctness; atomic
// access keeps the package-level default counter safe if Hub.Add is ever
@@ -172,6 +182,11 @@ type Server struct {
// admits against maxConnections so a pre-auth connection flood cannot
// hold an unbounded number of reader goroutines, buffers, and outboxes.
conns atomic.Int64
+ // connsByIP bounds concurrent connections from one client address so a
+ // single source cannot exhaust the global cap with idle pre-auth
+ // sockets. Guarded by connsByIPMu.
+ connsByIPMu sync.Mutex
+ connsByIP map[string]int
// idleTimeout returns the read timeout armed before each message. It is a
// field so tests can drive the idle-drop path without waiting out the
// production windows; nil means sessionIdleTimeout.
@@ -204,6 +219,9 @@ const (
maxConnections = 1000
loginReadTimeout = 2 * time.Minute
idleReadTimeout = 30 * time.Minute
+ // maxConnectionsPerIP stops one source from exhausting the global
+ // maxConnections with idle pre-auth sockets.
+ maxConnectionsPerIP = 8
)
// admitConnection reserves a connection slot, returning false when the server
@@ -220,6 +238,52 @@ func (s *Server) releaseConnection() {
s.conns.Add(-1)
}
+// admitIP reserves a per-address connection slot, returning false when the
+// address already holds maxConnectionsPerIP. An empty address (synthetic
+// connections) is always admitted.
+func (s *Server) admitIP(addr string) bool {
+ ip := remoteHost(addr)
+ if ip == "" {
+ return true
+ }
+ s.connsByIPMu.Lock()
+ defer s.connsByIPMu.Unlock()
+ if s.connsByIP == nil {
+ s.connsByIP = make(map[string]int)
+ }
+ if s.connsByIP[ip] >= maxConnectionsPerIP {
+ return false
+ }
+ s.connsByIP[ip]++
+ return true
+}
+
+func (s *Server) releaseIP(addr string) {
+ ip := remoteHost(addr)
+ if ip == "" {
+ return
+ }
+ s.connsByIPMu.Lock()
+ defer s.connsByIPMu.Unlock()
+ if s.connsByIP[ip] <= 1 {
+ delete(s.connsByIP, ip)
+ return
+ }
+ s.connsByIP[ip]--
+}
+
+// remoteHost strips the port from a network address, returning it verbatim
+// when it has none.
+func remoteHost(addr string) string {
+ if addr == "" {
+ return ""
+ }
+ if host, _, err := net.SplitHostPort(addr); err == nil {
+ return host
+ }
+ return addr
+}
+
// sessionIdleTimeout returns the read timeout to arm for this session.
func (s *Server) sessionIdleTimeout(sess *Session) time.Duration {
if s.idleTimeout != nil {
@@ -531,8 +595,10 @@ func NewServer(cfg *config.Config) (*Server, error) {
if cfg.HTTP.Enabled {
s.httpServer = &http.Server{
- Addr: fmt.Sprintf(":%d", cfg.HTTP.Port),
- Handler: newHTTPMux(s),
+ Addr: fmt.Sprintf(":%d", cfg.HTTP.Port),
+ Handler: newHTTPMux(s),
+ ReadHeaderTimeout: 10 * time.Second,
+ IdleTimeout: 60 * time.Second,
}
}
@@ -546,8 +612,10 @@ func NewServer(cfg *config.Config) (*Server, error) {
MinVersion: tls.VersionTLS12,
}
s.httpsServer = &http.Server{
- Addr: fmt.Sprintf(":%d", cfg.HTTPS.Port),
- Handler: newHTTPMux(s),
+ Addr: fmt.Sprintf(":%d", cfg.HTTPS.Port),
+ Handler: newHTTPMux(s),
+ ReadHeaderTimeout: 10 * time.Second,
+ IdleTimeout: 60 * time.Second,
}
}
@@ -785,6 +853,13 @@ func (s *Server) handleSession(sess *Session, handler func(*Session, string)) {
}
defer s.releaseConnection()
+ remoteAddr := sess.RemoteAddr()
+ if !s.admitIP(remoteAddr) {
+ sess.Write("Too many connections from your address. Please try again later.\r\n")
+ return
+ }
+ defer s.releaseIP(remoteAddr)
+
sess.Write("\033[2J\033[H")
sess.Write(WelcomeBanner())
@@ -799,8 +874,8 @@ func (s *Server) handleSession(sess *Session, handler func(*Session, string)) {
log.Printf("session read error: %v", err)
return
}
- if len(line) > 1024 {
- line = line[:1024]
+ if len(line) > maxLineLength {
+ line = truncateLine(line, maxLineLength)
}
handler(sess, line)
}
diff --git a/internal/net/web.go b/internal/net/web.go
index 8353c8d..0ba0c98 100644
--- a/internal/net/web.go
+++ b/internal/net/web.go
@@ -6,6 +6,7 @@ import (
"net"
"net/http"
"strings"
+ "time"
"github.com/gorilla/websocket"
)
@@ -14,8 +15,9 @@ import (
var terminalHTML embed.FS
var upgrader = websocket.Upgrader{
- ReadBufferSize: 1024,
- WriteBufferSize: 1024,
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ HandshakeTimeout: 10 * time.Second,
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
diff --git a/internal/net/wsconn.go b/internal/net/wsconn.go
index 29d1947..95f714c 100644
--- a/internal/net/wsconn.go
+++ b/internal/net/wsconn.go
@@ -27,6 +27,14 @@ func (w *wsConn) Write(b []byte) (int, error) {
return len(b), err
}
+// RemoteAddr reports the peer address for the login throttle.
+func (w *wsConn) RemoteAddr() string {
+ if w.conn == nil {
+ return ""
+ }
+ return w.conn.RemoteAddr().String()
+}
+
// SetWriteDeadline delegates to the underlying websocket connection, which
// supports deadlines natively.
func (w *wsConn) SetWriteDeadline(ts time.Time) error {
diff --git a/internal/persistence/atomic.go b/internal/persistence/atomic.go
index a3cc06a..2b24391 100644
--- a/internal/persistence/atomic.go
+++ b/internal/persistence/atomic.go
@@ -3,11 +3,19 @@
package persistence
import (
+ "errors"
"fmt"
"os"
"path/filepath"
)
+// ErrDurabilityUncertain reports that the atomic replacement was renamed into
+// place but the directory could not be synced (or a post-rename chmod
+// failed). The new content is visible to readers, but a crash may still lose
+// it. Callers that must distinguish "nothing happened" from "the replacement
+// landed but may not survive a crash" should test errors.Is against this.
+var ErrDurabilityUncertain = errors.New("replacement completed; durability uncertain")
+
// WriteFile writes and syncs a temporary file in the destination directory,
// atomically renames it over path, then syncs the directory. Existing file
// permissions are preserved; perm is used for new files. Callers must own the
@@ -43,11 +51,11 @@ func WriteFile(path string, data []byte, perm os.FileMode) error {
}
d, err := os.Open(dir)
if err != nil {
- return fmt.Errorf("replacement completed; open directory for sync: %w", err)
+ return fmt.Errorf("%w: open directory for sync: %v", ErrDurabilityUncertain, err)
}
defer d.Close()
if err := d.Sync(); err != nil {
- return fmt.Errorf("replacement completed; sync directory: %w", err)
+ return fmt.Errorf("%w: sync directory: %v", ErrDurabilityUncertain, err)
}
return nil
}
diff --git a/internal/player/password.go b/internal/player/password.go
index 5a43e1b..de5fe5f 100644
--- a/internal/player/password.go
+++ b/internal/player/password.go
@@ -120,7 +120,7 @@ func CheckPassword(password, stored string) bool {
return false
}
hash := sha256.Sum256(append(salt, []byte(password)...))
- return legacy[2] == fmt.Sprintf("%x", hash)
+ return subtle.ConstantTimeCompare([]byte(legacy[2]), []byte(fmt.Sprintf("%x", hash))) == 1
default:
return false
}
diff --git a/internal/player/store.go b/internal/player/store.go
index 0b0e70e..5d69ec3 100644
--- a/internal/player/store.go
+++ b/internal/player/store.go
@@ -52,7 +52,13 @@ func writePrivate(path string, data []byte) error {
if err := persistence.WriteFile(path, data, 0600); err != nil {
return err
}
- return os.Chmod(path, 0600)
+ // The file was already renamed into place; a chmod failure is a
+ // post-replacement durability-uncertain condition, not "nothing
+ // happened".
+ if err := os.Chmod(path, 0600); err != nil {
+ return fmt.Errorf("%w: chmod: %v", persistence.ErrDurabilityUncertain, err)
+ }
+ return nil
}
// MarshalAccount serializes an account; callers that own the account (the
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index bfa8342..aee46f2 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -531,6 +531,10 @@ func validateMobs(s Source) []Issue {
}
}
+ if def.Talk != nil {
+ issues = append(issues, validateTalkConfig(fmt.Sprintf("Mob %q: talk", id), def.Talk, itemIDs, roomIndex, mobIDs, dropIDs)...)
+ }
+
if def.Combat != nil && def.Combat.FinishingBlow != "" && !itemIDs[def.Combat.FinishingBlow] {
issues = append(issues, Issue{
Level: "ERROR",
@@ -709,6 +713,7 @@ func validateObjects(s Source) []Issue {
func validateItems(s Source) []Issue {
var issues []Issue
itemIDs := s.Items.IDSet()
+ objIDs := s.Objects.IDSet()
dropIDs := dropTableIDSet(s.DataDir)
roomIndex := s.World.RoomIndex()
mobIDs := s.Mobs.AllDefIDs()
@@ -744,6 +749,17 @@ func validateItems(s Source) []Issue {
id, def.FarmProduct),
})
}
+
+ // The runtime derives "<farm_patch_type>_patch" and requires that
+ // object to exist; a type with no matching patch can never be planted.
+ if def.FarmPatchType != "" && !objIDs[def.FarmPatchType+"_patch"] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q (seed): farm_patch_type %q has no matching %q object",
+ id, def.FarmPatchType, def.FarmPatchType+"_patch"),
+ })
+ }
}
return issues
@@ -1117,6 +1133,16 @@ func validateCourses(s Source) []Issue {
})
}
}
+ for _, rid := range cv.OnFailRooms {
+ if _, ok := roomIndex[rid]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Course %q: obstacle on_fail room %d does not exist (a failed obstacle would teleport the player into a void)",
+ cv.ID, rid),
+ })
+ }
+ }
}
return issues
@@ -1268,7 +1294,7 @@ func validateRoomWiring(s Source) []Issue {
// does not exist: the game assigns the id unconditionally, so the player is
// saved into a void with no exits.
func validateModuleDestinations(s Source) []Issue {
- if len(s.ModuleDestinations) == 0 {
+ if len(s.ModuleDestinations) == 0 && len(s.TransportModules) == 0 {
return nil
}
roomIndex := s.World.RoomIndex()
@@ -1289,6 +1315,24 @@ func validateModuleDestinations(s Source) []Issue {
Message: fmt.Sprintf("Module %q: destination room %d does not exist", id, dest),
})
}
+ // A transport module whose destination was omitted or <= 0 is not in
+ // ModuleDestinations (the source only records positive destinations), so
+ // report it explicitly: the runtime assigns the id unconditionally.
+ tids := make([]string, 0, len(s.TransportModules))
+ for id := range s.TransportModules {
+ tids = append(tids, id)
+ }
+ sort.Strings(tids)
+ for _, id := range tids {
+ if dest, ok := s.ModuleDestinations[id]; ok && dest > 0 {
+ continue
+ }
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Module %q: transport module has no valid destination room (the player would be saved into room 0)", id),
+ })
+ }
return issues
}
@@ -1691,14 +1735,14 @@ func validateStep(prefix string, step *behavior.Step, itemIDs map[string]bool, r
}
if step.Condition != nil {
- issues = append(issues, validateCondition(prefix, step.Condition, roomIndex)...)
+ issues = append(issues, validateCondition(prefix, step.Condition, roomIndex, itemIDs)...)
}
return issues
}
// validateCondition checks a Condition tree's Room references (structural room
// IDs) and recurses into all_of/any_of.
-func validateCondition(prefix string, c *behavior.Condition, roomIndex map[int]bool) []Issue {
+func validateCondition(prefix string, c *behavior.Condition, roomIndex map[int]bool, itemIDs map[string]bool) []Issue {
if c == nil {
return nil
}
@@ -1713,11 +1757,19 @@ func validateCondition(prefix string, c *behavior.Condition, roomIndex map[int]b
})
}
}
+ if c.HasItem != "" && !itemIDs[c.HasItem] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: condition has_item references nonexistent item %q (the gate can never pass)",
+ prefix, c.HasItem),
+ })
+ }
for i := range c.AllOf {
- issues = append(issues, validateCondition(prefix, &c.AllOf[i], roomIndex)...)
+ issues = append(issues, validateCondition(prefix, &c.AllOf[i], roomIndex, itemIDs)...)
}
for i := range c.AnyOf {
- issues = append(issues, validateCondition(prefix, &c.AnyOf[i], roomIndex)...)
+ issues = append(issues, validateCondition(prefix, &c.AnyOf[i], roomIndex, itemIDs)...)
}
return issues
}
@@ -1755,7 +1807,7 @@ func validateTriggerBlock(prefix string, block []behavior.Trigger, itemIDs map[s
})
}
if t.Condition != nil {
- issues = append(issues, validateCondition(entryPrefix, t.Condition, roomIndex)...)
+ issues = append(issues, validateCondition(entryPrefix, t.Condition, roomIndex, itemIDs)...)
}
for si := range t.Steps {
diff --git a/internal/validate/shipped_data_test.go b/internal/validate/shipped_data_test.go
index 9c5bef1..f03b1f4 100644
--- a/internal/validate/shipped_data_test.go
+++ b/internal/validate/shipped_data_test.go
@@ -12,7 +12,7 @@ import (
// silently discarded at load time.
func TestShippedDataHasNoUnknownFields(t *testing.T) {
dataDir := filepath.Join("..", "..", "data")
- for _, issue := range validateKnownFields(dataDir) {
+ for _, issue := range validateKnownFields(dataDir, nil) {
if issue.Level == "ERROR" {
t.Errorf("%s", issue.Message)
}
diff --git a/internal/validate/validate.go b/internal/validate/validate.go
index 6a05eb6..d77eafa 100644
--- a/internal/validate/validate.go
+++ b/internal/validate/validate.go
@@ -35,6 +35,18 @@ type CourseView struct {
ID string
StartRoom int
ObstacleRooms []int
+ // OnFailRooms are the authored on_fail destinations (>0). The runtime
+ // teleports to them unconditionally on an obstacle failure.
+ OnFailRooms []int
+}
+
+// KnownFieldTarget lets the game package contribute its own content types to
+// the KnownFields decode pass; the validate package cannot import game
+// without a cycle.
+type KnownFieldTarget struct {
+ SubDir string
+ Label string
+ NewDef func() any
}
// TechView is the subset of a tech definition needed for validation.
@@ -64,6 +76,13 @@ type Source struct {
// ModuleDestinations maps module id to destination room for transport
// modules. A destination that is not a real room soft-locks the player.
ModuleDestinations map[string]int
+ // TransportModules is the set of transport-category module IDs, so a
+ // transport module whose destination is omitted or <= 0 is still
+ // reported (the runtime saves the player into room 0).
+ TransportModules map[string]bool
+ // KnownFieldTargets are game-owned content types (techs, modules,
+ // courses, help) included in the strict KnownFields decode pass.
+ KnownFieldTargets []KnownFieldTarget
}
// Run executes all checks and returns the collected issues.
@@ -79,7 +98,7 @@ func Run(s Source) []Issue {
issues = append(issues, validateIDs(s.DataDir, dir.sub, dir.label)...)
}
- issues = append(issues, validateKnownFields(s.DataDir)...)
+ issues = append(issues, validateKnownFields(s.DataDir, s.KnownFieldTargets)...)
issues = append(issues, validateRoomFilenames(s.DataDir)...)
issues = append(issues, validateRooms(s)...)
@@ -126,31 +145,29 @@ func validateRoomFilenames(dataDir string) []Issue {
// silently discarded by yaml.Unmarshal. Only package-owned content types are
// covered (world/object/item/behavior); game-owned definitions are checked by
// their own loaders.
-func validateKnownFields(dataDir string) []Issue {
- targets := []struct {
- subDir string
- label string
- newDef func() any
- }{
+func validateKnownFields(dataDir string, extra []KnownFieldTarget) []Issue {
+ targets := []KnownFieldTarget{
{"rooms", "room", func() any { return new(world.Room) }},
{"mobs", "mob", func() any { return new(world.MobDef) }},
{"objects", "object", func() any { return new(object.ObjectDef) }},
{"items", "item", func() any { return new(item.ItemDef) }},
{"drops", "drop table", func() any { return new(behavior.DropTableDef) }},
{"hazards", "hazard", func() any { return new(world.HazardDef) }},
+ {"triggers", "trigger", func() any { return new(behavior.Trigger) }},
}
+ targets = append(targets, extra...)
var issues []Issue
for _, t := range targets {
- dir := filepath.Join(dataDir, t.subDir)
+ dir := filepath.Join(dataDir, t.SubDir)
_ = behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
- if err := dec.Decode(t.newDef()); err != nil {
+ if err := dec.Decode(t.NewDef()); err != nil {
rel := strings.TrimPrefix(path, dataDir+"/")
issues = append(issues, Issue{
Level: "ERROR",
Type: "unknown-field",
- Message: fmt.Sprintf("%s %s: %v", t.label, rel, err),
+ Message: fmt.Sprintf("%s %s: %v", t.Label, rel, err),
})
}
return nil
diff --git a/internal/world/ground.go b/internal/world/ground.go
index 6915072..9cfc40c 100644
--- a/internal/world/ground.go
+++ b/internal/world/ground.go
@@ -10,6 +10,8 @@ const ReserveTicks = 100
type GroundItemInfo struct {
ItemID string
Quantity int
+ Quality int
+ MaxQuality int
ReservedFor string
ReserveTimer int
DespawnTimer int
@@ -19,6 +21,8 @@ type GroundItemInfo struct {
type groundEntry struct {
itemID string
quantity int
+ quality int
+ maxQuality int
isSpawn bool
respawnTimer int // >0 = counting down to respawn
respawnQty int
@@ -39,6 +43,8 @@ func (w *World) GroundItemsDetailed(roomID int) []GroundItemInfo {
info := GroundItemInfo{
ItemID: e.itemID,
Quantity: e.quantity,
+ Quality: e.quality,
+ MaxQuality: e.maxQuality,
DespawnTimer: e.despawnTimer,
IsSpawn: e.isSpawn,
}
@@ -64,11 +70,19 @@ func (w *World) GroundItems(roomID int) map[string]int {
}
func (w *World) AddReservedItem(roomID int, itemID string, qty int, owner string) {
+ w.AddReservedItemQ(roomID, itemID, qty, owner, 0, 0)
+}
+
+// AddReservedItemQ is AddReservedItem with the item's quality preserved (a
+// non-stackable max_quality item dropped on death/trigger keeps its charge).
+func (w *World) AddReservedItemQ(roomID int, itemID string, qty int, owner string, quality, maxQuality int) {
w.mu.Lock()
defer w.mu.Unlock()
e := &groundEntry{
itemID: itemID,
quantity: qty,
+ quality: quality,
+ maxQuality: maxQuality,
despawnTimer: DropDespawnTicks,
reservedFor: owner,
reserveTimer: ReserveTicks,
@@ -77,11 +91,19 @@ func (w *World) AddReservedItem(roomID int, itemID string, qty int, owner string
}
func (w *World) AddGroundItem(roomID int, itemID string, qty int) {
+ w.AddGroundItemQ(roomID, itemID, qty, 0, 0)
+}
+
+// AddGroundItemQ is AddGroundItem with the item's quality preserved. A
+// dropped partially-used max_quality item must not come back full on pickup.
+func (w *World) AddGroundItemQ(roomID int, itemID string, qty, quality, maxQuality int) {
w.mu.Lock()
defer w.mu.Unlock()
e := &groundEntry{
itemID: itemID,
quantity: qty,
+ quality: quality,
+ maxQuality: maxQuality,
despawnTimer: DropDespawnTicks,
}
w.groundItems[roomID] = append(w.groundItems[roomID], e)
@@ -94,6 +116,18 @@ func (w *World) AddGroundItem(roomID int, itemID string, qty int) {
// is returned. Callers must never lose already-removed items to a failed
// pickup, so the removal is verified before any state is touched.
func (w *World) RemoveReservedItem(roomID int, itemID string, qty int, owner string) (int, bool) {
+ removed, _, _, ok := w.removeReservedItem(roomID, itemID, qty, owner)
+ return removed, ok
+}
+
+// RemoveReservedItemQ is RemoveReservedItem that also reports the quality of
+// the entry the pickup actually came from, so a partially-used non-stackable
+// item keeps its charge through a drop/pickup round trip.
+func (w *World) RemoveReservedItemQ(roomID int, itemID string, qty int, owner string) (removed, quality, maxQuality int, ok bool) {
+ return w.removeReservedItem(roomID, itemID, qty, owner)
+}
+
+func (w *World) removeReservedItem(roomID int, itemID string, qty int, owner string) (removed, quality, maxQuality int, ok bool) {
w.mu.Lock()
defer w.mu.Unlock()
entries := w.groundItems[roomID]
@@ -115,7 +149,7 @@ func (w *World) RemoveReservedItem(roomID int, itemID string, qty int, owner str
available += e.quantity
}
if available < qty {
- return 0, false
+ return 0, 0, 0, false
}
// Pass 2: take. Every entry reachable here is takeable, so no
@@ -123,8 +157,9 @@ func (w *World) RemoveReservedItem(roomID int, itemID string, qty int, owner str
// without it a player could consume a stack reserved for someone else
// when a public stack and a reserved stack coexist (the reserved entry
// comes first in the slice, so the take loop would drain it first).
- removed := 0
remaining := qty
+ tookQuality, tookMaxQuality := 0, 0
+ tookFromEntry := false
for _, e := range entries {
if remaining <= 0 {
break
@@ -139,6 +174,10 @@ func (w *World) RemoveReservedItem(roomID int, itemID string, qty int, owner str
if take > remaining {
take = remaining
}
+ if !tookFromEntry {
+ tookQuality, tookMaxQuality = e.quality, e.maxQuality
+ tookFromEntry = true
+ }
e.quantity -= take
removed += take
remaining -= take
@@ -146,7 +185,25 @@ func (w *World) RemoveReservedItem(roomID int, itemID string, qty int, owner str
e.respawnTimer = e.respawnDelay
}
}
- return removed, true
+ return removed, tookQuality, tookMaxQuality, true
+}
+
+// AccessibleGroundQuality reports the quality of the first ground entry this
+// owner may take, so a pickup can stamp the item it is about to create. The
+// boolean is false when no accessible entry exists.
+func (w *World) AccessibleGroundQuality(roomID int, itemID, owner string) (quality, maxQuality int, ok bool) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ for _, e := range w.groundItems[roomID] {
+ if e.quantity <= 0 || !strings.EqualFold(e.itemID, itemID) {
+ continue
+ }
+ if e.reserveTimer > 0 && e.reservedFor != "" && e.reservedFor != owner {
+ continue
+ }
+ return e.quality, e.maxQuality, true
+ }
+ return 0, 0, false
}
// RemoveAvailableItem removes up to maxQty of itemID from the room's
@@ -221,6 +278,11 @@ func (w *World) RemoveGroundItem(roomID int, itemID string, qty int) int {
if e.quantity <= 0 {
continue
}
+ // This helper has no owner, so it must not consume another player's
+ // reserved entry (its siblings are reservation-aware).
+ if e.reserveTimer > 0 && e.reservedFor != "" {
+ continue
+ }
take := e.quantity
if take > remaining {
take = remaining
@@ -237,7 +299,7 @@ func (w *World) RemoveGroundItem(roomID int, itemID string, qty int) int {
}
func (w *World) tickGroundItems() {
- for _, entries := range w.groundItems {
+ for roomID, entries := range w.groundItems {
for _, e := range entries {
if e.respawnTimer > 0 {
e.respawnTimer--
@@ -259,12 +321,29 @@ func (w *World) tickGroundItems() {
}
}
}
+ // Prune dead entries: without this every drop/loot/firemaking action
+ // appends an entry that lives for the process lifetime and is scanned
+ // on every tick and pickup. A spawn entry awaiting respawn keeps its
+ // slot; everything else with no quantity is gone for good.
+ kept := entries[:0]
+ for _, e := range entries {
+ if e.quantity <= 0 && e.respawnTimer <= 0 {
+ continue
+ }
+ kept = append(kept, e)
+ }
+ if len(kept) == 0 {
+ delete(w.groundItems, roomID)
+ } else {
+ w.groundItems[roomID] = kept
+ }
}
}
func (w *World) mergeNearbyItems(entries []*groundEntry, target *groundEntry) {
for _, other := range entries {
if other != target && other.quantity > 0 && strings.EqualFold(other.itemID, target.itemID) &&
+ other.quality == target.quality && other.maxQuality == target.maxQuality &&
(other.reserveTimer <= 0 || other.reservedFor == "") &&
other.despawnTimer <= 0 {
target.quantity += other.quantity
diff --git a/internal/world/ground_quality_test.go b/internal/world/ground_quality_test.go
new file mode 100644
index 0000000..e287642
--- /dev/null
+++ b/internal/world/ground_quality_test.go
@@ -0,0 +1,43 @@
+package world
+
+import "testing"
+
+// TestGroundPreservesQualityAcrossDropAndPickup pins the deferred item-quality
+// fix: a partially-used max_quality item dropped on the ground must not come
+// back at full quality when picked up.
+func TestGroundPreservesQualityAcrossDropAndPickup(t *testing.T) {
+ w := New(t.TempDir())
+ w.AddGroundItemQ(1, "lighter", 1, 42, 100)
+
+ if q, mq, ok := w.AccessibleGroundQuality(1, "lighter", "alice"); !ok || q != 42 || mq != 100 {
+ t.Fatalf("ground quality = (%d,%d,%v), want (42,100,true)", q, mq, ok)
+ }
+ removed, q, mq, ok := w.RemoveReservedItemQ(1, "lighter", 1, "alice")
+ if !ok || removed != 1 || q != 42 || mq != 100 {
+ t.Fatalf("pickup = (%d,%d,%d,%v), want (1,42,100,true)", removed, q, mq, ok)
+ }
+}
+
+// TestGroundMergeRequiresMatchingQuality: a full-quality spawn stack must not
+// absorb a used player-dropped stack (which would erase the charge).
+func TestGroundMergeRequiresMatchingQuality(t *testing.T) {
+ w := New(t.TempDir())
+ w.AddGroundItemQ(1, "lighter", 1, 100, 100)
+ w.AddGroundItemQ(1, "lighter", 1, 20, 100)
+ if got := len(w.GroundItemsDetailed(1)); got != 2 {
+ t.Fatalf("distinct-quality entries merged: %d entries, want 2", got)
+ }
+}
+
+// TestRemoveGroundItemSkipsReserved: the firemaking helper has no owner, so it
+// must never consume another player's reserved loot.
+func TestRemoveGroundItemSkipsReserved(t *testing.T) {
+ w := New(t.TempDir())
+ w.AddReservedItem(1, "log", 3, "bob")
+ if got := w.RemoveGroundItem(1, "log", 3); got != 0 {
+ t.Fatalf("RemoveGroundItem consumed a reserved stack: took %d", got)
+ }
+ if got := w.AccessibleGround(1, "bob")["log"]; got != 3 {
+ t.Fatalf("bob's reserved stock = %d, want 3", got)
+ }
+}
diff --git a/internal/world/world.go b/internal/world/world.go
index a084f63..cb53b77 100644
--- a/internal/world/world.go
+++ b/internal/world/world.go
@@ -114,6 +114,7 @@ func (w *World) SeedGroundItems(roomID int) {
merged := false
for _, e := range w.groundItems[roomID] {
if e.quantity > 0 && strings.EqualFold(e.itemID, s.ID) &&
+ e.quality == 0 && e.maxQuality == 0 &&
(e.reserveTimer <= 0 || e.reservedFor == "") &&
e.despawnTimer <= 0 {
e.quantity += s.Quantity