aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-19 01:55:34 -0400
committerhistoria <[not public]>2026-09-19 01:55:34 -0400
commitcc33072eced3d5460512c09c42ec4531c807d7af (patch)
treeb1b451fa87fa389cb8343a3645372f3b43f5523e /internal/game
parent2c38386484cdb507c4687caceb37e0b963e33594 (diff)
downloadthehouseoficarus-cc33072eced3d5460512c09c42ec4531c807d7af.tar.gz
fix: casino stake preservation, world state reconciliation, casino game registration
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/act_burn.go2
-rw-r--r--internal/game/casino.go37
-rw-r--r--internal/game/combat_mob.go16
-rw-r--r--internal/game/room_reconcile.go21
-rw-r--r--internal/game/snapshot_render_guard_test.go40
-rw-r--r--internal/game/sys_safespot.go5
-rw-r--r--internal/game/sys_triggers.go6
7 files changed, 116 insertions, 11 deletions
diff --git a/internal/game/act_burn.go b/internal/game/act_burn.go
index a823b19..877fd78 100644
--- a/internal/game/act_burn.go
+++ b/internal/game/act_burn.go
@@ -431,7 +431,7 @@ func (g *Game) FireTick() {
for _, st := range g.World.AllFireStates() {
st.Quality--
if st.Quality <= 0 {
- g.World.RemoveObjInstance(st.RoomID, st.DefID)
+ g.World.RemoveObjInstanceIndex(st.RoomID, st.DefID, st.Index)
g.cancelStokers(st.RoomID)
if def, err := g.ObjectStore.Load(st.DefID); err == nil && def.RemovalItem != "" {
g.World.AddGroundItem(st.RoomID, def.RemovalItem, 1)
diff --git a/internal/game/casino.go b/internal/game/casino.go
index c52e5eb..58e1e69 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -116,9 +116,14 @@ func (w casinoWallet) ResolveStakes() {
if len(w.p.PendingWagers) == 0 {
return
}
+ before := append([]player.PendingWager(nil), w.p.PendingWagers...)
w.p.PendingWagers = nil
if err := w.g.AccountStore.SaveCharacter(w.p); err != nil {
- fmt.Fprintf(os.Stderr, "casino resolve stakes: save for %s: %v\n", w.p.Name, err)
+ // Keep the in-memory ledger consistent with the durable file: the
+ // stakes are still pending on disk, so restore them for a later
+ // retry or startup recovery instead of silently diverging.
+ w.p.PendingWagers = before
+ fmt.Fprintf(os.Stderr, "casino resolve stakes: save for %s: %v (ledger restored in memory)\n", w.p.Name, err)
}
}
@@ -127,11 +132,18 @@ func (w casinoWallet) ResolveStakes() {
// already removed it (PayCredits) makes this a no-op, so no redundant
// save is paid.
func (w casinoWallet) ResolveStake(stakeID string) {
- if stakeID == "" || !removePendingWager(w.p, stakeID) {
+ if stakeID == "" {
+ return
+ }
+ removed := takePendingWagers(w.p, []string{stakeID})
+ if len(removed) == 0 {
return
}
if err := w.g.AccountStore.SaveCharacter(w.p); err != nil {
- fmt.Fprintf(os.Stderr, "casino resolve stake: save for %s: %v\n", w.p.Name, err)
+ // The durable file still lists the stake, so restore it 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 stake: save for %s: %v (ledger restored in memory)\n", w.p.Name, err)
}
}
@@ -260,8 +272,25 @@ func (g *Game) casinoMachine(roomID int, gameName string) (*casino.Machine, bool
if casino.ValidateRoomConfigs(room.CasinoTables, room.CasinoMachines) != nil {
return nil, false
}
+ // 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 {
+ if cfg.Variant != "" && strings.EqualFold(cfg.Variant, gameName) {
+ machine := g.Casino.EnsureMachine(roomID, cfg)
+ return machine, machine != nil
+ }
+ }
+ // Then a bare game name: prefer a variant-less machine so a machine that
+ // declares its own variant is reached by that variant, and a sole machine
+ // of a game is still reachable by the game name.
+ for _, cfg := range room.CasinoMachines {
+ if cfg.Variant == "" && strings.EqualFold(string(cfg.Game), gameName) {
+ machine := g.Casino.EnsureMachine(roomID, cfg)
+ return machine, machine != nil
+ }
+ }
for _, cfg := range room.CasinoMachines {
- if strings.EqualFold(string(cfg.Game), gameName) || strings.EqualFold(cfg.Variant, gameName) {
+ if strings.EqualFold(string(cfg.Game), gameName) {
machine := g.Casino.EnsureMachine(roomID, cfg)
return machine, machine != nil
}
diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go
index 21aaf4c..b16e4e1 100644
--- a/internal/game/combat_mob.go
+++ b/internal/game/combat_mob.go
@@ -330,10 +330,16 @@ func (g *Game) awardKillDrops(sess *net.Session, p *player.Player, mob *world.Mo
// scheduleMobRespawn removes trigger-spawned mobs or schedules a normal respawn.
func (g *Game) scheduleMobRespawn(mob *world.MobInstance) {
- instanceID := mob.InstanceID
+ if mob == nil {
+ return
+ }
if mob.SpawnedByTrigger {
g.SubscribeTicks(10, func() bool {
- g.MobStore.RemoveInstance(instanceID)
+ // Read the ID at fire time: a room-ID migration may have rewritten
+ // it while the timer was pending.
+ if id := mob.InstanceID; id != "" {
+ g.MobStore.RemoveInstance(id)
+ }
return false
})
return
@@ -343,7 +349,11 @@ func (g *Game) scheduleMobRespawn(mob *world.MobInstance) {
respawnTicks = engine.ToTicks(30)
}
g.SubscribeTicks(respawnTicks, func() bool {
- g.respawnMob(instanceID)
+ // Re-read the current ID at fire time instead of capturing the string:
+ // RekeyRooms rewrites an instance's key when its home room is rekeyed,
+ // so a captured ID would miss, the mob would stay at HP 0 forever, and
+ // SeedMobs would skip the (now-correct) key as already present.
+ g.respawnMob(mob.InstanceID)
return false
})
}
diff --git a/internal/game/room_reconcile.go b/internal/game/room_reconcile.go
new file mode 100644
index 0000000..7491776
--- /dev/null
+++ b/internal/game/room_reconcile.go
@@ -0,0 +1,21 @@
+package game
+
+// ReconcileRoom refreshes a room's live runtime state from its on-disk
+// definition after a content edit, without a restart. Object instances and
+// item-spawn ground entries are reconciled by World; mob instances are dropped
+// and reseeded here because MobStore is owned by the game layer. Player-dropped
+// ground items and live reservations are preserved.
+//
+// It runs on the owner goroutine (the admin callbacks that call it already do)
+// and must not redispatch.
+func (g *Game) ReconcileRoom(roomID int) {
+ room, err := g.World.LoadRoom(roomID)
+ if err != nil {
+ return
+ }
+ g.World.ReconcileRoomState(roomID, room)
+ if g.MobStore != nil {
+ g.MobStore.RemoveMobsInRoom(roomID)
+ g.MobStore.SeedMobs(roomID, room.Mobs)
+ }
+}
diff --git a/internal/game/snapshot_render_guard_test.go b/internal/game/snapshot_render_guard_test.go
new file mode 100644
index 0000000..83833d5
--- /dev/null
+++ b/internal/game/snapshot_render_guard_test.go
@@ -0,0 +1,40 @@
+package game
+
+// Guard for the last per-game presentation seam: the Snapshot marker interface
+// guarantees an event carries a structured view, but renderCasinoSnapshot's
+// type switch silently returns "" for a snapshot it does not know. This
+// source-level test pairs every `func (*X) snapshot() {}` in the casino package
+// with a `case *casino.X:` in the renderer, so adding a game cannot silently
+// drop its view (the text message would still appear, hiding the loss).
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+func TestEveryCasinoSnapshotHasARenderer(t *testing.T) {
+ typesSrc, err := os.ReadFile(filepath.Join("..", "casino", "types.go"))
+ if err != nil {
+ t.Fatalf("read casino types: %v", err)
+ }
+ renderSrc, err := os.ReadFile("casino.go")
+ if err != nil {
+ t.Fatalf("read renderer: %v", err)
+ }
+
+ re := regexp.MustCompile(`func \(\*(\w+)\) snapshot\(\)`)
+ matches := re.FindAllStringSubmatch(string(typesSrc), -1)
+ if len(matches) == 0 {
+ t.Fatal("no Snapshot implementations found in casino/types.go")
+ }
+ for _, m := range matches {
+ name := m[1]
+ want := "case *casino." + name + ":"
+ if !strings.Contains(string(renderSrc), want) {
+ t.Errorf("snapshot %s has no renderer: add `%s` to renderCasinoSnapshot", name, want)
+ }
+ }
+}
diff --git a/internal/game/sys_safespot.go b/internal/game/sys_safespot.go
index 2529471..7876b38 100644
--- a/internal/game/sys_safespot.go
+++ b/internal/game/sys_safespot.go
@@ -204,7 +204,7 @@ func (g *Game) degradeSafespot(st *world.ObjState, objDef *object.ObjectDef, cfg
if cfg.RespawnTicks > 0 {
g.scheduleSafespotRespawn(st, objDef, cfg)
} else {
- g.World.RemoveObjInstance(st.RoomID, objDef.ID)
+ g.World.RemoveObjInstanceIndex(st.RoomID, objDef.ID, st.Index)
}
return
}
@@ -227,8 +227,9 @@ func (g *Game) scheduleSafespotRespawn(st *world.ObjState, objDef *object.Object
}
roomID := st.RoomID
defID := objDef.ID
+ index := st.Index
g.SubscribeTicks(engine.ToTicks(cfg.RespawnTicks), func() bool {
- respawnSt := g.World.GetObjState(roomID, defID, 0)
+ respawnSt := g.World.GetObjState(roomID, defID, index)
if respawnSt == nil {
return false
}
diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go
index 61eb325..7a6466a 100644
--- a/internal/game/sys_triggers.go
+++ b/internal/game/sys_triggers.go
@@ -418,7 +418,11 @@ func (g *Game) TransientMobTick() {
}
}
if inAllowed {
- inst.DespawnCounter = inst.DespawnTickCount()
+ // The owner is back inside the allowed rooms: reset the grace
+ // period. (The old code assigned DespawnTickCount() back to the
+ // same field, a no-op that made the countdown cumulative across
+ // brief absences.)
+ inst.ResetDespawnCounter(engine.ToTicks(inst.DespawnTicks))
continue
}
inst.DecrementDespawnCounter()