aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/act.go17
-rw-r--r--internal/game/casino.go39
-rw-r--r--internal/game/casino_machine_address_test.go31
-rw-r--r--internal/game/cmd_quit.go19
-rw-r--r--internal/game/cmd_registry.go4
-rw-r--r--internal/game/cmd_room.go48
-rw-r--r--internal/game/cmd_room_insert.go18
-rw-r--r--internal/game/cmd_room_mob.go11
-rw-r--r--internal/game/cmd_room_object.go24
-rw-r--r--internal/game/cmd_room_reconcile_regression_test.go47
-rw-r--r--internal/game/cmd_room_remove.go19
-rw-r--r--internal/game/cmd_room_spawn.go11
-rw-r--r--internal/game/cmd_sneak.go15
-rw-r--r--internal/game/cmd_undig.go1
-rw-r--r--internal/game/core_login_account.go33
-rw-r--r--internal/game/exit_migrate.go2
-rw-r--r--internal/game/game.go20
-rw-r--r--internal/game/lifecycle.go31
-rw-r--r--internal/game/login_offowner_regression_test.go43
-rw-r--r--internal/game/look_entities.go17
-rw-r--r--internal/game/quit_safespot_regression_test.go47
-rw-r--r--internal/game/render_help.go44
-rw-r--r--internal/game/render_help_test.go31
-rw-r--r--internal/game/room_reconcile.go3
24 files changed, 361 insertions, 214 deletions
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 <text>")
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_<id>_<dir>), 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)