From 0e60567a7a0addb9421769ee7be7853bfa8788cb Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sat, 19 Sep 2026 03:15:59 -0400 Subject: refactor: simplifcation, change how rooms are stored in player yaml, remove legacy code --- internal/game/act.go | 17 +++----- internal/game/casino.go | 39 +++++++++++++----- internal/game/casino_machine_address_test.go | 31 ++++++++++++++ internal/game/cmd_quit.go | 19 ++++----- internal/game/cmd_registry.go | 4 ++ internal/game/cmd_room.go | 48 +++++++++++++++++----- internal/game/cmd_room_insert.go | 18 +------- internal/game/cmd_room_mob.go | 11 +---- internal/game/cmd_room_object.go | 24 ++++------- .../game/cmd_room_reconcile_regression_test.go | 47 +++++++++++++++++++++ internal/game/cmd_room_remove.go | 19 ++------- internal/game/cmd_room_spawn.go | 11 +---- internal/game/cmd_sneak.go | 15 +++++++ internal/game/cmd_undig.go | 1 + internal/game/core_login_account.go | 33 ++------------- internal/game/exit_migrate.go | 2 +- internal/game/game.go | 20 ++++----- internal/game/lifecycle.go | 31 ++++++++++++++ internal/game/login_offowner_regression_test.go | 43 ++++--------------- internal/game/look_entities.go | 17 ++++---- internal/game/quit_safespot_regression_test.go | 47 +++++++++++++++++++++ internal/game/render_help.go | 44 +++++++++++--------- internal/game/render_help_test.go | 31 ++++++++++++++ internal/game/room_reconcile.go | 3 ++ 24 files changed, 361 insertions(+), 214 deletions(-) create mode 100644 internal/game/casino_machine_address_test.go create mode 100644 internal/game/cmd_room_reconcile_regression_test.go create mode 100644 internal/game/lifecycle.go create mode 100644 internal/game/quit_safespot_regression_test.go (limited to 'internal/game') diff --git a/internal/game/act.go b/internal/game/act.go index 1317df4..449b2da 100644 --- a/internal/game/act.go +++ b/internal/game/act.go @@ -14,18 +14,13 @@ import ( "thehouseoficarus/internal/world" ) +// verbAliases maps a gather-style verb that has no commandRegistry entry to +// the action it starts. Verbs that are registered directly (mine, chop, fish, +// cut, talk/speak/ask, steal/thieve, hide) must not be duplicated here: a +// registered command is looked up first, so an entry would be dead and its +// removal untested. Only shear remains as a pure action alias. var verbAliases = map[string]string{ - "mine": "gather", - "chop": "gather", - "fish": "gather", - "cut": "gather", - "shear": "gather", - "talk": "talk", - "speak": "talk", - "ask": "talk", - "steal": "steal", - "thieve": "steal", - "hide": "safespot", + "shear": "gather", } var verbSkill = map[string]string{ diff --git a/internal/game/casino.go b/internal/game/casino.go index 58e1e69..23b6c51 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -147,6 +147,26 @@ func (w casinoWallet) ResolveStake(stakeID string) { } } +// ResolveStakesByIDs consumes the listed pending stakes in one durable save. +// A losing split/doubled hand used to call ResolveStake once per handle, each +// with its own full character save; batching removes that repeated fsync and +// the crash window between saves. +func (w casinoWallet) ResolveStakesByIDs(stakeIDs ...string) { + if len(stakeIDs) == 0 { + return + } + removed := takePendingWagers(w.p, stakeIDs) + if len(removed) == 0 { + return + } + if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { + // The durable file still lists the stakes, so restore them in memory to + // keep the two records consistent for a later retry/recovery. + w.p.PendingWagers = append(w.p.PendingWagers, removed...) + fmt.Fprintf(os.Stderr, "casino resolve stakes: save for %s: %v (ledger restored in memory)\n", w.p.Name, err) + } +} + // RefundOutstanding returns every pending stake to the player — chips in // kind, credits as credits — and clears the ledger in one save. It is used // by graceful shutdown/eviction and by a pre-deal disconnect. On a failed @@ -272,6 +292,15 @@ func (g *Game) casinoMachine(roomID int, gameName string) (*casino.Machine, bool if casino.ValidateRoomConfigs(room.CasinoTables, room.CasinoMachines) != nil { return nil, false } + // An explicit machine id is the most specific address, so match it first: + // the shipped room's machine `huff_puff_01` must be reachable by name even + // though it also declares a variant. + for _, cfg := range room.CasinoMachines { + if strings.EqualFold(cfg.ID, gameName) { + machine := g.Casino.EnsureMachine(roomID, cfg) + return machine, machine != nil + } + } // Prefer an exact variant match first: a room may host several variants of // one game, where a bare game name is ambiguous. for _, cfg := range room.CasinoMachines { @@ -295,16 +324,6 @@ func (g *Game) casinoMachine(roomID int, gameName string) (*casino.Machine, bool return machine, machine != nil } } - // Migrate older room definitions that declared slots under casino_tables; - // slots are still always instantiated as one-player machines. - if strings.EqualFold(gameName, "slots") { - for _, cfg := range room.CasinoTables { - if strings.EqualFold(string(cfg.Game), "slots") { - machine := g.Casino.EnsureMachine(roomID, casino.LegacySlotMachine(cfg)) - return machine, machine != nil - } - } - } return nil, false } diff --git a/internal/game/casino_machine_address_test.go b/internal/game/casino_machine_address_test.go new file mode 100644 index 0000000..3a0280c --- /dev/null +++ b/internal/game/casino_machine_address_test.go @@ -0,0 +1,31 @@ +package game + +import ( + "os" + "path/filepath" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/world" +) + +// TestCasinoMachineAddressableByID: a slot cabinet's authored id must be a +// usable address. The shipped room declares `id: huff_puff_01` with a variant, +// and matching only the variant/game left the cabinet unreachable as +// `play huff_puff_01`. +func TestCasinoMachineAddressableByID(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "rooms"), 0755); err != nil { + t.Fatal(err) + } + room := "name: Casino\ncasino_machines:\n - id: huff_puff_01\n game: slots\n variant: huff_and_even_more_puff\n" + if err := os.WriteFile(filepath.Join(dir, "rooms", "1.yaml"), []byte(room), 0644); err != nil { + t.Fatal(err) + } + g := &Game{Deps: Deps{World: world.New(dir)}, Casino: casino.NewManager(1)} + + machine, ok := g.casinoMachine(1, "huff_puff_01") + if !ok || machine == nil { + t.Fatal("machine id huff_puff_01 is not addressable") + } +} diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go index 0a8b8ca..50f1ec5 100644 --- a/internal/game/cmd_quit.go +++ b/internal/game/cmd_quit.go @@ -92,7 +92,10 @@ func (g *Game) beginQuitCountdown(sess *net.Session) { case 3: sess.WriteLine("You close your eyes...") case 0: - g.cancelRest(p.Name) + // Release combat, cover, actions and hacking state before the + // disconnect. This path used to leave the character occupying a + // safespot cover after logging out. + g.releaseCharacterRuntime(sess, p) g.restoreGodPlayer(p) // Forfeit any committed casino wager and free the seat only // now — immediately before the disconnect. Settling games only @@ -101,16 +104,10 @@ func (g *Game) beginQuitCountdown(sess *net.Session) { g.emitCasinoEvents(g.Casino.MarkDisconnected(p.Name)) } g.saveCharacter(p) - g.charsMu.Lock() - delete(g.loggedInChars, p.Name) - g.charsMu.Unlock() - // The character is leaving the world: drop its queued free/ - // active/consume commands with the registry entry, or they - // retain this session and replay on a later login as the same - // character. - if g.queue != nil { - g.queue.ClearPlayer(p.Name) - } + // Drop the registry entry and any queued commands: leftover queue + // entries retain this session and would replay on a later login as + // the same character. + g.detachSession(p.Name) if g.Hub != nil { g.Hub.LeaveRoom(sess) } diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index 9a42d1e..fb72c99 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -242,6 +242,10 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI } else { g.startAction(sess, "talk", strings.Join(args, " ")) } + default: + // No handler for this alias: never return silently, or a typo'd + // alias becomes an invisible no-op. + sess.WriteLine("Unknown command.") } return } diff --git a/internal/game/cmd_room.go b/internal/game/cmd_room.go index b381cf3..33627eb 100644 --- a/internal/game/cmd_room.go +++ b/internal/game/cmd_room.go @@ -83,7 +83,7 @@ func (g *Game) roomSetName(sess *net.Session, args []string) { return } text := strings.Join(args, " ") - g.writeRoom(sess, func(room *world.Room) { + g.mutateRoom(sess, func(room *world.Room) { room.Name = text }) sess.WriteLine(fmt.Sprintf("Room name set to: %s", text)) @@ -94,10 +94,29 @@ func (g *Game) roomSetDesc(sess *net.Session, args []string) { sess.WriteLine("Usage: room desc ") return } + room, _, err := g.loadRoomWrite(sess.Player) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error loading room: %v", err)) + return + } + // Replacing the list wholesale would destroy alternates; require a direct + // YAML edit when the room already has conditional or multiple variants. + if len(room.Description) > 1 { + sess.WriteLine("This room has multiple or conditional descriptions; edit the room YAML directly to keep them.") + return + } + for _, v := range room.Description { + if v.Condition != nil { + sess.WriteLine("This room has conditional descriptions; edit the room YAML directly to keep them.") + return + } + } text := strings.Join(args, " ") - g.writeRoom(sess, func(room *world.Room) { + if !g.mutateRoom(sess, func(room *world.Room) { room.Description = behavior.DescList{{Text: text}} - }) + }) { + return + } sess.WriteLine("Room description updated.") } @@ -121,7 +140,7 @@ func (g *Game) roomSetColor(sess *net.Session, args []string) { return } } - g.writeRoom(sess, func(room *world.Room) { + g.mutateRoom(sess, func(room *world.Room) { room.Color = value }) if value == "" { @@ -176,21 +195,30 @@ func parseLocalObjArgs(rawInput, action string) (name string, aliases []string, return name, nil, desc } -func (g *Game) writeRoom(sess *net.Session, fn func(*world.Room)) { +// mutateRoom loads the player's current room, applies fn, writes it back, and +// reconciles the live runtime state from the new file. Every successful +// in-game room edit must go through it: a plain file write left the live room +// unchanged (a removed mob kept standing, an added spawn never appeared) until +// the next room entry or restart. +// +// It runs on the owner goroutine and returns false after reporting an error. +func (g *Game) mutateRoom(sess *net.Session, fn func(*world.Room)) bool { p := sess.Player room, path, err := g.loadRoomWrite(p) if err != nil { sess.WriteLine(fmt.Sprintf("Error loading room: %v", err)) - return + return false } fn(room) data, err := yaml.Marshal(room) if err != nil { - sess.WriteLine("Error marshaling room YAML.") - return + sess.WriteLine(fmt.Sprintf("Error marshaling room YAML: %v", err)) + return false } if err := writeRoomFile(path, data); err != nil { - sess.WriteLine("Error writing room file.") - return + sess.WriteLine(fmt.Sprintf("Error writing room file: %v", err)) + return false } + g.ReconcileRoom(p.RoomID) + return true } diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go index bf95282..2d7ff58 100644 --- a/internal/game/cmd_room_insert.go +++ b/internal/game/cmd_room_insert.go @@ -134,14 +134,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { g.World.AddRoomPath(newID, newPath) - curRoom.Exits[dir] = world.ExitDef{ - Room: newID, - Condition: exitDef.Condition, - BlockedMessage: exitDef.BlockedMessage, - OnTraverse: exitDef.OnTraverse, - Hidden: exitDef.Hidden, - AlwaysBlocked: exitDef.AlwaysBlocked, - } + curRoom.Exits[dir] = world.RewirePreserving(exitDef, newID) curData, err := yaml.Marshal(curRoom) if err != nil { sess.WriteLine("Error updating current room YAML.") @@ -153,14 +146,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { } if targetExit, tok := targetRoom.Exits[oppositeDir]; tok && targetExit.Room == p.RoomID { - targetRoom.Exits[oppositeDir] = world.ExitDef{ - Room: newID, - Condition: targetExit.Condition, - BlockedMessage: targetExit.BlockedMessage, - OnTraverse: targetExit.OnTraverse, - Hidden: targetExit.Hidden, - AlwaysBlocked: targetExit.AlwaysBlocked, - } + targetRoom.Exits[oppositeDir] = world.RewirePreserving(targetExit, newID) } targetPath, tok2 := g.World.GetRoomPath(targetID) if tok2 { diff --git a/internal/game/cmd_room_mob.go b/internal/game/cmd_room_mob.go index ce729d4..5adc4b2 100644 --- a/internal/game/cmd_room_mob.go +++ b/internal/game/cmd_room_mob.go @@ -3,7 +3,6 @@ package game import ( "fmt" - "gopkg.in/yaml.v3" "thehouseoficarus/internal/net" "thehouseoficarus/internal/world" ) @@ -41,10 +40,7 @@ func (g *Game) roomAddMob(sess *net.Session, args []string) { } room.Mobs = append(room.Mobs, world.RoomMob{ID: mobID}) - path, _ := g.World.GetRoomPath(p.RoomID) - data, _ := yaml.Marshal(room) - if err := writeRoomFile(path, data); err != nil { - sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + if !g.mutateRoom(sess, func(r *world.Room) { *r = *room }) { return } sess.WriteLine(fmt.Sprintf("Added mob '%s' to room.", mobID)) @@ -77,10 +73,7 @@ func (g *Game) roomRemMob(sess *net.Session, args []string) { } room.Mobs = filtered - path, _ := g.World.GetRoomPath(p.RoomID) - data, _ := yaml.Marshal(room) - if err := writeRoomFile(path, data); err != nil { - sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + if !g.mutateRoom(sess, func(r *world.Room) { *r = *room }) { return } sess.WriteLine(fmt.Sprintf("Removed mob '%s' from room.", mobID)) diff --git a/internal/game/cmd_room_object.go b/internal/game/cmd_room_object.go index 38b828a..e11d178 100644 --- a/internal/game/cmd_room_object.go +++ b/internal/game/cmd_room_object.go @@ -4,7 +4,6 @@ import ( "fmt" "strings" - "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" @@ -47,7 +46,9 @@ func (g *Game) roomAddObj(sess *net.Session, args []string) { } } room.Objects = append(room.Objects, world.RoomObject{ID: objID}) - g.writeRoom(sess, func(r *world.Room) { *r = *room }) + if !g.mutateRoom(sess, func(r *world.Room) { *r = *room }) { + return + } sess.WriteLine(fmt.Sprintf("Added object '%s' to room.", objID)) } @@ -77,7 +78,9 @@ func (g *Game) roomRemObj(sess *net.Session, args []string) { return } room.Objects = filtered - g.writeRoom(sess, func(r *world.Room) { *r = *room }) + if !g.mutateRoom(sess, func(r *world.Room) { *r = *room }) { + return + } sess.WriteLine(fmt.Sprintf("Removed object '%s' from room.", objID)) } @@ -115,10 +118,7 @@ func (g *Game) roomAddLocalObj(sess *net.Session, name string, aliases []string, }, }) - path, _ := g.World.GetRoomPath(p.RoomID) - data, _ := yaml.Marshal(room) - if err := writeRoomFile(path, data); err != nil { - sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + if !g.mutateRoom(sess, func(r *world.Room) { *r = *room }) { return } sess.WriteLine(fmt.Sprintf("Added local object '%s' to room.", name)) @@ -153,10 +153,7 @@ func (g *Game) roomRemLocalObj(sess *net.Session, name string) { } room.Objects = filtered - path, _ := g.World.GetRoomPath(p.RoomID) - data, _ := yaml.Marshal(room) - if err := writeRoomFile(path, data); err != nil { - sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + if !g.mutateRoom(sess, func(r *world.Room) { *r = *room }) { return } sess.WriteLine(fmt.Sprintf("Removed local object '%s' from room.", name)) @@ -220,10 +217,7 @@ func (g *Game) roomToggleHidden(sess *net.Session, input string, hidden bool) { return } - path, _ := g.World.GetRoomPath(p.RoomID) - data, _ := yaml.Marshal(room) - if err := writeRoomFile(path, data); err != nil { - sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + if !g.mutateRoom(sess, func(r *world.Room) { *r = *room }) { return } diff --git a/internal/game/cmd_room_reconcile_regression_test.go b/internal/game/cmd_room_reconcile_regression_test.go new file mode 100644 index 0000000..790cb00 --- /dev/null +++ b/internal/game/cmd_room_reconcile_regression_test.go @@ -0,0 +1,47 @@ +package game + +import ( + "os" + "path/filepath" + "testing" + + "thehouseoficarus/internal/player" +) + +// TestRoomAddMobReconcilesLiveState: the in-game room editor used to write the +// YAML but leave the live room untouched, so an added mob did not appear until +// the room was re-entered or the server restarted. A successful room edit must +// reconcile runtime state like the admin editor does. +func TestRoomAddMobReconcilesLiveState(t *testing.T) { + dir := t.TempDir() + for _, sub := range []string{"mobs", "rooms"} { + if err := os.MkdirAll(filepath.Join(dir, sub), 0755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(dir, "mobs", "rat.yaml"), + []byte("id: rat\nname: giant rat\ndescription: A scruffy rat.\ncombat:\n stats:\n hp: 5\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "rooms", "1.yaml"), + []byte("name: Central\n"), 0644); err != nil { + t.Fatal(err) + } + + g := newRemoveGame(t, dir) + sess := newRemoveSession(player.New("alice")) + sess.Player.RoomID = 1 + + if got := len(g.MobStore.MobsInRoom(1)); got != 0 { + t.Fatalf("room should start with no mobs, got %d", got) + } + g.roomAddMob(sess, []string{"rat"}) + if got := len(g.MobStore.MobsInRoom(1)); got != 1 { + t.Fatalf("room addmob did not reconcile live state: %d mobs in room, want 1", got) + } + for _, m := range g.MobStore.MobsInRoom(1) { + if m.DefID != "rat" { + t.Fatalf("seeded mob DefID = %q, want rat", m.DefID) + } + } +} diff --git a/internal/game/cmd_room_remove.go b/internal/game/cmd_room_remove.go index 77a09b3..361cc74 100644 --- a/internal/game/cmd_room_remove.go +++ b/internal/game/cmd_room_remove.go @@ -167,14 +167,7 @@ func (g *Game) roomRemove(sess *net.Session, args []string) { } // Apply: rewire A's dir exit to C, preserving A's current dir-exit props. - aRoom.Exits[dir] = world.ExitDef{ - Room: cID, - Condition: aExit.Condition, - BlockedMessage: aExit.BlockedMessage, - OnTraverse: aExit.OnTraverse, - Hidden: aExit.Hidden, - AlwaysBlocked: aExit.AlwaysBlocked, - } + aRoom.Exits[dir] = world.RewirePreserving(aExit, cID) aData, err := yaml.Marshal(aRoom) if err != nil { sess.WriteLine("Error updating current room YAML.") @@ -186,14 +179,7 @@ func (g *Game) roomRemove(sess *net.Session, args []string) { } // Rewire C's opposite exit to A, preserving C's current opposite-exit props. - cRoom.Exits[oppositeDir] = world.ExitDef{ - Room: aID, - Condition: cOppExit.Condition, - BlockedMessage: cOppExit.BlockedMessage, - OnTraverse: cOppExit.OnTraverse, - Hidden: cOppExit.Hidden, - AlwaysBlocked: cOppExit.AlwaysBlocked, - } + cRoom.Exits[oppositeDir] = world.RewirePreserving(cOppExit, aID) cPath, cPathOK := g.World.GetRoomPath(cID) if cPathOK { cData, cerr := yaml.Marshal(cRoom) @@ -218,6 +204,7 @@ func (g *Game) roomRemove(sess *net.Session, args []string) { g.World.RebuildRoomIndex(g.DataDir) g.MobStore.RemoveMobsInRoom(bID) g.World.ClearRoomState(bID) + g.clearGuardWatchRoom(bID) // Drop any cached in-room games and refund their outstanding wagers // before the room stops existing (same eviction semantics as the // admin editor's room deletion). diff --git a/internal/game/cmd_room_spawn.go b/internal/game/cmd_room_spawn.go index c626acb..d48664a 100644 --- a/internal/game/cmd_room_spawn.go +++ b/internal/game/cmd_room_spawn.go @@ -4,7 +4,6 @@ import ( "fmt" "strconv" - "gopkg.in/yaml.v3" "thehouseoficarus/internal/net" "thehouseoficarus/internal/world" ) @@ -60,10 +59,7 @@ func (g *Game) roomAddSpawn(sess *net.Session, args []string) { RespawnTicks: respawnTicks, }) - path, _ := g.World.GetRoomPath(p.RoomID) - data, _ := yaml.Marshal(room) - if err := writeRoomFile(path, data); err != nil { - sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + if !g.mutateRoom(sess, func(r *world.Room) { *r = *room }) { return } sess.WriteLine(fmt.Sprintf("Added item spawn '%s' x%d (respawn: %.0f ticks) to room.", itemID, quantity, respawnTicks)) @@ -96,10 +92,7 @@ func (g *Game) roomRemSpawn(sess *net.Session, args []string) { } room.ItemSpawns = filtered - path, _ := g.World.GetRoomPath(p.RoomID) - data, _ := yaml.Marshal(room) - if err := writeRoomFile(path, data); err != nil { - sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + if !g.mutateRoom(sess, func(r *world.Room) { *r = *room }) { return } sess.WriteLine(fmt.Sprintf("Removed item spawn '%s' from room.", itemID)) diff --git a/internal/game/cmd_sneak.go b/internal/game/cmd_sneak.go index 5ae60e7..ce063ce 100644 --- a/internal/game/cmd_sneak.go +++ b/internal/game/cmd_sneak.go @@ -2,10 +2,25 @@ package game import ( "fmt" + "strconv" + "strings" "thehouseoficarus/internal/net" ) +// clearGuardWatchRoom drops the sneak/guard-watch cycle timers for one room. +// Called when the room's content is reconciled or the room is deleted, so the +// map does not retain entries for rooms/objects that no longer exist for the +// life of the process. +func (g *Game) clearGuardWatchRoom(roomID int) { + prefix := strconv.Itoa(roomID) + ":" + for key := range g.guardWatchTimers { + if strings.HasPrefix(key, prefix) { + delete(g.guardWatchTimers, key) + } + } +} + func (g *Game) executeSneak(sess *net.Session, args []string, rawInput string) { g.doSneak(sess) } diff --git a/internal/game/cmd_undig.go b/internal/game/cmd_undig.go index 88dd661..0b42f5c 100644 --- a/internal/game/cmd_undig.go +++ b/internal/game/cmd_undig.go @@ -246,6 +246,7 @@ func (g *Game) performUndig(sess *net.Session, p *player.Player, targetID int) { g.MobStore.RemoveMobsInRoom(targetID) g.World.ClearRoomState(targetID) + g.clearGuardWatchRoom(targetID) // Drop any cached in-room games and refund their outstanding wagers // (same eviction semantics as the other room-removal paths). g.EvictCasinoRoom(targetID) diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go index 3b35b15..0224a06 100644 --- a/internal/game/core_login_account.go +++ b/internal/game/core_login_account.go @@ -2,7 +2,6 @@ package game import ( "fmt" - "log" "strings" "time" @@ -86,23 +85,13 @@ func (g *Game) handlePassword(sess *net.Session, input string) { return } auth := passwordAuth{ok: true, stored: acc.PasswordHash} - // Transparent upgrade: legacy single-pass SHA-256 hashes rehash to - // Argon2id after a successful verification. A failed save keeps the - // legacy hash and retries on the next login. - if player.NeedsRehash(acc.PasswordHash) { - if hash, hashErr := player.HashPassword(input); hashErr == nil { - auth.rehash = hash - } else { - log.Printf("login: password rehash for %s: %v", acc.Name, hashErr) - } - } g.applyPasswordAuth(sess, auth) } // passwordAuth carries the result of the EXPENSIVE password work (Argon2id -// verification and any rehash), which runs on the caller's connection -// goroutine — never on the owner, where a burst of logins would stall -// every command and tick behind password hashing. +// verification), which runs on the caller's connection goroutine — never on +// the owner, where a burst of logins would stall every command and tick +// behind password hashing. type passwordAuth struct { ok bool // loadErr marks an account read failure off the owner: the owner task @@ -112,8 +101,6 @@ type passwordAuth struct { // file against it so a password change, rename, or delete made during // verification cannot authorize a stale login. stored string - // rehash is an upgraded hash to persist after a successful check. - rehash string } // handlePasswordOffOwner verifies the password on the caller's connection @@ -170,16 +157,6 @@ func (g *Game) handlePasswordOffOwner(sess *net.Session, input string) { 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. - if player.NeedsRehash(acc.PasswordHash) { - if hash, hashErr := player.HashPassword(input); hashErr == nil { - auth.rehash = hash - } else { - 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 @@ -229,10 +206,6 @@ func (g *Game) applyPasswordAuth(sess *net.Session, auth passwordAuth) { reject("Account changed; sign in again.") return } - if auth.rehash != "" { - acc.PasswordHash = auth.rehash - g.saveAccount(acc) - } sess.Account = &player.Account{ Name: acc.Name, PasswordHash: acc.PasswordHash, diff --git a/internal/game/exit_migrate.go b/internal/game/exit_migrate.go index 55289f2..66fccfe 100644 --- a/internal/game/exit_migrate.go +++ b/internal/game/exit_migrate.go @@ -10,7 +10,7 @@ import ( // rewritePlayerRoomIDs remaps room-ID-embedded state in a single player per // idMap (oldID -> newID): discovered-exit player flag keys // (hidden_exit__), current RoomID, PendingSequence.RoomID, MapSymbols -// keys, and Stats.RoomsVisited bitset. Returns whether any field was changed. +// keys, and Stats.RoomsVisited. Returns whether any field was changed. // // idMap is assumed to be a bijection (see Room.RewriteRoomIDs); the flag-key // and int-map rekeys use delete-all-then-set-all so a swap (A<->B) cannot diff --git a/internal/game/game.go b/internal/game/game.go index 5aad784..6066179 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -197,22 +197,18 @@ 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) + // The session is leaving the world: end any live fight, cancel + // a running logout countdown, and step out of any safespot + // cover first, so nothing keeps acting on a detached session + // (ghost combat that saves over a reconnected character, a + // duplicate logout that drops the reconnected session's + // registry entry, or a phantom cover occupant). + g.releaseCharacterRuntime(sess, p) g.restoreGodPlayer(p) g.persistLockedOnDisconnect(p) g.saveCharacter(p) p.DeactivateAllTechs() g.detachSession(p.Name) - delete(g.hackingStates, p.Name) - if ss, ok := g.safespot.Get(p.Name); ok { - g.forceLeaveSafespot(sess, p, &ss, "") - } } }) }) @@ -440,7 +436,7 @@ func (g *Game) HandleSession(sess *net.Session, input string) { g.handleRecipeChoice(sess, input) case net.StateHowMany: g.handleHowMany(sess, input) - case net.StateSmithProduct, net.StateFletchProduct, net.StateCraftProduct, net.StateProductChoice: + case net.StateProductChoice: g.handleProductChoice(sess, input) case net.StateColorChoice: g.handleColorChoice(sess, input) diff --git a/internal/game/lifecycle.go b/internal/game/lifecycle.go new file mode 100644 index 0000000..b39c805 --- /dev/null +++ b/internal/game/lifecycle.go @@ -0,0 +1,31 @@ +package game + +import ( + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +// releaseCharacterRuntime tears down a character's live world presence when it +// leaves the world, whether through the quit countdown final tick or a hard +// disconnect. Keeping it in one place is what stops a per-player system from +// being cleaned up on one exit path and forgotten on another: the quit path +// used to leave a player occupying a safespot cover and holding a stale action. +// +// It deliberately does NOT detach the session registry, touch trigger +// sequences, persist the character, or resolve casino wagers. Callers own +// those because their ordering differs: disconnect must snapshot locked +// sequences (persistLockedOnDisconnect) before sequences are dropped, and +// resolves casino wagers through its deferral/refund path, while quit forfeits +// a mid-round stake immediately before saving. +func (g *Game) releaseCharacterRuntime(sess *net.Session, p *player.Player) { + if p == nil { + return + } + g.stopCombat(p.Name) + g.cancelRest(p.Name) + g.cancelAction(p) + if ss, ok := g.safespot.Get(p.Name); ok { + g.forceLeaveSafespot(sess, p, &ss, "") + } + delete(g.hackingStates, p.Name) +} diff --git a/internal/game/login_offowner_regression_test.go b/internal/game/login_offowner_regression_test.go index fa8324c..4826c4c 100644 --- a/internal/game/login_offowner_regression_test.go +++ b/internal/game/login_offowner_regression_test.go @@ -6,8 +6,6 @@ package game // transition on the owner. The observable login behavior is unchanged. import ( - "crypto/sha256" - "encoding/base64" "strings" "testing" "time" @@ -29,26 +27,13 @@ func waitForOutput(t *testing.T, conn *chanConn, substr string) { } } -// TestLoginThroughServerEntryUsesOffOwnerPathAndUpgradesLegacyHash drives -// the real server entry point (HandleSessionOnOwner) through: legacy-hash -// account creation, a WRONG password attempt, and a correct login that -// rehashes the legacy SHA-256 hash to Argon2id durably. -func TestLoginThroughServerEntryUsesOffOwnerPathAndUpgradesLegacyHash(t *testing.T) { +// TestLoginThroughServerEntryUsesOffOwnerPath drives the real server entry +// point (HandleSessionOnOwner) through a WRONG password attempt and a correct +// login, exercising the off-owner verification path. +func TestLoginThroughServerEntryUsesOffOwnerPath(t *testing.T) { h := newHarness(t) h.seedAccount("alice") h.seedCharacter("alice", "alice") - acc, err := h.g.AccountStore.LoadAccount("alice") - if err != nil { - t.Fatal(err) - } - // Downgrade to a legacy salted SHA-256 hash so the login exercises the - // transparent rehash. - salt := []byte("0123456789abcdef") - sum := sha256.Sum256(append(append([]byte{}, salt...), []byte("pw")...)) - acc.PasswordHash = "sha256:" + base64.RawStdEncoding.EncodeToString(salt) + ":" + strings.ToLower(sha256HashHex(sum)) - if err := h.g.AccountStore.SaveAccount(acc); err != nil { - t.Fatal(err) - } conn := newChanConn() sess := &net.Session{Conn: conn} @@ -67,15 +52,12 @@ func TestLoginThroughServerEntryUsesOffOwnerPathAndUpgradesLegacyHash(t *testing if sess.Account == nil || sess.Account.Name != "alice" || len(sess.Account.Characters) != 1 { t.Fatalf("login did not complete: %+v", sess.Account) } - upgraded, err := h.g.AccountStore.LoadAccount("alice") + loaded, err := h.g.AccountStore.LoadAccount("alice") if err != nil { t.Fatal(err) } - if !strings.HasPrefix(upgraded.PasswordHash, "argon2id:") { - t.Errorf("legacy hash not upgraded on login: %s", upgraded.PasswordHash[:12]) - } - if !player.CheckPassword("pw", upgraded.PasswordHash) { - t.Error("upgraded hash no longer verifies the password") + if !player.CheckPassword("pw", loaded.PasswordHash) { + t.Error("stored hash no longer verifies the password") } } @@ -111,14 +93,3 @@ func TestNewAccountThroughServerEntryHashesOffOwner(t *testing.T) { t.Error("created hash does not verify the password") } } - -// sha256HashHex formats like hex.EncodeToString for the legacy hash check. -func sha256HashHex(sum [32]byte) string { - const hexdigits = "0123456789abcdef" - out := make([]byte, 64) - for i, b := range sum { - out[i*2] = hexdigits[b>>4] - out[i*2+1] = hexdigits[b&0x0f] - } - return string(out) -} diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go index e06d49f..1239f31 100644 --- a/internal/game/look_entities.go +++ b/internal/game/look_entities.go @@ -230,18 +230,17 @@ func (g *Game) showRoomCasinos(sess *net.Session, room *world.Room) []string { } var lines []string for _, table := range room.CasinoTables { - // Legacy slots declared under casino_tables are instantiated as the - // room's slots machine, so label them as a machine, not a table. - kind := "table" - if table.Game == casino.GameSlots { - kind = "machine" - } - name := casinoGameLabel(table.Game) + " " + kind + name := casinoGameLabel(table.Game) + " table" lines = append(lines, fmt.Sprintf("A %s is here.", g.colorize(sess, "casino_action", name))) } for _, machine := range room.CasinoMachines { - name := casinoGameLabel(machine.Game) + " machine" - lines = append(lines, fmt.Sprintf("A %s is here.", g.colorize(sess, "casino_action", name))) + label := casinoGameLabel(machine.Game) + " machine" + if machine.Variant != "" { + // The variant (or the machine id) is the addressable name; show it + // so two variants in one room do not render identical lines. + label = casinoGameLabel(casino.GameName(machine.Variant)) + " " + casinoGameLabel(machine.Game) + " machine" + } + lines = append(lines, fmt.Sprintf("A %s is here.", g.colorize(sess, "casino_action", label))) } return lines } diff --git a/internal/game/quit_safespot_regression_test.go b/internal/game/quit_safespot_regression_test.go new file mode 100644 index 0000000..99e8b4f --- /dev/null +++ b/internal/game/quit_safespot_regression_test.go @@ -0,0 +1,47 @@ +package game + +import ( + "testing" + + "thehouseoficarus/internal/config" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +// TestQuitCountdownClearsSafespot: the safespot predicates are room-agnostic, +// so logging out while covered used to leave the character registered as a +// phantom occupant of the cover (blocking a max_occupants cover for an offline +// player) and resume hazard immunity on the next login. The quit path must +// release cover just like every other room-changing path. +func TestQuitCountdownClearsSafespot(t *testing.T) { + dir := t.TempDir() + seedWorld(t, dir) + colors := &config.ColorsConfig{} + g := New(dir, colors, colors, config.ValidationConfig{}, 1) + g.SetHub(net.NewHub()) + + sess := &net.Session{Player: player.New("alice"), Conn: &casinoTestConn{}} + sess.Player.Credits = 1000 + g.charsMu.Lock() + g.loggedInChars["alice"] = sess + g.charsMu.Unlock() + + g.safespot.Set("alice", SafespotState{Active: true, ObjectDefID: "rock", ObjectIndex: 0, RoomID: 1}) + if !g.isSafespotted("alice") { + t.Fatal("safespot not active before quit") + } + + g.doQuit(sess) + for i := 0; i < 15 && sess.Player != nil; i++ { + g.Ticks.Tick() + } + if sess.Player != nil { + t.Fatal("quit countdown never completed") + } + if g.safespot.Has("alice") { + t.Fatal("safespot state survived logout") + } + if g.isSafespotted("alice") { + t.Fatal("player still counts as safespotted after logout") + } +} diff --git a/internal/game/render_help.go b/internal/game/render_help.go index 7886f48..a11ac74 100644 --- a/internal/game/render_help.go +++ b/internal/game/render_help.go @@ -28,10 +28,13 @@ type cmdEntry struct { var commandList = []cmdEntry{ {"alias", "Instant", "Create command shortcuts"}, + {"aps", "Active", "Auto-travel using Agility"}, {"attack / kill", "Active", "Attack a mob"}, {"autotrigger / auto", "Instant", "Set a module to auto-trigger"}, {"bank", "Active", "Open bank interface"}, + {"browse / list", "Instant", "Browse a shop's stock"}, {"burn", "Active", "Start a fire"}, + {"buy", "Instant", "Buy from a shop"}, {"chop / cut", "Active", "Chop trees (Woodcutting)"}, {"clean", "Free", "Clean grimy herbs (Pharmacy)"}, {"color / colors", "Instant", "Customize display colors"}, @@ -39,19 +42,23 @@ var commandList = []cmdEntry{ {"construct / make", "Active", "Build furniture (Construction)"}, {"cook", "Active", "Cook raw food on a fire or range"}, {"craft", "Active", "Craft jewelry (Crafting)"}, + {"cure", "Active", "Cure diseased crops (Farming)"}, {"description / desc", "Instant", "Set your character description"}, {"drink", "Free", "Drink a potion"}, {"drop", "Active", "Drop items to the ground"}, {"eat", "Free", "Eat food to restore hitpoints"}, {"equipment / eq", "Instant", "Show equipped items"}, {"exits", "Instant", "List available exits"}, - {"farm", "Active", "Farm crops (Farming)"}, {"fish", "Active", "Fish at fishing spots (Fishing)"}, {"fletch", "Free", "Fletch logs into bows (Fletching)"}, {"get / take / pick", "Active", "Pick up items from the ground"}, {"global", "Instant", "Chat with everyone on the server"}, + {"harvest", "Active", "Harvest a grown farming patch"}, {"help", "Instant", "Show help topics"}, + {"hide / safespot", "Active", "Hide in a safespot"}, + {"home / recall", "Instant", "Return to your home room"}, {"id / identify", "Active", "Identify herbs (Pharmacy)"}, + {"inspect", "Instant", "Inspect a mob, object, or item"}, {"inventory / i / inv", "Instant", "Show your inventory"}, {"jack / jackin", "Active", "Jack into a terminal (Hacking)"}, {"look / l", "Instant", "Look around or examine things"}, @@ -67,27 +74,37 @@ var commandList = []cmdEntry{ {"mods / modlist", "Instant", "List available science modules"}, {"north / south / east / west / up / down", "Active", "Move in a direction"}, {"option / options", "Instant", "View or change settings"}, + {"plant", "Active", "Plant seeds in a farming patch"}, {"prompt", "Instant", "Set custom command prompt"}, {"pull / push", "Active", "Interact with objects"}, {"queued", "Instant", "Show pending tick actions"}, {"quit", "Active", "Rest and disconnect (\"quit now\" forfeits a mid-game bet)"}, + {"rake", "Active", "Rake a farming patch"}, {"remove / unwear / unwield", "Free", "Unequip items"}, {"say", "Instant", "Chat with players in your room"}, {"score / sc", "Instant", "View your stats and skills"}, {"search", "Active", "Search items for loot"}, + {"sell", "Instant", "Sell to a shop"}, + {"skills / sk", "Instant", "Show your skill levels"}, {"smelt", "Active", "Smelt ore into bars (Smithing)"}, {"smith", "Active", "Smith bars into items (Smithing)"}, {"sneak", "Instant", "Toggle sneak mode"}, + {"stats", "Instant", "Show your combat stats"}, {"steal", "Active", "Steal from mobs (Thieving)"}, + {"stop", "Instant", "Stop the current action"}, {"stoke", "Active", "Add logs to a fire"}, {"style", "Instant", "Change combat style"}, {"symbol", "Instant", "Set custom map symbol for current room"}, {"talk / speak / ask", "Active", "Talk to mobs"}, + {"task", "Instant", "Show your current task"}, {"tech", "Instant", "Toggle technology abilities"}, {"trigger", "Active", "Trigger a science module"}, {"unalias", "Instant", "Remove command shortcuts"}, + {"unhide / unsafespot", "Active", "Leave a safespot"}, {"use", "Active", "Use an object (crafting)"}, + {"verbs / commands", "Instant", "List every command you can use"}, {"walk", "Active", "Pathfind to a room or multi-step walk"}, + {"water", "Active", "Water crops (Farming)"}, {"wear / wield", "Free", "Equip items"}, {"who", "Instant", "List everyone online and their combat level"}, } @@ -106,43 +123,32 @@ func LoadHelp(dataDir string) ([]HelpDef, error) { return helps, nil } -// helpRaw accepts both the canonical schema (name/description) and the -// legacy variant (title/body) used by a few help files. +// helpRaw mirrors the canonical help schema and is also used by data +// validation to flag unknown fields. type helpRaw struct { Name string `yaml:"name"` - Title string `yaml:"title"` Aliases []string `yaml:"aliases"` Related []string `yaml:"related"` Category string `yaml:"category"` Description string `yaml:"description"` - Body string `yaml:"body"` } -// parseHelpDef unmarshals a help definition, normalizing legacy title/body -// files onto the canonical HelpDef. Returns nil for files that are not help -// topics (no name at all). +// parseHelpDef unmarshals a help definition. Returns nil for files that are +// not help topics (no name at all). func parseHelpDef(data []byte) *HelpDef { var raw helpRaw if err := yaml.Unmarshal(data, &raw); err != nil { return nil } - name := raw.Name - if name == "" { - name = raw.Title - } - if name == "" { + if raw.Name == "" { return nil } - content := raw.Description - if content == "" { - content = raw.Body - } return &HelpDef{ - Name: name, + Name: raw.Name, Aliases: raw.Aliases, Related: raw.Related, Category: raw.Category, - Content: content, + Content: raw.Description, } } diff --git a/internal/game/render_help_test.go b/internal/game/render_help_test.go index b8ad614..dcfa619 100644 --- a/internal/game/render_help_test.go +++ b/internal/game/render_help_test.go @@ -79,3 +79,34 @@ func TestHelpAliasesResolveWithoutCollision(t *testing.T) { } } } + +// Every verb advertised in the help table must actually be a command. "farm" +// was listed for a long time while not existing, so typing it returned +// "Unknown command." A help entry is the first thing a new player trusts. +func TestHelpCommandListMatchesRegistry(t *testing.T) { + for _, entry := range commandList { + for _, verb := range strings.Split(entry.Name, "/") { + verb = strings.TrimSpace(verb) + if verb == "" { + continue + } + if _, ok := commandRegistry[verb]; ok { + continue + } + if _, ok := verbAliases[verb]; ok { + continue + } + t.Errorf("help lists %q (from %q) but it is not a registered command", verb, entry.Name) + } + } +} + +// alwaysAvailable is a second hand-maintained verb list; it must not drift +// from the registry either. +func TestAlwaysAvailableCommandsAreRegistered(t *testing.T) { + for _, verb := range alwaysAvailable { + if _, ok := commandRegistry[verb]; !ok { + t.Errorf("alwaysAvailable lists %q but it is not registered", verb) + } + } +} diff --git a/internal/game/room_reconcile.go b/internal/game/room_reconcile.go index 7491776..31b66f6 100644 --- a/internal/game/room_reconcile.go +++ b/internal/game/room_reconcile.go @@ -14,6 +14,9 @@ func (g *Game) ReconcileRoom(roomID int) { return } g.World.ReconcileRoomState(roomID, room) + // Guard-watch cycle timers are keyed by room:object; an object edit can + // remove the object the timer belonged to, so drop the room's timers. + g.clearGuardWatchRoom(roomID) if g.MobStore != nil { g.MobStore.RemoveMobsInRoom(roomID) g.MobStore.SeedMobs(roomID, room.Mobs) -- cgit v1.2.3