From 8fe9e3c3fef0049df82281a62afe35dda516dfe2 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 17:48:26 -0400 Subject: fix: close combat ghost, admin traversal, login throttle, journal double-pay, and quality/economy gaps --- internal/game/act_burn.go | 5 + internal/game/act_clean.go | 12 ++- internal/game/act_effects.go | 2 +- internal/game/act_gather.go | 2 +- internal/game/casino.go | 13 +++ internal/game/cmd_consume.go | 5 + internal/game/cmd_drop.go | 16 ++- internal/game/cmd_get.go | 54 +++++++--- internal/game/cmd_quit.go | 4 +- internal/game/cmd_trigger_utility.go | 2 +- internal/game/cmd_wear.go | 7 ++ internal/game/combat_attack.go | 17 ++++ internal/game/combat_mob.go | 4 + internal/game/core_login_account.go | 10 ++ internal/game/core_production.go | 7 ++ internal/game/core_validate.go | 18 ++++ internal/game/economy.go | 74 +++++++++++++- internal/game/game.go | 19 ++++ internal/game/hardening_disconnect_combat_test.go | 31 ++++++ internal/game/login_throttle.go | 114 ++++++++++++++++++++++ internal/game/login_throttle_test.go | 35 +++++++ internal/game/session_registry.go | 14 +++ internal/game/sys_triggers.go | 7 +- internal/game/tick.go | 5 + 24 files changed, 447 insertions(+), 30 deletions(-) create mode 100644 internal/game/hardening_disconnect_combat_test.go create mode 100644 internal/game/login_throttle.go create mode 100644 internal/game/login_throttle_test.go (limited to 'internal/game') diff --git a/internal/game/act_burn.go b/internal/game/act_burn.go index 84f7f14..a823b19 100644 --- a/internal/game/act_burn.go +++ b/internal/game/act_burn.go @@ -366,6 +366,11 @@ func (g *Game) consumeFireTool(sess *net.Session, p *player.Player, toolItemID s g.saveCharacter(p) return true } + // The tool is carried/equipped but has no charge record (a stripped + // or depleted max_quality tool). Refuse instead of falling through + // to the stackable check and burning for free forever. + sess.WriteLine("The lighter is out of butane!") + return false } if def != nil && def.Stackable { diff --git a/internal/game/act_clean.go b/internal/game/act_clean.go index 3525475..4efe293 100644 --- a/internal/game/act_clean.go +++ b/internal/game/act_clean.go @@ -56,7 +56,17 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) { } craft := matchItem.FirstCraft() - consumeID := craft.Ingredients[0].Items[0] + // Resolve the exact alternative the availability check selected instead + // of assuming the first listed item: an ingredient-less or + // alternative-item cleaning recipe would otherwise panic or consume the + // wrong stack while still awarding XP. + plan := planIngredients(craft, p.CountItem) + if len(plan) == 0 { + sess.WriteLine("You've finished cleaning herbs.") + g.cancelBgAction(p) + return + } + consumeID := plan[0].id for i := 0; i < 28; i++ { slot := p.InvSlot(i) diff --git a/internal/game/act_effects.go b/internal/game/act_effects.go index e51deab..e175c8f 100644 --- a/internal/game/act_effects.go +++ b/internal/game/act_effects.go @@ -292,7 +292,7 @@ func (g *Game) giveTriggerDrop(sess *net.Session, p *player.Player, drop *behavi return } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty}) + p.SetInvSlot(freeSlot, g.newInventorySlot(drop.ItemID, qty)) sess.WriteLine(fmt.Sprintf("You receive %s.", g.itemColorize(sess, lootDef, name))) g.saveCharacter(p) } diff --git a/internal/game/act_gather.go b/internal/game/act_gather.go index 08ffbb1..3bef8a0 100644 --- a/internal/game/act_gather.go +++ b/internal/game/act_gather.go @@ -432,7 +432,7 @@ func (g *Game) checkBirdNest(sess *net.Session, p *player.Player, cfg *behavior. return } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: birdsNestItemID, Quantity: 1}) + p.SetInvSlot(freeSlot, g.newInventorySlot(birdsNestItemID, 1)) g.saveCharacter(p) sess.WriteLine(fmt.Sprintf("A %s falls out of the tree!", nestName)) } diff --git a/internal/game/casino.go b/internal/game/casino.go index dcf4273..3e382e9 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -1,6 +1,7 @@ package game import ( + "errors" "fmt" "os" "strconv" @@ -11,6 +12,7 @@ import ( "thehouseoficarus/internal/casino" "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" + "thehouseoficarus/internal/persistence" "thehouseoficarus/internal/player" ) @@ -54,6 +56,17 @@ func (w casinoWallet) Wager(amount int) (casino.Wager, error) { } w.p.Credits -= credits if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { + if errors.Is(err, persistence.ErrDurabilityUncertain) { + // The character file was replaced but the directory sync failed: + // the debit may be durable. Keep the in-memory debit and the + // journal record OPEN instead of voiding it, so recovery compares + // checkpoints against whatever file survived and either voids or + // refunds consistently. Proceeding is safe: the record stays open + // (refundable) until the round resolves. + fmt.Fprintf(os.Stderr, "casino wager: %s save durability uncertain: %v — record kept open\n", w.p.Name, err) + w.g.Economy.registerStake(rec) + return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: rec.ID}, nil + } // Revert the in-memory mutation: the play must not proceed on a // stake that could not be made durable. if chips > 0 { diff --git a/internal/game/cmd_consume.go b/internal/game/cmd_consume.go index 2e05620..f12332e 100644 --- a/internal/game/cmd_consume.go +++ b/internal/game/cmd_consume.go @@ -279,5 +279,10 @@ func (g *Game) applyPotion(sess *net.Session, p *player.Player, itemID string, d p.ConsumeCooldown = 3 + // Persist the consumption and its effects: without this a crash before + // the next save replays the potion (duplicating the item) while losing + // the HP/battery/buff change. + g.saveCharacter(p) + g.broadcastAction(sess, "%s drinks a %s.", p.Name, def.Name) } diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go index 2e27f91..278a57d 100644 --- a/internal/game/cmd_drop.go +++ b/internal/game/cmd_drop.go @@ -152,12 +152,13 @@ func (g *Game) doDrop(sess *net.Session, input string) { if take > remaining { take = remaining } + quality, maxQuality := slot.Quality, slot.MaxQuality if slot.Quantity > take { slot.Quantity -= take } else { p.SetInvSlot(match.Slot, nil) } - g.World.AddGroundItem(p.RoomID, itemID, take) + g.World.AddGroundItemQ(p.RoomID, itemID, take, quality, maxQuality) remaining -= take } dropped := qty - remaining @@ -179,13 +180,20 @@ func (g *Game) doDrop(sess *net.Session, input string) { if slot == nil || slot.ItemID != itemID || slot.Quantity <= 0 { continue } - take := 1 + // Drop as much of the requested quantity as this slot holds, not + // just one: a non-stackable slot holding several units must honour + // "drop 3 ". + take := slot.Quantity + if take > remaining { + take = remaining + } + quality, maxQuality := slot.Quality, slot.MaxQuality if slot.Quantity > take { slot.Quantity -= take } else { p.SetInvSlot(match.Slot, nil) } - g.World.AddGroundItem(p.RoomID, itemID, take) + g.World.AddGroundItemQ(p.RoomID, itemID, take, quality, maxQuality) remaining -= take } @@ -236,7 +244,7 @@ func (g *Game) handleDropAll(sess *net.Session, input string) { if slot == nil || slot.Quantity <= 0 { continue } - g.World.AddGroundItem(p.RoomID, slot.ItemID, slot.Quantity) + g.World.AddGroundItemQ(p.RoomID, slot.ItemID, slot.Quantity, slot.Quality, slot.MaxQuality) def, _ := g.ItemStore.Load(slot.ItemID) name := slot.ItemID if def != nil { diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go index a2ad137..33f55de 100644 --- a/internal/game/cmd_get.go +++ b/internal/game/cmd_get.go @@ -75,7 +75,7 @@ func (g *Game) doGet(sess *net.Session, input string) { // doGetAllNamed). See TestPickupMergesIntoFullInventory. if def != nil && def.Stackable { - quality, maxQuality := g.pickedStackProps(itemID) + quality, maxQuality := g.groundStackProps(p, itemID) for i := 0; i < 28; i++ { slot := p.InvSlot(i) if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality { @@ -96,11 +96,12 @@ func (g *Game) doGet(sess *net.Session, input string) { sess.WriteLine("Your inventory is full.") return } - if _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, qty, p.Name); !ok { + removed, q, mq, ok := g.World.RemoveReservedItemQ(p.RoomID, itemID, qty, p.Name) + if !ok { sess.WriteLine("That's not yours!") return } - p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, qty)) + p.SetInvSlot(freeSlot, g.groundSlot(itemID, removed, q, mq)) g.saveCharacter(p) if qty == 1 { sess.WriteLine(fmt.Sprintf("You pick up a %s.", g.itemColorize(sess, def, def.Name))) @@ -116,10 +117,11 @@ func (g *Game) doGet(sess *net.Session, input string) { if fs == -1 { break } - if _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, 1, p.Name); !ok { + _, q, mq, ok := g.World.RemoveReservedItemQ(p.RoomID, itemID, 1, p.Name) + if !ok { break } - p.SetInvSlot(fs, g.newInventorySlot(itemID, 1)) + p.SetInvSlot(fs, g.groundSlot(itemID, 1, q, mq)) picked++ } @@ -195,7 +197,7 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) { coloredName := g.itemColorize(sess, def, name) if def != nil && def.Stackable { - quality, maxQuality := g.pickedStackProps(itemID) + quality, maxQuality := g.groundStackProps(p, itemID) for i := 0; i < 28; i++ { slot := p.InvSlot(i) if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality { @@ -216,12 +218,12 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) { sess.WriteLine("Your inventory is full.") return } - removed, ok := g.World.RemoveReservedItem(p.RoomID, itemID, wanted, p.Name) + removed, q, mq, ok := g.World.RemoveReservedItemQ(p.RoomID, itemID, wanted, p.Name) if !ok { sess.WriteLine("That's not yours!") return } - p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, removed)) + p.SetInvSlot(freeSlot, g.groundSlot(itemID, removed, q, mq)) g.saveCharacter(p) if removed == 1 { sess.WriteLine(fmt.Sprintf("You pick up a %s.", coloredName)) @@ -237,11 +239,11 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) { if fs == -1 { break } - _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, 1, p.Name) + _, q, mq, ok := g.World.RemoveReservedItemQ(p.RoomID, itemID, 1, p.Name) if !ok { break } - p.SetInvSlot(fs, g.newInventorySlot(itemID, 1)) + p.SetInvSlot(fs, g.groundSlot(itemID, 1, q, mq)) picked++ } @@ -303,7 +305,7 @@ func (g *Game) doGetAll(sess *net.Session) { for qty > 0 { if def != nil && def.Stackable { stacked := false - quality, maxQuality := g.pickedStackProps(itemID) + quality, maxQuality := g.groundStackProps(p, itemID) for i := 0; i < 28; i++ { slot := p.InvSlot(i) if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality { @@ -335,11 +337,11 @@ func (g *Game) doGetAll(sess *net.Session) { g.saveCharacter(p) return } - _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, qty, p.Name) + removed, q, mq, ok := g.World.RemoveReservedItemQ(p.RoomID, itemID, qty, p.Name) if !ok { break } - p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, qty)) + p.SetInvSlot(freeSlot, g.groundSlot(itemID, removed, q, mq)) name := itemID if def != nil { name = def.Name @@ -361,11 +363,11 @@ func (g *Game) doGetAll(sess *net.Session) { } take := 1 - _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, take, p.Name) + _, q, mq, ok := g.World.RemoveReservedItemQ(p.RoomID, itemID, take, p.Name) if !ok { break } - p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, take)) + p.SetInvSlot(freeSlot, g.groundSlot(itemID, take, q, mq)) name := itemID if def != nil { name = def.Name @@ -433,6 +435,28 @@ func (g *Game) pickedStackProps(itemID string) (quality, maxQuality int) { return stackProps(def) } +// groundStackProps is pickedStackProps for a ground pickup: it prefers the +// quality carried by the ground entry so a partially-used item is not +// re-created at full quality (and a pickup only merges into a slot with the +// same properties). +func (g *Game) groundStackProps(p *player.Player, itemID string) (quality, maxQuality int) { + if q, mq, ok := g.World.AccessibleGroundQuality(p.RoomID, itemID, p.Name); ok && mq > 0 { + return q, mq + } + return g.pickedStackProps(itemID) +} + +// groundSlot builds an inventory slot for a ground pickup, preserving the +// quality carried by the ground entry. A ground entry with no quality record +// (loot/trigger/spawn drops) falls back to the item's full quality, matching +// newInventorySlot. +func (g *Game) groundSlot(itemID string, qty, quality, maxQuality int) *player.InventorySlot { + if maxQuality > 0 { + return &player.InventorySlot{ItemID: itemID, Quantity: qty, Quality: quality, MaxQuality: maxQuality} + } + return g.newInventorySlot(itemID, qty) +} + func (g *Game) collectMatches(input string, each func(func(itemID string, slot int) bool)) []itemMatch { var matches []itemMatch each(func(itemID string, slot int) bool { diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go index 249df80..0a8b8ca 100644 --- a/internal/game/cmd_quit.go +++ b/internal/game/cmd_quit.go @@ -97,7 +97,9 @@ func (g *Game) beginQuitCountdown(sess *net.Session) { // Forfeit any committed casino wager and free the seat only // now — immediately before the disconnect. Settling games only // pay seated bettors, so this forfeits a mid-round stake. - g.emitCasinoEvents(g.Casino.MarkDisconnected(p.Name)) + if g.Casino != nil { + g.emitCasinoEvents(g.Casino.MarkDisconnected(p.Name)) + } g.saveCharacter(p) g.charsMu.Lock() delete(g.loggedInChars, p.Name) diff --git a/internal/game/cmd_trigger_utility.go b/internal/game/cmd_trigger_utility.go index 75512d1..481cd40 100644 --- a/internal/game/cmd_trigger_utility.go +++ b/internal/game/cmd_trigger_utility.go @@ -194,7 +194,7 @@ func (g *Game) triggerEmGrab(sess *net.Session, p *player.Player, mod *ModDef, t if freeSlot < 0 { break } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: matchedID, Quantity: 1}) + p.SetInvSlot(freeSlot, g.newInventorySlot(matchedID, 1)) placed++ } if placed < taken { diff --git a/internal/game/cmd_wear.go b/internal/game/cmd_wear.go index 5a293de..e459386 100644 --- a/internal/game/cmd_wear.go +++ b/internal/game/cmd_wear.go @@ -141,6 +141,13 @@ func (g *Game) displacedEquipSlot(p *player.Player, itemID string) *player.Inven slot.MaxQuality = def.MaxQuality } delete(p.EquipQuality, itemID) + return slot + } + // No charge record: keep a max_quality item quality-bearing but empty so + // consumeFireTool refuses it instead of treating it as an infinite + // firesteel. Stamping full quality here would refill the item. + if def, _ := g.ItemStore.Load(itemID); def != nil && def.MaxQuality > 0 { + slot.MaxQuality = def.MaxQuality } return slot } diff --git a/internal/game/combat_attack.go b/internal/game/combat_attack.go index 80994e1..1e53510 100644 --- a/internal/game/combat_attack.go +++ b/internal/game/combat_attack.go @@ -194,6 +194,10 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn } g.SubscribeTicks(1, func() bool { + if !g.sessionRegistered(sess, p.Name) { + g.Combat.Leave(p.Name) + return false + } cs := g.Combat.Get(p.Name) if cs == nil || !cs.Active { return false @@ -256,6 +260,10 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn if !isTask { g.SubscribeTicks(engine.ToTicks(mob.Speed), func() bool { + if !g.sessionRegistered(sess, p.Name) { + g.Combat.Leave(p.Name) + return false + } cs := g.Combat.Get(p.Name) if cs == nil || !cs.Active { return false @@ -354,6 +362,15 @@ func (g *Game) playerAttackUnarmed(sess *net.Session, p *player.Player, mob *wor return } + // Unarmed is a melee crush attack, so it must leave cover exactly like + // every other melee attack: otherwise a safespotted player can punch a + // melee-only mob that cannot retaliate. + if g.isSafespotted(p.Name) { + if ss, ok := g.safespot.Get(p.Name); ok { + g.forceLeaveSafespot(sess, p, &ss, "You leave your cover to close in for melee!") + } + } + _, _, attRoll, defRoll, maxHit := g.calculateUnarmedAttackRoll(p, mob) if combat.HitCheck(attRoll, defRoll) { diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go index ace02b5..21aaf4c 100644 --- a/internal/game/combat_mob.go +++ b/internal/game/combat_mob.go @@ -426,6 +426,10 @@ func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRan {string(player.Defense), quarter}, {string(player.Hitpoints), quarter}, } + default: + // A legacy or hand-edited save may omit attack_style; default to + // accurate so melee combat never silently grants zero XP. + gains = []xpGain{{string(player.Accuracy), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} } } diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go index a5106ea..e9fa67f 100644 --- a/internal/game/core_login_account.go +++ b/internal/game/core_login_account.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "strings" + "time" "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" @@ -156,6 +157,8 @@ func (g *Game) handlePasswordOffOwner(sess *net.Session, input string) { }) return } + throttle := g.ensureLoginThrottle() + key := loginThrottleKey(name, sess.RemoteAddr()) auth := passwordAuth{} acc, err := g.AccountStore.LoadAccount(name) switch { @@ -163,6 +166,7 @@ func (g *Game) handlePasswordOffOwner(sess *net.Session, input string) { auth.loadErr = true 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. @@ -173,6 +177,12 @@ func (g *Game) handlePasswordOffOwner(sess *net.Session, input string) { 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 + // connection's own goroutine, never the owner, so the world is not + // stalled by an attacker. + time.Sleep(throttle.delayFor(key)) } g.RunOnOwner(func() { if g.isShuttingDown() { diff --git a/internal/game/core_production.go b/internal/game/core_production.go index 34ad8a9..afffa7c 100644 --- a/internal/game/core_production.go +++ b/internal/game/core_production.go @@ -93,6 +93,13 @@ var productionTypes = map[string]productionTypeInfo{ SuccessMessage: "You mix a %n.", EndMessage: "You've finished mixing.", }, + "cleaning": { + ActionType: behavior.TypeClean, + DisplayVerb: "cleaning", + StartMessage: "You start cleaning %i1.", + SuccessMessage: "You clean the %n.", + EndMessage: "You've finished cleaning.", + }, "construction": { ActionType: behavior.TypeConstruct, DisplayVerb: "constructing", diff --git a/internal/game/core_validate.go b/internal/game/core_validate.go index b6cb3b9..a858933 100644 --- a/internal/game/core_validate.go +++ b/internal/game/core_validate.go @@ -16,13 +16,18 @@ func (g *Game) validationSource() validate.Source { courseViews := make([]validate.CourseView, 0, len(courses)) for id, cfg := range courses { rooms := make([]int, 0, len(cfg.Obstacles)) + failRooms := make([]int, 0, len(cfg.Obstacles)) for _, obs := range cfg.Obstacles { rooms = append(rooms, obs.RoomID) + if obs.OnFailRoom > 0 { + failRooms = append(failRooms, obs.OnFailRoom) + } } courseViews = append(courseViews, validate.CourseView{ ID: id, StartRoom: cfg.StartRoom, ObstacleRooms: rooms, + OnFailRooms: failRooms, }) } @@ -39,6 +44,7 @@ func (g *Game) validationSource() validate.Source { moduleElements := make(map[string]bool) moduleDestinations := make(map[string]int) + transportModules := make(map[string]bool) for _, m := range AllMods { if m.Element != "" { moduleElements[m.Element] = true @@ -46,6 +52,16 @@ func (g *Game) validationSource() validate.Source { if m.Destination > 0 { moduleDestinations[m.ID] = m.Destination } + if m.Category == ModTransport { + transportModules[m.ID] = true + } + } + + knownFieldTargets := []validate.KnownFieldTarget{ + {SubDir: "techs", Label: "tech", NewDef: func() any { return new(TechDef) }}, + {SubDir: "modules", Label: "module", NewDef: func() any { return new(ModDef) }}, + {SubDir: "courses", Label: "course", NewDef: func() any { return new(CourseConfig) }}, + {SubDir: "help", Label: "help topic", NewDef: func() any { return new(helpRaw) }}, } return validate.Source{ @@ -61,5 +77,7 @@ func (g *Game) validationSource() validate.Source { IgnoreUnreachable: g.ValidationConfig.IgnoreUnreachable, ModuleElements: moduleElements, ModuleDestinations: moduleDestinations, + TransportModules: transportModules, + KnownFieldTargets: knownFieldTargets, } } diff --git a/internal/game/economy.go b/internal/game/economy.go index 0b9e92f..9853671 100644 --- a/internal/game/economy.go +++ b/internal/game/economy.go @@ -184,15 +184,39 @@ func (j *economyJournal) append(rec economyRecord) error { return fmt.Errorf("open economy journal: %w", err) } defer f.Close() + // Record the pre-write size so a failed write/sync rolls the record back. + // "append failed" must mean "no record bytes remain": the wallet's + // journal-unavailable fallback commits the payout through the character + // file only, and a later recovery must not see the same payout record as + // still owed (that double-pays). It also keeps a short write from merging + // into the next append as a malformed mid-file line. + startSize := int64(-1) + if fi, statErr := f.Stat(); statErr == nil { + startSize = fi.Size() + } + rollback := func(cause error) error { + if startSize >= 0 { + if terr := f.Truncate(startSize); terr != nil { + fmt.Fprintf(os.Stderr, "economy journal: rollback truncate after %v: %v\n", cause, terr) + } else { + _ = f.Sync() + } + } + return cause + } if _, err := f.Write(append(data, '\n')); err != nil { - return fmt.Errorf("append economy journal: %w", err) + return rollback(fmt.Errorf("append economy journal: %w", err)) } if err := f.Sync(); err != nil { - return fmt.Errorf("sync economy journal: %w", err) + return rollback(fmt.Errorf("sync economy journal: %w", err)) } if !j.dirSynced { if err := syncDir(filepath.Dir(j.path)); err != nil { - return fmt.Errorf("sync economy journal dir: %w", err) + // The record bytes are in the file but the directory entry is not + // guaranteed durable. Roll the record back so the caller's failure + // is truthful, and leave dirSynced false so a later append retries + // the directory sync. + return rollback(fmt.Errorf("sync economy journal dir: %w", err)) } j.dirSynced = true } @@ -1043,6 +1067,10 @@ func (g *Game) recoverEconomy() { return evidence, settlement } compensated := 0 + // skipCompact is set when a recovery-time append that a later run depends + // on could not be made durable; compaction must then be skipped so the + // evidence it would erase is preserved. + skipCompact := false // Phase 1 — payouts, in append order. Records with identical // checkpoints are decided as one group: k sibling records all journaled @@ -1097,6 +1125,19 @@ func (g *Game) recoverEconomy() { // settled; a save failure (e.g. the credit cap) leaves the unpaid // remainder open for operator inspection — a liability is never erased. reapplyGroup := func(group []*economyRecord, p *player.Player, credit int) { + // A mixed tracked/untracked group can contain a record already settled + // earlier in this pass; paying it again would create credits. Filter + // to the still-unsettled members. + active := make([]*economyRecord, 0, len(group)) + for _, rec := range group { + if !settled[rec.ID] { + active = append(active, rec) + } + } + group = active + if len(group) == 0 { + return + } paid := 0 capBlocked := false for m := 0; m < len(group); m++ { @@ -1130,7 +1171,13 @@ func (g *Game) recoverEconomy() { // and settles the remainder as "already durable"). reanchorOwedPayout(other, p.Credits, other.Credit) settled[other.ID] = true // handled for the rest of this run - _ = g.Economy.append(*other) + if err := g.Economy.appendRetry(*other, 3); err != nil { + // The re-anchor is the evidence the next run needs to avoid + // silently settling this liability. If it is not durable, + // skip compaction so the settled sibling evidence survives. + fmt.Fprintf(os.Stderr, "economy recovery: re-anchor append %s failed: %v — skipping compaction this run\n", other.ID, err) + skipCompact = true + } continue } // A TRANSIENT save failure blocked the FIRST re-application @@ -1323,11 +1370,28 @@ func (g *Game) recoverEconomy() { g.Economy.settle(rec) continue } + // The chips were returned in kind and persisted; clear the + // record's chip obligation durably so a later session refund + // (RefundOutstanding) cannot return the same chips again. The + // record stays open for the credit side. + if chipsOwed { + rec.Chips = 0 + rec.ChipsBefore = chipsNow + rec.ChipsAfter = chipsNow + if err := g.Economy.appendRetry(*rec, 3); err != nil { + fmt.Fprintf(os.Stderr, "economy recovery: chip-obligation clear %s failed: %v\n", rec.ID, err) + skipCompact = true + } + } fmt.Fprintf(os.Stderr, "economy recovery: ambiguous wager %s for %s (credits %d, want before %d or after %d) — kept open for operator inspection\n", rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter) } } - g.Economy.compact() + if skipCompact { + fmt.Fprintln(os.Stderr, "economy recovery: compaction skipped to preserve payout evidence") + } else { + g.Economy.compact() + } // Final evidence prune: the phases above settled or voided records, so // evidence IDs that no longer correspond to an open record are stale // (a settled stake's resolution evidence, an applied payout's entry). diff --git a/internal/game/game.go b/internal/game/game.go index 5ad1033..af2ff66 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -110,6 +110,12 @@ type Game struct { // Economy is the durable money journal; see economy.go. Economy *economyJournal + // loginThrottle applies an escalating per-(account, IP) delay to failed + // interactive password attempts. Lazily created so Game literals built + // directly in tests work without it. + loginThrottleOnce sync.Once + loginThrottle *loginThrottle + // owner serializes all live-state mutations; see owner.go. owner *owner ownerStop sync.Once @@ -197,6 +203,13 @@ 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) g.restoreGodPlayer(p) g.persistLockedOnDisconnect(p) g.saveCharacter(p) @@ -443,6 +456,12 @@ func (g *Game) HandleSession(sess *net.Session, input string) { g.handleDangerConfirm(sess, input) case net.StateUndigConfirm: g.handleUndigConfirm(sess, input) + default: + // A declared state with no handler would otherwise silently swallow + // every line. Re-prompt and log so a future menu flow cannot strand + // the player on a dead input state. + log.Printf("unhandled session state %v", sess.State()) + sess.WriteLine("Something went wrong; please try again.") } } diff --git a/internal/game/hardening_disconnect_combat_test.go b/internal/game/hardening_disconnect_combat_test.go new file mode 100644 index 0000000..7bd6708 --- /dev/null +++ b/internal/game/hardening_disconnect_combat_test.go @@ -0,0 +1,31 @@ +package game + +import "testing" + +// TestDisconnectHardRemoveEndsCombat pins the P0 fix: a session hard-removed +// while a fight is live must have that fight ended. Otherwise the combat tick +// subscribers (keyed by character name) keep attacking and saving a detached +// Player, which duplicates items/credits on death and can permanently lock a +// finishing_blow mob. +func TestDisconnectHardRemoveEndsCombat(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + h.seedCharacter("alice", "alice") + sess, _ := h.connect("alice") + p := sess.Player + + h.g.RunOnOwner(func() { + sess.StartDisconnect(1) + h.g.Combat.Enter(p.Name, "skeleton") + for i := 0; i < disconnectCombatGraceTicks+5 && len(h.g.Hub.AllSessions()) > 0; i++ { + h.g.DisconnectTick() + } + }) + + if len(h.g.Hub.AllSessions()) != 0 { + t.Fatal("session was not hard-removed") + } + if h.g.Combat.Get(p.Name) != nil { + t.Fatal("combat survived the hard remove; a ghost fight would keep saving the detached character") + } +} diff --git a/internal/game/login_throttle.go b/internal/game/login_throttle.go new file mode 100644 index 0000000..b819188 --- /dev/null +++ b/internal/game/login_throttle.go @@ -0,0 +1,114 @@ +package game + +import ( + "sync" + "time" +) + +// Interactive login throttling. The admin HTTP /login route has its own +// limiter; the telnet/TLS/websocket login is the primary credential surface +// and needs one too. The policy is an escalating delay keyed on +// (account, client IP): a hard lockout is deliberately avoided because +// account existence is discoverable and a lockout would be a targeted +// griefing tool. The delay runs on the connection goroutine, never the +// owner, so a brute-force attempt stalls only its own connection. + +const ( + // loginThrottleMaxDelay caps the escalation so a legitimate player who + // fat-fingers a password a few times is inconvenienced, not locked out. + loginThrottleMaxDelay = 4 * time.Second + // loginThrottleMaxKeys bounds the tracked keys so a source that cycles + // account names or addresses cannot grow server memory without bound. + loginThrottleMaxKeys = 4096 + // loginThrottleEntryTTL is how long an untouched entry is retained. + loginThrottleEntryTTL = 10 * time.Minute +) + +type loginThrottleEntry struct { + failures int + lastSeen time.Time +} + +type loginThrottle struct { + mu sync.Mutex + entries map[string]loginThrottleEntry +} + +func newLoginThrottle() *loginThrottle { + return &loginThrottle{entries: make(map[string]loginThrottleEntry)} +} + +// ensureLoginThrottle returns the game's login throttle, creating it on first +// use. The connection goroutine calls this; sync.Once makes the lazy init +// race-free for Game literals that did not go through New. +func (g *Game) ensureLoginThrottle() *loginThrottle { + g.loginThrottleOnce.Do(func() { + g.loginThrottle = newLoginThrottle() + }) + return g.loginThrottle +} + +// loginThrottleKey combines the attempted account and the client address. +// Either may be empty (synthetic connections). +func loginThrottleKey(account, ip string) string { + return account + "\x00" + ip +} + +// delayFor records a failed attempt and returns the escalating delay the +// caller should wait before responding: 0, 250ms, 500ms, 1s, 2s, 4s, 4s... +func (t *loginThrottle) delayFor(key string) time.Duration { + t.mu.Lock() + defer t.mu.Unlock() + now := time.Now() + if len(t.entries) >= loginThrottleMaxKeys { + t.evictLocked(now) + } + e := t.entries[key] + e.failures++ + e.lastSeen = now + t.entries[key] = e + if e.failures < 2 { + // The first wrong password is free: a single typo must not cost a + // legitimate player any time. + return 0 + } + shift := e.failures - 2 + if shift > 4 { // 250ms << 4 == loginThrottleMaxDelay + shift = 4 + } + d := time.Duration(250*int64(1< loginThrottleMaxDelay { + d = loginThrottleMaxDelay + } + return d +} + +// clear forgets a key after a successful login. +func (t *loginThrottle) clear(key string) { + t.mu.Lock() + delete(t.entries, key) + t.mu.Unlock() +} + +// evictLocked drops expired entries, then the oldest entry if still at the +// cap. Caller holds mu. +func (t *loginThrottle) evictLocked(now time.Time) { + for k, e := range t.entries { + if now.Sub(e.lastSeen) > loginThrottleEntryTTL { + delete(t.entries, k) + } + } + if len(t.entries) < loginThrottleMaxKeys { + return + } + oldestKey := "" + var oldest time.Time + for k, e := range t.entries { + if oldestKey == "" || e.lastSeen.Before(oldest) { + oldestKey, oldest = k, e.lastSeen + } + } + if oldestKey != "" { + delete(t.entries, oldestKey) + } +} diff --git a/internal/game/login_throttle_test.go b/internal/game/login_throttle_test.go new file mode 100644 index 0000000..b2565c4 --- /dev/null +++ b/internal/game/login_throttle_test.go @@ -0,0 +1,35 @@ +package game + +import ( + "testing" + "time" +) + +// TestLoginThrottleEscalatesAndResets pins the telnet login throttle: the +// first wrong password is free, then the delay escalates to a cap, and a +// successful login clears the state. +func TestLoginThrottleEscalatesAndResets(t *testing.T) { + th := newLoginThrottle() + key := loginThrottleKey("alice", "203.0.113.7") + + if d := th.delayFor(key); d != 0 { + t.Fatalf("first failure delay = %v, want 0", d) + } + if d := th.delayFor(key); d != 250*time.Millisecond { + t.Fatalf("second failure delay = %v, want 250ms", d) + } + if d := th.delayFor(key); d != 500*time.Millisecond { + t.Fatalf("third failure delay = %v, want 500ms", d) + } + for i := 0; i < 10; i++ { + th.delayFor(key) + } + if d := th.delayFor(key); d != loginThrottleMaxDelay { + t.Fatalf("capped delay = %v, want %v", d, loginThrottleMaxDelay) + } + + th.clear(key) + if d := th.delayFor(key); d != 0 { + t.Fatalf("delay after clear = %v, want 0", d) + } +} diff --git a/internal/game/session_registry.go b/internal/game/session_registry.go index c4f5d67..cf4e8df 100644 --- a/internal/game/session_registry.go +++ b/internal/game/session_registry.go @@ -23,3 +23,17 @@ func (g *Game) detachSession(name string) { g.queue.ClearPlayer(name) } } + +// sessionRegistered reports whether sess is still the live session for name. +// Tick subscribers that can outlive a disconnect/re-login must consult this +// before acting on a session that is no longer in the world (a detached +// combat or logout subscriber would otherwise keep saving a ghost). +func (g *Game) sessionRegistered(sess *net.Session, name string) bool { + if sess == nil || sess.Player == nil { + return false + } + g.charsMu.Lock() + current := g.loggedInChars[name] + g.charsMu.Unlock() + return current == sess +} diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go index 8bba630..a6feec2 100644 --- a/internal/game/sys_triggers.go +++ b/internal/game/sys_triggers.go @@ -2,6 +2,7 @@ package game import ( "fmt" + "log" "os" "path/filepath" "sort" @@ -572,7 +573,11 @@ func (g *Game) loadGlobalTriggers(dataDir string) error { } var t behavior.Trigger if err := yamlUnmarshal(data, &t); err != nil { - return fmt.Errorf("trigger %q: %w", full, err) + // Log and continue: aborting the walk would silently leave + // every trigger after this file unindexed. The validator also + // reports the bad file, so a reload/restart surfaces it. + log.Printf("trigger load %q: %v (skipped)", full, err) + continue } stem := strings.TrimSuffix(entry.Name(), ext) if t.OnPlayerFlag != "" { diff --git a/internal/game/tick.go b/internal/game/tick.go index ef8089a..21489ca 100644 --- a/internal/game/tick.go +++ b/internal/game/tick.go @@ -50,6 +50,11 @@ func (g *Game) DisconnectTick() { } if sess.TickDisconnectCountdown() { delete(g.disconnectDeferrals, sess) + // The fight must not outlive the session: the combat tick + // subscribers are keyed by name and would otherwise keep + // attacking and saving for a character no longer in the world. + g.stopCombat(p.Name) + g.cancelRest(p.Name) g.restoreGodPlayer(p) g.saveCharacter(p) g.Hub.HardRemove(sess) -- cgit v1.2.3