diff options
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/cmd_prefix.go | 114 | ||||
| -rw-r--r-- | internal/game/cmd_prefix_test.go | 125 | ||||
| -rw-r--r-- | internal/game/cmd_verbs.go | 117 | ||||
| -rw-r--r-- | internal/game/game.go | 15 |
4 files changed, 321 insertions, 50 deletions
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 { |
