aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 17:48:26 -0400
committerhistoria <[not public]>2026-09-18 17:48:26 -0400
commit8fe9e3c3fef0049df82281a62afe35dda516dfe2 (patch)
treedd269f2c0710f334e19f00a191260ebc46284a90 /internal/game
parent194164a77eb4f2479b94c19e6f0e1278ab608781 (diff)
downloadthehouseoficarus-8fe9e3c3fef0049df82281a62afe35dda516dfe2.tar.gz
fix: close combat ghost, admin traversal, login throttle, journal double-pay, and quality/economy gaps
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/act_burn.go5
-rw-r--r--internal/game/act_clean.go12
-rw-r--r--internal/game/act_effects.go2
-rw-r--r--internal/game/act_gather.go2
-rw-r--r--internal/game/casino.go13
-rw-r--r--internal/game/cmd_consume.go5
-rw-r--r--internal/game/cmd_drop.go16
-rw-r--r--internal/game/cmd_get.go54
-rw-r--r--internal/game/cmd_quit.go4
-rw-r--r--internal/game/cmd_trigger_utility.go2
-rw-r--r--internal/game/cmd_wear.go7
-rw-r--r--internal/game/combat_attack.go17
-rw-r--r--internal/game/combat_mob.go4
-rw-r--r--internal/game/core_login_account.go10
-rw-r--r--internal/game/core_production.go7
-rw-r--r--internal/game/core_validate.go18
-rw-r--r--internal/game/economy.go74
-rw-r--r--internal/game/game.go19
-rw-r--r--internal/game/hardening_disconnect_combat_test.go31
-rw-r--r--internal/game/login_throttle.go114
-rw-r--r--internal/game/login_throttle_test.go35
-rw-r--r--internal/game/session_registry.go14
-rw-r--r--internal/game/sys_triggers.go7
-rw-r--r--internal/game/tick.go5
24 files changed, 447 insertions, 30 deletions
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 <item>".
+ 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<<uint(shift))) * time.Millisecond
+ if d > 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)