aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-07 16:46:32 -0400
committerhistoria <[not public]>2026-07-07 16:46:32 -0400
commit726997d799edc388cd448e978d5bb0755aaa551c (patch)
treeb7aa8dc2dc9d2edcad1c0a4012a28d01d348db69
parentab2597b22ccdb71116992aec78080d9858358d04 (diff)
downloadthehouseoficarus-726997d799edc388cd448e978d5bb0755aaa551c.tar.gz
feat: bitpacked and base64 encoded VisitedRooms in player YAML to keep the file human readable
-rw-r--r--internal/game/cmd_score.go2
-rw-r--r--internal/game/exit_migrate.go6
-rw-r--r--internal/game/exit_migrate_test.go8
-rw-r--r--internal/game/map_test.go4
-rw-r--r--internal/game/render_map.go16
-rw-r--r--internal/player/player.go3
-rw-r--r--internal/player/roomset.go109
-rw-r--r--internal/player/roomset_test.go87
-rw-r--r--internal/player/stats.go7
9 files changed, 218 insertions, 24 deletions
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_<id>_<dir>), 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<<bitIdx) != 0 {
+ keys = append(keys, base+bitIdx)
+ }
+ }
+ }
+ return keys
+}
+
+func (rs *RoomSet) Rekey(idMap map[int]int) (RoomSet, bool) {
+ var next RoomSet
+ changed := false
+ for _, k := range rs.Keys() {
+ newK, ok := idMap[k]
+ if !ok {
+ newK = k
+ } else if newK != k {
+ changed = true
+ }
+ next.Add(newK)
+ }
+ return next, changed
+}
+
+func NewRoomSetFrom(ids ...int) RoomSet {
+ var rs RoomSet
+ for _, id := range ids {
+ rs.Add(id)
+ }
+ return rs
+}
+
+func (rs RoomSet) MarshalYAML() (any, error) {
+ return base64.StdEncoding.EncodeToString(rs.bits), nil
+}
+
+func (rs *RoomSet) UnmarshalYAML(value *yaml.Node) error {
+ if value.Kind == yaml.ScalarNode && (value.Tag == "!!null" || value.Value == "null") {
+ rs.bits = nil
+ return nil
+ }
+ var s string
+ if err := value.Decode(&s); err != nil {
+ return err
+ }
+ if s == "" {
+ rs.bits = nil
+ return nil
+ }
+ b, err := base64.StdEncoding.DecodeString(s)
+ if err != nil {
+ return err
+ }
+ rs.bits = b
+ return nil
+}
diff --git a/internal/player/roomset_test.go b/internal/player/roomset_test.go
new file mode 100644
index 0000000..2b6b9cb
--- /dev/null
+++ b/internal/player/roomset_test.go
@@ -0,0 +1,87 @@
+package player
+
+import (
+ "strings"
+ "testing"
+
+ "gopkg.in/yaml.v3"
+)
+
+func TestRoomSetYAMLRoundTrip(t *testing.T) {
+ stats := PlayerStats{
+ RoomsVisited: NewRoomSetFrom(1, 5, 300),
+ MobKills: map[string]int{"rat": 3},
+ TotalKills: 3,
+ Deaths: 0,
+ }
+ data, err := yaml.Marshal(stats)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ out := string(data)
+ if !strings.Contains(out, "rooms_visited:") {
+ t.Fatalf("expected rooms_visited key in output:\n%s", out)
+ }
+
+ var loaded PlayerStats
+ if err := yaml.Unmarshal(data, &loaded); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if loaded.RoomsVisited.Len() != 3 {
+ t.Errorf("Len = %d, want 3", loaded.RoomsVisited.Len())
+ }
+ for _, id := range []int{1, 5, 300} {
+ if !loaded.RoomsVisited.Has(id) {
+ t.Errorf("expected Has(%d) == true", id)
+ }
+ }
+ if loaded.RoomsVisited.Has(2) {
+ t.Error("expected Has(2) == false")
+ }
+}
+
+func TestRoomSetOmitEmpty(t *testing.T) {
+ stats := PlayerStats{}
+ data, err := yaml.Marshal(stats)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ out := string(data)
+ if strings.Contains(out, "rooms_visited:") {
+ t.Errorf("expected rooms_visited to be absent for empty set:\n%s", out)
+ }
+
+ var loaded PlayerStats
+ if err := yaml.Unmarshal(data, &loaded); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if loaded.RoomsVisited.Len() != 0 {
+ t.Errorf("Len = %d, want 0", loaded.RoomsVisited.Len())
+ }
+}
+
+func TestRoomSetIsZero(t *testing.T) {
+ var zero RoomSet
+ if !zero.IsZero() {
+ t.Error("zero value should be IsZero")
+ }
+ visited := NewRoomSetFrom(1)
+ if visited.IsZero() {
+ t.Error("non-empty set should not be IsZero")
+ }
+}
+
+func TestRoomSetMarshalEmpty(t *testing.T) {
+ var rs RoomSet
+ v, err := rs.MarshalYAML()
+ if err != nil {
+ t.Fatalf("MarshalYAML: %v", err)
+ }
+ s, ok := v.(string)
+ if !ok {
+ t.Fatalf("MarshalYAML returned %T, want string", v)
+ }
+ if s != "" {
+ t.Errorf("MarshalYAML empty = %q, want empty string", s)
+ }
+}
diff --git a/internal/player/stats.go b/internal/player/stats.go
index 82b741c..2b63558 100644
--- a/internal/player/stats.go
+++ b/internal/player/stats.go
@@ -1,17 +1,14 @@
package player
type PlayerStats struct {
- RoomsVisited map[int]bool `yaml:"rooms_visited,omitempty"`
+ RoomsVisited RoomSet `yaml:"rooms_visited,omitempty"`
MobKills map[string]int `yaml:"mob_kills,omitempty"`
TotalKills int `yaml:"total_kills"`
Deaths int `yaml:"deaths"`
}
func (s *PlayerStats) RecordRoomVisit(roomID int) {
- if s.RoomsVisited == nil {
- s.RoomsVisited = make(map[int]bool)
- }
- s.RoomsVisited[roomID] = true
+ s.RoomsVisited.Add(roomID)
}
func (s *PlayerStats) RecordMobKill(mobDefID string) {