aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-03 17:36:06 -0400
committerhistoria <[not public]>2026-10-03 17:36:06 -0400
commitc6d4702c0be4d2b37e1d82ff96265c52847c3f9a (patch)
treec0a3d0638b961a9ba06e6fdb30ee5f1cd2061f2e /internal
parent0fb83bdf433b9b48240f4ac7a5d4c912f6b808cc (diff)
downloadthehouseoficarus-c6d4702c0be4d2b37e1d82ff96265c52847c3f9a.tar.gz
feat: partial commands now always work
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/big_six.go43
-rw-r--r--internal/casino/big_six_test.go46
-rw-r--r--internal/game/cmd_prefix.go114
-rw-r--r--internal/game/cmd_prefix_test.go125
-rw-r--r--internal/game/cmd_verbs.go117
-rw-r--r--internal/game/game.go15
6 files changed, 406 insertions, 54 deletions
diff --git a/internal/casino/big_six.go b/internal/casino/big_six.go
index 6d16bbc..b17bde4 100644
--- a/internal/casino/big_six.go
+++ b/internal/casino/big_six.go
@@ -34,9 +34,43 @@ func bigSixHouseEdge(_ TableConfig, bet string) HouseEdge {
func bigSixTarget(s string) string {
return strings.TrimPrefix(strings.ToUpper(strings.TrimSpace(s)), "$")
}
+
+// bigSixResolveTarget maps a written target to a betting symbol. An exact
+// label always wins over a longer label that shares its prefix, so "1" is the
+// $1 symbol and never an ambiguous prefix of $10. Otherwise an unambiguous
+// prefix is accepted, so "SW", "SWO" and "SWORD" all name the sword and "SH"
+// names the shield. DOUBLE is not a betting target, so "DO" resolves to
+// nothing. It reports false when nothing or more than one symbol matches.
+func bigSixResolveTarget(s string) (string, bool) {
+ t := bigSixTarget(s)
+ if t == "" {
+ return "", false
+ }
+ match := ""
+ for _, spot := range BigSixSpots() {
+ if spot.Pays <= 0 {
+ continue
+ }
+ if spot.Label == t {
+ return spot.Label, true
+ }
+ if strings.HasPrefix(spot.Label, t) {
+ if match != "" {
+ return "", false
+ }
+ match = spot.Label
+ }
+ }
+ return match, match != ""
+}
+
func bigSixPayout(s string) int {
+ target, ok := bigSixResolveTarget(s)
+ if !ok {
+ return 0
+ }
for _, spot := range BigSixSpots() {
- if spot.Label == bigSixTarget(s) && spot.Pays > 0 {
+ if spot.Label == target {
return spot.Pays + 1
}
}
@@ -225,10 +259,11 @@ func (t *bigSixTable) bet(name, spot string, amount int, w Wallet) []Event {
if spot == "" {
spot = p.lastSpot
}
- spot = bigSixTarget(spot)
- if bigSixPayout(spot) == 0 {
- return []Event{t.private(name, "Bet on 1, 2, 5, 10, 20, SWORD or SHIELD. DOUBLE is automatic, not a bet.")}
+ resolved, ok := bigSixResolveTarget(spot)
+ if !ok {
+ return []Event{t.private(name, "Bet on 1, 2, 5, 10, 20, SWORD or SHIELD — an unambiguous prefix is fine. DOUBLE is automatic, not a bet.")}
}
+ spot = resolved
if amount < t.Config.MinBet || amount > t.Config.MaxBet {
return []Event{t.private(name, "Big 6 bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")}
}
diff --git a/internal/casino/big_six_test.go b/internal/casino/big_six_test.go
index cccad77..04bc7e3 100644
--- a/internal/casino/big_six_test.go
+++ b/internal/casino/big_six_test.go
@@ -291,3 +291,49 @@ func TestBigSixSettlementFailureAndDeparture(t *testing.T) {
t.Fatal("departing player lost result visibility")
}
}
+
+func TestBigSixPartialTargets(t *testing.T) {
+ for _, tc := range []struct {
+ in string
+ want string
+ ok bool
+ }{
+ {"1", "1", true},
+ {"10", "10", true},
+ {"$20", "20", true},
+ {"sw", "SWORD", true},
+ {"SWO", "SWORD", true},
+ {"sword", "SWORD", true},
+ {"sh", "SHIELD", true},
+ {"shi", "SHIELD", true},
+ {"shield", "SHIELD", true},
+ {"s", "", false}, // SWORD or SHIELD
+ {"do", "", false}, // DOUBLE is automatic, not a wager
+ {"double", "", false}, // and its full name is not a wager either
+ {"", "", false},
+ {"99", "", false},
+ } {
+ got, ok := bigSixResolveTarget(tc.in)
+ if got != tc.want || ok != tc.ok {
+ t.Fatalf("bigSixResolveTarget(%q) = (%q,%v), want (%q,%v)", tc.in, got, ok, tc.want, tc.ok)
+ }
+ }
+ // A partial target must behave exactly like the full label at bet time,
+ // both in the stored wager and in the house-edge reference.
+ cfg := TableConfig{Game: GameBigSix}
+ if e := cfg.HouseEdge("sw"); len(e.Parts) != 1 || e.Parts[0].Loss != 1.0/49 {
+ t.Fatalf("partial house edge: %+v", e)
+ }
+ table := newBigSixTest()
+ table.join("a")
+ w := &recordingWallet{testWallet: testWallet{credits: 100}}
+ table.bet("a", "sw", 5, w)
+ table.bet("a", "sh", 7, w)
+ bets := table.players.get("a").bets
+ if len(w.open) != 2 || len(bets) != 2 || bets[0].spot != "SWORD" || bets[1].spot != "SHIELD" {
+ t.Fatalf("partial targets not canonicalized: %+v", bets)
+ }
+ if bigSixPayout("s") != 0 || bigSixPayout("sw") != 48 || bigSixPayout("sh") != 48 {
+ t.Fatal("partial payout")
+ }
+}
diff --git a/internal/game/cmd_prefix.go b/internal/game/cmd_prefix.go
new file mode 100644
index 0000000..da3e646
--- /dev/null
+++ b/internal/game/cmd_prefix.go
@@ -0,0 +1,114 @@
+package game
+
+import (
+ "strings"
+
+ "thehouseoficarus/internal/net"
+)
+
+// resolveCommandPrefix expands an unambiguous, currently usable command prefix
+// to its full command name. It is the general (non-casino) counterpart to
+// casinoAbbreviation: a prefix such as "inven" runs inventory, while an
+// ambiguous prefix such as "in" is left alone.
+//
+// Exact commands are never rewritten — the registry dispatch already handles
+// them, and rewriting would make the result indistinguishable from a real
+// command. When several commands share a prefix, the player's own room breaks
+// the tie: if exactly one of the candidates is advertised where they are
+// standing (an object verb, a mob action, an exit, an obstacle, a fixture, or
+// the always-runnable play gateway), that one wins. This is what lets "pl"
+// mean play even though "plant" also starts with "pl".
+func (g *Game) resolveCommandPrefix(sess *net.Session, cmd string) (string, bool) {
+ if sess == nil || sess.Player == nil || cmd == "" {
+ return "", false
+ }
+ // Exact commands and action aliases dispatch as typed.
+ if _, ok := commandRegistry[cmd]; ok {
+ return "", false
+ }
+ if _, ok := verbAliases[cmd]; ok {
+ return "", false
+ }
+ obstacleVerbsMu.RLock()
+ exactObstacle := obstacleVerbs[cmd]
+ obstacleVerbsMu.RUnlock()
+ if exactObstacle {
+ return "", false
+ }
+
+ var matches []string
+ add := func(name string) {
+ if strings.HasPrefix(name, cmd) {
+ matches = append(matches, name)
+ }
+ }
+ for name := range commandRegistry {
+ add(name)
+ }
+ for name := range verbAliases {
+ add(name)
+ }
+ obstacleVerbsMu.RLock()
+ for name := range obstacleVerbs {
+ add(name)
+ }
+ obstacleVerbsMu.RUnlock()
+
+ switch len(matches) {
+ case 0:
+ return "", false
+ case 1:
+ return matches[0], true
+ }
+
+ // Ambiguous on the whole command table: prefer a single candidate the
+ // player can actually use here.
+ usable := g.roomVerbSet(sess)
+ only := ""
+ for _, name := range matches {
+ if !usable[name] {
+ continue
+ }
+ if only != "" {
+ return "", false
+ }
+ only = name
+ }
+ if only != "" {
+ return only, true
+ }
+ return "", false
+}
+
+// roomVerbSet returns the verbs the player can use where they are: the verbs
+// advertised by the current room's objects, mobs, ground and exits, plus the
+// always-runnable play gateway. It deliberately excludes the global
+// always-available list so a context-free command cannot shadow a same-prefix
+// command that is actually offered here.
+func (g *Game) roomVerbSet(sess *net.Session) map[string]bool {
+ set := map[string]bool{
+ // play responds anywhere: at a table it opens the game, and away from
+ // one it reports that there is nothing to play. Making it usable
+ // everywhere lets "pl" name play instead of tying with plant.
+ "play": true,
+ }
+ if sess == nil || sess.Player == nil {
+ return set
+ }
+ rc := g.collectRoomCommands(sess)
+ for _, group := range [][]string{rc.objects, rc.mobs, rc.ground} {
+ for _, entry := range group {
+ if fields := strings.Fields(entry); len(fields) > 0 {
+ set[fields[0]] = true
+ }
+ }
+ }
+ for _, exit := range rc.exits {
+ for _, dir := range strings.Split(exit, ",") {
+ if dir = strings.TrimSpace(dir); dir != "" {
+ set[dir] = true
+ }
+ }
+ }
+ return set
+}
diff --git a/internal/game/cmd_prefix_test.go b/internal/game/cmd_prefix_test.go
new file mode 100644
index 0000000..8f42a6f
--- /dev/null
+++ b/internal/game/cmd_prefix_test.go
@@ -0,0 +1,125 @@
+package game
+
+import (
+ "testing"
+
+ "thehouseoficarus/internal/player"
+)
+
+// TestResolveCommandPrefix covers the general (non-casino) prefix expansion:
+// unique prefixes run, exact commands and ambiguous prefixes do not.
+func TestResolveCommandPrefix(t *testing.T) {
+ plain, sess, _ := houseEdgeGame(t, "name: Nowhere\n")
+
+ if got, ok := plain.resolveCommandPrefix(sess, "inven"); !ok || got != "inventory" {
+ t.Fatalf("inven -> (%q,%v), want inventory", got, ok)
+ }
+ if got, ok := plain.resolveCommandPrefix(sess, "scor"); !ok || got != "score" {
+ t.Fatalf("scor -> (%q,%v), want score", got, ok)
+ }
+ // An exact command is dispatched by the registry and must not be rewritten.
+ if _, ok := plain.resolveCommandPrefix(sess, "look"); ok {
+ t.Fatal("exact command was rewritten")
+ }
+ // The play gateway is runnable anywhere, so it breaks the play/plant tie
+ // even without a casino fixture.
+ if got, ok := plain.resolveCommandPrefix(sess, "pl"); !ok || got != "play" {
+ t.Fatalf("pl -> (%q,%v), want play", got, ok)
+ }
+ if got, ok := plain.resolveCommandPrefix(sess, "plan"); !ok || got != "plant" {
+ t.Fatalf("plan -> (%q,%v), want plant", got, ok)
+ }
+ // A unique one-letter prefix runs: "k" can only mean kill.
+ if got, ok := plain.resolveCommandPrefix(sess, "k"); !ok || got != "kill" {
+ t.Fatalf("k -> (%q,%v), want kill", got, ok)
+ }
+ // An exact one-letter command is still handled by the registry.
+ if _, ok := plain.resolveCommandPrefix(sess, "i"); ok {
+ t.Fatal("exact single-letter command was rewritten")
+ }
+ // A many-way one-letter prefix stays ambiguous.
+ if _, ok := plain.resolveCommandPrefix(sess, "q"); ok {
+ t.Fatal("ambiguous one-letter prefix resolved")
+ }
+ // Nothing matches.
+ if _, ok := plain.resolveCommandPrefix(sess, "zz"); ok {
+ t.Fatal("unknown prefix resolved")
+ }
+}
+
+// TestCommandPrefixCasinoTieBreak verifies that the room's advertised verbs
+// break a general prefix tie, and that the play gateway resolves whether or
+// not the room actually holds a table.
+func TestCommandPrefixCasinoTieBreak(t *testing.T) {
+ g, sess, _ := houseEdgeGame(t, "name: Wheel\ncasino_tables:\n - id: big6\n game: big6\n")
+ for _, in := range []string{"pl", "pla"} {
+ if got, ok := g.resolveCommandPrefix(sess, in); !ok || got != "play" {
+ t.Fatalf("%s at a casino -> (%q,%v), want play", in, got, ok)
+ }
+ }
+ // The full word still resolves to the other command.
+ if got, ok := g.resolveCommandPrefix(sess, "plan"); !ok || got != "plant" {
+ t.Fatalf("plan -> (%q,%v), want plant", got, ok)
+ }
+
+ // The same break happens on the real dispatch path.
+ g.queue = NewCommandQueue()
+ g.handleGameCommand(sess, "pl")
+ if qc, ok := g.queue.PeekActive("alice"); !ok || qc.Command != "play" {
+ t.Fatalf("pl queued %+v (ok=%v), want play", qc, ok)
+ }
+}
+
+// TestCommandPrefixSingleLetterDispatch checks the user-facing case that a
+// one-letter prefix works when it can only mean one command.
+func TestCommandPrefixSingleLetterDispatch(t *testing.T) {
+ g, sess, _ := houseEdgeGame(t, "name: Nowhere\n")
+ g.queue = NewCommandQueue()
+ g.handleGameCommand(sess, "k man")
+ qc, ok := g.queue.PeekActive("alice")
+ if !ok || qc.Command != "kill" || qc.Args != "man" {
+ t.Fatalf("k man queued %+v (ok=%v), want kill man", qc, ok)
+ }
+}
+
+// TestCommandPrefixRespectsAliases pins the alias/prefix relationship:
+// a prefix never expands an alias, but an exact alias always wins before any
+// prefix resolution.
+func TestCommandPrefixRespectsAliases(t *testing.T) {
+ // Alias "kim" only shares a prefix with "ki". Typing "ki man" must not
+ // run the alias, and must not let it block the kill prefix either.
+ g, sess, _ := houseEdgeGame(t, "name: Nowhere\n")
+ g.queue = NewCommandQueue()
+ sess.Account = &player.Account{Name: "alice", Aliases: map[string]string{"kim": "drop sword"}}
+ g.handleGameCommand(sess, "ki man")
+ qc, ok := g.queue.PeekActive("alice")
+ if !ok || qc.Command != "kill" || qc.Args != "man" {
+ t.Fatalf("ki man with alias kim queued %+v (ok=%v), want kill man", qc, ok)
+ }
+
+ // Exact alias "ki" wins outright: the kill prefix is never considered.
+ g2, sess2, _ := houseEdgeGame(t, "name: Nowhere\n")
+ g2.queue = NewCommandQueue()
+ sess2.Account = &player.Account{Name: "alice", Aliases: map[string]string{"ki": "drop sword"}}
+ g2.handleGameCommand(sess2, "ki man")
+ qc2, ok := g2.queue.PeekActive("alice")
+ if !ok || qc2.Command != "drop" || qc2.Args != "sword man" {
+ t.Fatalf("exact alias ki queued %+v (ok=%v), want drop sword man", qc2, ok)
+ }
+}
+
+// TestCommandPrefixSeatedCasinoStaysScoped guards the existing rule that a
+// seated casino player only abbreviates their own game's verbs: "spl" must
+// not fall through the general resolver to blackjack's split at Big 6.
+func TestCommandPrefixSeatedCasinoStaysScoped(t *testing.T) {
+ g, sess, _ := houseEdgeGame(t, "name: Wheel\ncasino_tables:\n - id: big6\n game: big6\n")
+ g.queue = NewCommandQueue()
+ g.executePlay(sess, []string{"big6"}, "play big6")
+ if _, playing := g.Casino.Participation(sess.Player.Name); !playing {
+ t.Fatal("join failed")
+ }
+ g.handleGameCommand(sess, "spl")
+ if qc, ok := g.queue.PeekActive("alice"); ok {
+ t.Fatalf("seated spl queued %+v, want nothing", qc)
+ }
+}
diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go
index d13b803..5cbbd5f 100644
--- a/internal/game/cmd_verbs.go
+++ b/internal/game/cmd_verbs.go
@@ -45,28 +45,23 @@ var alwaysAvailable = []string{
"drop",
}
-func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) {
- p := sess.Player
- // A seated casino player has no room verbs: the only valid commands are
- // the game's live hints, so what/verbs shows those instead of exits and
- // objects that cannot be used mid-hand.
- if g.Casino != nil {
- if table, playing := g.Casino.Participation(p.Name); playing {
- if table != nil {
- g.showCasinoHints(sess, table.Hints(p.Name))
- } else if machine, ok := g.Casino.MachineFor(p.Name); ok {
- g.showCasinoHints(sess, machine.Hints())
- }
- return
- }
- }
- showAll := len(args) == 1 && strings.ToLower(args[0]) == "all"
+// roomCommandSections holds the interactable commands advertised for the
+// player's current room, before rendering. It is shared by the verbs display
+// and by prefix resolution, so an abbreviation can only beat a same-prefix
+// rival when the player can actually use it here.
+type roomCommandSections struct {
+ objects []string
+ mobs []string
+ exits []string
+ ground []string
+}
+// collectRoomCommands builds the room's object, mob, exit and ground command
+// listings without writing anything to the session.
+func (g *Game) collectRoomCommands(sess *net.Session) roomCommandSections {
+ p := sess.Player
+ var rc roomCommandSections
seen := make(map[string]bool)
- var sectionObjs []string
- var sectionMobs []string
- var sectionExits []string
- var sectionGround []string
for _, st := range g.World.AllObjInstances(p.RoomID) {
def, _, err := g.resolveObjectDef(p.RoomID, st.DefID)
@@ -80,25 +75,25 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) {
if bt == "gather" && def.Gather != nil {
for verb, skill := range verbSkill {
if strings.EqualFold(def.Gather.Skill, skill) {
- addUnique(&sectionObjs, &seen, verb+" "+name)
+ addUnique(&rc.objects, &seen, verb+" "+name)
}
}
}
if bt == "talk" {
- addUnique(&sectionObjs, &seen, "talk "+name)
+ addUnique(&rc.objects, &seen, "talk "+name)
}
for _, ui := range def.OnUse {
if ui.ItemID == "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) {
- addUnique(&sectionObjs, &seen, "use "+name)
+ addUnique(&rc.objects, &seen, "use "+name)
break
}
}
recipes := g.stationRecipes(p, def.ID)
if len(recipes) > 0 {
- addUnique(&sectionObjs, &seen, "use "+name)
+ addUnique(&rc.objects, &seen, "use "+name)
for _, r := range recipes {
c := r.FirstCraft()
key := c.Subtype
@@ -107,13 +102,13 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) {
}
if info, ok := productionTypes[key]; ok {
if info.ActionType != behavior.TypeCombine && info.ActionType != behavior.TypeFletch && info.ActionType != behavior.TypeMix {
- addUnique(&sectionObjs, &seen, string(info.ActionType))
+ addUnique(&rc.objects, &seen, string(info.ActionType))
}
}
for _, c := range r.FirstCraft().Ingredients {
for _, itemID := range c.Items {
if itemDef, err := g.ItemStore.Load(itemID); err == nil {
- addUnique(&sectionObjs, &seen, "use "+itemDef.Name+" on "+name)
+ addUnique(&rc.objects, &seen, "use "+itemDef.Name+" on "+name)
}
}
}
@@ -123,31 +118,31 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) {
for _, ui := range def.OnUse {
if ui.ItemID != "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) {
if itemDef, err := g.ItemStore.Load(ui.ItemID); err == nil {
- addUnique(&sectionObjs, &seen, "use "+itemDef.Name+" on "+name)
+ addUnique(&rc.objects, &seen, "use "+itemDef.Name+" on "+name)
}
}
}
if def.Steal != nil && len(def.Steal.Drops) > 0 {
- addUnique(&sectionObjs, &seen, "steal "+name)
+ addUnique(&rc.objects, &seen, "steal "+name)
}
- addUnique(&sectionObjs, &seen, "look "+name)
+ addUnique(&rc.objects, &seen, "look "+name)
}
for _, m := range g.MobStore.MobsInRoom(p.RoomID) {
mName := strings.ToLower(m.Name)
if !m.Protected {
- addUnique(&sectionMobs, &seen, "attack "+mName)
+ addUnique(&rc.mobs, &seen, "attack "+mName)
}
if m.IsTalkable() {
- addUnique(&sectionMobs, &seen, "talk "+mName)
+ addUnique(&rc.mobs, &seen, "talk "+mName)
}
if len(m.StealDrops) > 0 {
- addUnique(&sectionMobs, &seen, "steal "+mName)
+ addUnique(&rc.mobs, &seen, "steal "+mName)
}
- addUnique(&sectionMobs, &seen, "look "+mName)
+ addUnique(&rc.mobs, &seen, "look "+mName)
}
if room, err := g.World.LoadRoom(p.RoomID); err == nil {
@@ -163,7 +158,7 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) {
exitDirs = append(exitDirs, string(dir))
}
if len(exitDirs) > 0 {
- sectionExits = append(sectionExits, strings.Join(exitDirs, ", "))
+ rc.exits = append(rc.exits, strings.Join(exitDirs, ", "))
}
}
@@ -171,42 +166,66 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) {
if len(ground) > 0 {
for itemID := range ground {
if def, err := g.ItemStore.Load(itemID); err == nil {
- addUnique(&sectionGround, &seen, "get "+def.Name)
+ addUnique(&rc.ground, &seen, "get "+def.Name)
}
}
- addUnique(&sectionGround, &seen, "get all")
+ addUnique(&rc.ground, &seen, "get all")
}
- if info := g.CourseStore.GetObstacle(p.RoomID); info != nil {
- addUnique(&sectionGround, &seen, info.Verb)
+ if g.CourseStore != nil {
+ if info := g.CourseStore.GetObstacle(p.RoomID); info != nil {
+ addUnique(&rc.ground, &seen, info.Verb)
+ }
}
if g.canUseBank(p) {
- addUnique(&sectionObjs, &seen, "bank")
+ addUnique(&rc.objects, &seen, "bank")
}
if g.findTerminalInRoom(p.RoomID) != "" {
- addUnique(&sectionObjs, &seen, "jack")
+ addUnique(&rc.objects, &seen, "jack")
}
- hasRoomCmds := len(sectionObjs) > 0 || len(sectionMobs) > 0 || len(sectionExits) > 0 || len(sectionGround) > 0
+ return rc
+}
+
+func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) {
+ p := sess.Player
+ // A seated casino player has no room verbs: the only valid commands are
+ // the game's live hints, so what/verbs shows those instead of exits and
+ // objects that cannot be used mid-hand.
+ if g.Casino != nil {
+ if table, playing := g.Casino.Participation(p.Name); playing {
+ if table != nil {
+ g.showCasinoHints(sess, table.Hints(p.Name))
+ } else if machine, ok := g.Casino.MachineFor(p.Name); ok {
+ g.showCasinoHints(sess, machine.Hints())
+ }
+ return
+ }
+ }
+ showAll := len(args) == 1 && strings.ToLower(args[0]) == "all"
+
+ rc := g.collectRoomCommands(sess)
+
+ hasRoomCmds := len(rc.objects) > 0 || len(rc.mobs) > 0 || len(rc.exits) > 0 || len(rc.ground) > 0
if hasRoomCmds {
- if len(sectionObjs) > 0 {
+ if len(rc.objects) > 0 {
sess.WriteLine("Objects:")
- writeCmdLines(sess, sectionObjs)
+ writeCmdLines(sess, rc.objects)
}
- if len(sectionMobs) > 0 {
+ if len(rc.mobs) > 0 {
sess.WriteLine("Mobs:")
- writeCmdLines(sess, sectionMobs)
+ writeCmdLines(sess, rc.mobs)
}
- if len(sectionExits) > 0 {
+ if len(rc.exits) > 0 {
sess.WriteLine("Exits:")
- writeCmdLines(sess, sectionExits)
+ writeCmdLines(sess, rc.exits)
}
- if len(sectionGround) > 0 {
+ if len(rc.ground) > 0 {
sess.WriteLine("Ground:")
- writeCmdLines(sess, sectionGround)
+ writeCmdLines(sess, rc.ground)
}
} else {
sess.WriteLine("There is nothing notable to interact with here.")
diff --git a/internal/game/game.go b/internal/game/game.go
index a3a9684..32e3c24 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -599,6 +599,11 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
sess.ClearPrompt()
}
+ // Player aliases resolve first and by exact name only. A partial word never
+ // expands an alias, and aliases do not join the prefix vocabulary below, so
+ // an alias "kim" does not stop "ki" from meaning kill. When the whole word
+ // is an alias ("ki"), that alias wins outright and the built-in command it
+ // might otherwise abbreviate is never considered.
if sess.Account != nil && sess.Account.Aliases != nil {
firstSpace := strings.Index(input, " ")
var firstWord, rest string
@@ -627,11 +632,19 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
// Seated casino players may shorten any of their game's verbs to an
// unambiguous prefix (b 1000 -> bet 1000, sp -> spin at Big 6). Only the
// current game's vocabulary is considered, and exact commands still win, so
- // movement and non-casino commands are never shadowed.
+ // movement and non-casino commands are never shadowed. Elsewhere a unique
+ // prefix that the room actually offers may stand for the full command
+ // (inven -> inventory, pl -> play at a casino table).
if canon, ok := g.casinoAbbreviation(sess, cmd); ok {
cmd = canon
parts[0] = canon
input = strings.Join(parts, " ")
+ } else if len(g.casinoVerbsFor(sess.Player.Name)) == 0 {
+ if canon, ok := g.resolveCommandPrefix(sess, cmd); ok {
+ cmd = canon
+ parts[0] = canon
+ input = strings.Join(parts, " ")
+ }
}
if len(parts) == 1 {