diff options
| author | historia <[not public]> | 2026-09-19 01:55:34 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-19 01:55:34 -0400 |
| commit | cc33072eced3d5460512c09c42ec4531c807d7af (patch) | |
| tree | b1b451fa87fa389cb8343a3645372f3b43f5523e /internal/game | |
| parent | 2c38386484cdb507c4687caceb37e0b963e33594 (diff) | |
| download | thehouseoficarus-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.go | 2 | ||||
| -rw-r--r-- | internal/game/casino.go | 37 | ||||
| -rw-r--r-- | internal/game/combat_mob.go | 16 | ||||
| -rw-r--r-- | internal/game/room_reconcile.go | 21 | ||||
| -rw-r--r-- | internal/game/snapshot_render_guard_test.go | 40 | ||||
| -rw-r--r-- | internal/game/sys_safespot.go | 5 | ||||
| -rw-r--r-- | internal/game/sys_triggers.go | 6 |
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() |
