From d6ab0e93a64525ce34f89e26d5272efbac513c32 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 11 Jun 2026 20:41:06 -0400 Subject: feat: big map and options changes --- internal/game/action_gather.go | 2 +- internal/game/action_use.go | 2 +- internal/game/cmd_attack.go | 4 +- internal/game/cmd_look.go | 12 ++-- internal/game/cmd_map.go | 140 +++++++++++++++++++++++++++++++++++++++++ internal/game/cmd_move.go | 6 +- internal/game/cmd_option.go | 116 ++++++++++++++++++++++++++++++++++ internal/game/cmd_toggle.go | 56 ----------------- internal/game/game.go | 6 +- internal/game/map_test.go | 86 +++++++++++++++++++++++++ internal/game/tick.go | 4 +- internal/player/player.go | 127 +++++++++++++++++++++++++++++++++---- 12 files changed, 477 insertions(+), 84 deletions(-) create mode 100644 internal/game/cmd_map.go create mode 100644 internal/game/cmd_option.go delete mode 100644 internal/game/cmd_toggle.go (limited to 'internal') diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go index e1e724e..62637c3 100644 --- a/internal/game/action_gather.go +++ b/internal/game/action_gather.go @@ -216,7 +216,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { if msg == "" { msg = fmt.Sprintf("You manage to get some %s.", itemName) } - if xp > 0 && p.Toggles["xpdrops"] { + if xp > 0 && p.OptionBool("xpdrops") { msg += fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[player.SkillName(cfg.Skill)]) } sess.WriteLine(msg) diff --git a/internal/game/action_use.go b/internal/game/action_use.go index c1a07ed..69d29c1 100644 --- a/internal/game/action_use.go +++ b/internal/game/action_use.go @@ -105,7 +105,7 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) { itemName = def.Name } line := fmt.Sprintf("You make a %s.", itemName) - if cfg.XP > 0 && p.Toggles["xpdrops"] { + if cfg.XP > 0 && p.OptionBool("xpdrops") { line += fmt.Sprintf(" (+%dxp %s)", cfg.XP, player.SkillAbbr[player.SkillName(cfg.Skill)]) } sess.WriteLine(line) diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go index 4e2d2c0..f6ccf0c 100644 --- a/internal/game/cmd_attack.go +++ b/internal/game/cmd_attack.go @@ -210,7 +210,7 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI prefix := fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg) hpPart := fmt.Sprintf("[%d/%dhp]", mob.HP, mob.MaxHP) line := fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart) - if p.Toggles["xpdrops"] && len(gains) > 0 { + if p.OptionBool("xpdrops") && len(gains) > 0 { var parts []string for _, gain := range gains { parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) @@ -453,7 +453,7 @@ func (g *Game) respawnMob(instanceID string) { if g.Hub != nil { for _, sess := range g.Hub.PlayersInRoom(homeRoom) { - if p, ok := sess.Player.(*player.Player); ok && p.Toggles["mobspawn"] { + if p, ok := sess.Player.(*player.Player); ok && p.OptionBool("mobspawn") { sess.WriteLine(fmt.Sprintf("\n%s (level %d) spawns in the area.", mobDisplayName(inst, false), mobCombatLevel(inst))) } } diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go index 0bec915..6d19023 100644 --- a/internal/game/cmd_look.go +++ b/internal/game/cmd_look.go @@ -25,7 +25,7 @@ func (g *Game) doLook(sess *net.Session) { room.Name, ) - if p.Toggles["tinymap"] && g.MapWidth > 0 { + if p.OptionBool("tinymap") && g.MapWidth > 0 { descLines := wrapText(room.Description, g.MapWidth) mapLines := buildTinyMap(g, p.RoomID) if len(mapLines) == 7 { @@ -112,7 +112,7 @@ func (g *Game) doLook(sess *net.Session) { }) } multi := len(instances) > 1 - showTimers := p.Toggles["depletion"] + showTimers := p.OptionBool("depletion") var freshIdxs []int var timed, depleted []instInfo @@ -205,7 +205,7 @@ func (g *Game) doLook(sess *net.Session) { } reserved := "" if info.ReservedFor != "" { - if p.Toggles["reserve"] { + if p.OptionBool("reserve") { reserved = fmt.Sprintf(" (reserved for %s for %d ticks)", info.ReservedFor, info.ReserveTimer) } else { reserved = " (reserved)" @@ -225,7 +225,7 @@ func (g *Game) doLook(sess *net.Session) { if len(room.Exits) > 0 { sess.WriteLine("") - if p.Toggles["exits"] { + if p.OptionBool("exits") { sess.WriteLine("Exits:") for _, dir := range world.ExitOrder { exitDef, ok := room.Exits[dir] @@ -345,7 +345,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { sess.WriteLine(fmt.Sprintf(" %s", desc)) } - if p.Toggles["depletion"] { + if p.OptionBool("depletion") { for _, ist := range instances { if ist.Depleted { if len(instances) > 1 { @@ -525,7 +525,7 @@ func (g *Game) writeLookSideBySide(sess *net.Session, p *player.Player, descLine if len(mapLines) > total { total = len(mapLines) } - leftMap := p.Toggles["left-tinymap"] + leftMap := p.OptionBool("left-tinymap") for i := 0; i < total; i++ { desc := "" if i < len(descLines) { diff --git a/internal/game/cmd_map.go b/internal/game/cmd_map.go new file mode 100644 index 0000000..7c506f9 --- /dev/null +++ b/internal/game/cmd_map.go @@ -0,0 +1,140 @@ +package game + +import ( + "strings" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" + "thirdcollapse/internal/world" +) + +func (g *Game) doMap(sess *net.Session) { + p := sess.Player.(*player.Player) + mapWidth := p.OptionInt("mapwidth") + mapHeight := p.OptionInt("mapheight") + if mapWidth < 5 { + mapWidth = 5 + } + if mapHeight < 5 { + mapHeight = 5 + } + + lines := buildFullMap(g, p.RoomID, mapWidth, mapHeight) + + mode := p.OptionString("mappadding") + if mode == "none" || mode == "x" { + lines = stripBlankRows(lines) + } + if mode == "none" || mode == "y" { + lines = leftTrimCommon(lines) + } + + if len(lines) == 0 { + sess.WriteLine("\nNo map data to display.") + return + } + sess.WriteLine("") + for _, line := range lines { + sess.WriteLine(line) + } +} + +func stripBlankRows(lines []string) []string { + var out []string + for _, line := range lines { + if strings.TrimSpace(line) != "" { + out = append(out, line) + } + } + return out +} + +func leftTrimCommon(lines []string) []string { + min := -1 + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + n := 0 + for _, r := range line { + if r == ' ' { + n++ + } else { + break + } + } + if min < 0 || n < min { + min = n + } + } + if min <= 0 { + return lines + } + result := make([]string, len(lines)) + for i, line := range lines { + if len(line) <= min { + result[i] = "" + } else { + result[i] = line[min:] + } + } + return result +} + +func buildFullMap(g *Game, roomID, mapWidth, mapHeight int) []string { + mg := buildGraph(g, roomID) + + grid := make([][]rune, mapHeight) + for i := range grid { + grid[i] = make([]rune, mapWidth) + for j := range grid[i] { + grid[i][j] = ' ' + } + } + + cx := mapWidth / 2 + cy := mapHeight / 2 + + for pos, rid := range mg.posToRoom { + gr := cy + pos[1]*2 + gc := cx + pos[0]*2 + if gr < 0 || gr >= mapHeight || gc < 0 || gc >= mapWidth { + continue + } + if rid == roomID { + grid[gr][gc] = '@' + } else { + grid[gr][gc] = roomMapSymbol(g, rid) + } + } + + for pos, rid := range mg.posToRoom { + x, y := pos[0], pos[1] + + if rightID, ok := mg.posToRoom[[2]int{x + 1, y}]; ok { + if exitsConnect(g, rid, rightID, world.East, world.West) { + gr := cy + y*2 + gc := cx + x*2 + 1 + if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { + grid[gr][gc] = '─' + } + } + } + + if bottomID, ok := mg.posToRoom[[2]int{x, y + 1}]; ok { + if exitsConnect(g, rid, bottomID, world.South, world.North) { + gr := cy + y*2 + 1 + gc := cx + x*2 + if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { + grid[gr][gc] = '│' + } + } + } + } + + lines := make([]string, mapHeight) + for i := range grid { + lines[i] = string(grid[i]) + } + return lines +} diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index 79ca45e..d62701d 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -79,7 +79,7 @@ func (g *Game) doMove(sess *net.Session, dir string) { } sess.WriteLine(fmt.Sprintf("\nYou walk %s.", exitDir)) - if p.Toggles["description"] { + if p.OptionBool("description") { g.doLook(sess) } else { targetRoom, _ := g.World.LoadRoom(targetID) @@ -88,6 +88,10 @@ func (g *Game) doMove(sess *net.Session, dir string) { } } + if p.OptionBool("automap") { + g.doMap(sess) + } + g.RunEnterSteps(sess, targetID) } diff --git a/internal/game/cmd_option.go b/internal/game/cmd_option.go new file mode 100644 index 0000000..49085db --- /dev/null +++ b/internal/game/cmd_option.go @@ -0,0 +1,116 @@ +package game + +import ( + "fmt" + "strconv" + "strings" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) doOption(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + + if input == "" { + sess.WriteLine("") + sess.WriteLine(fmt.Sprintf(" %-14s %-10s %-22s %s", "Option", "Value", "Valid", "Description")) + for _, def := range player.OptionDefs { + val := formatOptionValue(p, &def) + valid := formatValidValues(&def) + sess.WriteLine(fmt.Sprintf(" %-14s %-10s %-22s %s", def.Name, val, valid, def.Description)) + } + return + } + + parts := strings.Fields(input) + name := strings.ToLower(parts[0]) + def := player.GetOptionDef(name) + if def == nil { + sess.WriteLine(fmt.Sprintf("\nUnknown option: %s", name)) + return + } + + if len(parts) == 1 { + val := formatOptionValue(p, def) + valid := formatValidValues(def) + sess.WriteLine(fmt.Sprintf("\n%s = %s [%s]", def.Name, val, valid)) + sess.WriteLine(fmt.Sprintf(" %s", def.Description)) + return + } + + value := strings.ToLower(parts[1]) + parsed, ok := parseOptionValue(def, value) + if !ok { + valid := formatValidValues(def) + sess.WriteLine(fmt.Sprintf("\nInvalid value for %s: %s [%s]", def.Name, value, valid)) + return + } + + if p.Options == nil { + p.Options = make(map[string]any) + } + p.Options[def.Name] = parsed + g.AccountStore.SaveCharacter(p) + sess.WriteLine(fmt.Sprintf("\n%s set to %s.", def.Name, formatOptionValue(p, def))) +} + +func formatOptionValue(p *player.Player, def *player.OptionDef) string { + switch def.Type { + case player.OptBool: + if p.OptionBool(def.Name) { + return "on" + } + return "off" + case player.OptString: + return p.OptionString(def.Name) + case player.OptInt: + return strconv.Itoa(p.OptionInt(def.Name)) + } + return "" +} + +func formatValidValues(def *player.OptionDef) string { + switch def.Type { + case player.OptBool: + return "on/off" + case player.OptString: + if len(def.ValidValues) > 0 { + return strings.Join(def.ValidValues, "/") + } + return "string" + case player.OptInt: + return "num" + } + return "" +} + +func parseOptionValue(def *player.OptionDef, input string) (any, bool) { + switch def.Type { + case player.OptBool: + switch input { + case "on", "true", "yes", "1": + return true, true + case "off", "false", "no", "0": + return false, true + } + return nil, false + case player.OptString: + if len(def.ValidValues) == 0 { + return input, true + } + for _, v := range def.ValidValues { + if strings.EqualFold(v, input) { + return v, true + } + } + return nil, false + case player.OptInt: + n, err := strconv.Atoi(input) + if err != nil { + return nil, false + } + return n, true + } + return nil, false +} diff --git a/internal/game/cmd_toggle.go b/internal/game/cmd_toggle.go deleted file mode 100644 index a399483..0000000 --- a/internal/game/cmd_toggle.go +++ /dev/null @@ -1,56 +0,0 @@ -package game - -import ( - "fmt" - "strings" - - "thirdcollapse/internal/net" - "thirdcollapse/internal/player" -) - -var toggles = []struct { - Name string - Description string -}{ - {"description", "Long room descriptions"}, - {"tinymap", "Mini-map display"}, - {"left-tinymap", "Mini-map on left side of descriptions"}, - {"xpdrops", "XP drop messages"}, - {"exits", "Long exit display in look"}, - {"mobenter", "Messages when mobs enter the room"}, - {"mobleave", "Messages when mobs leave the room"}, - {"mobspawn", "Messages when mobs spawn in the area"}, - {"reserve", "Show full reserved item details"}, - {"depletion", "Show depletion and despawn timers on objects"}, -} - -func (g *Game) doToggle(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - - if input == "" { - sess.WriteLine("") - for _, t := range toggles { - status := "Off" - if p.Toggles[t.Name] { - status = "On" - } - sess.WriteLine(fmt.Sprintf(" %-12s %-3s %s", t.Name, status, t.Description)) - } - g.AccountStore.SaveCharacter(p) - return - } - - for _, t := range toggles { - if strings.ToLower(input) == t.Name { - p.Toggles[t.Name] = !p.Toggles[t.Name] - status := "Off" - if p.Toggles[t.Name] { - status = "On" - } - sess.WriteLine(fmt.Sprintf("\n%s %s.", t.Description, status)) - g.AccountStore.SaveCharacter(p) - return - } - } - sess.WriteLine(fmt.Sprintf("\nUnknown toggle: %s", input)) -} diff --git a/internal/game/game.go b/internal/game/game.go index 0675a13..8151877 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -194,10 +194,12 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { return case "description", "desc": g.doDescription(sess) - case "toggle": - g.doToggle(sess, strings.Join(args, " ")) + case "option", "options": + g.doOption(sess, strings.Join(args, " ")) case "exits": g.doExits(sess) + case "map": + g.doMap(sess) case "help": if len(args) == 0 { diff --git a/internal/game/map_test.go b/internal/game/map_test.go index a50c2c2..adba595 100644 --- a/internal/game/map_test.go +++ b/internal/game/map_test.go @@ -92,3 +92,89 @@ func TestWrapText(t *testing.T) { } } } + +func TestBuildFullMap(t *testing.T) { + g := &Game{ + World: world.New("../../data"), + MapWidth: 70, + } + + tests := []struct { + name string + roomID int + width int + height int + }{ + {"room 1 30x20", 1, 30, 20}, + {"room 1 20x10", 1, 20, 10}, + {"room 25 30x20", 25, 30, 20}, + {"min size 5x5", 1, 5, 5}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lines := buildFullMap(g, tt.roomID, tt.width, tt.height) + if len(lines) != tt.height { + t.Errorf("expected %d lines, got %d", tt.height, len(lines)) + } + for i, line := range lines { + if len([]rune(line)) != tt.width { + t.Errorf("line %d: expected %d runes, got %d in %q", i, tt.width, len([]rune(line)), line) + } + } + t.Logf("Room %d %dx%d map:\n%s", tt.roomID, tt.width, tt.height, strings.Join(lines, "\n")) + }) + } +} + +func TestStripBlankRows(t *testing.T) { + input := []string{" ", "hello", "", " world ", " "} + want := []string{"hello", " world "} + got := stripBlankRows(input) + if len(got) != len(want) { + t.Fatalf("expected %d lines, got %d", len(want), len(got)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("line %d: want %q, got %q", i, want[i], got[i]) + } + } +} + +func TestLeftTrimCommon(t *testing.T) { + input := []string{" hello", " world", " ", " foo"} + got := leftTrimCommon(input) + // min leading spaces across non-blank lines: " hello"=4, " world"=4, " foo"=2 → min=2 + // trim 2 from all lines + if got[0] != " hello" { + t.Errorf("line 0: want %q, got %q", " hello", got[0]) + } + if got[1] != " world" { + t.Errorf("line 1: want %q, got %q", " world", got[1]) + } + if got[2] != "" { + t.Errorf("line 2: want %q, got %q", "", got[2]) + } + if got[3] != "foo" { + t.Errorf("line 3: want %q, got %q", "foo", got[3]) + } +} + +func TestLeftTrimCommonEmpty(t *testing.T) { + input := []string{" ", "", " "} + got := leftTrimCommon(input) + if len(got) != 3 { + t.Fatalf("expected 3 lines, got %d", len(got)) + } +} + +func TestLeftTrimCommonZero(t *testing.T) { + input := []string{"hello", "world"} + got := leftTrimCommon(input) + if got[0] != "hello" { + t.Errorf("line 0: want %q, got %q", "hello", got[0]) + } + if got[1] != "world" { + t.Errorf("line 1: want %q, got %q", "world", got[1]) + } +} diff --git a/internal/game/tick.go b/internal/game/tick.go index 7eb63eb..3c3f1d3 100644 --- a/internal/game/tick.go +++ b/internal/game/tick.go @@ -126,14 +126,14 @@ func (g *Game) WanderTick() { if !ok { continue } - if p.RoomID == m.fromRoom && p.Toggles["mobleave"] { + if p.RoomID == m.fromRoom && p.OptionBool("mobleave") { if m.level > 0 { sess.WriteLine(fmt.Sprintf("\n%s (level %d) leaves.", m.name, m.level)) } else { sess.WriteLine(fmt.Sprintf("\n%s moves away.", m.name)) } } - if p.RoomID == m.toRoom && p.Toggles["mobenter"] { + if p.RoomID == m.toRoom && p.OptionBool("mobenter") { if m.level > 0 { sess.WriteLine(fmt.Sprintf("\n%s (level %d) enters.", m.name, m.level)) } else { diff --git a/internal/player/player.go b/internal/player/player.go index 56ab06c..97874e3 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -86,6 +86,53 @@ type InventorySlot struct { Quantity int `yaml:"quantity"` } +type OptionType int + +const ( + OptBool OptionType = iota + OptString + OptInt +) + +type OptionDef struct { + Name string + Type OptionType + Default any + ValidValues []string + Description string +} + +var OptionDefs = []OptionDef{ + {"description", OptBool, true, nil, "Long room descriptions when moving"}, + {"tinymap", OptBool, true, nil, "Mini-map display"}, + {"left-tinymap", OptBool, false, nil, "Mini-map on left side of descriptions"}, + {"xpdrops", OptBool, true, nil, "XP drop messages"}, + {"exits", OptBool, true, nil, "Long exit display in look"}, + {"mobenter", OptBool, true, nil, "Messages when mobs enter the room"}, + {"mobleave", OptBool, true, nil, "Messages when mobs leave the room"}, + {"mobspawn", OptBool, true, nil, "Messages when mobs spawn in the area"}, + {"reserve", OptBool, true, nil, "Show full reserved item details"}, + {"depletion", OptBool, false, nil, "Show depletion and despawn timers"}, + {"color", OptString, "none", []string{"none", "ansi", "xterm256"}, "Color output mode"}, + {"mapwidth", OptInt, 30, nil, "Map width for the map command"}, + {"mapheight", OptInt, 20, nil, "Map height for the map command"}, + {"mappadding", OptString, "none", []string{"none", "x", "y", "xy"}, "Map output padding mode"}, + {"automap", OptBool, false, nil, "Show map automatically after moving"}, +} + +var optionByName map[string]*OptionDef + +func init() { + optionByName = make(map[string]*OptionDef, len(OptionDefs)) + for i := range OptionDefs { + optionByName[OptionDefs[i].Name] = &OptionDefs[i] + } +} + +func GetOptionDef(name string) *OptionDef { + return optionByName[name] +} + type Player struct { Name string `yaml:"name"` Skills map[SkillName]int `yaml:"skills"` @@ -96,12 +143,73 @@ type Player struct { Credits int `yaml:"credits"` Description string `yaml:"description"` AttackStyle AttackStyle `yaml:"attack_style"` - Toggles map[string]bool `yaml:"toggles"` + Options map[string]any `yaml:"options"` Flags map[string]any `yaml:"flags"` RegenerateTick int Action *action.Action `yaml:"-"` } +func (p *Player) OptionBool(name string) bool { + def := GetOptionDef(name) + if def == nil || def.Type != OptBool { + return false + } + if p.Options == nil { + return def.Default.(bool) + } + val, ok := p.Options[name] + if !ok { + return def.Default.(bool) + } + b, ok := val.(bool) + if !ok { + return def.Default.(bool) + } + return b +} + +func (p *Player) OptionString(name string) string { + def := GetOptionDef(name) + if def == nil || def.Type != OptString { + return "" + } + if p.Options == nil { + return def.Default.(string) + } + val, ok := p.Options[name] + if !ok { + return def.Default.(string) + } + s, ok := val.(string) + if !ok { + return def.Default.(string) + } + return s +} + +func (p *Player) OptionInt(name string) int { + def := GetOptionDef(name) + if def == nil || def.Type != OptInt { + return 0 + } + if p.Options == nil { + return def.Default.(int) + } + val, ok := p.Options[name] + if !ok { + return def.Default.(int) + } + switch v := val.(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + } + return def.Default.(int) +} + func (p *Player) InvSlot(i int) *InventorySlot { if p.Inventory == nil { return nil @@ -140,18 +248,11 @@ func New(name string) *Player { Skills: make(map[SkillName]int), Equipment: make(map[object.EquipSlot]string), AttackStyle: Accurate, - Toggles: map[string]bool{ - "description": true, - "tinymap": true, - "left-tinymap": false, - "xpdrops": true, - "exits": true, - "mobenter": true, - "mobleave": true, - "mobspawn": true, - "reserve": true, - }, - RoomID: 0, + Options: make(map[string]any), + RoomID: 0, + } + for _, def := range OptionDefs { + p.Options[def.Name] = def.Default } for _, s := range AllSkills { p.Skills[s] = 0 -- cgit v1.2.3