package player import ( "encoding/base64" "encoding/binary" "fmt" "sort" "strings" "gopkg.in/yaml.v3" ) // RoomSet is the set of room IDs a character has visited, used for the map // discovery view. // // It is stored sparsely: room IDs are filename-derived and may be large and // widely spaced (the shipped example rooms run from 1001 to 99999301), so an // ID-indexed bitset would grow with the *largest* ID rather than the number of // visited rooms. A sparse map keeps memory proportional to the visited count, // and the YAML encoding is a sorted, delta-encoded uvarint blob (see // MarshalYAML) which is a handful of bytes for a typical character regardless // of how high the IDs run. // // The zero value is a valid empty set. Copies share the underlying map, so a // RoomSet should not be copied after a concurrent mutation (the game layer only // touches it on the owner goroutine). type RoomSet struct { ids map[int]struct{} } // roomSetV1Prefix tags the delta-encoded encoding. The colon is safe inside a // YAML plain scalar: ':' is only a mapping indicator when followed by a space, // and ':' is not in the base64 alphabet, so the prefix cannot be confused with // payload bytes. const roomSetV1Prefix = "v1:" // roomSetMaxID bounds decoded room IDs. IDs are filename-derived and fit // comfortably in an int32; rejecting anything larger keeps a corrupt delta // blob from synthesizing absurd IDs. const roomSetMaxID = 1<<31 - 1 func (rs *RoomSet) ensure() { if rs.ids == nil { rs.ids = make(map[int]struct{}) } } func (rs *RoomSet) Add(roomID int) { if roomID < 0 || roomID > roomSetMaxID { return } rs.ensure() rs.ids[roomID] = struct{}{} } func (rs *RoomSet) Has(roomID int) bool { if roomID < 0 || rs.ids == nil { return false } _, ok := rs.ids[roomID] return ok } func (rs *RoomSet) Len() int { if rs.ids == nil { return 0 } return len(rs.ids) } func (rs RoomSet) IsZero() bool { return len(rs.ids) == 0 } // Keys returns the visited room IDs in ascending order. func (rs *RoomSet) Keys() []int { if len(rs.ids) == 0 { return nil } keys := make([]int, 0, len(rs.ids)) for id := range rs.ids { keys = append(keys, id) } sort.Ints(keys) return keys } // Rekey remaps every visited room through idMap, leaving unmapped IDs in place. // It returns the new set and whether any ID changed. The receiver is not // modified. func (rs *RoomSet) Rekey(idMap map[int]int) (RoomSet, bool) { var next RoomSet changed := false for id := range rs.ids { newID, ok := idMap[id] if !ok { newID = id } else if newID != id { changed = true } next.Add(newID) } return next, changed } func NewRoomSetFrom(ids ...int) RoomSet { var rs RoomSet for _, id := range ids { rs.Add(id) } return rs } // MarshalYAML emits the current encoding: ascending IDs as delta-encoded // uvarints, base64-encoded behind the "v1:" tag. IDs come from filenames, so a // small delta usually costs one byte and the total is O(visited). func (rs RoomSet) MarshalYAML() (any, error) { if len(rs.ids) == 0 { return "", nil } keys := rs.Keys() buf := make([]byte, 0, len(keys)*2) prev := 0 for _, id := range keys { buf = binary.AppendUvarint(buf, uint64(id-prev)) prev = id } return roomSetV1Prefix + base64.StdEncoding.EncodeToString(buf), nil } // UnmarshalYAML accepts only the v1 delta encoding (plus null/empty for the // zero value). There is deliberately no legacy decoding: the game is a // prototype and character files are recreated rather than migrated. func (rs *RoomSet) UnmarshalYAML(value *yaml.Node) error { rs.ids = nil if value.Kind == yaml.ScalarNode && (value.Tag == "!!null" || value.Value == "null" || value.Value == "") { return nil } var s string if err := value.Decode(&s); err != nil { return err } if s == "" { return nil } if !strings.HasPrefix(s, roomSetV1Prefix) { return fmt.Errorf("rooms_visited: unrecognized encoding, want %q prefix", roomSetV1Prefix) } return rs.decodeDelta(s[len(roomSetV1Prefix):]) } func (rs *RoomSet) decodeDelta(encoded string) error { raw, err := base64.StdEncoding.DecodeString(encoded) if err != nil { return fmt.Errorf("rooms_visited: %w", err) } prev := 0 for len(raw) > 0 { delta, n := binary.Uvarint(raw) if n <= 0 { return fmt.Errorf("rooms_visited: corrupt delta encoding") } raw = raw[n:] if delta > roomSetMaxID || prev > roomSetMaxID-int(delta) { return fmt.Errorf("rooms_visited: room ID out of range") } prev += int(delta) rs.Add(prev) } return nil }