aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/server.go14
-rw-r--r--internal/admin/static/map.js21
-rw-r--r--internal/behavior/desc.go69
-rw-r--r--internal/behavior/desc_test.go86
-rw-r--r--internal/behavior/store.go34
-rw-r--r--internal/casino/baccarat.go10
-rw-r--r--internal/casino/blackjack.go8
-rw-r--r--internal/casino/config_validate.go77
-rw-r--r--internal/casino/config_validate_test.go50
-rw-r--r--internal/casino/core.go16
-rw-r--r--internal/casino/hardening_casino_validate_test.go14
-rw-r--r--internal/casino/manager_test.go8
-rw-r--r--internal/casino/registry.go18
-rw-r--r--internal/casino/slots.go6
-rw-r--r--internal/casino/types.go11
-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
-rw-r--r--internal/net/server.go4
-rw-r--r--internal/player/hardening_password_params_test.go24
-rw-r--r--internal/player/password.go77
-rw-r--r--internal/player/password_test.go34
-rw-r--r--internal/player/roomset.go142
-rw-r--r--internal/player/roomset_test.go58
-rw-r--r--internal/player/skill_abbr_test.go19
-rw-r--r--internal/validate/checks.go64
-rw-r--r--internal/validate/validate.go11
-rw-r--r--internal/world/world.go38
49 files changed, 1049 insertions, 439 deletions
diff --git a/internal/admin/server.go b/internal/admin/server.go
index 9c62249..96b5981 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -754,12 +754,8 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) {
}
// Expensive password hashing stays off the owner. Reload the account
- // inside the task so an upgrade cannot overwrite a concurrent character
- // rename/create or a password/admin change made since authentication.
- var upgradedHash string
- if player.NeedsRehash(acc.PasswordHash) {
- upgradedHash, _ = player.HashPassword(password)
- }
+ // inside the task so a concurrent character rename/create or a
+ // password/admin change made since authentication is not overwritten.
authorized := false
if !s.runOnOwner(func() {
current, err := s.accountStore.LoadAccount(account)
@@ -767,12 +763,6 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
authorized = true
- if upgradedHash != "" {
- current.PasswordHash = upgradedHash
- if err := s.accountStore.SaveAccount(current); err != nil {
- log.Printf("admin login: password rehash save for %s: %v", account, err)
- }
- }
}) {
writeJSONError(w, "game is shutting down", http.StatusServiceUnavailable)
return
diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js
index f8cee51..4c0cae4 100644
--- a/internal/admin/static/map.js
+++ b/internal/admin/static/map.js
@@ -2056,7 +2056,26 @@ async function doSavePanel() {
var el = $('#roomName'); if (el) r.name = el.value;
el = $('#roomColor'); if (el) r.color = el.value;
- el = $('#roomDesc'); if (el && el.value) r.description = [{text: el.value}];
+ // Only rewrite description when the textarea actually changed. The
+ // textarea is populated by joining every variant, so unconditionally
+ // assigning a single variant here destroyed conditional descriptions on
+ // any unrelated panel save (rename, block_transport toggle, ...).
+ el = $('#roomDesc');
+ if (el) {
+ var origDesc = '';
+ if (Array.isArray(r.description)) {
+ origDesc = r.description.map(function(d) { return d.text || ''; }).join('\n\n');
+ } else if (typeof r.description === 'string') {
+ origDesc = r.description;
+ }
+ if (el.value !== origDesc) {
+ var hasConditional = Array.isArray(r.description) && r.description.some(function(d) { return d.condition; });
+ if (hasConditional && !confirm('This room has conditional descriptions. Saving the edited text will replace them all with one description. Continue?')) {
+ return;
+ }
+ r.description = el.value ? [{text: el.value}] : [];
+ }
+ }
el = $('#roomBlockTransport'); if (el) r.block_transport = el.checked;
if (currentRoomData && currentRoomData.room) {
diff --git a/internal/behavior/desc.go b/internal/behavior/desc.go
index f6dbaf3..744b97e 100644
--- a/internal/behavior/desc.go
+++ b/internal/behavior/desc.go
@@ -1,8 +1,77 @@
package behavior
+import (
+ "fmt"
+
+ "gopkg.in/yaml.v3"
+)
+
+// DescVariant is one alternative description, optionally gated by a condition.
type DescVariant struct {
Text string `yaml:"text"`
Condition *Condition `yaml:"condition,omitempty"`
}
+// DescList is an ordered list of description variants. The first variant whose
+// condition matches is shown.
+//
+// Content may author the common single, unconditional description two ways:
+// as a bare scalar (`description: "A dark room."`) or as a list of variants
+// (`description: [{text: "A dark room."}]`). MarshalYAML collapses a single
+// unconditional variant back to the scalar form so machine-written room files
+// stay close to what a human would type.
type DescList []DescVariant
+
+func (d *DescList) UnmarshalYAML(value *yaml.Node) error {
+ switch value.Kind {
+ case yaml.ScalarNode:
+ var text string
+ if err := value.Decode(&text); err != nil {
+ return err
+ }
+ if text == "" {
+ *d = nil
+ return nil
+ }
+ *d = DescList{{Text: text}}
+ return nil
+ case yaml.SequenceNode:
+ out := make(DescList, 0, len(value.Content))
+ for _, item := range value.Content {
+ // Accept a bare string element as shorthand for a variant.
+ if item.Kind == yaml.ScalarNode {
+ var text string
+ if err := item.Decode(&text); err != nil {
+ return err
+ }
+ out = append(out, DescVariant{Text: text})
+ continue
+ }
+ var variant DescVariant
+ if err := item.Decode(&variant); err != nil {
+ return err
+ }
+ out = append(out, variant)
+ }
+ *d = out
+ return nil
+ case yaml.AliasNode:
+ return d.UnmarshalYAML(value.Alias)
+ case 0:
+ // An omitted/zero node leaves the list empty.
+ *d = nil
+ return nil
+ default:
+ return fmt.Errorf("description must be a string or a list of variants")
+ }
+}
+
+func (d DescList) MarshalYAML() (any, error) {
+ if len(d) == 0 {
+ return []DescVariant{}, nil
+ }
+ if len(d) == 1 && d[0].Condition == nil {
+ return d[0].Text, nil
+ }
+ return []DescVariant(d), nil
+}
diff --git a/internal/behavior/desc_test.go b/internal/behavior/desc_test.go
new file mode 100644
index 0000000..d506530
--- /dev/null
+++ b/internal/behavior/desc_test.go
@@ -0,0 +1,86 @@
+package behavior
+
+import (
+ "strings"
+ "testing"
+
+ "gopkg.in/yaml.v3"
+)
+
+func TestDescListScalarForm(t *testing.T) {
+ var wrap struct {
+ Description DescList `yaml:"description"`
+ }
+ if err := yaml.Unmarshal([]byte("description: \"A dark room.\"\n"), &wrap); err != nil {
+ t.Fatalf("scalar description failed to decode: %v", err)
+ }
+ if len(wrap.Description) != 1 || wrap.Description[0].Text != "A dark room." {
+ t.Fatalf("scalar decode = %#v", wrap.Description)
+ }
+}
+
+func TestDescListSequenceForm(t *testing.T) {
+ src := "description:\n - text: \"Default\"\n - text: \"Lit\"\n condition:\n player_flag: lamp_lit\n"
+ var wrap struct {
+ Description DescList `yaml:"description"`
+ }
+ if err := yaml.Unmarshal([]byte(src), &wrap); err != nil {
+ t.Fatalf("sequence description failed to decode: %v", err)
+ }
+ if len(wrap.Description) != 2 {
+ t.Fatalf("got %d variants, want 2", len(wrap.Description))
+ }
+ if wrap.Description[1].Condition == nil {
+ t.Fatal("conditional variant lost its condition")
+ }
+}
+
+func TestDescListRoundTripPreservesCondition(t *testing.T) {
+ original := DescList{
+ {Text: "Default"},
+ {Text: "Lit", Condition: &Condition{PlayerFlag: "lamp_lit"}},
+ }
+ data, err := yaml.Marshal(struct {
+ Description DescList `yaml:"description"`
+ }{Description: original})
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ // A conditional list must not collapse to the scalar shorthand.
+ if strings.Contains(string(data), "description: Default") {
+ t.Fatalf("conditional list collapsed to scalar:\n%s", data)
+ }
+ var loaded struct {
+ Description DescList `yaml:"description"`
+ }
+ if err := yaml.Unmarshal(data, &loaded); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(loaded.Description) != 2 || loaded.Description[1].Condition == nil {
+ t.Fatalf("round trip lost structure: %#v", loaded.Description)
+ }
+}
+
+func TestDescListSingleVariantMarshalsAsScalar(t *testing.T) {
+ data, err := yaml.Marshal(struct {
+ Description DescList `yaml:"description"`
+ }{Description: DescList{{Text: "A dark room."}}})
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if !strings.Contains(string(data), "description: A dark room.") {
+ t.Fatalf("single unconditional variant should use the scalar form:\n%s", data)
+ }
+}
+
+func TestDescListEmptyStillMarshals(t *testing.T) {
+ data, err := yaml.Marshal(struct {
+ Description DescList `yaml:"description"`
+ }{})
+ if err != nil {
+ t.Fatalf("marshal empty: %v", err)
+ }
+ if !strings.Contains(string(data), "description:") {
+ t.Fatalf("empty description should still emit a key:\n%s", data)
+ }
+}
diff --git a/internal/behavior/store.go b/internal/behavior/store.go
index 4051836..f2e9350 100644
--- a/internal/behavior/store.go
+++ b/internal/behavior/store.go
@@ -12,12 +12,32 @@ import (
"gopkg.in/yaml.v3"
)
+// excludedContentPath reports whether path lies under a directory or file
+// whose name starts with "_", relative to root. That is the project's
+// convention for work-in-progress content that must not be loaded, validated,
+// indexed, or published while a builder is still authoring it.
+func excludedContentPath(root, path string) bool {
+ rel, err := filepath.Rel(root, path)
+ if err != nil {
+ return false
+ }
+ for _, part := range strings.Split(rel, string(os.PathSeparator)) {
+ if strings.HasPrefix(part, "_") {
+ return true
+ }
+ }
+ return false
+}
+
func BuildPathIndex(dir string) map[string]string {
index := make(map[string]string)
filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") {
return nil
}
+ if excludedContentPath(dir, path) {
+ return nil
+ }
id := strings.TrimSuffix(d.Name(), ".yaml")
index[id] = path
return nil
@@ -31,6 +51,9 @@ func BuildRoomIndex(dir string) map[int]string {
if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") {
return nil
}
+ if excludedContentPath(dir, path) {
+ return nil
+ }
id := strings.TrimSuffix(d.Name(), ".yaml")
n, convErr := strconv.Atoi(id)
if convErr != nil {
@@ -39,6 +62,11 @@ func BuildRoomIndex(dir string) map[int]string {
// skip it here and let validation flag it.
return nil
}
+ // Room IDs are positive; every other code path treats <= 0 as "no
+ // room". A 0.yaml/-1.yaml must not become a real room.
+ if n <= 0 {
+ return nil
+ }
index[n] = path
return nil
})
@@ -59,6 +87,9 @@ func WalkYAMLDir(dir string, fn func(path, id string, data []byte) error) error
if d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") {
return nil
}
+ if excludedContentPath(dir, path) {
+ return nil
+ }
data, readErr := os.ReadFile(path)
if readErr != nil {
if os.IsNotExist(readErr) {
@@ -82,6 +113,9 @@ func CheckDuplicateIDs(dir string) []Duplicate {
if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") {
return nil
}
+ if excludedContentPath(dir, path) {
+ return nil
+ }
id := strings.TrimSuffix(d.Name(), ".yaml")
seen[id] = append(seen[id], path)
return nil
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
index 62a83a5..53ed0a9 100644
--- a/internal/casino/baccarat.go
+++ b/internal/casino/baccarat.go
@@ -241,7 +241,15 @@ func (t *baccaratTable) markDisconnected(name string) []Event {
return nil
}
if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil {
- p.Wallet.RefundOutstanding()
+ if _, _, err := p.Wallet.RefundOutstanding(); err != nil {
+ // The stake stays pending on disk and startup recovery will return
+ // it, but tell the departing player instead of silently swallowing
+ // the failure.
+ t.removeSeatLocked(name)
+ events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)}
+ events = append(events, t.private(name, "Your stake could not be returned just now; it stays pending and will be refunded after the next server restart."))
+ return t.closeIfEmptyLocked(events)
+ }
}
t.removeSeatLocked(name)
events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)}
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index cb71034..ed56d15 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -777,6 +777,7 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event {
if index < 0 {
return nil
}
+ refundFailed := false
if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil {
if t.phase == blackjackPlaying || t.phase == blackjackInsurance {
// A disconnected player forfeits the round in flight: consume
@@ -791,12 +792,17 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event {
} else {
// No card has been dealt for this stake yet, so refund it: nothing
// the bettor paid for has been revealed or played.
- p.Wallet.RefundOutstanding()
+ if _, _, err := p.Wallet.RefundOutstanding(); err != nil {
+ refundFailed = true
+ }
}
}
t.removeSeatLocked(name)
events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)}
events = t.closeIfEmptyLocked(events)
+ if refundFailed {
+ events = append(events, t.private(name, "Your stake could not be returned just now; it stays pending and will be refunded after the next server restart."))
+ }
if t.phase != blackjackPlaying {
// Betting and insurance resolve against the remaining seats on the
// next tick; waiting has no round in flight.
diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go
index 6181883..9ad1776 100644
--- a/internal/casino/config_validate.go
+++ b/internal/casino/config_validate.go
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"math"
+ "sort"
"strings"
)
@@ -21,8 +22,8 @@ func (c MachineConfig) Validate() error {
return fmt.Errorf("unsupported slots variant %q", c.Variant)
}
}
- if c.SpinTicks < 0 {
- return fmt.Errorf("spin_ticks must not be negative")
+ if c.SpinTicks < 0 || c.SpinTicks > maxSpinTicks {
+ return fmt.Errorf("spin_ticks must be between 0 and %d", maxSpinTicks)
}
if !finiteNonnegative(c.Payout) || c.Payout > 1 {
return fmt.Errorf("payout must be finite and between 0 and 1 (0 uses the default)")
@@ -37,16 +38,40 @@ func (c TableConfig) Validate() error {
if err := validateIdentityAndBets(c.ID, c.MinBet, c.MaxBet); err != nil {
return err
}
- if c.MaxPlayers < 0 || c.BettingTicks < 0 {
- return fmt.Errorf("max_players and betting_ticks must not be negative")
+ if c.MaxPlayers < 0 || c.MaxPlayers > maxTablePlayers {
+ return fmt.Errorf("max_players must be between 0 and %d", maxTablePlayers)
+ }
+ if c.BettingTicks < 0 || c.BettingTicks > maxBettingTicks {
+ return fmt.Errorf("betting_ticks must be between 0 and %d", maxBettingTicks)
}
def, ok := tableGameDefFor(c.Game)
if !ok {
return fmt.Errorf("unsupported table game %q", c.Game)
}
+ if foreign := foreignRuleBlocks(c); len(foreign) > 0 {
+ return fmt.Errorf("table game %q has a rule block for a different game (%s); remove it or set game to match", c.Game, strings.Join(foreign, ", "))
+ }
return def.validate(c)
}
+// foreignRuleBlocks names every game-specific rule block present on c that
+// does not belong to c.Game, in a deterministic order. Each game registers its
+// own block in tableGames, so a new game widens this automatically instead of
+// needing another hand-maintained switch.
+func foreignRuleBlocks(c TableConfig) []string {
+ var foreign []string
+ for game, def := range tableGames {
+ if game == c.Game || def.hasRuleBlock == nil || def.ruleBlockName == "" {
+ continue
+ }
+ if def.hasRuleBlock(c) {
+ foreign = append(foreign, def.ruleBlockName)
+ }
+ }
+ sort.Strings(foreign)
+ return foreign
+}
+
// normalizeBlackjackTable applies the blackjack rule defaults. It is the
// game's Normalize entry in tableGames.
func normalizeBlackjackTable(c TableConfig) TableConfig {
@@ -66,8 +91,8 @@ func validateBlackjackTable(c TableConfig) error {
if err := validateShoe(b.Decks, b.ShuffleAt); err != nil {
return err
}
- if b.MaxSplitHands < 0 || b.ActionTicks < 0 {
- return fmt.Errorf("max_split_hands and action_ticks must not be negative")
+ if b.MaxSplitHands < 0 || b.ActionTicks < 0 || b.ActionTicks > maxActionTicks {
+ return fmt.Errorf("max_split_hands must not be negative and action_ticks must be between 0 and %d", maxActionTicks)
}
if !finiteNonnegative(b.BlackjackPayout) || !finiteNonnegative(b.InsurancePayout) {
return fmt.Errorf("blackjack_payout and insurance_payout must be finite and nonnegative")
@@ -130,6 +155,22 @@ func finiteNonnegative(n float64) bool {
return !math.IsNaN(n) && !math.IsInf(n, 0) && n >= 0
}
+// hasBlackjackRuleBlock reports whether c carries any explicit blackjack rule.
+// Used by foreignRuleBlocks to reject a block authored for another game.
+func hasBlackjackRuleBlock(c TableConfig) bool {
+ b := c.Blackjack
+ return b.Decks != 0 || b.ShuffleAt != 0 || b.BlackjackPayout != 0 ||
+ b.DealerHitSoft17 != nil || b.MaxSplitHands != 0 || b.ResplitAces != nil ||
+ b.DoubleAfterSplit != nil || b.DoubleSplitBlackjack != nil ||
+ b.InsurancePayout != 0 || b.Surrender != nil || b.ActionTicks != 0 ||
+ b.SplitUnlikeTenValues != nil
+}
+
+func hasBaccaratRuleBlock(c TableConfig) bool {
+ b := c.Baccarat
+ return b.Decks != 0 || b.ShuffleAt != 0 || b.TiePayout != 0 || b.BankerPayout != 0
+}
+
func validateShoe(decks int, shuffleAt float64) error {
// A generous resource limit prevents malformed content from allocating an
// unbounded shoe. Zero retains the existing eight-deck default.
@@ -158,6 +199,18 @@ const (
maxBankerPayout = 0.99
)
+// Authoring limits for tick-based windows. A typo such as betting_ticks:
+// 2000000 would otherwise publish a table that holds its betting window open
+// for days; while the window is open a committed stake cannot be withdrawn, so
+// the bettor is trapped. These are generous relative to normal play (a tick is
+// 600ms).
+const (
+ maxTablePlayers = 64
+ maxBettingTicks = 3600
+ maxActionTicks = 3600
+ maxSpinTicks = 3600
+)
+
// maxSlotSymbolLen is the fixed width of one reel cell in the renderer
// (" " + symbol + padding + " "); a longer id would make the renderer's
// width padding negative and panic.
@@ -187,6 +240,9 @@ const baccaratRoundCards = 6
// the geometry here keeps that gate meaningful.
func (p SlotProfile) Validate() error {
profile := p.Normalize()
+ if len(profile.Symbols) == 0 {
+ return fmt.Errorf("slot profile has no symbols")
+ }
if profile.Rows != slotGridRows || profile.Reels != slotGridReels {
return fmt.Errorf("unsupported slot geometry %dx%d: the runtime evaluator supports %dx%d only",
profile.Rows, profile.Reels, slotGridRows, slotGridReels)
@@ -265,7 +321,7 @@ func machineSlotProfile(c MachineConfig) SlotProfile {
}
// ValidateRoomConfigs checks the shared room-local ID namespace as well as
-// individual definitions. Legacy slots under casino_tables remain supported.
+// individual definitions.
//
// It also enforces one table/machine per game per room: a room is addressed
// by game name ("play blackjack"), so a second entry for the same game (or
@@ -291,13 +347,6 @@ func ValidateRoomConfigs(tables []TableConfig, machines []MachineConfig) error {
addressable[key] = id
}
for _, c := range tables {
- if c.Game == GameSlots {
- // A slots "table" is instantiated as the room's slots machine.
- legacy := LegacySlotMachine(c)
- check("legacy slots", c.ID, legacy.Validate())
- checkAddressable(strings.ToLower(string(GameSlots)), c.ID)
- continue
- }
check("table", c.ID, c.Validate())
checkAddressable(strings.ToLower(string(c.Game)), c.ID)
}
diff --git a/internal/casino/config_validate_test.go b/internal/casino/config_validate_test.go
index f0f87f5..5e72054 100644
--- a/internal/casino/config_validate_test.go
+++ b/internal/casino/config_validate_test.go
@@ -75,15 +75,16 @@ func TestTableConfigValidation(t *testing.T) {
}
}
-func TestRoomCasinoIDsAndLegacySlots(t *testing.T) {
- tables := []TableConfig{{ID: "slots", Game: GameSlots}}
+func TestRoomCasinoIDsRejectDuplicates(t *testing.T) {
machines := []MachineConfig{{ID: "slots", Game: GameSlots}}
- if err := ValidateRoomConfigs(tables, nil); err != nil {
- t.Fatalf("legacy slots rejected: %v", err)
- }
- if ValidateRoomConfigs(tables, machines) == nil || ValidateRoomConfigs(nil, append(machines, machines...)) == nil {
+ if ValidateRoomConfigs(nil, append(machines, machines...)) == nil {
t.Fatal("duplicate casino IDs accepted")
}
+
+ // Slots are machines only: declaring them under casino_tables is invalid.
+ if err := ValidateRoomConfigs([]TableConfig{{ID: "slots", Game: GameSlots}}, nil); err == nil {
+ t.Fatal("slots table accepted; slots must be declared under casino_machines")
+ }
}
func TestManagerGamesHaveIndependentRandomStreams(t *testing.T) {
@@ -122,3 +123,40 @@ func TestScaleToRTPDoesNotChangeSourceProfile(t *testing.T) {
t.Fatal("profiles share mutable paytables")
}
}
+
+func TestSlotProfileEmptySymbolsRejected(t *testing.T) {
+ // A new slotVariants entry that forgets its symbol table must not validate
+ // and silently play the built-in cabinet.
+ if err := (SlotProfile{}).Validate(); err == nil {
+ t.Fatal("empty slot profile validated; expected an error")
+ }
+}
+
+func TestTableConfigRejectsForeignRuleBlock(t *testing.T) {
+ c := TableConfig{ID: "t", Game: GameBaccarat, Blackjack: BlackjackConfig{BlackjackPayout: 15}}
+ if err := c.Validate(); err == nil {
+ t.Fatal("baccarat table with a blackjack rule block validated; expected an error")
+ }
+ ok := TableConfig{ID: "t", Game: GameBlackjack}
+ if err := ok.Validate(); err != nil {
+ t.Fatalf("valid blackjack table rejected: %v", err)
+ }
+}
+
+func TestTableConfigTimerCaps(t *testing.T) {
+ cases := map[string]TableConfig{
+ "huge betting window": {ID: "t", Game: GameBaccarat, BettingTicks: maxBettingTicks + 1},
+ "huge action window": {ID: "t", Game: GameBlackjack, Blackjack: BlackjackConfig{ActionTicks: maxActionTicks + 1}},
+ }
+ for name, c := range cases {
+ t.Run(name, func(t *testing.T) {
+ if err := c.Validate(); err == nil {
+ t.Fatal("expected an error")
+ }
+ })
+ }
+ m := MachineConfig{ID: "m", Game: GameSlots, SpinTicks: maxSpinTicks + 1}
+ if err := m.Validate(); err == nil {
+ t.Fatal("expected an error for an oversized spin window")
+ }
+}
diff --git a/internal/casino/core.go b/internal/casino/core.go
index 0b9d0af..7143841 100644
--- a/internal/casino/core.go
+++ b/internal/casino/core.go
@@ -143,11 +143,7 @@ func resolveStakeIDs(w Wallet, ids []string) {
if w == nil {
return
}
- for _, id := range ids {
- if id != "" {
- w.ResolveStake(id)
- }
- }
+ w.ResolveStakesByIDs(ids...)
}
// settleStakeOutcome applies one wager's outcome through the wallet and
@@ -175,12 +171,10 @@ func settleStakeOutcome(w Wallet, payout int, stakeIDs []string, payoutErr error
if payout > 0 {
return w.PayCredits(payout, stakeIDs...) != nil
}
- // A zero payout is a loss: consume the stakes so recovery cannot refund a
- // round that resolved.
- for _, id := range stakeIDs {
- if id != "" {
- w.ResolveStake(id)
- }
+ // A zero payout is a loss: consume the stakes in one save so recovery can
+ // never refund a round that resolved.
+ if w != nil {
+ w.ResolveStakesByIDs(stakeIDs...)
}
return false
}
diff --git a/internal/casino/hardening_casino_validate_test.go b/internal/casino/hardening_casino_validate_test.go
index 1d27d54..dfec79b 100644
--- a/internal/casino/hardening_casino_validate_test.go
+++ b/internal/casino/hardening_casino_validate_test.go
@@ -162,13 +162,13 @@ func TestRoomRejectsASecondEntryForOneGame(t *testing.T) {
if ok != nil {
t.Fatalf("one entry per game rejected: %v", ok)
}
- // A legacy slots table and a slots machine both answer to "slots".
- legacy := ValidateRoomConfigs(
- []TableConfig{{ID: "legacy_slots", Game: GameSlots}},
- []MachineConfig{{ID: "slots", Game: GameSlots}},
- )
- if legacy == nil || !strings.Contains(legacy.Error(), "duplicate casino game") {
- t.Fatalf("legacy slots table plus a slots machine accepted: %v", legacy)
+ // Slots are machines only: a slots entry under casino_tables is rejected
+ // outright rather than treated as a machine.
+ if legacy := ValidateRoomConfigs(
+ []TableConfig{{ID: "slots_table", Game: GameSlots}},
+ nil,
+ ); legacy == nil {
+ t.Fatal("slots table accepted; slots must be declared under casino_machines")
}
// Two machines of the same variant are equally unreachable.
twoVariants := ValidateRoomConfigs(nil, []MachineConfig{
diff --git a/internal/casino/manager_test.go b/internal/casino/manager_test.go
index 9b5d35a..a832d53 100644
--- a/internal/casino/manager_test.go
+++ b/internal/casino/manager_test.go
@@ -45,6 +45,14 @@ func (w *testWallet) ResolveStake(stakeID string) {
w.open = kept
}
+// ResolveStakesByIDs consumes the listed stakes in one step, mirroring the
+// production wallet.
+func (w *testWallet) ResolveStakesByIDs(stakeIDs ...string) {
+ for _, id := range stakeIDs {
+ w.ResolveStake(id)
+ }
+}
+
// ResolveStakes mirrors the production wallet: every outstanding stake is
// settled (no longer refundable).
func (w *testWallet) ResolveStakes() { w.open = nil }
diff --git a/internal/casino/registry.go b/internal/casino/registry.go
index 9a973d4..26acd8c 100644
--- a/internal/casino/registry.go
+++ b/internal/casino/registry.go
@@ -13,6 +13,12 @@ type tableGameDef struct {
validate func(TableConfig) error
// normalize applies this game's rule defaults to a table config.
normalize func(TableConfig) TableConfig
+ // ruleBlockName is the YAML rule block this game owns (for error messages),
+ // and hasRuleBlock reports whether that block carries any explicit value.
+ // Together they let TableConfig.Validate reject a block authored for a
+ // different game without a hand-maintained switch.
+ ruleBlockName string
+ hasRuleBlock func(TableConfig) bool
// new builds the game. It is called only after validate passes and the
// shared table config has been normalized.
new func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame
@@ -26,15 +32,19 @@ var tableGames map[GameName]tableGameDef
func init() {
tableGames = map[GameName]tableGameDef{
GameBlackjack: {
- validate: validateBlackjackTable,
- normalize: normalizeBlackjackTable,
+ validate: validateBlackjackTable,
+ normalize: normalizeBlackjackTable,
+ ruleBlockName: "blackjack",
+ hasRuleBlock: hasBlackjackRuleBlock,
new: func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame {
return newBlackjackTable(roomID, cfg, rng)
},
},
GameBaccarat: {
- validate: validateBaccaratTable,
- normalize: normalizeBaccaratTable,
+ validate: validateBaccaratTable,
+ normalize: normalizeBaccaratTable,
+ ruleBlockName: "baccarat",
+ hasRuleBlock: hasBaccaratRuleBlock,
new: func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame {
return newBaccaratTable(roomID, cfg, rng)
},
diff --git a/internal/casino/slots.go b/internal/casino/slots.go
index 217e9fb..419cfcf 100644
--- a/internal/casino/slots.go
+++ b/internal/casino/slots.go
@@ -76,9 +76,9 @@ func (p SlotProfile) Normalize() SlotProfile {
if p.FreeSpinCount <= 0 {
p.FreeSpinCount = 8
}
- if len(p.Symbols) == 0 {
- return DefaultHuffAndPuffProfile()
- }
+ // An empty symbol table is NOT defaulted: a new variant that forgets its
+ // symbols must fail validation, not silently play the built-in cabinet.
+ // machineSlotProfile is the one place that starts from a real profile.
return p
}
diff --git a/internal/casino/types.go b/internal/casino/types.go
index e057ba6..2d4ba02 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -129,13 +129,6 @@ type MachineConfig struct {
Payout float64 `yaml:"payout,omitempty"`
}
-// LegacySlotMachine converts a legacy slots TableConfig (older rooms declared
-// slots under casino_tables) into the MachineConfig the room actually
-// instantiates. Shared by validation and room lookup so the two cannot drift.
-func LegacySlotMachine(c TableConfig) MachineConfig {
- return MachineConfig{ID: c.ID, Game: GameSlots, MinBet: c.MinBet, MaxBet: c.MaxBet}
-}
-
func (c MachineConfig) Normalize() MachineConfig {
if c.MinBet <= 0 {
c.MinBet = 1
@@ -216,6 +209,10 @@ type Wallet interface {
// ResolveStake consumes ONE pending stake by wallet handle (a single
// hand paid or lost while a sibling hand's round is still in flight).
ResolveStake(stakeID string)
+ // ResolveStakesByIDs consumes the listed pending stakes in ONE save. It is
+ // the batched form of ResolveStake used when a losing round has several
+ // stakes (split/double hands) so they are not committed one save at a time.
+ ResolveStakesByIDs(stakeIDs ...string)
// RefundOutstanding returns every pending stake to the player (chips as
// chips, credits as credits) and clears the ledger. An error means the
// refund could not be made durable: nothing was returned and the stakes
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)
diff --git a/internal/net/server.go b/internal/net/server.go
index daf3e47..80710ad 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -31,7 +31,6 @@ const (
StateNewAccount
StateMenu
StateNewCharName
- StateNewCharConfirm
StateRenameAccount
StateRenameChar
StateRenameCharName
@@ -44,9 +43,6 @@ const (
StateDropAllConfirm
StateRecipeChoice
StateHowMany
- StateSmithProduct
- StateFletchProduct
- StateCraftProduct
StateProductChoice
StateColorChoice
StateBank
diff --git a/internal/player/hardening_password_params_test.go b/internal/player/hardening_password_params_test.go
index 4c171f1..32b399b 100644
--- a/internal/player/hardening_password_params_test.go
+++ b/internal/player/hardening_password_params_test.go
@@ -5,12 +5,7 @@ package player
// (e.g. argon2id:1:4294967295:...) must fail the check, not request a
// multi-TiB allocation that kills the process.
-import (
- "crypto/sha256"
- "encoding/base64"
- "encoding/hex"
- "testing"
-)
+import "testing"
func TestCheckPasswordRejectsOutOfRangeArgonParams(t *testing.T) {
// Enormous memory cost (0xFFFFFFFF KiB) with an otherwise well-formed
@@ -33,7 +28,7 @@ func TestCheckPasswordRejectsOutOfRangeArgonParams(t *testing.T) {
}
}
-func TestCheckPasswordAcceptsNormalAndLegacyHashes(t *testing.T) {
+func TestCheckPasswordAcceptsNormalHashes(t *testing.T) {
hashed, err := HashPassword("s3cret")
if err != nil {
t.Fatal(err)
@@ -44,19 +39,4 @@ func TestCheckPasswordAcceptsNormalAndLegacyHashes(t *testing.T) {
if CheckPassword("wrong", hashed) {
t.Fatal("wrong password accepted")
}
-
- // Legacy single-pass sha256 hashes must keep verifying so old accounts
- // can still log in and be upgraded.
- salt := []byte("sixteen-byte-slt")
- sum := sha256.Sum256(append(append([]byte{}, salt...), []byte("pw")...))
- legacy := "sha256:" + base64.RawStdEncoding.EncodeToString(salt) + ":" + hex.EncodeToString(sum[:])
- if !CheckPassword("pw", legacy) {
- t.Fatal("legacy sha256 hash rejected")
- }
- if CheckPassword("nope", legacy) {
- t.Fatal("wrong password accepted against legacy sha256 hash")
- }
- if !NeedsRehash(legacy) {
- t.Fatal("legacy sha256 hash not flagged for rehash")
- }
}
diff --git a/internal/player/password.go b/internal/player/password.go
index de5fe5f..706ef45 100644
--- a/internal/player/password.go
+++ b/internal/player/password.go
@@ -2,7 +2,6 @@ package player
import (
"crypto/rand"
- "crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
@@ -19,10 +18,8 @@ import (
//
// argon2id:<time>:<memoryKiB>:<threads>:<b64salt>:<hexkey>
//
-// Legacy accounts may still carry the old single-pass salted SHA-256 hash
-// ("sha256:<b64salt>:<hexhash>"); CheckPassword verifies both, and the
-// login paths rehash to Argon2id on successful verification (see
-// NeedsRehash).
+// Only Argon2id is accepted: the prototype has no password-hash migration and
+// accounts are recreated rather than upgraded.
const (
argonTime = 1
argonMemory = 64 * 1024 // KiB
@@ -78,57 +75,33 @@ func HashPassword(password string) (string, error) {
base64.RawStdEncoding.EncodeToString(salt), hex.EncodeToString(key)), nil
}
-// CheckPassword verifies a password against a stored hash in either the
-// current Argon2id format or the legacy salted SHA-256 format.
+// CheckPassword verifies a password against a stored Argon2id hash.
func CheckPassword(password, stored string) bool {
parts := strings.SplitN(stored, ":", 6)
- if len(parts) == 0 {
+ if len(parts) == 0 || parts[0] != "argon2id" {
return false
}
- switch parts[0] {
- case "argon2id":
- if len(parts) != 6 {
- return false
- }
- t, errT := strconv.Atoi(parts[1])
- m, errM := strconv.Atoi(parts[2])
- p, errP := strconv.Atoi(parts[3])
- salt, errSalt := base64.RawStdEncoding.DecodeString(parts[4])
- expected, errKey := hex.DecodeString(parts[5])
- if errT != nil || errM != nil || errP != nil || errSalt != nil || errKey != nil {
- return false
- }
- if t < argonMinTime || t > argonMaxTime ||
- m < argonMinMemory || m > argonMaxMemory ||
- p < argonMinThreads || p > argonMaxThreads ||
- len(expected) < argonMinKeyLen || len(expected) > argonMaxKeyLen {
- // Out-of-range parameters: fail the check instead of trusting the
- // stored values and requesting an enormous allocation.
- return false
- }
- acquireHashSlot()
- key := argon2.IDKey([]byte(password), salt, uint32(t), uint32(m), uint8(p), uint32(len(expected)))
- releaseHashSlot()
- return subtle.ConstantTimeCompare(key, expected) == 1
- case "sha256":
- legacy := strings.SplitN(stored, ":", 3)
- if len(legacy) != 3 {
- return false
- }
- salt, err := base64.RawStdEncoding.DecodeString(legacy[1])
- if err != nil {
- return false
- }
- hash := sha256.Sum256(append(salt, []byte(password)...))
- return subtle.ConstantTimeCompare([]byte(legacy[2]), []byte(fmt.Sprintf("%x", hash))) == 1
- default:
+ if len(parts) != 6 {
return false
}
-}
-
-// NeedsRehash reports whether a stored hash uses the legacy single-pass
-// SHA-256 scheme. Callers rehash with HashPassword after a successful
-// verification and persist the new hash (transparent upgrade on login).
-func NeedsRehash(stored string) bool {
- return strings.HasPrefix(stored, "sha256:")
+ t, errT := strconv.Atoi(parts[1])
+ m, errM := strconv.Atoi(parts[2])
+ p, errP := strconv.Atoi(parts[3])
+ salt, errSalt := base64.RawStdEncoding.DecodeString(parts[4])
+ expected, errKey := hex.DecodeString(parts[5])
+ if errT != nil || errM != nil || errP != nil || errSalt != nil || errKey != nil {
+ return false
+ }
+ if t < argonMinTime || t > argonMaxTime ||
+ m < argonMinMemory || m > argonMaxMemory ||
+ p < argonMinThreads || p > argonMaxThreads ||
+ len(expected) < argonMinKeyLen || len(expected) > argonMaxKeyLen {
+ // Out-of-range parameters: fail the check instead of trusting the
+ // stored values and requesting an enormous allocation.
+ return false
+ }
+ acquireHashSlot()
+ key := argon2.IDKey([]byte(password), salt, uint32(t), uint32(m), uint8(p), uint32(len(expected)))
+ releaseHashSlot()
+ return subtle.ConstantTimeCompare(key, expected) == 1
}
diff --git a/internal/player/password_test.go b/internal/player/password_test.go
index 82b580b..2a7d596 100644
--- a/internal/player/password_test.go
+++ b/internal/player/password_test.go
@@ -1,9 +1,6 @@
package player
import (
- "crypto/sha256"
- "encoding/base64"
- "fmt"
"strings"
"testing"
)
@@ -29,30 +26,15 @@ func TestHashPasswordUsesArgon2idAndVerifies(t *testing.T) {
}
}
-func TestCheckPasswordVerifiesLegacySHA256(t *testing.T) {
- salt := make([]byte, 16)
- for i := range salt {
- salt[i] = byte(i)
- }
- sum := sha256.Sum256(append(salt, []byte("legacy pw")...))
- legacy := fmt.Sprintf("sha256:%s:%x", base64.RawStdEncoding.EncodeToString(salt), sum)
-
- if !CheckPassword("legacy pw", legacy) {
- t.Fatal("correct legacy password rejected")
- }
- if CheckPassword("wrong", legacy) {
- t.Fatal("wrong legacy password accepted")
- }
- if !NeedsRehash(legacy) {
- t.Fatal("legacy hash not flagged for rehash")
- }
- if NeedsRehash("argon2id:1:65536:2:c2FsdA:x") {
- t.Fatal("argon2id hash flagged for rehash")
- }
-}
-
func TestCheckPasswordRejectsGarbage(t *testing.T) {
- for _, stored := range []string{"", "plain", "argon2id:1:2:3:no-salt:zz", "unknown:v1:salt:deadbeef"} {
+ for _, stored := range []string{
+ "",
+ "plain",
+ "argon2id:1:2:3:no-salt:zz",
+ "unknown:v1:salt:deadbeef",
+ // Legacy single-pass SHA-256 hashes are no longer accepted.
+ "sha256:AAAAAAAAAAAAAAAAAAAAAA:0011223344556677889900112233445566778899001122334455667788990011",
+ } {
if CheckPassword("anything", stored) {
t.Fatalf("garbage hash accepted: %q", stored)
}
diff --git a/internal/player/roomset.go b/internal/player/roomset.go
index 3565bb6..d7e1b47 100644
--- a/internal/player/roomset.go
+++ b/internal/player/roomset.go
@@ -2,75 +2,103 @@ package player
import (
"encoding/base64"
- "math/bits"
+ "encoding/binary"
+ "fmt"
+ "sort"
+ "strings"
"gopkg.in/yaml.v3"
)
+// RoomSet is the set of room IDs a character has visited, used for the map
+// discovery view.
+//
+// It is stored sparsely: room IDs are filename-derived and may be large and
+// widely spaced (the shipped example rooms run from 1001 to 99999301), so an
+// ID-indexed bitset would grow with the *largest* ID rather than the number of
+// visited rooms. A sparse map keeps memory proportional to the visited count,
+// and the YAML encoding is a sorted, delta-encoded uvarint blob (see
+// MarshalYAML) which is a handful of bytes for a typical character regardless
+// of how high the IDs run.
+//
+// The zero value is a valid empty set. Copies share the underlying map, so a
+// RoomSet should not be copied after a concurrent mutation (the game layer only
+// touches it on the owner goroutine).
type RoomSet struct {
- bits []byte
+ ids map[int]struct{}
+}
+
+// roomSetV1Prefix tags the delta-encoded encoding. The colon is safe inside a
+// YAML plain scalar: ':' is only a mapping indicator when followed by a space,
+// and ':' is not in the base64 alphabet, so the prefix cannot be confused with
+// payload bytes.
+const roomSetV1Prefix = "v1:"
+
+// roomSetMaxID bounds decoded room IDs. IDs are filename-derived and fit
+// comfortably in an int32; rejecting anything larger keeps a corrupt delta
+// blob from synthesizing absurd IDs.
+const roomSetMaxID = 1<<31 - 1
+
+func (rs *RoomSet) ensure() {
+ if rs.ids == nil {
+ rs.ids = make(map[int]struct{})
+ }
}
func (rs *RoomSet) Add(roomID int) {
if roomID < 0 {
return
}
- byteIdx := roomID / 8
- if byteIdx >= len(rs.bits) {
- nb := make([]byte, byteIdx+1)
- copy(nb, rs.bits)
- rs.bits = nb
- }
- rs.bits[byteIdx] |= 1 << (roomID % 8)
+ rs.ensure()
+ rs.ids[roomID] = struct{}{}
}
func (rs *RoomSet) Has(roomID int) bool {
- if roomID < 0 {
+ if roomID < 0 || rs.ids == nil {
return false
}
- byteIdx := roomID / 8
- return byteIdx < len(rs.bits) && rs.bits[byteIdx]&(1<<(roomID%8)) != 0
+ _, ok := rs.ids[roomID]
+ return ok
}
func (rs *RoomSet) Len() int {
- n := 0
- for _, b := range rs.bits {
- n += bits.OnesCount8(b)
+ if rs.ids == nil {
+ return 0
}
- return n
+ return len(rs.ids)
}
func (rs RoomSet) IsZero() bool {
- return len(rs.bits) == 0
+ return len(rs.ids) == 0
}
+// Keys returns the visited room IDs in ascending order.
func (rs *RoomSet) Keys() []int {
- var keys []int
- for byteIdx, b := range rs.bits {
- if b == 0 {
- continue
- }
- base := byteIdx * 8
- for bitIdx := 0; bitIdx < 8; bitIdx++ {
- if b&(1<<bitIdx) != 0 {
- keys = append(keys, base+bitIdx)
- }
- }
+ if len(rs.ids) == 0 {
+ return nil
}
+ keys := make([]int, 0, len(rs.ids))
+ for id := range rs.ids {
+ keys = append(keys, id)
+ }
+ sort.Ints(keys)
return keys
}
+// Rekey remaps every visited room through idMap, leaving unmapped IDs in place.
+// It returns the new set and whether any ID changed. The receiver is not
+// modified.
func (rs *RoomSet) Rekey(idMap map[int]int) (RoomSet, bool) {
var next RoomSet
changed := false
- for _, k := range rs.Keys() {
- newK, ok := idMap[k]
+ for id := range rs.ids {
+ newID, ok := idMap[id]
if !ok {
- newK = k
- } else if newK != k {
+ newID = id
+ } else if newID != id {
changed = true
}
- next.Add(newK)
+ next.Add(newID)
}
return next, changed
}
@@ -83,13 +111,29 @@ func NewRoomSetFrom(ids ...int) RoomSet {
return rs
}
+// MarshalYAML emits the current encoding: ascending IDs as delta-encoded
+// uvarints, base64-encoded behind the "v1:" tag. IDs come from filenames, so a
+// small delta usually costs one byte and the total is O(visited).
func (rs RoomSet) MarshalYAML() (any, error) {
- return base64.StdEncoding.EncodeToString(rs.bits), nil
+ if len(rs.ids) == 0 {
+ return "", nil
+ }
+ keys := rs.Keys()
+ buf := make([]byte, 0, len(keys)*2)
+ prev := 0
+ for _, id := range keys {
+ buf = binary.AppendUvarint(buf, uint64(id-prev))
+ prev = id
+ }
+ return roomSetV1Prefix + base64.StdEncoding.EncodeToString(buf), nil
}
+// UnmarshalYAML accepts only the v1 delta encoding (plus null/empty for the
+// zero value). There is deliberately no legacy decoding: the game is a
+// prototype and character files are recreated rather than migrated.
func (rs *RoomSet) UnmarshalYAML(value *yaml.Node) error {
- if value.Kind == yaml.ScalarNode && (value.Tag == "!!null" || value.Value == "null") {
- rs.bits = nil
+ rs.ids = nil
+ if value.Kind == yaml.ScalarNode && (value.Tag == "!!null" || value.Value == "null" || value.Value == "") {
return nil
}
var s string
@@ -97,13 +141,31 @@ func (rs *RoomSet) UnmarshalYAML(value *yaml.Node) error {
return err
}
if s == "" {
- rs.bits = nil
return nil
}
- b, err := base64.StdEncoding.DecodeString(s)
+ if !strings.HasPrefix(s, roomSetV1Prefix) {
+ return fmt.Errorf("rooms_visited: unrecognized encoding, want %q prefix", roomSetV1Prefix)
+ }
+ return rs.decodeDelta(s[len(roomSetV1Prefix):])
+}
+
+func (rs *RoomSet) decodeDelta(encoded string) error {
+ raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
- return err
+ return fmt.Errorf("rooms_visited: %w", err)
+ }
+ prev := 0
+ for len(raw) > 0 {
+ delta, n := binary.Uvarint(raw)
+ if n <= 0 {
+ return fmt.Errorf("rooms_visited: corrupt delta encoding")
+ }
+ raw = raw[n:]
+ if delta > roomSetMaxID || prev > roomSetMaxID-int(delta) {
+ return fmt.Errorf("rooms_visited: room ID out of range")
+ }
+ prev += int(delta)
+ rs.Add(prev)
}
- rs.bits = b
return nil
}
diff --git a/internal/player/roomset_test.go b/internal/player/roomset_test.go
index 2b6b9cb..38bd993 100644
--- a/internal/player/roomset_test.go
+++ b/internal/player/roomset_test.go
@@ -1,6 +1,8 @@
package player
import (
+ "encoding/base64"
+ "encoding/binary"
"strings"
"testing"
@@ -85,3 +87,59 @@ func TestRoomSetMarshalEmpty(t *testing.T) {
t.Errorf("MarshalYAML empty = %q, want empty string", s)
}
}
+
+// Sparse, high room IDs are the reason RoomSet is not a dense bitset: the
+// encoding must stay proportional to the visited count, not the largest ID.
+func TestRoomSetSparseEncodingIsCompact(t *testing.T) {
+ var rs RoomSet
+ for _, id := range []int{1001, 1002, 1003, 2001, 2006, 99999301} {
+ rs.Add(id)
+ }
+ data, err := yaml.Marshal(PlayerStats{RoomsVisited: rs})
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if len(data) > 200 {
+ t.Fatalf("encoding is not compact for sparse IDs: %d bytes\n%s", len(data), data)
+ }
+
+ var loaded PlayerStats
+ if err := yaml.Unmarshal(data, &loaded); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ for _, id := range []int{1001, 1002, 1003, 2001, 2006, 99999301} {
+ if !loaded.RoomsVisited.Has(id) {
+ t.Errorf("round trip lost room %d", id)
+ }
+ }
+ if loaded.RoomsVisited.Has(5000) {
+ t.Error("round trip invented room 5000")
+ }
+}
+
+// The prototype has no legacy formats: anything without the v1 prefix is a
+// hard error rather than a silently-decoded old bitset.
+func TestRoomSetRejectsUnknownEncoding(t *testing.T) {
+ var wrap struct {
+ Visited RoomSet `yaml:"rooms_visited"`
+ }
+ if err := yaml.Unmarshal([]byte("rooms_visited: AQIDBA==\n"), &wrap); err == nil {
+ t.Fatal("expected error for unprefixed encoding")
+ }
+ if err := yaml.Unmarshal([]byte("rooms_visited: [7, 42, 900]\n"), &wrap); err == nil {
+ t.Fatal("expected error for YAML sequence encoding")
+ }
+}
+
+func TestRoomSetRejectsOutOfRangeDelta(t *testing.T) {
+ // A valid uvarint delta above roomSetMaxID must fail rather than wrap or
+ // synthesize an absurd room ID.
+ buf := binary.AppendUvarint(nil, uint64(roomSetMaxID)+1)
+ encoded := roomSetV1Prefix + base64.StdEncoding.EncodeToString(buf)
+ var wrap struct {
+ Visited RoomSet `yaml:"rooms_visited"`
+ }
+ if err := yaml.Unmarshal([]byte("rooms_visited: "+encoded+"\n"), &wrap); err == nil {
+ t.Fatal("expected error for out-of-range delta")
+ }
+}
diff --git a/internal/player/skill_abbr_test.go b/internal/player/skill_abbr_test.go
new file mode 100644
index 0000000..81039f0
--- /dev/null
+++ b/internal/player/skill_abbr_test.go
@@ -0,0 +1,19 @@
+package player
+
+import "testing"
+
+// Every skill must have an abbreviation. A missing entry silently renders as an
+// empty XP drop and an empty column in the skills display, with no error, so
+// this must fail loudly when a skill is added without its abbreviation.
+func TestEverySkillHasAbbreviation(t *testing.T) {
+ for _, skill := range AllSkills {
+ if SkillAbbr[skill] == "" {
+ t.Errorf("skill %q has no abbreviation", skill)
+ }
+ }
+ for skill := range SkillAbbr {
+ if !ValidSkill(skill) {
+ t.Errorf("SkillAbbr contains unknown skill %q", skill)
+ }
+ }
+}
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index 1c87c67..6341108 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -9,6 +9,7 @@ import (
"sort"
"strings"
+ "gopkg.in/yaml.v3"
"thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/casino"
"thehouseoficarus/internal/color"
@@ -132,6 +133,24 @@ func validateRooms(s Source) []Issue {
id, sp.ID),
})
}
+ // A non-positive quantity is silently skipped by every ground-item
+ // read path, so the spawn is dead content with no feedback.
+ if sp.Quantity <= 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: item spawn %q has quantity %d; it must be positive or the spawn can never appear",
+ id, sp.ID, sp.Quantity),
+ })
+ }
+ if sp.RespawnTicks < 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: item spawn %q has negative respawn_ticks %.1f",
+ id, sp.ID, sp.RespawnTicks),
+ })
+ }
}
var collEntries []roomObjEntry
@@ -1180,8 +1199,51 @@ func validateCourses(s Source) []Issue {
return issues
}
+// validateFlagTriggerFiles validates every file-backed flag trigger under
+// data/triggers. A file that sets neither on_player_flag nor on_global_flag is
+// loaded but indexed nowhere and can never fire, so it is an ERROR; the steps
+// are checked for dangling references like any other trigger block.
+func validateFlagTriggerFiles(s Source) []Issue {
+ var issues []Issue
+ roomIndex := s.World.RoomIndex()
+ itemIDs := s.Items.IDSet()
+ mobIDs := s.Mobs.AllDefIDs()
+ dropIDs := dropTableIDSet(s.DataDir)
+ dir := filepath.Join(s.DataDir, "triggers")
+ if err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
+ var t behavior.Trigger
+ if err := yaml.Unmarshal(data, &t); err != nil {
+ // validateKnownFields already reports the decode error.
+ return nil
+ }
+ rel := strings.TrimPrefix(filepath.ToSlash(path), filepath.ToSlash(s.DataDir)+"/")
+ if t.OnPlayerFlag == "" && t.OnGlobalFlag == "" {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "trigger",
+ Message: fmt.Sprintf("trigger %s: neither on_player_flag nor on_global_flag is set, so it is never indexed and can never fire", rel),
+ })
+ return nil
+ }
+ if t.Condition != nil {
+ issues = append(issues, validateCondition(rel, t.Condition, roomIndex, itemIDs)...)
+ }
+ for si := range t.Steps {
+ issues = append(issues, validateStep(fmt.Sprintf("%s: step[%d]", rel, si), &t.Steps[si], itemIDs, roomIndex, mobIDs, dropIDs)...)
+ }
+ return nil
+ }); err != nil {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "trigger",
+ Message: fmt.Sprintf("triggers: could not scan trigger files: %v", err),
+ })
+ }
+ return issues
+}
+
// validateRoomTriggers validates the on_enter and on_exit trigger blocks on
-// every room. Flag-change triggers (data/triggers) are not validated here.
+// every room. Flag-change triggers (data/triggers) are validated separately.
func validateRoomTriggers(s Source) []Issue {
var issues []Issue
roomIndex := s.World.RoomIndex()
diff --git a/internal/validate/validate.go b/internal/validate/validate.go
index a7ce1b0..dbcf06f 100644
--- a/internal/validate/validate.go
+++ b/internal/validate/validate.go
@@ -110,6 +110,7 @@ func Run(s Source) []Issue {
issues = append(issues, validateDropTables(s)...)
issues = append(issues, validateCourses(s)...)
issues = append(issues, validateRoomTriggers(s)...)
+ issues = append(issues, validateFlagTriggerFiles(s)...)
issues = append(issues, validateTechs(s)...)
issues = append(issues, validateRoomWiring(s)...)
issues = append(issues, validateModuleDestinations(s)...)
@@ -127,13 +128,21 @@ func validateRoomFilenames(dataDir string) []Issue {
dir := filepath.Join(dataDir, "rooms")
var issues []Issue
if err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
- if _, err := strconv.Atoi(id); err != nil {
+ n, convErr := strconv.Atoi(id)
+ if convErr != nil {
rel := strings.TrimPrefix(path, dataDir+"/")
issues = append(issues, Issue{
Level: "ERROR",
Type: "integrity",
Message: fmt.Sprintf("room %s: filename is not a numeric room ID and the file is ignored", rel),
})
+ } else if n <= 0 {
+ rel := strings.TrimPrefix(path, dataDir+"/")
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "integrity",
+ Message: fmt.Sprintf("room %s: room ID must be positive, got %d", rel, n),
+ })
}
return nil
}); err != nil {
diff --git a/internal/world/world.go b/internal/world/world.go
index 7a91894..10ceb08 100644
--- a/internal/world/world.go
+++ b/internal/world/world.go
@@ -18,10 +18,14 @@ type World struct {
dataDir string
mu sync.Mutex
roomPathIndex map[int]string
- groundItems map[int][]*groundEntry
- seeded map[int]bool
- objStates map[string]*ObjState
- objMoves []ObjMove
+ // roomIDMismatchLogged dedupes the "declared id ignored" warning, which
+ // would otherwise fire on every prompt/tick for a room file copied from a
+ // template with a stale id:. Guarded by mu.
+ roomIDMismatchLogged map[int]bool
+ groundItems map[int][]*groundEntry
+ seeded map[int]bool
+ objStates map[string]*ObjState
+ objMoves []ObjMove
hazardMu sync.Mutex
hazardPathIndex map[string]string
@@ -30,12 +34,13 @@ type World struct {
func New(dataDir string) *World {
return &World{
- dataDir: dataDir,
- roomPathIndex: behavior.BuildRoomIndex(filepath.Join(dataDir, "rooms")),
- groundItems: make(map[int][]*groundEntry),
- seeded: make(map[int]bool),
- objStates: make(map[string]*ObjState),
- hazardDefs: make(map[string]*HazardDef),
+ dataDir: dataDir,
+ roomPathIndex: behavior.BuildRoomIndex(filepath.Join(dataDir, "rooms")),
+ roomIDMismatchLogged: make(map[int]bool),
+ groundItems: make(map[int][]*groundEntry),
+ seeded: make(map[int]bool),
+ objStates: make(map[string]*ObjState),
+ hazardDefs: make(map[string]*HazardDef),
}
}
@@ -58,8 +63,17 @@ func (w *World) LoadRoom(id int) (*Room, error) {
// declared id: is silently ignored, so surface it rather than leave the
// author believing their written id is in use.
if room.ID != 0 && room.ID != id {
- log.Printf("room %d: file %s declares id %d but is loaded as room %d; the filename is authoritative and the declared id is ignored",
- id, path, room.ID, id)
+ w.mu.Lock()
+ if w.roomIDMismatchLogged == nil {
+ w.roomIDMismatchLogged = make(map[int]bool)
+ }
+ already := w.roomIDMismatchLogged[id]
+ w.roomIDMismatchLogged[id] = true
+ w.mu.Unlock()
+ if !already {
+ log.Printf("room %d: file %s declares id %d but is loaded as room %d; the filename is authoritative and the declared id is ignored",
+ id, path, room.ID, id)
+ }
}
room.ID = id
if room.Exits == nil {