From 726997d799edc388cd448e978d5bb0755aaa551c Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 7 Jul 2026 16:46:32 -0400 Subject: feat: bitpacked and base64 encoded VisitedRooms in player YAML to keep the file human readable --- internal/game/cmd_score.go | 2 +- internal/game/exit_migrate.go | 6 +- internal/game/exit_migrate_test.go | 8 +-- internal/game/map_test.go | 4 +- internal/game/render_map.go | 16 +++--- internal/player/player.go | 3 +- internal/player/roomset.go | 109 +++++++++++++++++++++++++++++++++++++ internal/player/roomset_test.go | 87 +++++++++++++++++++++++++++++ internal/player/stats.go | 7 +-- 9 files changed, 218 insertions(+), 24 deletions(-) create mode 100644 internal/player/roomset.go create mode 100644 internal/player/roomset_test.go diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go index b493e80..59cedb1 100644 --- a/internal/game/cmd_score.go +++ b/internal/game/cmd_score.go @@ -174,7 +174,7 @@ func (g *Game) doScore(sess *net.Session) { sep() stats := p.Stats statsLine := fmt.Sprintf("Rooms Explored: %d Kills: %d Deaths: %d", - len(stats.RoomsVisited), stats.TotalKills, stats.Deaths) + stats.RoomsVisited.Len(), stats.TotalKills, stats.Deaths) content(statsLine) content(fmt.Sprintf("APS Nodes Found: %d", len(getKnownApsNodes(p)))) diff --git a/internal/game/exit_migrate.go b/internal/game/exit_migrate.go index 845a6b2..2872f34 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__), current RoomID, EnterSeqRoom, MapSymbols keys, and -// Stats.RoomsVisited keys. Returns whether any field was changed. +// Stats.RoomsVisited bitset. 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 @@ -32,7 +32,9 @@ func rewritePlayerRoomIDs(p *player.Player, idMap map[int]int) bool { if len(p.MapSymbols) > 0 && rekeyIntMap(p.MapSymbols, idMap) { changed = true } - if len(p.Stats.RoomsVisited) > 0 && rekeyIntMap(p.Stats.RoomsVisited, idMap) { + next, moved := p.Stats.RoomsVisited.Rekey(idMap) + if moved { + p.Stats.RoomsVisited = next changed = true } return changed diff --git a/internal/game/exit_migrate_test.go b/internal/game/exit_migrate_test.go index 05edea5..f86b85b 100644 --- a/internal/game/exit_migrate_test.go +++ b/internal/game/exit_migrate_test.go @@ -21,7 +21,7 @@ func TestRewritePlayerRoomIDsRename(t *testing.T) { 30: {Char: "Y"}, }, } - p.Stats.RoomsVisited = map[int]bool{10: true, 40: true} + p.Stats.RoomsVisited = player.NewRoomSetFrom(10, 40) idMap := map[int]int{10: 100, 20: 200} if !rewritePlayerRoomIDs(p, idMap) { @@ -53,13 +53,13 @@ func TestRewritePlayerRoomIDsRename(t *testing.T) { if _, ok := p.MapSymbols[10]; ok { t.Error("MapSymbols should not still have key 10") } - if !p.Stats.RoomsVisited[100] { + if !p.Stats.RoomsVisited.Has(100) { t.Error("RoomsVisited missing key 100") } - if !p.Stats.RoomsVisited[40] { + if !p.Stats.RoomsVisited.Has(40) { t.Error("RoomsVisited missing unchanged key 40") } - if p.Stats.RoomsVisited[10] { + if p.Stats.RoomsVisited.Has(10) { t.Error("RoomsVisited should not still have key 10") } } diff --git a/internal/game/map_test.go b/internal/game/map_test.go index 2fe9c1e..2975551 100644 --- a/internal/game/map_test.go +++ b/internal/game/map_test.go @@ -406,7 +406,7 @@ func TestMap3DDisconnectedComponent(t *testing.T) { // Player has only visited tower 1. Tower 2 should appear but dimmed (unvisited). sess := &net.Session{Player: &player.Player{ - Stats: player.PlayerStats{RoomsVisited: map[int]bool{1: true}}, + Stats: player.PlayerStats{RoomsVisited: player.NewRoomSetFrom(1)}, Flags: map[string]any{}, }} @@ -429,7 +429,7 @@ func TestMap3DDifferentZExcluded(t *testing.T) { g := &Game{Deps: Deps{World: world.New(dir)}, GlobalFlags: NewGlobalFlagStore()} mg := mapGlyphsForPlayer(false) sess := &net.Session{Player: &player.Player{ - Stats: player.PlayerStats{RoomsVisited: map[int]bool{1: true, 2: true}}, + Stats: player.PlayerStats{RoomsVisited: player.NewRoomSetFrom(1, 2)}, Flags: map[string]any{}, }} diff --git a/internal/game/render_map.go b/internal/game/render_map.go index 0177595..3f9c4da 100644 --- a/internal/game/render_map.go +++ b/internal/game/render_map.go @@ -131,7 +131,7 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string if rid == roomID { grid[gr][gc] = mapCell{char: '@', spec: atSpec} } else { - unvisited := visited != nil && !visited[rid] + unvisited := visited != nil && !visited.Has(rid) ch, spec := roomMapSymbol(g, sess, rid, unvisited) if unvisited { spec = dimSpec @@ -307,7 +307,7 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m if rid == roomID { grid[gr][gc] = mapCell{char: '@', spec: atSpec} } else { - unvisited := visited != nil && !visited[rid] + unvisited := visited != nil && !visited.Has(rid) ch, spec := roomMapSymbol(g, sess, rid, unvisited) if unvisited { spec = dimSpec @@ -409,9 +409,9 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m return renderMapCells(grid, colorMode, 0, mapHeight, 0) } -func roomsVisited(sess *net.Session) map[int]bool { +func roomsVisited(sess *net.Session) *player.RoomSet { if sess != nil && sess.Player != nil { - return sess.Player.Stats.RoomsVisited + return &sess.Player.Stats.RoomsVisited } return nil } @@ -457,7 +457,7 @@ type mapRenderCtx struct { g *Game sess *net.Session bg *mapGraph - visited map[int]bool + visited *player.RoomSet currentRoom int atSpec color.ColorSpec dimSpec color.ColorSpec @@ -473,7 +473,7 @@ func (c *mapRenderCtx) nodeSpec(roomID int) color.ColorSpec { if roomID == c.currentRoom { return c.atSpec } - if c.visited != nil && !c.visited[roomID] { + if c.visited != nil && !c.visited.Has(roomID) { return c.dimSpec } _, spec := roomMapSymbol(c.g, c.sess, roomID, false) @@ -548,7 +548,7 @@ func (c *mapRenderCtx) isCourseLink(roomA, roomB int) bool { // courseColoredCell draws a one-way course arrow using the map_course color // (dimmed if either endpoint is unvisited). func (c *mapRenderCtx) courseColoredCell(roomA, roomB int, glyph rune) (mapCell, bool) { - if c.visited != nil && (!c.visited[roomA] || !c.visited[roomB]) { + if c.visited != nil && (!c.visited.Has(roomA) || !c.visited.Has(roomB)) { return mapCell{char: glyph, spec: c.dimSpec}, true } return mapCell{char: glyph, spec: c.courseSpec}, true @@ -557,7 +557,7 @@ func (c *mapRenderCtx) courseColoredCell(roomA, roomB int, glyph rune) (mapCell, // coloredCell applies the shared link coloring logic for bar/arrow glyphs // (dim if either endpoint is unvisited, otherwise the gradient average). func (c *mapRenderCtx) coloredCell(roomA, roomB int, glyph rune) (mapCell, bool) { - if c.visited != nil && (!c.visited[roomA] || !c.visited[roomB]) { + if c.visited != nil && (!c.visited.Has(roomA) || !c.visited.Has(roomB)) { return mapCell{char: glyph, spec: c.dimSpec}, true } return mapCell{char: glyph, spec: color.Average(c.nodeSpec(roomA), c.nodeSpec(roomB))}, true diff --git a/internal/player/player.go b/internal/player/player.go index 65026e7..fcf274b 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -333,8 +333,7 @@ func New(name string) *Player { RoomID: 0, MapSymbols: make(map[int]MapSymbolData), Stats: PlayerStats{ - RoomsVisited: make(map[int]bool), - MobKills: make(map[string]int), + MobKills: make(map[string]int), }, } for _, s := range AllSkills { diff --git a/internal/player/roomset.go b/internal/player/roomset.go new file mode 100644 index 0000000..3565bb6 --- /dev/null +++ b/internal/player/roomset.go @@ -0,0 +1,109 @@ +package player + +import ( + "encoding/base64" + "math/bits" + + "gopkg.in/yaml.v3" +) + +type RoomSet struct { + bits []byte +} + +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) +} + +func (rs *RoomSet) Has(roomID int) bool { + if roomID < 0 { + return false + } + byteIdx := roomID / 8 + return byteIdx < len(rs.bits) && rs.bits[byteIdx]&(1<<(roomID%8)) != 0 +} + +func (rs *RoomSet) Len() int { + n := 0 + for _, b := range rs.bits { + n += bits.OnesCount8(b) + } + return n +} + +func (rs RoomSet) IsZero() bool { + return len(rs.bits) == 0 +} + +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<