diff options
| author | historia <[not public]> | 2026-09-18 17:48:26 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 17:48:26 -0400 |
| commit | 8fe9e3c3fef0049df82281a62afe35dda516dfe2 (patch) | |
| tree | dd269f2c0710f334e19f00a191260ebc46284a90 /internal | |
| parent | 194164a77eb4f2479b94c19e6f0e1278ab608781 (diff) | |
| download | thehouseoficarus-8fe9e3c3fef0049df82281a62afe35dda516dfe2.tar.gz | |
fix: close combat ghost, admin traversal, login throttle, journal double-pay, and quality/economy gaps
Diffstat (limited to 'internal')
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 |
