From c6d4702c0be4d2b37e1d82ff96265c52847c3f9a Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sat, 3 Oct 2026 17:36:06 -0400 Subject: feat: partial commands now always work --- data/help/actions.yaml | 6 ++ data/help/alias.yaml | 5 +- data/help/big6.yaml | 3 + data/help/verbs.yaml | 4 ++ internal/casino/big_six.go | 43 ++++++++++++-- internal/casino/big_six_test.go | 46 ++++++++++++++ internal/game/cmd_prefix.go | 114 +++++++++++++++++++++++++++++++++++ internal/game/cmd_prefix_test.go | 125 +++++++++++++++++++++++++++++++++++++++ internal/game/cmd_verbs.go | 117 +++++++++++++++++++++--------------- internal/game/game.go | 15 ++++- 10 files changed, 423 insertions(+), 55 deletions(-) create mode 100644 internal/game/cmd_prefix.go create mode 100644 internal/game/cmd_prefix_test.go diff --git a/data/help/actions.yaml b/data/help/actions.yaml index 7f6c7d8..93b4da0 100644 --- a/data/help/actions.yaml +++ b/data/help/actions.yaml @@ -21,6 +21,12 @@ description: | 1. Free actions (per player, in order) 2. Active actions (globally, sorted by real-time timestamp) + You can shorten most commands to an unambiguous prefix. "inven" runs + inventory and "scor" runs score. An exact command always wins, and a + prefix that matches more than one command is not run. Where you are + standing can break a tie: for example "pl" means play, while "plan" + still means plant. + This means free actions always take effect before active actions, even if you typed the active one first. For example, "attack goblin" then "style aggressive" in the same tick means style applies first, then combat starts. diff --git a/data/help/alias.yaml b/data/help/alias.yaml index 2caf8ed..e5f822b 100644 --- a/data/help/alias.yaml +++ b/data/help/alias.yaml @@ -12,7 +12,10 @@ description: | to "kill man". Aliases resolve before built-in commands. An alias can shadow an - existing command name. + existing command name. Aliases match by exact name only: a partial + word never expands an alias, and aliases never join the prefix + vocabulary. So with an alias "kim", "ki man" still runs kill, but + an alias named "ki" runs itself and kill is not considered. aliases: - "unalias" diff --git a/data/help/big6.yaml b/data/help/big6.yaml index 718f3a8..1d0b5cf 100644 --- a/data/help/big6.yaml +++ b/data/help/big6.yaml @@ -11,6 +11,9 @@ description: | bet 10 on 1 Bet on the $1 symbol (not a one-credit stake) bet 5 on $20 Add a bet on $20 bet 2 on sword Add a SWORD wager; SHIELD works the same way + Target names accept any unambiguous prefix, so + "sw", "swo" and "sh" all work (and "1" is the + $1 symbol, never a prefix of $10). bet / rebet Add another copy of your most recent wager spin Start the wheel after placing your bets, when alone (any unambiguous prefix works, so 'sp' spins here). diff --git a/data/help/verbs.yaml b/data/help/verbs.yaml index f8d37d6..1d1a298 100644 --- a/data/help/verbs.yaml +++ b/data/help/verbs.yaml @@ -13,3 +13,7 @@ description: | and "say" are omitted since they work in every room. Add "all" to include the full list of always-available commands. + + Any command may be shortened to an unambiguous prefix, so "inven" + runs inventory where "inv" is already its own alias. A prefix that + matches more than one command is not run. 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(§ionObjs, &seen, verb+" "+name) + addUnique(&rc.objects, &seen, verb+" "+name) } } } if bt == "talk" { - addUnique(§ionObjs, &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(§ionObjs, &seen, "use "+name) + addUnique(&rc.objects, &seen, "use "+name) break } } recipes := g.stationRecipes(p, def.ID) if len(recipes) > 0 { - addUnique(§ionObjs, &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(§ionObjs, &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(§ionObjs, &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(§ionObjs, &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(§ionObjs, &seen, "steal "+name) + addUnique(&rc.objects, &seen, "steal "+name) } - addUnique(§ionObjs, &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(§ionMobs, &seen, "attack "+mName) + addUnique(&rc.mobs, &seen, "attack "+mName) } if m.IsTalkable() { - addUnique(§ionMobs, &seen, "talk "+mName) + addUnique(&rc.mobs, &seen, "talk "+mName) } if len(m.StealDrops) > 0 { - addUnique(§ionMobs, &seen, "steal "+mName) + addUnique(&rc.mobs, &seen, "steal "+mName) } - addUnique(§ionMobs, &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(§ionGround, &seen, "get "+def.Name) + addUnique(&rc.ground, &seen, "get "+def.Name) } } - addUnique(§ionGround, &seen, "get all") + addUnique(&rc.ground, &seen, "get all") } - if info := g.CourseStore.GetObstacle(p.RoomID); info != nil { - addUnique(§ionGround, &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(§ionObjs, &seen, "bank") + addUnique(&rc.objects, &seen, "bank") } if g.findTerminalInRoom(p.RoomID) != "" { - addUnique(§ionObjs, &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 { -- cgit v1.2.3