diff options
| author | historia <[not public]> | 2026-09-18 20:03:13 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 20:03:13 -0400 |
| commit | 79764732406d6484f67d3dec045483dcb5d08c28 (patch) | |
| tree | 7a64e8581a540b97ed554f5723e2d587424ab1c6 /internal | |
| parent | f1e461ab6aec6b9bf056bbe2daa88a251e0f21da (diff) | |
| download | thehouseoficarus-79764732406d6484f67d3dec045483dcb5d08c28.tar.gz | |
feat: better card ascii art
Diffstat (limited to 'internal')
62 files changed, 699 insertions, 340 deletions
diff --git a/internal/admin/api_colors.go b/internal/admin/api_colors.go index 7989e35..b6285c4 100644 --- a/internal/admin/api_colors.go +++ b/internal/admin/api_colors.go @@ -12,6 +12,7 @@ import ( "thehouseoficarus/internal/color" "thehouseoficarus/internal/config" "thehouseoficarus/internal/game" + "thehouseoficarus/internal/persistence" ) // colorCategory describes one editable color category: its config key, a @@ -595,13 +596,13 @@ func (s *AdminServer) writeConfigFile() error { if merr != nil { return merr } - return os.WriteFile(s.configPath, out, 0644) + return persistence.WriteFile(s.configPath, out, 0644) } out, err := patchColorMaps(data, s.cfg.DefaultColors, s.cfg.ConstantColors) if err != nil { return err } - return os.WriteFile(s.configPath, out, 0644) + return persistence.WriteFile(s.configPath, out, 0644) } // backupConfigFile copies the current config.yaml next to itself before an @@ -615,7 +616,7 @@ func (s *AdminServer) backupConfigFile() error { } return err } - return os.WriteFile(s.configPath+".bak", data, 0600) + return persistence.WriteFile(s.configPath+".bak", data, 0600) } // patchColorMaps updates the default_colors and constant_colors mappings in a diff --git a/internal/admin/api_entity_path_test.go b/internal/admin/api_entity_path_test.go index 75fed7a..3a0f87b 100644 --- a/internal/admin/api_entity_path_test.go +++ b/internal/admin/api_entity_path_test.go @@ -56,10 +56,31 @@ func TestEntityAPICannotEscapeData(t *testing.T) { if !strings.Contains(rec.Body.String(), "error") { t.Fatalf("traversal rename id not rejected: status %d body %s", rec.Code, rec.Body.String()) } + // Traversal SOURCE id via the rename and move routes: the source is + // joined into a path too, so it must be rejected before it can move a + // file outside data/ (e.g. the server's own config.yaml). + sentinel := filepath.Join(root, "sentinel.yaml") + if err := os.WriteFile(sentinel, []byte("name: Sentinel\n"), 0o600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct{ path, body string }{ + {"/api/objects/rename", `{"id":"../../sentinel","new_id":"stolen"}`}, + {"/api/objects/move", `{"id":"../../sentinel","dir":"sub"}`}, + } { + if rec := post(tc.path, tc.body); !strings.Contains(rec.Body.String(), "error") { + t.Fatalf("traversal source id not rejected at %s: status %d body %s", tc.path, rec.Code, rec.Body.String()) + } + } + if _, err := os.Stat(sentinel); err != nil { + t.Fatalf("traversal source id moved a file outside data/: %v", err) + } // Nothing may exist outside the data directory. if _, err := os.Stat(filepath.Join(root, "outside.yaml")); !os.IsNotExist(err) { t.Fatal("entity API escaped the data directory") } + if _, err := os.Stat(filepath.Join(root, "stolen.yaml")); !os.IsNotExist(err) { + t.Fatal("traversal rename escaped the data directory") + } } // TestTechModuleCreateRejectsTraversalID: POST /api/techs and /api/modules diff --git a/internal/admin/api_files.go b/internal/admin/api_files.go index 2aa0e5f..b9f54c5 100644 --- a/internal/admin/api_files.go +++ b/internal/admin/api_files.go @@ -227,9 +227,10 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) { // writing, so the raw editor can never disclose or overwrite it. body = restoreAccountSecret(relPath, fullPath, body) - var oldContent []byte - if existing, err := snapshotFile(fullPath); err == nil { - oldContent = existing + oldContent, existed, err := snapshotBefore(fullPath) + if err != nil { + failJSON(w, "read existing file: "+err.Error()) + return } dir := filepath.Dir(fullPath) @@ -243,7 +244,7 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) { return } - isCreate := oldContent == nil + isCreate := !existed desc := "Updated file " + relPath if isCreate { desc = "Created file " + relPath diff --git a/internal/admin/api_modules.go b/internal/admin/api_modules.go index 8356b2d..ea547c5 100644 --- a/internal/admin/api_modules.go +++ b/internal/admin/api_modules.go @@ -96,9 +96,10 @@ func (s *AdminServer) handleModuleByID(w http.ResponseWriter, r *http.Request) { delete(body, "_raw") path := filepath.Join(s.dataDir, "modules", id+".yaml") - var oldContent []byte - if existing, err := snapshotFile(path); err == nil { - oldContent = existing + oldContent, existed, err := snapshotBefore(path) + if err != nil { + failJSON(w, "read existing file: "+err.Error()) + return } newContent, err := writeMapAsYAML(path, body) @@ -107,7 +108,7 @@ func (s *AdminServer) handleModuleByID(w http.ResponseWriter, r *http.Request) { return } - isCreate := oldContent == nil + isCreate := !existed desc := "Updated module " + id if isCreate { desc = "Created module " + id diff --git a/internal/admin/api_room_courses.go b/internal/admin/api_room_courses.go index 5a33e6a..db74adf 100644 --- a/internal/admin/api_room_courses.go +++ b/internal/admin/api_room_courses.go @@ -204,16 +204,17 @@ func (s *AdminServer) handleCourseByID(w http.ResponseWriter, r *http.Request) { delete(body, "_raw") delete(body, "_path") - var oldContent []byte - if existing, err := snapshotFile(path); err == nil { - oldContent = existing + oldContent, existed, err := snapshotBefore(path) + if err != nil { + failJSON(w, "read existing file: "+err.Error()) + return } newContent, err := writeMapAsYAML(path, body) if err != nil { failJSON(w, "write error: "+err.Error()) return } - isCreate := oldContent == nil + isCreate := !existed desc := "Updated course " + id if isCreate { desc = "Created course " + id diff --git a/internal/admin/api_room_insert_remove.go b/internal/admin/api_room_insert_remove.go index 45537d6..5f03b72 100644 --- a/internal/admin/api_room_insert_remove.go +++ b/internal/admin/api_room_insert_remove.go @@ -11,6 +11,7 @@ import ( "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/persistence" "thehouseoficarus/internal/world" ) @@ -368,7 +369,7 @@ func rollbackWrite(path string, oldContent []byte) { if oldContent == nil { return } - if err := os.WriteFile(path, oldContent, 0644); err != nil { + if err := persistence.WriteFile(path, oldContent, 0644); err != nil { fmt.Printf("admin: rollback write %s failed: %v\n", path, err) } } diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go index 32a6f1c..863af47 100644 --- a/internal/admin/api_rooms.go +++ b/internal/admin/api_rooms.go @@ -365,7 +365,7 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) { if loadErr != nil { continue } - changed := false + changed := otherRoom.RemoveRoomRefs(id) for _, dir := range world.ExitOrder { exit, exists := otherRoom.Exits[dir] if !exists || exit.Room != id { diff --git a/internal/admin/api_rooms_bulk_delete.go b/internal/admin/api_rooms_bulk_delete.go index bba1879..d15549e 100644 --- a/internal/admin/api_rooms_bulk_delete.go +++ b/internal/admin/api_rooms_bulk_delete.go @@ -120,6 +120,11 @@ func (s *AdminServer) handleBulkDelete(w http.ResponseWriter, r *http.Request) { continue } changed := false + for deletedID := range deletedSet { + if otherRoom.RemoveRoomRefs(deletedID) { + changed = true + } + } for _, dir := range world.ExitOrder { exit, exists := otherRoom.Exits[dir] if !exists || !deletedSet[exit.Room] { diff --git a/internal/admin/api_techs.go b/internal/admin/api_techs.go index 9e742e1..d2c5f65 100644 --- a/internal/admin/api_techs.go +++ b/internal/admin/api_techs.go @@ -111,9 +111,16 @@ func (s *AdminServer) handleTechByID(w http.ResponseWriter, r *http.Request) { var oldContent []byte if !isCreate { - if existing, err := snapshotFile(path); err == nil { - oldContent = existing + existing, existed, err := snapshotBefore(path) + if err != nil { + failJSON(w, "read existing file: "+err.Error()) + return } + if !existed { + failJSON(w, "not found: "+id) + return + } + oldContent = existing } newContent, err := writeMapAsYAML(path, body) diff --git a/internal/admin/api_triggers.go b/internal/admin/api_triggers.go index 299ca10..0dd086c 100644 --- a/internal/admin/api_triggers.go +++ b/internal/admin/api_triggers.go @@ -11,6 +11,7 @@ import ( "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/persistence" ) func (s *AdminServer) handleTriggers(w http.ResponseWriter, r *http.Request) { @@ -138,7 +139,7 @@ func (s *AdminServer) handleTriggerByID(w http.ResponseWriter, r *http.Request) return } - if err := os.WriteFile(newPath, triggerData, 0644); err != nil { + if err := persistence.WriteFile(newPath, triggerData, 0644); err != nil { failJSON(w, "write error: "+err.Error()) return } diff --git a/internal/admin/server.go b/internal/admin/server.go index 05586ef..f35be84 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -982,8 +982,7 @@ func (s *AdminServer) handleEntityRename(entityType string) http.HandlerFunc { ID string `json:"id"` NewID string `json:"new_id"` } - if err := readJSON(r, &body); err != nil || body.ID == "" || body.NewID == "" || - !validEntityID(body.NewID) { + if err := readJSON(r, &body); err != nil || !validEntityID(body.ID) || !validEntityID(body.NewID) { failJSON(w, "invalid") return } @@ -1015,7 +1014,7 @@ func (s *AdminServer) handleEntityMove(entityType string) http.HandlerFunc { ID string `json:"id"` Dir string `json:"dir"` } - if err := readJSON(r, &body); err != nil || body.ID == "" { + if err := readJSON(r, &body); err != nil || !validEntityID(body.ID) { failJSON(w, "invalid") return } diff --git a/internal/admin/undo.go b/internal/admin/undo.go index c2ea61d..3001c8b 100644 --- a/internal/admin/undo.go +++ b/internal/admin/undo.go @@ -227,6 +227,17 @@ func (us *UndoStack) Redo() *ChangeDesc { return &last } +// requireSnapshot rejects a nil before/after image on a change that must +// restore real content. A failed before-image read leaves nil, and writing +// it would truncate the file to zero bytes (which parses as a zero-value +// def), so a missing image must fail the undo/redo instead. +func requireSnapshot(content []byte, path, action string) error { + if content == nil { + return fmt.Errorf("%s %s: no snapshot recorded", action, path) + } + return nil +} + // applyUndo reverts a change: the primary file, then every extra file. // Extra-file failures propagate: a multi-file undo that restored only part // of a change must be reported as failed, never as success. @@ -270,6 +281,9 @@ func (us *UndoStack) applyUndo(c ChangeDesc) error { return fmt.Errorf("remove created %s: %w", c.FilePath, err) } } else { + if err := requireSnapshot(c.OldContent, c.FilePath, "revert"); err != nil { + return err + } if err := us.writeSnapshot(c.FilePath, restored); err != nil { return fmt.Errorf("revert %s: %w", c.FilePath, err) } @@ -332,6 +346,9 @@ func (us *UndoStack) applyRedo(c ChangeDesc) error { if c.NewFilePath != "" { target = c.NewFilePath } + if err := requireSnapshot(c.NewContent, target, "reapply"); err != nil { + return err + } if err := us.writeSnapshot(target, restored); err != nil { return fmt.Errorf("reapply %s: %w", target, err) } @@ -362,6 +379,9 @@ func (us *UndoStack) applyUndoExtraFile(ef ExtraFile) error { return fmt.Errorf("undo restore %s: %w", ef.FilePath, err) } case ef.NewFilePath != "": + if err := requireSnapshot(ef.OldContent, ef.FilePath, "undo restore"); err != nil { + return err + } if err := os.Remove(ef.NewFilePath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("undo remove %s: %w", ef.NewFilePath, err) } @@ -369,6 +389,9 @@ func (us *UndoStack) applyUndoExtraFile(ef ExtraFile) error { return fmt.Errorf("undo restore %s: %w", ef.FilePath, err) } default: + if err := requireSnapshot(ef.OldContent, ef.FilePath, "undo revert"); err != nil { + return err + } if err := us.writeRestored(ef.FilePath, ef.OldContent); err != nil { return fmt.Errorf("undo revert %s: %w", ef.FilePath, err) } @@ -387,6 +410,9 @@ func (us *UndoStack) applyRedoExtraFile(ef ExtraFile) error { return fmt.Errorf("redo create %s: %w", ef.FilePath, err) } case ef.NewFilePath != "": + if err := requireSnapshot(ef.NewContent, ef.NewFilePath, "redo create"); err != nil { + return err + } if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("redo remove %s: %w", ef.FilePath, err) } @@ -394,6 +420,9 @@ func (us *UndoStack) applyRedoExtraFile(ef ExtraFile) error { return fmt.Errorf("redo create %s: %w", ef.NewFilePath, err) } default: + if err := requireSnapshot(ef.NewContent, ef.FilePath, "redo reapply"); err != nil { + return err + } if err := us.writeRestored(ef.FilePath, ef.NewContent); err != nil { return fmt.Errorf("redo reapply %s: %w", ef.FilePath, err) } @@ -450,7 +479,9 @@ func (us *UndoStack) writeSnapshot(path string, content []byte) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } - if err := os.WriteFile(path, content, us.fileMode(path)); err != nil { + // Atomic temp+fsync+rename: a crash mid-undo must never leave a + // truncated world file (empty YAML parses as a zero-value def). + if err := persistence.WriteFile(path, content, us.fileMode(path)); err != nil { return err } if us.isPlayerDataPath(path) { @@ -513,39 +544,46 @@ func (us *UndoStack) Info() UndoInfo { return info } +// undoExtraEntry and undoEntry are the persisted undo/redo wire types. They +// are declared ONCE and shared by save and load: when each function declared +// its own copy, a field added to one and not the other silently dropped +// persisted history on the next restart. +type undoExtraEntry struct { + FilePath string `json:"file_path"` + NewFilePath string `json:"new_file_path,omitempty"` + OldContent string `json:"old_content"` + NewContent string `json:"new_content"` + IsDelete bool `json:"is_delete,omitempty"` + IsCreate bool `json:"is_create,omitempty"` + IsDirDelete bool `json:"is_dir_delete,omitempty"` +} + +type undoEntry struct { + Time string `json:"time"` + Description string `json:"description"` + FilePath string `json:"file_path"` + NewFilePath string `json:"new_file_path,omitempty"` + OldContent string `json:"old_content"` + NewContent string `json:"new_content"` + IsDelete bool `json:"is_delete"` + IsCreate bool `json:"is_create"` + IsDirDelete bool `json:"is_dir_delete,omitempty"` + ExtraFiles []undoExtraEntry `json:"extra_files,omitempty"` + IDMap map[int]int `json:"id_map,omitempty"` +} + +type undoFile struct { + History []undoEntry `json:"history"` + Redo []undoEntry `json:"redo"` +} + func (us *UndoStack) save() { - type extraEntry struct { - FilePath string `json:"file_path"` - NewFilePath string `json:"new_file_path,omitempty"` - OldContent string `json:"old_content"` - NewContent string `json:"new_content"` - IsDelete bool `json:"is_delete,omitempty"` - IsCreate bool `json:"is_create,omitempty"` - IsDirDelete bool `json:"is_dir_delete,omitempty"` - } - type entry struct { - Time string `json:"time"` - Description string `json:"description"` - FilePath string `json:"file_path"` - NewFilePath string `json:"new_file_path,omitempty"` - OldContent string `json:"old_content"` - NewContent string `json:"new_content"` - IsDelete bool `json:"is_delete"` - IsCreate bool `json:"is_create"` - IsDirDelete bool `json:"is_dir_delete,omitempty"` - ExtraFiles []extraEntry `json:"extra_files,omitempty"` - IDMap map[int]int `json:"id_map,omitempty"` - } - type saveData struct { - History []entry `json:"history"` - Redo []entry `json:"redo"` - } - toEntries := func(changes []ChangeDesc) []entry { - var entries []entry + toEntries := func(changes []ChangeDesc) []undoEntry { + var entries []undoEntry for _, c := range changes { - var extra []extraEntry + var extra []undoExtraEntry for _, ef := range c.ExtraFiles { - extra = append(extra, extraEntry{ + extra = append(extra, undoExtraEntry{ FilePath: ef.FilePath, NewFilePath: ef.NewFilePath, OldContent: string(us.redactContent(ef.OldContent, ef.FilePath, ef.NewFilePath)), @@ -555,7 +593,7 @@ func (us *UndoStack) save() { IsDirDelete: ef.IsDirDelete, }) } - entries = append(entries, entry{ + entries = append(entries, undoEntry{ Time: c.Time, Description: c.Description, FilePath: c.FilePath, @@ -571,7 +609,7 @@ func (us *UndoStack) save() { } return entries } - data := saveData{ + data := undoFile{ History: toEntries(us.history), Redo: toEntries(us.redo), } @@ -708,38 +746,12 @@ func (us *UndoStack) load() { if err != nil { return } - type extraEntry struct { - FilePath string `json:"file_path"` - NewFilePath string `json:"new_file_path,omitempty"` - OldContent string `json:"old_content"` - NewContent string `json:"new_content"` - IsDelete bool `json:"is_delete,omitempty"` - IsCreate bool `json:"is_create,omitempty"` - IsDirDelete bool `json:"is_dir_delete,omitempty"` - } - type entry struct { - Time string `json:"time"` - Description string `json:"description"` - FilePath string `json:"file_path"` - NewFilePath string `json:"new_file_path,omitempty"` - OldContent string `json:"old_content"` - NewContent string `json:"new_content"` - IsDelete bool `json:"is_delete"` - IsCreate bool `json:"is_create"` - IsDirDelete bool `json:"is_dir_delete,omitempty"` - ExtraFiles []extraEntry `json:"extra_files,omitempty"` - IDMap map[int]int `json:"id_map,omitempty"` - } - type saveData struct { - History []entry `json:"history"` - Redo []entry `json:"redo"` - } - var sd saveData + var sd undoFile if err := json.Unmarshal(data, &sd); err != nil { log.Printf("undo: failed to load history: %v", err) return } - toChanges := func(entries []entry) []ChangeDesc { + toChanges := func(entries []undoEntry) []ChangeDesc { out := make([]ChangeDesc, 0, len(entries)) for _, e := range entries { c := ChangeDesc{ diff --git a/internal/admin/yaml_util.go b/internal/admin/yaml_util.go index 8cd3252..e1d4942 100644 --- a/internal/admin/yaml_util.go +++ b/internal/admin/yaml_util.go @@ -157,6 +157,21 @@ func snapshotFile(path string) ([]byte, error) { return os.ReadFile(path) } +// snapshotBefore reads a file's content for an undo record and reports +// whether it existed. A missing file returns (nil, false, nil) — a create. A +// read failure on an EXISTING file returns an error: treating it as a create +// would make the undo record delete the real file, so the caller must abort. +func snapshotBefore(path string) ([]byte, bool, error) { + data, err := os.ReadFile(path) + if err == nil { + return data, true, nil + } + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, err +} + func findEntityPath(dataDir, subdir, id string) (string, error) { _, path, err := readYAMLFile(dataDir, subdir, id) if err == nil { @@ -167,6 +182,9 @@ func findEntityPath(dataDir, subdir, id string) (string, error) { } func moveEntityFile(dataDir, subdir, id, targetDir string) (string, string, []byte, error) { + if !validEntityID(id) { + return "", "", nil, fmt.Errorf("invalid id: %q", id) + } if targetDir != "" && !validEntityID(targetDir) { return "", "", nil, fmt.Errorf("invalid directory: %q", targetDir) } @@ -219,6 +237,9 @@ func createEntityDir(dataDir, subdir, name string) error { } func renameEntityFile(dataDir, subdir, oldID, newID string) (string, string, []byte, error) { + if !validEntityID(oldID) { + return "", "", nil, fmt.Errorf("invalid id: %q", oldID) + } if !validEntityID(newID) { return "", "", nil, fmt.Errorf("invalid new id: %q", newID) } diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index b762b6f..cf8a289 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -51,7 +51,7 @@ func newBaccaratTable(roomID int, cfg TableConfig, rng *rand.Rand) *baccaratTabl } func (t *baccaratTable) menuEvent(name string) Event { - return t.betMenuEvent(name, MenuBaccaratBet, []MenuCommand{ + return t.betMenuEvent(name, []MenuCommand{ {Command: "bet <amount>", Desc: "Place a wager (min/max accepted)"}, {Command: "bet <amount> on player|banker|tie", Desc: "Wager on a specific outcome"}, {Command: "stop", Desc: "Leave the table"}, @@ -161,45 +161,30 @@ func (t *baccaratTable) deal() []Event { player, banker := playTableau(t.shoe) playerTotal, bankerTotal := baccaratTotal(player), baccaratTotal(banker) round := t.event("New baccarat hand!", true) - round.Baccarat = t.snapshot(player, banker) + round.Snapshot = t.snapshot(player, banker) events := []Event{round, t.event(baccaratOutcomeMessage(playerTotal, bankerTotal), true)} for _, p := range t.players.ordered() { if !p.HasBet { continue } 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 - } - } + // The shared settlement path pays before announcing, leaves the + // stake open on any failure (recovery re-applies the owed payout), + // and resolves it only once durable. + payoutFailed := settleStakeOutcome(p.Wallet, payout, []string{p.stakeID}, settleErr) + publicResult, privateResult := baccaratResultMessages(p.Name, result, payout) if payoutFailed { - publicResult, _ := baccaratResultMessages(p.Name, result, payout) - settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage()) - if result == "win" { + privateResult = payoutFailedMessage() + } + settlement := t.playerAction(p.Name, publicResult, privateResult) + if result == "win" { + if p.lastSpot == baccaratSpotTie { + settlement.Color = "casino_jackpot_win" + } else { 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" { - if p.lastSpot == baccaratSpotTie { - settlement.Color = "casino_jackpot_win" - } else { - settlement.Color = "casino_big_win" - } - } - events = append(events, settlement) } + events = append(events, settlement) if p.Wallet != nil && !payoutFailed { // The round resolved; the stakes are no longer outstanding. p.Wallet.ResolveStakes() diff --git a/internal/casino/baccarat_test.go b/internal/casino/baccarat_test.go index 41f5acf..49e8fdd 100644 --- a/internal/casino/baccarat_test.go +++ b/internal/casino/baccarat_test.go @@ -150,8 +150,8 @@ func TestBaccaratSoloBetDealsImmediately(t *testing.T) { // The round event carries a snapshot of both hands and the bets. var snapshot *BaccaratSnapshot for _, event := range events { - if event.Baccarat != nil { - snapshot = event.Baccarat + if s, ok := event.Snapshot.(*BaccaratSnapshot); ok { + snapshot = s } } if snapshot == nil || len(snapshot.Player) != 2 || len(snapshot.Banker) != 2 || @@ -354,8 +354,8 @@ func TestBaccaratMenuAndActions(t *testing.T) { menu = event.Menu } } - if menu == nil || menu.Kind != MenuBaccaratBet { - t.Fatalf("join menu=%+v, want MenuBaccaratBet", menu) + if menu == nil || !menu.ShowBalance || menu.Heading != "New baccarat hand! Place your bet." { + t.Fatalf("join menu=%+v, want a baccarat betting menu", menu) } if len(menu.Commands) != 3 || menu.Commands[0].Command != "bet <amount>" || menu.Commands[2].Command != "stop" { t.Fatalf("baccarat menu commands=%+v", menu.Commands) diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index fe212f7..55ee702 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -102,7 +102,7 @@ func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTa } func (t *blackjackTable) menuEvent(name string) Event { - return t.betMenuEvent(name, MenuBlackjackBet, []MenuCommand{ + return t.betMenuEvent(name, []MenuCommand{ {Command: "bet", Desc: "Place a wager (min/max accepted)"}, {Command: "bet <amount>", Desc: "Place a specific wager"}, {Command: "stop", Desc: "Leave the table"}, @@ -258,7 +258,7 @@ func (t *blackjackTable) action(name, action string) []Event { hand.bust = true } e := t.playerAction(name, fmt.Sprintf("%s hits and receives %s.", name, hand.cards[len(hand.cards)-1]), fmt.Sprintf("You hit and receive %s.", hand.cards[len(hand.cards)-1])) - e.Blackjack = t.snapshot(false) + e.Snapshot = t.snapshot(false) events := []Event{e} if total >= 21 { events = append(events, t.finishHand()...) @@ -286,7 +286,7 @@ func (t *blackjackTable) action(name, action string) []Event { hand.bust = true } e := t.playerAction(name, fmt.Sprintf("%s doubles down.", name), "You double down.") - e.Blackjack = t.snapshot(false) + e.Snapshot = t.snapshot(false) return append([]Event{e}, t.finishHand()...) case "split": return t.splitHand(name, p, hand) @@ -376,7 +376,7 @@ func (t *blackjackTable) splitHand(name string, p *blackjackPlayer, hand *blackj index := t.activeHand p.hands = append(p.hands[:index], append([]*blackjackHand{left, right}, p.hands[index+1:]...)...) e := t.playerAction(name, fmt.Sprintf("%s splits the hand.", name), "You split the hand.") - e.Blackjack = t.snapshot(false) + e.Snapshot = t.snapshot(false) events := []Event{e} if left.cards[0].rank == "A" { // Split aces receive one card each; they only stay live when a @@ -418,7 +418,7 @@ func (t *blackjackTable) startRound() []Event { } } roundEvent := t.event("", true) - roundEvent.Blackjack = t.snapshot(false) + roundEvent.Snapshot = t.snapshot(false) events := []Event{roundEvent} if t.dealer.cards[1].rank == "A" { t.phase = blackjackInsurance @@ -490,7 +490,7 @@ func (t *blackjackTable) prompt() []Event { actions := t.availableActions(p, h) e := t.private(name, fmt.Sprintf("Your turn (%s).\nActions: %s", strings.Join(ranks, ","), strings.Join(actions, ", "))) e.Color = "casino_menu" - e.Menu = &MenuView{Kind: MenuBlackjackTurn, Title: fmt.Sprintf("Your turn (%s)", strings.Join(ranks, ",")), Actions: actions, Commands: blackjackActionCommands(actions)} + e.Menu = &MenuView{Heading: fmt.Sprintf("Your turn (%s)", strings.Join(ranks, ",")), Commands: blackjackActionCommands(actions)} return []Event{e} } @@ -585,7 +585,7 @@ func (t *blackjackTable) settle() []Event { } dealerTotal, _ := t.dealer.total() dealerEvent := t.event("", true) - dealerEvent.Blackjack = t.snapshot(true) + dealerEvent.Snapshot = t.snapshot(true) events := []Event{dealerEvent} dealerBlackjack := t.dealerBlackjack() for _, p := range t.players.ordered() { @@ -595,50 +595,20 @@ func (t *blackjackTable) settle() []Event { payoutFailed := false for _, hand := range p.hands { result, payout, settleErr := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules) - if settleErr != nil { - // A payout multiplier that overflows the credit cap is a - // configuration defect, not a payable win. Treat it like a - // failed settlement so the stake stays open for recovery to - // refund rather than paying a clamped/absurd amount. + // One shared settlement path for every game: pay before + // announcing, leave the stakes open on any failure, and resolve + // them only once the payout is durable. + if settleStakeOutcome(p.Wallet, payout, hand.stakes, settleErr) { payoutFailed = true - events = append(events, t.playerAction(p.Name, "Settlement unavailable.", payoutFailedMessage())) + publicResult, _ := blackjackResultMessages(p.Name, result, payout) + settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage()) + colorizeBlackjackSettlement(&settlement, result) + events = append(events, settlement) continue } - if payout > 0 && p.Wallet != nil { - if err := p.Wallet.PayStakeCover(payout, hand.stakes); err != nil { - // The payout could not be made durable. The journal - // record stays open listing the hand's stakes as - // covered: recovery re-applies the owed payout (which - // returns those stakes) and refunds must not pay them - // again. The win is not announced as paid. - payoutFailed = true - publicResult, _ := blackjackResultMessages(p.Name, result, payout) - settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage()) - if result == "blackjack" { - settlement.Color = "casino_jackpot_win" - } else if result == "win" { - settlement.Color = "casino_big_win" - } - events = append(events, settlement) - continue - } - // The hand was paid: its stakes are resolved immediately - // so a later sibling-hand payout failure cannot cause a - // refund of a stake this settlement already returned. - t.resolveHandStakes(p.Wallet, hand) - } else if payout == 0 && p.Wallet != nil { - // A lost hand resolves its stakes even when a sibling - // hand's payout is still in flight: the stake was - // consumed by the rules of the game. - t.resolveHandStakes(p.Wallet, hand) - } publicResult, privateResult := blackjackResultMessages(p.Name, result, payout) settlement := t.playerAction(p.Name, publicResult, privateResult) - if result == "blackjack" { - settlement.Color = "casino_jackpot_win" - } else if result == "win" { - settlement.Color = "casino_big_win" - } + colorizeBlackjackSettlement(&settlement, result) events = append(events, settlement) } if p.insurance > 0 { @@ -647,23 +617,12 @@ func (t *blackjackTable) settle() []Event { if dealerBlackjack { insurancePayout, insuranceErr = MulPayout(p.insurance, 1+t.Rules.InsurancePayout) } - switch { - case insuranceErr != nil: - // Overflow is a config defect: leave the insurance stake open - // for recovery to refund instead of paying a clamped amount. + if settleStakeOutcome(p.Wallet, insurancePayout, []string{p.insuranceStakeID}, insuranceErr) { payoutFailed = true events = append(events, t.private(p.Name, payoutFailedMessage())) - case insurancePayout > 0: - if err := p.Wallet.PayStakeCover(insurancePayout, []string{p.insuranceStakeID}); err != nil { - payoutFailed = true - events = append(events, t.private(p.Name, payoutFailedMessage())) - } else { - p.Wallet.ResolveStake(p.insuranceStakeID) - events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout))) - } - default: - // Insurance lost: its stake was consumed by the rules. - p.Wallet.ResolveStake(p.insuranceStakeID) + } else if insurancePayout > 0 { + events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout))) + } else { events = append(events, t.private(p.Name, "Insurance pays 0 credits.")) } } @@ -725,13 +684,13 @@ func (t *blackjackTable) resetRound() { } } -// resolveHandStakes settles every stake wager a hand committed. Nil-safe. -func (t *blackjackTable) resolveHandStakes(wallet Wallet, hand *blackjackHand) { - if wallet == nil { - return - } - for _, stakeID := range hand.stakes { - wallet.ResolveStake(stakeID) +// colorizeBlackjackSettlement paints a winning settlement with the big-win +// (or jackpot, for a natural) color. +func colorizeBlackjackSettlement(e *Event, result string) { + if result == "blackjack" { + e.Color = "casino_jackpot_win" + } else if result == "win" { + e.Color = "casino_big_win" } } diff --git a/internal/casino/blackjack_test.go b/internal/casino/blackjack_test.go index 2e2e985..e3ffcaf 100644 --- a/internal/casino/blackjack_test.go +++ b/internal/casino/blackjack_test.go @@ -130,8 +130,8 @@ func TestBlackjackPromptNamesActiveHandAndOnlyOffersAvailableActions(t *testing. if event.Message != "Your turn (K,6).\nActions: hit, stand, double" { t.Fatalf("prompt=%q", event.Message) } - if event.Menu == nil || event.Menu.Kind != MenuBlackjackTurn || event.Menu.Title != "Your turn (K,6)" || - len(event.Menu.Actions) != 3 || event.Menu.Actions[0] != "hit" || event.Menu.Actions[1] != "stand" || event.Menu.Actions[2] != "double" { + if event.Menu == nil || event.Menu.Heading != "Your turn (K,6)" || event.Menu.ShowBalance || + len(event.Menu.Commands) != 3 || event.Menu.Commands[0].Command != "hit" || event.Menu.Commands[1].Command != "stand" || event.Menu.Commands[2].Command != "double" { t.Fatalf("prompt menu=%+v", event.Menu) } } @@ -139,7 +139,7 @@ func TestBlackjackPromptNamesActiveHandAndOnlyOffersAvailableActions(t *testing. func TestBlackjackMenuEventCarriesStructuredMenu(t *testing.T) { table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand()) event := table.menuEvent("alice") - if event.Menu == nil || event.Menu.Kind != MenuBlackjackBet { + if event.Menu == nil || !event.Menu.ShowBalance { t.Fatalf("menu event=%+v", event.Menu) } if len(event.Menu.Commands) != 3 || event.Menu.Commands[0].Command != "bet" || event.Menu.Commands[2].Command != "stop" { @@ -355,7 +355,7 @@ func hasMessage(events []Event, fragment string) bool { func hasSnapshot(events []Event) bool { for _, event := range events { - if event.Blackjack != nil { + if _, ok := event.Snapshot.(*BlackjackSnapshot); ok { return true } } diff --git a/internal/casino/core.go b/internal/casino/core.go index 85a5e28..a0ac1c8 100644 --- a/internal/casino/core.go +++ b/internal/casino/core.go @@ -127,6 +127,40 @@ func (c *tableCore[P]) refundAllLocked() []Event { return events } +// settleStakeOutcome applies one wager's outcome through the wallet and +// reports whether the settlement could NOT be made durable. It carries the +// money-critical ordering shared by every table game (documented in +// building_guide/concurrency.md): compute the payout, pay it via +// PayStakeCover BEFORE announcing the win, and only then resolve the stakes +// the settlement returned. On a persistence failure the stakes are left open +// so recovery re-applies the owed payout and a shutdown refund never returns +// them a second time; the caller must not announce a paid win and must not +// run the player-wide ResolveStakes. +// +// payoutErr is a payout-computation error (for example an overflowed +// multiplier): it is treated exactly like a persistence failure so a +// clamped or absurd amount is never paid. A nil wallet (test scaffolding) +// resolves the stakes in memory only. +func settleStakeOutcome(w Wallet, payout int, stakeIDs []string, payoutErr error) (failed bool) { + if payoutErr != nil { + return true + } + if w == nil { + return false + } + if payout > 0 { + if err := w.PayStakeCover(payout, stakeIDs); err != nil { + return true + } + } + for _, id := range stakeIDs { + if id != "" { + w.ResolveStake(id) + } + } + return false +} + // gameTitle renders a configured game name as a message heading. func gameTitle(game GameName) string { name := string(game) diff --git a/internal/casino/machine.go b/internal/casino/machine.go index f285518..3e1e0af 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -468,7 +468,7 @@ func (m *Machine) playerAction(publicMessage, privateMessage string) []Event { func (m *Machine) publicSlot(message string, stopped int) []Event { return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, Player: m.player.Name, - Message: message, Color: "casino_action", Public: true, Slot: &SlotSnapshot{Grid: m.spin.Grid, StoppedReels: stopped}}} + Message: message, Color: "casino_action", Public: true, Snapshot: &SlotSnapshot{Grid: m.spin.Grid, StoppedReels: stopped}}} } func stoppedScatters(grid [slotGridRows][slotGridReels]string, reels int, trigger string) int { @@ -503,7 +503,7 @@ func (m *Machine) privateMenu(prefix string) []Event { events := m.private(prefix) events[0].Color = "casino_menu" if m.phase == machineReady { - events[0].Menu = &MenuView{Kind: MenuSlotCommands, Commands: slotCommands} + events[0].Menu = &MenuView{Heading: prefix, ShowBalance: true, Commands: slotCommands} } return events } diff --git a/internal/casino/machine_test.go b/internal/casino/machine_test.go index 73687b2..1e8cdad 100644 --- a/internal/casino/machine_test.go +++ b/internal/casino/machine_test.go @@ -45,7 +45,7 @@ func TestMachineMenuEventCarriesStructuredMenu(t *testing.T) { m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7))) m.player = &Participant{Name: "alice", Connected: true} events := m.privateMenu("You sit down at the slot machine.") - if len(events) != 1 || events[0].Menu == nil || events[0].Menu.Kind != MenuSlotCommands || len(events[0].Menu.Commands) == 0 { + if len(events) != 1 || events[0].Menu == nil || !events[0].Menu.ShowBalance || len(events[0].Menu.Commands) == 0 { t.Fatalf("slot menu event=%+v", events) } } diff --git a/internal/casino/table.go b/internal/casino/table.go index 4be60a0..e3104ae 100644 --- a/internal/casino/table.go +++ b/internal/casino/table.go @@ -50,11 +50,12 @@ func (b *tableBase) playerAction(name, publicMessage, privateMessage string) Eve } // betMenuEvent builds the shared "place your bet" menu heading for a table -// game; only the menu kind and command list differ per game. -func (b *tableBase) betMenuEvent(name string, kind MenuKind, commands []MenuCommand) Event { - e := b.private(name, fmt.Sprintf("New %s hand! Place your bet.", b.Config.Game)) +// game; only the command list differs per game. +func (b *tableBase) betMenuEvent(name string, commands []MenuCommand) Event { + heading := fmt.Sprintf("New %s hand! Place your bet.", b.Config.Game) + e := b.private(name, heading) e.Color = "casino_menu" - e.Menu = &MenuView{Kind: kind, Commands: commands} + e.Menu = &MenuView{Heading: heading, ShowBalance: true, Commands: commands} return e } diff --git a/internal/casino/types.go b/internal/casino/types.go index 3d31820..980769c 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -234,36 +234,24 @@ type Wallet interface { type EventType string +// Only the event types the games actually construct are defined. Payout and +// win styling travels on Event.Color, so there is no separate payout event +// type to keep in sync. const ( - EventJoined EventType = "joined" - EventBetting EventType = "betting" - EventBetPlaced EventType = "bet_placed" - EventRoundStarted EventType = "round_started" - EventResult EventType = "result" - EventPayout EventType = "payout" - EventTimedOut EventType = "timed_out" - EventLeft EventType = "left" - EventClosed EventType = "closed" -) - -type MenuKind string - -const ( - MenuSlotCommands MenuKind = "slot_commands" - MenuBlackjackBet MenuKind = "blackjack_bet" - MenuBlackjackTurn MenuKind = "blackjack_turn" - MenuBaccaratBet MenuKind = "baccarat_bet" + EventJoined EventType = "joined" + EventResult EventType = "result" + EventLeft EventType = "left" ) // MenuView carries a structured menu alongside its plain-text Message so the // presentation layer can enrich it (balances, command colors) without parsing -// message prefixes. +// message prefixes. It is deliberately game-agnostic: every game supplies a +// heading and its command list, and the renderer has no per-game switch that +// could silently drop a new game's commands. type MenuView struct { - Kind MenuKind - Body string // command list (MenuSlotCommands) - Title string // prompt heading (MenuBlackjackTurn) - Actions []string // available commands (MenuBlackjackTurn) - Commands []MenuCommand // unified command list for all casino menus + Heading string // prompt heading shown above the command list + ShowBalance bool // prepend the player's chips/credits balance + Commands []MenuCommand } // MenuCommand is one selectable command shown in a casino menu: the typed @@ -283,17 +271,27 @@ type Event struct { PrivateTo string Color string Public bool - Slot *SlotSnapshot - Blackjack *BlackjackSnapshot - Baccarat *BaccaratSnapshot - Menu *MenuView + // Snapshot is the optional per-game view attached to the event. A game + // implements the Snapshot interface for its view type; the presentation + // layer type-switches on it. Because the field is typed, a new game's + // snapshot can never be assigned without implementing the interface, and + // the event filter can test it with a single nil check instead of + // enumerating every game's field. + Snapshot Snapshot + Menu *MenuView } +// Snapshot is the marker interface for a game's structured event view. The +// method is unexported so only types in this package can implement it. +type Snapshot interface{ snapshot() } + type SlotSnapshot struct { Grid [slotGridRows][slotGridReels]string StoppedReels int } +func (*SlotSnapshot) snapshot() {} + // CardView renders one card for any card-based table game. type CardView struct { Rank string @@ -315,6 +313,8 @@ type BlackjackSnapshot struct { Players []BlackjackPlayerView } +func (*BlackjackSnapshot) snapshot() {} + // BaccaratBetView lists one participant's wager on a baccarat outcome. type BaccaratBetView struct { Name string @@ -330,6 +330,8 @@ type BaccaratSnapshot struct { Bets []BaccaratBetView } +func (*BaccaratSnapshot) snapshot() {} + type Participant struct { Name string Connected bool diff --git a/internal/game/act_clean.go b/internal/game/act_clean.go index 4efe293..0540cb1 100644 --- a/internal/game/act_clean.go +++ b/internal/game/act_clean.go @@ -68,7 +68,7 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) { } consumeID := plan[0].id - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == consumeID { slot.ItemID = matchItem.ID diff --git a/internal/game/act_farm.go b/internal/game/act_farm.go index 6725351..e83beb1 100644 --- a/internal/game/act_farm.go +++ b/internal/game/act_farm.go @@ -257,7 +257,7 @@ func (g *Game) advanceHarvest(sess *net.Session, p *player.Player) { noRoom := false if stackable { quality, maxQuality := stackProps(productDef) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { s := p.InvSlot(i) // Only a stack with the properties a fresh harvest stack gets // may absorb it; merging into a differently qualified stack @@ -275,7 +275,7 @@ func (g *Game) advanceHarvest(sess *net.Session, p *player.Player) { // One slot per unit: the clamp and the placement loop must count // slots the same way (a nil 0..27 slot). freeSlots := 0 - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { if p.InvSlot(i) == nil { freeSlots++ } diff --git a/internal/game/act_gather.go b/internal/game/act_gather.go index 3bef8a0..df0ae49 100644 --- a/internal/game/act_gather.go +++ b/internal/game/act_gather.go @@ -417,7 +417,7 @@ func (g *Game) checkBirdNest(sess *net.Session, p *player.Player, cfg *behavior. } freeSlot := -1 - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { if i == currentDropSlot { continue } diff --git a/internal/game/act_identify.go b/internal/game/act_identify.go index 6ff4d61..89bef35 100644 --- a/internal/game/act_identify.go +++ b/internal/game/act_identify.go @@ -29,7 +29,7 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) { totalJunk := scrapCount * multiplier hasExistingStack := false - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == junkID { hasExistingStack = true @@ -42,7 +42,7 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) { return } - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == "scrap" { p.SetInvSlot(i, nil) @@ -50,7 +50,7 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) { } placed := false - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == junkID { slot.Quantity += totalJunk diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go index 5a7f93e..7df400b 100644 --- a/internal/game/act_steal.go +++ b/internal/game/act_steal.go @@ -42,7 +42,7 @@ var stallGuardTalk = &behavior.TalkConfig{ }, "jail": { Messages: []string{"Off to the detention cell with you!"}, - Action: &behavior.Step{Teleport: 99999162}, + Action: &behavior.Step{Teleport: jailRoomID}, Options: []behavior.TalkOption{{Text: "(You are dragged away)"}}, }, "fight": { diff --git a/internal/game/casino.go b/internal/game/casino.go index 971952a..736add2 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -489,42 +489,47 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { sess.WriteLine("You're not playing a game.") return } - // Table bet syntax: bet | bet <amount> [on] [spot] | bet min|max [on] [spot]. - // Tokens that parse as an amount (or min/max) set the wager; any other - // token is the bet target and the table game validates it. - amount, spot, hasAmount := 0, "", false + // Table bet syntax: bet | bet <amount> [on] <target> | bet min|max [on] <target>. + // BEFORE an "on" keyword, a token that parses as an amount (or min/max) + // sets the wager and any other token is the bet target; AFTER "on", the + // next token is always the target. That makes numeric targets reachable + // ("bet 5 on 17"), which roulette, craps, and keno need. + amount, spot, hasAmount, sawOn := 0, "", false, false for _, arg := range args { if strings.EqualFold(arg, "on") { + sawOn = true continue } - if strings.EqualFold(arg, "min") { - if hasAmount { - sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.") - return - } - amount, hasAmount = table.Config.MinBet, true - continue - } - if strings.EqualFold(arg, "max") { - if hasAmount { - sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.") - return + if !sawOn { + if strings.EqualFold(arg, "min") { + if hasAmount { + sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.") + return + } + amount, hasAmount = table.Config.MinBet, true + continue } - amount = min(table.Config.MaxBet, casinoAffordable(p)) - if amount < 1 { - sess.WriteLine("You don't have any chips or credits to bet.") - return + if strings.EqualFold(arg, "max") { + if hasAmount { + sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.") + return + } + amount = min(table.Config.MaxBet, casinoAffordable(p)) + if amount < 1 { + sess.WriteLine("You don't have any chips or credits to bet.") + return + } + hasAmount = true + continue } - hasAmount = true - continue - } - if value, err := strconv.Atoi(arg); err == nil && value > 0 { - if hasAmount { - sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.") - return + if value, err := strconv.Atoi(arg); err == nil && value > 0 { + if hasAmount { + sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.") + return + } + amount, hasAmount = value, true + continue } - amount, hasAmount = value, true - continue } if spot != "" { sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.") @@ -579,12 +584,12 @@ func (g *Game) CasinoTick() { func (g *Game) emitCasinoEvents(events []casino.Event) { for _, event := range events { - if event.Message == "" && event.PrivateMessage == "" && event.Menu == nil && event.Blackjack == nil && event.Baccarat == nil && event.Slot == nil { + if event.Message == "" && event.PrivateMessage == "" && event.Menu == nil && event.Snapshot == nil { continue } category := event.Color if category == "" { - category = casinoEventColor(event.Type) + category = "casino_action" } if event.Public && g.Hub != nil { for _, sess := range g.Hub.PlayersInRoom(event.RoomID) { @@ -592,14 +597,8 @@ func (g *Game) emitCasinoEvents(events []casino.Event) { continue } message := event.Message - if event.Slot != nil && sess.Player != nil { - message += "\n" + renderSlotSnapshot(event.Slot, g.colorMode(sess), sess.Player.OptionBool("unicode")) - } - if event.Blackjack != nil && sess.Player != nil { - message += "\n" + renderBlackjackSnapshot(event.Blackjack, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt) - } - if event.Baccarat != nil && sess.Player != nil { - message += "\n" + renderBaccaratSnapshot(event.Baccarat, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt) + if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" { + message += "\n" + rendered } sess.WriteLine(g.colorize(sess, category, message)) } @@ -621,11 +620,8 @@ func (g *Game) emitCasinoEvents(events []casino.Event) { continue } privateMessage := g.casinoPrivateMessage(sess, category, event.Menu, text) - if event.Blackjack != nil && sess.Player != nil { - privateMessage += "\n" + renderBlackjackSnapshot(event.Blackjack, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt) - } - if event.Baccarat != nil && sess.Player != nil { - privateMessage += "\n" + renderBaccaratSnapshot(event.Baccarat, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt) + if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" { + privateMessage += "\n" + rendered } sess.WriteLine(g.renderCasinoMessage(sess, category, event.Menu, privateMessage)) if category == "casino_menu" { @@ -643,29 +639,42 @@ func (g *Game) renderCasinoMessage(sess *net.Session, category string, menu *cas return g.colorize(sess, category, message) } -func casinoEventColor(eventType casino.EventType) string { - if eventType == casino.EventPayout { - return "casino_win" - } - return "casino_action" -} - func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *casino.MenuView, message string) string { if category != "casino_menu" || menu == nil { return message } - chips := g.colorize(sess, "casino_chips", fmt.Sprintf("%d", countChips(sess.Player))) - credits := g.colorize(sess, "casino_credits", fmt.Sprintf("%d", sess.Player.Credits)) - balance := fmt.Sprintf("You have %s chips and %s credits.", chips, credits) - switch menu.Kind { - case casino.MenuBlackjackBet, casino.MenuBaccaratBet: - return g.renderCasinoCommands(sess, balance, message, menu.Commands) - case casino.MenuBlackjackTurn: - return g.renderCasinoCommands(sess, "", menu.Title, menu.Commands) - case casino.MenuSlotCommands: - return g.renderCasinoCommands(sess, balance, message, menu.Commands) - } - return message + balance := "" + if menu.ShowBalance { + chips := g.colorize(sess, "casino_chips", fmt.Sprintf("%d", countChips(sess.Player))) + credits := g.colorize(sess, "casino_credits", fmt.Sprintf("%d", sess.Player.Credits)) + balance = fmt.Sprintf("You have %s chips and %s credits.", chips, credits) + } + heading := menu.Heading + if heading == "" { + heading = message + } + return g.renderCasinoCommands(sess, balance, heading, menu.Commands) +} + +// renderCasinoSnapshot renders a game's structured view for one session. It is +// the only per-game switch in the presentation layer: adding a game means +// adding its snapshot type here, and the Snapshot interface guarantees the +// event carries it rather than being silently dropped by a field filter. +func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot) string { + if snapshot == nil || sess.Player == nil { + return "" + } + mode := g.colorMode(sess) + unicode := sess.Player.OptionBool("unicode") + switch s := snapshot.(type) { + case *casino.SlotSnapshot: + return renderSlotSnapshot(s, mode, unicode) + case *casino.BlackjackSnapshot: + return renderBlackjackSnapshot(s, mode, unicode, sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt) + case *casino.BaccaratSnapshot: + return renderBaccaratSnapshot(s, mode, unicode, sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt) + } + return "" } // renderCasinoCommands lays every casino menu out in the same style: an diff --git a/internal/game/casino_test.go b/internal/game/casino_test.go index 026415f..ffd9958 100644 --- a/internal/game/casino_test.go +++ b/internal/game/casino_test.go @@ -74,6 +74,14 @@ func TestExecuteBetParsesBaccaratSpots(t *testing.T) { if !strings.Contains(string(conn.data), "must be on player, banker, or tie") { t.Fatalf("bet red 100: expected spot rejection, got %q", string(conn.data)) } + // A numeric token AFTER "on" is the target, not a second amount, so + // numeric spots (roulette numbers, craps hard ways) reach the table. + // Baccarat rejects the value, which proves the parser forwarded it. + conn.data = nil + g.executeBet(sess, []string{"5", "on", "17"}, "bet 5 on 17") + if !strings.Contains(string(conn.data), "must be on player, banker, or tie") { + t.Fatalf("bet 5 on 17: expected the table to receive the numeric spot, got %q", string(conn.data)) + } // Two amounts or two spots are usage errors. for _, args := range [][]string{{"10", "20"}, {"player", "banker"}} { conn.data = nil @@ -95,7 +103,7 @@ func TestCasinoMenuRendersAlignedColoredCommands(t *testing.T) { conn := &casinoTestConn{} sess := &net.Session{Player: player.New("alice"), Conn: conn} g.loggedInChars = map[string]*net.Session{"alice": sess} - menu := &casino.MenuView{Kind: casino.MenuSlotCommands, Commands: []casino.MenuCommand{ + menu := &casino.MenuView{Heading: "Your slot machine bet is set to 10.", ShowBalance: true, Commands: []casino.MenuCommand{ {Command: "bet <amount>", Desc: "Set your wager"}, {Command: "spin", Desc: "Spin using your current wager"}, }} diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go index 0bfc2a3..1ec0b7c 100644 --- a/internal/game/cmd_bank.go +++ b/internal/game/cmd_bank.go @@ -182,7 +182,7 @@ func (g *Game) doBankDepositAll(sess *net.Session) { p := sess.Player var deposited int - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil { continue @@ -299,7 +299,7 @@ func (g *Game) withdrawBankItem(sess *net.Session, p *player.Player, bankSlotIdx // means the exact same item instance: merging two different-quality // stacks would silently destroy the withdrawn stack's properties. if def != nil && def.Stackable { - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { if slot := p.InvSlot(i); slot != nil && slot.ItemID == bs.ItemID && slot.Quality == bs.Quality && slot.MaxQuality == bs.MaxQuality { slot.Quantity += bs.Quantity diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go index c58ba63..ff33490 100644 --- a/internal/game/cmd_dig.go +++ b/internal/game/cmd_dig.go @@ -127,7 +127,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { return } - newID, err := findNextRoomID(curPath) + newID, err := findNextRoomID(filepath.Dir(curPath), g.World.RoomIndex()) if err != nil { sess.WriteLine("Error scanning room directory.") return @@ -222,14 +222,22 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { g.checkAggro(sess) } -func findNextRoomID(currentRoomPath string) (int, error) { - dir := filepath.Dir(currentRoomPath) - entries, err := os.ReadDir(dir) +// findNextRoomID returns the first free room ID at or above localDir's +// minimum, checked against every room ID in the world (globallyUsed). The +// admin allocator (admin/id_alloc.go) does the same. A directory-scoped scan +// could hand out an ID that already exists in another area, and +// World.AddRoomPath would then silently shadow that room for the rest of the +// process while leaving a duplicate file on disk. +func findNextRoomID(localDir string, globallyUsed map[int]bool) (int, error) { + entries, err := os.ReadDir(localDir) if err != nil { return 0, err } - used := make(map[int]bool) + used := make(map[int]bool, len(globallyUsed)+len(entries)) + for id := range globallyUsed { + used[id] = true + } minID := 0 first := true for _, entry := range entries { @@ -248,11 +256,11 @@ func findNextRoomID(currentRoomPath string) (int, error) { } } - if first { - return 1, nil + start := 1 + if !first { + start = minID } - - for id := minID; ; id++ { + for id := start; ; id++ { if !used[id] { return id, nil } diff --git a/internal/game/cmd_dig_test.go b/internal/game/cmd_dig_test.go new file mode 100644 index 0000000..1a96c25 --- /dev/null +++ b/internal/game/cmd_dig_test.go @@ -0,0 +1,57 @@ +package game + +import ( + "os" + "path/filepath" + "testing" +) + +// TestFindNextRoomIDSkipsIDsInOtherDirectories: the in-game allocator must +// check every room ID in the world, not just the current room's directory. +// A directory-scoped scan hands out an ID that already exists elsewhere, and +// World.AddRoomPath then silently shadows that room. +func TestFindNextRoomIDSkipsIDsInOtherDirectories(t *testing.T) { + root := t.TempDir() + intro := filepath.Join(root, "intro") + arena := filepath.Join(root, "arena") + for _, dir := range []string{intro, arena} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + // intro owns 1001 and 1002; arena owns 1003. + for _, id := range []string{"1001", "1002"} { + if err := os.WriteFile(filepath.Join(intro, id+".yaml"), []byte("name: intro\n"), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(arena, "1003.yaml"), []byte("name: arena\n"), 0o644); err != nil { + t.Fatal(err) + } + globallyUsed := map[int]bool{1001: true, 1002: true, 1003: true} + + got, err := findNextRoomID(intro, globallyUsed) + if err != nil { + t.Fatalf("findNextRoomID: %v", err) + } + if got != 1004 { + t.Fatalf("findNextRoomID = %d, want 1004 (1003 is used by arena)", got) + } +} + +// TestFindNextRoomIDEmptyDirectoryStartsAtOne: an empty directory still +// allocates the globally lowest free ID rather than blindly returning 1. +func TestFindNextRoomIDEmptyDirectoryStartsAtOne(t *testing.T) { + root := t.TempDir() + empty := filepath.Join(root, "empty") + if err := os.MkdirAll(empty, 0o755); err != nil { + t.Fatal(err) + } + got, err := findNextRoomID(empty, map[int]bool{1: true, 2: true}) + if err != nil { + t.Fatalf("findNextRoomID: %v", err) + } + if got != 3 { + t.Fatalf("findNextRoomID = %d, want 3", got) + } +} diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go index 278a57d..1c30cce 100644 --- a/internal/game/cmd_drop.go +++ b/internal/game/cmd_drop.go @@ -5,6 +5,7 @@ import ( "strings" "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" ) func (g *Game) doDropAll(sess *net.Session) { @@ -120,7 +121,7 @@ func (g *Game) doDrop(sess *net.Session, input string) { if qty == 0 { if def != nil && def.Stackable { // "drop <item>" means the whole item: sum every matching stack. - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == itemID && slot.Quantity > 0 { qty += slot.Quantity @@ -239,7 +240,7 @@ func (g *Game) handleDropAll(sess *net.Session, input string) { } var dropped []string - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil || slot.Quantity <= 0 { continue diff --git a/internal/game/cmd_estate_directory.go b/internal/game/cmd_estate_directory.go index 9966e52..83c5b80 100644 --- a/internal/game/cmd_estate_directory.go +++ b/internal/game/cmd_estate_directory.go @@ -2,6 +2,7 @@ package game import ( "fmt" + "log" "os" "path/filepath" "sort" @@ -182,12 +183,20 @@ func (g *Game) getOwnedHouseRoom(p *player.Player, flagKey string) int { } houseMap := map[string]int{ - "local_neighborhood": 99999300, + "local_neighborhood": estateNeighborhoodID, } return houseMap[v] } func (g *Game) teleportPlayer(sess *net.Session, p *player.Player, roomID int) { + // Refuse a destination that does not exist: persisting a nonexistent + // RoomID soft-locks the character on login (see the login rescue in + // core_login_char.go). This is the single choke point every trigger, + // module, and estate teleport flows through. + if _, err := g.World.LoadRoom(roomID); err != nil { + log.Printf("teleport %s: destination room %d does not exist; ignoring", p.Name, roomID) + return + } if g.Hub != nil { for _, other := range g.Hub.PlayersInRoom(p.RoomID) { if other != sess { diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go index 33f55de..a806ffa 100644 --- a/internal/game/cmd_get.go +++ b/internal/game/cmd_get.go @@ -76,7 +76,7 @@ func (g *Game) doGet(sess *net.Session, input string) { if def != nil && def.Stackable { quality, maxQuality := g.groundStackProps(p, itemID) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality { continue @@ -198,7 +198,7 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) { if def != nil && def.Stackable { quality, maxQuality := g.groundStackProps(p, itemID) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality { continue @@ -306,7 +306,7 @@ func (g *Game) doGetAll(sess *net.Session) { if def != nil && def.Stackable { stacked := false quality, maxQuality := g.groundStackProps(p, itemID) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality { continue @@ -485,7 +485,7 @@ func (g *Game) findGroundMatches(input string, roomID int) []itemMatch { func (g *Game) findInventoryMatches(input string, p *player.Player) []itemMatch { return g.collectMatches(input, func(yield func(string, int) bool) { - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil { continue diff --git a/internal/game/cmd_inventory.go b/internal/game/cmd_inventory.go index b7b52ed..fbde186 100644 --- a/internal/game/cmd_inventory.go +++ b/internal/game/cmd_inventory.go @@ -4,6 +4,7 @@ import ( "fmt" "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" ) func (g *Game) executeInventory(sess *net.Session, args []string, rawInput string) { @@ -15,7 +16,7 @@ func (g *Game) doInventory(sess *net.Session) { sess.WriteLine(fmt.Sprintf("Inventory (%d free):", p.FreeSlots())) num := 0 - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil { continue diff --git a/internal/game/cmd_remove.go b/internal/game/cmd_remove.go index 53dd313..d03daec 100644 --- a/internal/game/cmd_remove.go +++ b/internal/game/cmd_remove.go @@ -98,7 +98,7 @@ func (g *Game) removeAmmo(sess *net.Session, p *player.Player, qty int) { } quality, maxQuality := g.pickedStackProps(ammoID) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { s := p.InvSlot(i) // Only a stack with the properties a fresh stack gets may absorb the // equipped stack (see pickedStackProps). @@ -198,7 +198,7 @@ func (g *Game) doRemoveAll(sess *net.Session) { // failure instead of silently destroying the ammo. func (g *Game) removeAllAmmo(sess *net.Session, p *player.Player, itemID string) { quality, maxQuality := g.pickedStackProps(itemID) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { s := p.InvSlot(i) // Only a stack with the properties a fresh stack gets may absorb the // equipped stack (see pickedStackProps). diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go index 8e01326..bf95282 100644 --- a/internal/game/cmd_room_insert.go +++ b/internal/game/cmd_room_insert.go @@ -58,7 +58,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { oppositeDir := world.OppositeExit[dir] - newID, err := findNextRoomID(curPath) + newID, err := findNextRoomID(filepath.Dir(curPath), g.World.RoomIndex()) if err != nil { sess.WriteLine("Error scanning room directory.") return diff --git a/internal/game/cmd_shop.go b/internal/game/cmd_shop.go index e2209fc..282d3ca 100644 --- a/internal/game/cmd_shop.go +++ b/internal/game/cmd_shop.go @@ -382,7 +382,7 @@ func (g *Game) doShopSell(sess *net.Session, mob *world.MobInstance, input strin func (g *Game) shopInvCapacity(p *player.Player, itemID string, def *item.ItemDef) int { if def != nil && def.Stackable { quality, maxQuality := stackProps(def) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { // Capacity must agree with giveShopItems: only a stack with the // properties a fresh stack gets can absorb a purchase (see // stackProps). Counting a differently qualified stack here would @@ -408,7 +408,7 @@ func (g *Game) giveShopItems(p *player.Player, itemID string, qty int, def *item // harvest, ammo return and pickup: a differently qualified stack is // not a merge target. quality, maxQuality := stackProps(def) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { if slot := p.InvSlot(i); slot != nil && slot.ItemID == itemID && slot.Quality == quality && slot.MaxQuality == maxQuality { slot.Quantity += qty return diff --git a/internal/game/cmd_swapid.go b/internal/game/cmd_swapid.go index 00b6568..8b637fb 100644 --- a/internal/game/cmd_swapid.go +++ b/internal/game/cmd_swapid.go @@ -117,7 +117,12 @@ func (g *Game) executeSwapID(sess *net.Session, args []string, rawInput string) // The two swapped rooms are always re-marshaled to correct their `id:` // field; other rooms are re-marshaled only if a reference actually moved. idMap := map[int]int{id1: id2, id2: id1} - var updated int + // Rewrite every room that references the swapped IDs in ONE journal + // transaction. The old per-file writeRoomFile loop swallowed errors, so + // a crash or a failed write mid-loop left a half-migrated world with no + // recovery record. + rewriteTxn := g.newTxn() + updated := 0 for id := range g.World.RoomIndex() { room, err := g.World.LoadRoom(id) if err != nil { @@ -133,13 +138,19 @@ func (g *Game) executeSwapID(sess *net.Session, args []string, rawInput string) } data, merr := yaml.Marshal(room) if merr != nil { - continue + sess.WriteLine(fmt.Sprintf("Error rewriting room %d: %v", id, merr)) + return } - if werr := writeRoomFile(path, data); werr != nil { - continue + if rerr := rewriteTxn.Replace(path, data, 0644); rerr != nil { + sess.WriteLine(fmt.Sprintf("Error rewriting room %d: %v", id, rerr)) + return } updated++ } + if rerr := rewriteTxn.Commit(); rerr != nil { + sess.WriteLine(fmt.Sprintf("Error rewriting room references: %v", rerr)) + return + } // Full runtime migration: Hub room buckets, casino room keys, world // room-keyed state (ground items, seeded markers, object states), live diff --git a/internal/game/cmd_trigger_combat.go b/internal/game/cmd_trigger_combat.go index 0578c2b..0d53e94 100644 --- a/internal/game/cmd_trigger_combat.go +++ b/internal/game/cmd_trigger_combat.go @@ -105,7 +105,7 @@ func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.Mob func (g *Game) findInventoryItem(p *player.Player, input string) (int, *player.InventorySlot) { lower := strings.ToLower(strings.TrimSpace(input)) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil { continue diff --git a/internal/game/cmd_trigger_utility.go b/internal/game/cmd_trigger_utility.go index 481cd40..fa81f23 100644 --- a/internal/game/cmd_trigger_utility.go +++ b/internal/game/cmd_trigger_utility.go @@ -97,7 +97,7 @@ func (g *Game) triggerBonesToNuts(sess *net.Session, p *player.Player, mod *ModD g.cancelAction(p) } - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == "bones" { slot.ItemID = "nutrient_bar" @@ -283,7 +283,7 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef outputQty = 1 } placed := false - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == match.ID { slot.Quantity += outputQty diff --git a/internal/game/cmd_undig.go b/internal/game/cmd_undig.go index a0fd1a0..c752c86 100644 --- a/internal/game/cmd_undig.go +++ b/internal/game/cmd_undig.go @@ -191,7 +191,7 @@ func (g *Game) performUndig(sess *net.Session, p *player.Player, targetID int) { return nil } - changed := false + changed := room.RemoveRoomRefs(targetID) for ed, exitDef := range room.Exits { if exitDef.Room == targetID { delete(room.Exits, ed) diff --git a/internal/game/cmd_wear.go b/internal/game/cmd_wear.go index e459386..773e3c6 100644 --- a/internal/game/cmd_wear.go +++ b/internal/game/cmd_wear.go @@ -196,7 +196,7 @@ func (g *Game) unequipAmmo(p *player.Player) bool { return true } quality, maxQuality := g.pickedStackProps(ammoID) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { s := p.InvSlot(i) // Only a stack with the properties a fresh stack gets may absorb the // equipped stack (see pickedStackProps). @@ -227,7 +227,7 @@ func (g *Game) doWearAll(sess *net.Session) { invSlot int }) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil { continue diff --git a/internal/game/content_refs.go b/internal/game/content_refs.go new file mode 100644 index 0000000..d3328a1 --- /dev/null +++ b/internal/game/content_refs.go @@ -0,0 +1,18 @@ +package game + +// Code-referenced room IDs. +// +// A few rooms are referenced by literal ID in game code rather than by YAML. +// Renaming, moving, or deleting them compiles clean and validates clean (no +// check inspects Go literals), but silently soft-locks the affected flow: the +// thief's "take me to jail" option and the estate directory's "enter" both +// teleport there. TestCodeReferencedRoomsExist pins their existence against +// the shipped data so a move fails loudly instead. +const ( + // jailRoomID is the destination of the stall-guard's "Take me to jail" + // step in act_steal.go. + jailRoomID = 99999162 + // estateNeighborhoodID is the local-neighborhood house room used by the + // construction/estate directory flow in cmd_estate_directory.go. + estateNeighborhoodID = 99999300 +) diff --git a/internal/game/content_refs_test.go b/internal/game/content_refs_test.go new file mode 100644 index 0000000..456a0ee --- /dev/null +++ b/internal/game/content_refs_test.go @@ -0,0 +1,21 @@ +package game + +import ( + "path/filepath" + "testing" + + "thehouseoficarus/internal/world" +) + +// TestCodeReferencedRoomsExist guards the literal room IDs in content_refs.go. +// If one of those rooms is moved or deleted, the thief-jail and +// estate-directory teleports would silently soft-lock players, so the shipped +// data must still contain them. +func TestCodeReferencedRoomsExist(t *testing.T) { + w := world.New(filepath.Join("..", "..", "data")) + for _, id := range []int{jailRoomID, estateNeighborhoodID} { + if _, err := w.LoadRoom(id); err != nil { + t.Errorf("code-referenced room %d is missing from data/rooms: %v", id, err) + } + } +} diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go index e9fa67f..3b35b15 100644 --- a/internal/game/core_login_account.go +++ b/internal/game/core_login_account.go @@ -158,7 +158,10 @@ func (g *Game) handlePasswordOffOwner(sess *net.Session, input string) { return } throttle := g.ensureLoginThrottle() - key := loginThrottleKey(name, sess.RemoteAddr()) + // Key on the host, not RemoteAddr: the latter carries the ephemeral + // source port, so a reconnect would land on a fresh key and reset the + // escalation. RemoteHost strips the port (see net.Session.RemoteHost). + key := loginThrottleKey(name, sess.RemoteHost()) auth := passwordAuth{} acc, err := g.AccountStore.LoadAccount(name) switch { diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go index 3a2e324..41e25b8 100644 --- a/internal/game/core_login_char.go +++ b/internal/game/core_login_char.go @@ -2,6 +2,7 @@ package game import ( "fmt" + "log" "strings" "thehouseoficarus/internal/net" @@ -128,6 +129,18 @@ func (g *Game) connectCharacter(sess *net.Session, name string) { } } + // Rescue a character whose saved room no longer exists (the room was + // deleted, or a code-referenced teleport target was removed). Persisting + // a nonexistent RoomID soft-locks the character on every later login + // because movement and every room lookup fail. + if _, err := g.World.LoadRoom(p.RoomID); err != nil { + log.Printf("character %s: saved room %d no longer exists; relocating to starting room %d", p.Name, p.RoomID, g.StartingRoom) + p.RoomID = g.StartingRoom + if _, err := g.World.LoadRoom(p.RoomID); err != nil { + log.Printf("character %s: starting room %d also missing; leaving room as %d", p.Name, g.StartingRoom, p.RoomID) + } + } + p.Stats.RecordRoomVisit(p.RoomID) g.World.SeedGroundItems(p.RoomID) g.seedRoomMobs(p.RoomID) diff --git a/internal/game/core_production.go b/internal/game/core_production.go index afffa7c..7a4b6e9 100644 --- a/internal/game/core_production.go +++ b/internal/game/core_production.go @@ -16,7 +16,7 @@ func (g *Game) findTool(p *player.Player, toolTypes []string) (toolSpeed float64 } } } - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil { continue diff --git a/internal/game/game.go b/internal/game/game.go index af2ff66..c0c6488 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -764,6 +764,10 @@ func (g *Game) ProcessQueuedCommands() { func (g *Game) buildCraftIndex() { items, err := g.ItemStore.LoadAll() if err != nil { + // A malformed item file must not silently empty every crafting menu; + // surface it so the operator can fix the file (the validator also + // reports it). + log.Printf("craft index: item load failed; craft index left empty: %v", err) return } g.CraftIndex.Build(items) diff --git a/internal/game/look_target.go b/internal/game/look_target.go index 5f3818d..49a2ec7 100644 --- a/internal/game/look_target.go +++ b/internal/game/look_target.go @@ -234,7 +234,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { return } - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil { continue diff --git a/internal/game/production_engine.go b/internal/game/production_engine.go index 89d6f55..9795db0 100644 --- a/internal/game/production_engine.go +++ b/internal/game/production_engine.go @@ -440,7 +440,7 @@ func (g *Game) canContinueProduction(p *player.Player, item *item.ItemDef) bool } outDef, _ := g.ItemStore.Load(item.ID) if outDef != nil && outDef.Stackable { - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == item.ID { return true diff --git a/internal/game/production_index.go b/internal/game/production_index.go index 35466a7..22e944e 100644 --- a/internal/game/production_index.go +++ b/internal/game/production_index.go @@ -293,7 +293,7 @@ func stackProps(def *item.ItemDef) (quality, maxQuality int) { func craftOutputFits(p *player.Player, itemID string, outDef *item.ItemDef) bool { if outDef != nil && outDef.Stackable { quality, maxQuality := stackProps(outDef) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == itemID && slot.Quality == quality && slot.MaxQuality == maxQuality { return true @@ -311,7 +311,7 @@ func craftOutputFits(p *player.Player, itemID string, outDef *item.ItemDef) bool func placeCraftOutput(p *player.Player, itemID string, outDef *item.ItemDef, outputQty int) bool { quality, maxQuality := stackProps(outDef) if outDef != nil && outDef.Stackable { - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == itemID && slot.Quality == quality && slot.MaxQuality == maxQuality { slot.Quantity += outputQty diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go index a6feec2..61eb325 100644 --- a/internal/game/sys_triggers.go +++ b/internal/game/sys_triggers.go @@ -569,6 +569,9 @@ func (g *Game) loadGlobalTriggers(dataDir string) error { } data, err := os.ReadFile(full) if err != nil { + // A permissions/IO error here would otherwise disable a + // file-backed flag trigger with no trace at all. + log.Printf("trigger load %q: %v (skipped)", full, err) continue } var t behavior.Trigger @@ -603,7 +606,9 @@ func copyTrigger(t behavior.Trigger) *behavior.Trigger { func (g *Game) loadAllFlagTriggers() { g.flagIndex.clear() - _ = g.loadGlobalTriggers(g.DataDir) + if err := g.loadGlobalTriggers(g.DataDir); err != nil { + log.Printf("trigger load: %v (some file-backed flag triggers may be missing)", err) + } } // firePlayerFlagTriggers starts sequences for every on_flag_change trigger diff --git a/internal/net/remote_host_test.go b/internal/net/remote_host_test.go new file mode 100644 index 0000000..690e758 --- /dev/null +++ b/internal/net/remote_host_test.go @@ -0,0 +1,34 @@ +package net + +import ( + "testing" + "time" +) + +// remoteAddrConn is a minimal Conn that also reports a peer address, so the +// Session.RemoteHost port-stripping contract can be tested without a socket. +type remoteAddrConn struct{ addr string } + +func (c remoteAddrConn) ReadMessage() (string, error) { return "", nil } +func (c remoteAddrConn) Write(b []byte) (int, error) { return len(b), nil } +func (c remoteAddrConn) Close() error { return nil } +func (c remoteAddrConn) SetWriteDeadline(time.Time) error { return nil } +func (c remoteAddrConn) SetEcho(bool) error { return nil } +func (c remoteAddrConn) RemoteAddr() string { return c.addr } + +// TestSessionRemoteHostStripsPort: login throttling keys on the client host, +// so RemoteHost must drop the ephemeral source port (RemoteAddr keeps it). +// Keying on the raw address would let a reconnect reset the limiter. +func TestSessionRemoteHostStripsPort(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"203.0.113.7:54321", "203.0.113.7"}, + {"[2001:db8::1]:443", "2001:db8::1"}, + {"203.0.113.7", "203.0.113.7"}, + {"", ""}, + } { + sess := &Session{Conn: remoteAddrConn{addr: tc.in}} + if got := sess.RemoteHost(); got != tc.want { + t.Fatalf("RemoteHost(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/internal/net/server.go b/internal/net/server.go index c818eef..daf3e47 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -151,6 +151,14 @@ func (sess *Session) RemoteAddr() string { return "" } +// RemoteHost returns the client's IP/host without the ephemeral source port. +// Rate-limit keys must use this: RemoteAddr includes a fresh port on every +// reconnect, so keying on it lets a reconnecting attacker reset the limiter. +// Synthetic/test connections without a transport address return "". +func (sess *Session) RemoteHost() string { + return remoteHost(sess.RemoteAddr()) +} + // 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 diff --git a/internal/validate/grid_test.go b/internal/validate/grid_test.go index 0e1d2dd..8bb8950 100644 --- a/internal/validate/grid_test.go +++ b/internal/validate/grid_test.go @@ -159,7 +159,7 @@ func TestGridDiagonalClean(t *testing.T) { // Room 1 -> NE -> Room 2 (at 1,-1). Clean diagonal placement. rooms := map[int]string{ 1: "exits:\n northeast: {room: 2}\n", - 2: "name: two\n exits:\n southwest: {room: 1}\n", + 2: "name: two\nexits:\n southwest: {room: 1}\n", } if issues := runGridCheck(t, rooms, 1); len(issues) != 0 { t.Errorf("expected no grid issues, got: %+v", issues) diff --git a/internal/world/grid_test.go b/internal/world/grid_test.go index 005f60f..6cf53d0 100644 --- a/internal/world/grid_test.go +++ b/internal/world/grid_test.go @@ -82,7 +82,7 @@ func TestBuildGridConflictsTwist(t *testing.T) { func TestBuildGridConflictsDiagonalClean(t *testing.T) { dir := t.TempDir() writeGridTestRoom(t, dir, 1, "exits:\n northeast: {room: 2}\n") - writeGridTestRoom(t, dir, 2, "name: two\n exits:\n southwest: {room: 1}\n") + writeGridTestRoom(t, dir, 2, "name: two\nexits:\n southwest: {room: 1}\n") w := New(dir) if conflicts := BuildGridConflicts(1, loadGridRoom(w)); len(conflicts) != 0 { diff --git a/internal/world/mob.go b/internal/world/mob.go index afd9024..4441108 100644 --- a/internal/world/mob.go +++ b/internal/world/mob.go @@ -747,7 +747,14 @@ func (s *MobStore) RekeyRooms(idMap map[int]int) { } s.mu.Lock() defer s.mu.Unlock() + // A seeded instance's key embeds its home room (SeedMobs builds + // "<def>_<room>_<i>"). Leaving the old key in place would make SeedMobs + // create a duplicate instance for the renamed room and would let a + // recycled ID skip seeding entirely, so the map is rebuilt with the + // rewritten keys. + rekeyed := make(map[string]*MobInstance, len(s.instances)) for _, inst := range s.instances { + oldHome := inst.HomeRoomID if newID, ok := idMap[inst.RoomID]; ok && newID != inst.RoomID { inst.RoomID = newID } @@ -764,5 +771,23 @@ func (s *MobStore) RekeyRooms(idMap map[int]int) { inst.DespawnRooms[i] = newID } } + if inst.HomeRoomID != oldHome { + inst.InstanceID = rekeySeededInstanceID(inst.InstanceID, inst.DefID, oldHome, inst.HomeRoomID) + } + rekeyed[inst.InstanceID] = inst + } + s.instances = rekeyed +} + +// rekeySeededInstanceID rewrites the room component of a seeded instance ID +// ("<def>_<oldRoom>_<suffix>"). IDs that do not match the seeded shape (a +// transient or test instance) are returned unchanged. The DefID prefix is +// stripped by string match rather than parsed because a mob def ID may itself +// contain underscores. +func rekeySeededInstanceID(instanceID, defID string, oldRoom, newRoom int) string { + prefix := fmt.Sprintf("%s_%d_", defID, oldRoom) + if !strings.HasPrefix(instanceID, prefix) { + return instanceID } + return fmt.Sprintf("%s_%d_%s", defID, newRoom, strings.TrimPrefix(instanceID, prefix)) } diff --git a/internal/world/mob_rekey_test.go b/internal/world/mob_rekey_test.go index 3a0eb85..8118059 100644 --- a/internal/world/mob_rekey_test.go +++ b/internal/world/mob_rekey_test.go @@ -2,6 +2,29 @@ package world import "testing" +// TestRekeyRoomsRewritesSeededInstanceID: a seeded instance's map key embeds +// its home room ("<def>_<room>_<i>"). Renaming the room must rewrite the key, +// or SeedMobs creates a duplicate instance for the renamed room and a later +// recycled ID skips seeding entirely. +func TestRekeyRoomsRewritesSeededInstanceID(t *testing.T) { + s := &MobStore{instances: map[string]*MobInstance{ + "rat_2_0": {InstanceID: "rat_2_0", DefID: "rat", RoomID: 2, HomeRoomID: 2}, + "bat_5_1": {InstanceID: "bat_5_1", DefID: "bat", RoomID: 5, HomeRoomID: 5}, + }} + s.RekeyRooms(map[int]int{2: 4}) + + if _, stale := s.instances["rat_2_0"]; stale { + t.Fatal("stale seeded key survived the rekey") + } + inst, ok := s.instances["rat_4_0"] + if !ok || inst.RoomID != 4 || inst.HomeRoomID != 4 { + t.Fatalf("rekeyed instance = %+v (present=%v), want rat_4_0 in room 4", inst, ok) + } + if _, ok := s.instances["bat_5_1"]; !ok { + t.Fatal("an unrelated instance was dropped") + } +} + // TestRekeyRoomsUpdatesMobDespawnRooms: DespawnRooms is a live room reference // (sys_triggers compares it against the owner's current room). A room rename // must remap it alongside RoomID/HomeRoomID/WanderRooms, or a transient mob diff --git a/internal/world/objects.go b/internal/world/objects.go index 849c47a..fa17f08 100644 --- a/internal/world/objects.go +++ b/internal/world/objects.go @@ -308,6 +308,13 @@ func (w *World) TickObjWander() { if toRoom == st.RoomID { continue } + // A wander_rooms entry may point at a room that was since deleted. + // Moving there would strand the object in a nonexistent room, so + // leave it where it is. (roomPathIndex is read under w.mu, which + // this method already holds; LoadRoom would deadlock here.) + if _, ok := w.roomPathIndex[toRoom]; !ok { + continue + } moves = append(moves, struct { st *ObjState toRoom int diff --git a/internal/world/room_migrate.go b/internal/world/room_migrate.go index b7840bc..42cd1ae 100644 --- a/internal/world/room_migrate.go +++ b/internal/world/room_migrate.go @@ -101,6 +101,48 @@ func (r *Room) RewriteRoomIDs(idMap map[int]int) bool { return changed } +// RemoveRoomRefs drops references to a deleted room from this room's +// per-instance mob and object wander lists. Exit targets are removed by the +// deletion caller. Teleport steps and conditions that name the deleted room +// are left in place and neutralized at runtime (teleportPlayer refuses a +// missing destination, and a Condition.Room that no longer exists simply +// never matches), so this only removes the references the validator would +// otherwise report as dangling. Returns whether anything changed. +func (r *Room) RemoveRoomRefs(removedID int) bool { + if r == nil || removedID <= 0 { + return false + } + changed := false + for i := range r.Mobs { + if removeRoomRef(&r.Mobs[i].WanderRooms, removedID) { + changed = true + } + } + for i := range r.Objects { + if removeRoomRef(&r.Objects[i].WanderRooms, removedID) { + changed = true + } + } + return changed +} + +// removeRoomRef removes target from list in place and reports whether it was +// present. +func removeRoomRef(list *[]int, target int) bool { + src := *list + out := src[:0] + removed := false + for _, v := range src { + if v == target { + removed = true + continue + } + out = append(out, v) + } + *list = out + return removed +} + // remapTriggers remaps step fields across a []behavior.Trigger (each trigger's // Steps + per-step Condition + trigger-level Condition). func remapTriggers(idMap map[int]int, list []behavior.Trigger) bool { |
