diff options
| -rw-r--r-- | internal/game/cmd_score.go | 2 | ||||
| -rw-r--r-- | internal/game/exit_migrate.go | 6 | ||||
| -rw-r--r-- | internal/game/exit_migrate_test.go | 8 | ||||
| -rw-r--r-- | internal/game/map_test.go | 4 | ||||
| -rw-r--r-- | internal/game/render_map.go | 16 | ||||
| -rw-r--r-- | internal/player/player.go | 3 | ||||
| -rw-r--r-- | internal/player/roomset.go | 109 | ||||
| -rw-r--r-- | internal/player/roomset_test.go | 87 | ||||
| -rw-r--r-- | internal/player/stats.go | 7 |
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) { |
