From 0cb2368249f1b37ce671bbd7690d796b2bc2a277 Mon Sep 17 00:00:00 2001 From: Volte6 <143822+Volte6@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:24:52 -0700 Subject: [PATCH] Adding `visit` admin command and `visited` user command. Updated `map` skill, scripting functions etc --- .../building/scripting/FUNCTIONS_ACTORS.md | 30 +- .../building/scripting/FUNCTIONS_PARTY.md | 28 +- _datafiles/world/default/keywords.yaml | 3 + .../admincommands/help/command.visit.template | 24 ++ .../templates/character/visited.template | 4 + .../world/default/templates/help/visited.md | 7 + .../world/default/templates/maps/map.template | 2 +- _datafiles/world/default/users/1.yaml | 27 +- _datafiles/world/empty/keywords.yaml | 3 + internal/characters/character.go | 65 ++++ internal/characters/context.md | 15 +- internal/characters/roombitset.go | 149 +++++++++ internal/characters/roombitset_test.go | 303 ++++++++++++++++++ internal/mapper/mapper.config.go | 13 + internal/mapper/mapper.go | 7 +- internal/rooms/roomdetails.go | 4 +- internal/rooms/roommanager.go | 5 + internal/scripting/actor_func.go | 41 +++ internal/scripting/party_func.go | 12 + internal/usercommands/admin.visit.go | 182 +++++++++++ internal/usercommands/skill.map.go | 29 +- internal/usercommands/usercommands.go | 2 + internal/usercommands/visited.go | 67 ++++ internal/users/context.md | 11 +- modules/gmcp/gmcp.Room.go | 2 +- 25 files changed, 1012 insertions(+), 23 deletions(-) create mode 100644 _datafiles/world/default/templates/admincommands/help/command.visit.template create mode 100644 _datafiles/world/default/templates/character/visited.template create mode 100644 _datafiles/world/default/templates/help/visited.md mode change 100755 => 100644 _datafiles/world/default/users/1.yaml create mode 100644 internal/characters/roombitset.go create mode 100644 internal/characters/roombitset_test.go create mode 100644 internal/usercommands/admin.visit.go create mode 100644 internal/usercommands/visited.go diff --git a/_datafiles/guides/building/scripting/FUNCTIONS_ACTORS.md b/_datafiles/guides/building/scripting/FUNCTIONS_ACTORS.md index d69188c86..45568d5cc 100644 --- a/_datafiles/guides/building/scripting/FUNCTIONS_ACTORS.md +++ b/_datafiles/guides/building/scripting/FUNCTIONS_ACTORS.md @@ -93,6 +93,8 @@ ActorObjects are the basic object that represents Users and NPCs - [ActorObject.TimerExpired(name string) bool](#actorobjecttimerexpiredname-string-bool) - [ActorObject.TimerExists(name string) bool](#actorobjecttimerexistsname-string-bool) - [ActorObject.AddEventLog(category string, message string)](#actorobjectaddeventlogcategory-string-message-string) + - [ActorObject.MarkVisitedRoom(roomId1 int [, roomId2, ...])](#actorobjectmarkvisitedroomroomid1-int--roomid2-) + - [ActorObject.MarkVisitedZone(zoneName string)](#actorobjectmarkvisitedzonezoneename-string) @@ -638,4 +640,30 @@ Adds a line to the users Event Log (`history`) | Argument | Explanation | | --- | --- | | category | A short single word category | -| message | A single line describing the event | \ No newline at end of file +| message | A single line describing the event | + +## [ActorObject.MarkVisitedRoom(roomId1 int [, roomId2, ...])](/internal/scripting/actor_func.go) +Marks one or more rooms as visited for this actor. Only applies to user actors; mobs do not track room visits. Each room is recorded under the zone it belongs to. + +_Note: Non-positive room IDs are silently ignored._ + +| Argument | Explanation | +| --- | --- | +| roomId1, roomId2, ... | One or more room IDs to mark as visited | + +**Example:** +```javascript +user.MarkVisitedRoom(101, 102, 103); +``` + +## [ActorObject.MarkVisitedZone(zoneName string)](/internal/scripting/actor_func.go) +Marks every room in the named zone as visited for this actor. Only applies to user actors; mobs do not track room visits. Accepts partial zone name matches. + +| Argument | Explanation | +| --- | --- | +| zoneName | The zone name (or partial name) to mark all rooms visited in | + +**Example:** +```javascript +user.MarkVisitedZone("frostfang"); +``` \ No newline at end of file diff --git a/_datafiles/guides/building/scripting/FUNCTIONS_PARTY.md b/_datafiles/guides/building/scripting/FUNCTIONS_PARTY.md index 696f285ad..7325fc664 100644 --- a/_datafiles/guides/building/scripting/FUNCTIONS_PARTY.md +++ b/_datafiles/guides/building/scripting/FUNCTIONS_PARTY.md @@ -26,6 +26,8 @@ PartyObjects represent collections of actors (users and NPCs) that are grouped t - [PartyObject.GiveExtraLife()](#partyobjectgiveextralife) - [PartyObject.GrantXP(xpAmt int, reason string)](#partyobjectgrantxpxpamt-int-reason-string) - [PartyObject.TimerSet(name string, period string)](#partyobjecttimersetname-string-period-string) + - [PartyObject.MarkVisitedRoom(roomId1 int [, roomId2, ...])](#partyobjectmarkvisitedroomroomid1-int--roomid2-) + - [PartyObject.MarkVisitedZone(zoneName string)](#partyobjectmarkvisitedzonezoneename-string) @@ -195,4 +197,28 @@ Starts a new Round timer for all party members. | Argument | Explanation | | --- | --- | | name | A string identifier. Reusing names will overwrite previously assigned names | -| period | How long until the timer expires. `1 real hour`, `1 hour`, etc | \ No newline at end of file +| period | How long until the timer expires. `1 real hour`, `1 hour`, etc | + +## [PartyObject.MarkVisitedRoom(roomId1 int [, roomId2, ...])](/internal/scripting/party_func.go) +Marks one or more rooms as visited for all user members of the party. Mobs in the party are unaffected. + +| Argument | Explanation | +| --- | --- | +| roomId1, roomId2, ... | One or more room IDs to mark as visited | + +**Example:** +```javascript +user.GetParty().MarkVisitedRoom(101, 102, 103); +``` + +## [PartyObject.MarkVisitedZone(zoneName string)](/internal/scripting/party_func.go) +Marks every room in the named zone as visited for all user members of the party. Mobs in the party are unaffected. Accepts partial zone name matches. + +| Argument | Explanation | +| --- | --- | +| zoneName | The zone name (or partial name) to mark all rooms visited in | + +**Example:** +```javascript +user.GetParty().MarkVisitedZone("frostfang"); +``` \ No newline at end of file diff --git a/_datafiles/world/default/keywords.yaml b/_datafiles/world/default/keywords.yaml index 9e0834a95..5e967f727 100644 --- a/_datafiles/world/default/keywords.yaml +++ b/_datafiles/world/default/keywords.yaml @@ -68,6 +68,7 @@ help: - races - who - history + - visited items: - drop - drink @@ -125,6 +126,7 @@ help: - buff - build - command + - copyover - deafen - item - grant @@ -145,6 +147,7 @@ help: - syslogs - zap - zone + - visit # Aliases for keywords when typing: help # Key is the target keyword, value is the list of aliases help-aliases: diff --git a/_datafiles/world/default/templates/admincommands/help/command.visit.template b/_datafiles/world/default/templates/admincommands/help/command.visit.template new file mode 100644 index 000000000..16e750703 --- /dev/null +++ b/_datafiles/world/default/templates/admincommands/help/command.visit.template @@ -0,0 +1,24 @@ +The visit command manages room visit tracking for players: + +visit list + List visit progress across all zones for yourself. + +visit list [username] + List visit progress across all zones for the specified user. + +visit set [zonename|all] + Mark all rooms in the specified zone as visited for yourself. + Use all to mark every zone. + +visit set [zonename|all] [username] + Mark all rooms in the specified zone as visited for the target user. + +visit unset [zonename|all] + Reset all rooms in the specified zone to unvisited for yourself. + Use all to reset every zone. + +visit unset [zonename|all] [username] + Reset all rooms in the specified zone to unvisited for the target user. + +Zone names support partial matching. The username can be an online character +name or an offline account username. diff --git a/_datafiles/world/default/templates/character/visited.template b/_datafiles/world/default/templates/character/visited.template new file mode 100644 index 000000000..b00daa197 --- /dev/null +++ b/_datafiles/world/default/templates/character/visited.template @@ -0,0 +1,4 @@ + ┌─ .:Zones Discovered ─────────────────────────────────────────────────────┐ + {{ $nlLen := sub (len .Records) 1 }}{{ range $idx, $zInfo := .Records }} {{ padRight 30 $zInfo.Name }} {{ $zInfo.BarFull }}{{ $zInfo.BarEmpty }} {{ padRight 4 $zInfo.Completion }}{{ if lt $idx $nlLen }}{{ end }} + {{ end -}} + └──────────────────────────────────────────────────────────────────────────┘ diff --git a/_datafiles/world/default/templates/help/visited.md b/_datafiles/world/default/templates/help/visited.md new file mode 100644 index 000000000..289c32549 --- /dev/null +++ b/_datafiles/world/default/templates/help/visited.md @@ -0,0 +1,7 @@ +# Help for ~visited~ + +The ~visited~ command shows all zones you have explored, along with the percentage of rooms visited in each. + +## Usage: + + ~visited~ diff --git a/_datafiles/world/default/templates/maps/map.template b/_datafiles/world/default/templates/maps/map.template index d71aa5f3a..d9e3aebb4 100644 --- a/_datafiles/world/default/templates/maps/map.template +++ b/_datafiles/world/default/templates/maps/map.template @@ -3,7 +3,7 @@ {{- $leftBorder := .LeftBorder -}} {{- $midBorder := .MidBorder -}} {{- $rightBorder := .RightBorder -}} -{{ $leftBorder.Top }} .:{{ printf ( printf "%%-%ds" ( sub $mapWidth 4 ) ) .Title }}{{ $rightBorder.Top }} +{{ $leftBorder.Top }} .:{{ printf ( printf "%%-%ds" ( sub $mapWidth 4 ) ) ( printf "%s (%d%%)" .Title .ZoneCompletePct ) }}{{ $rightBorder.Top }} {{ index $leftBorder.Mid 0 }}{{ padRightX "" $midBorder.Top $mapWidth }}{{ index $rightBorder.Mid 0 }} {{ range $index, $line := .DisplayLines }} {{- $mod := mod $index 2 -}} diff --git a/_datafiles/world/default/users/1.yaml b/_datafiles/world/default/users/1.yaml old mode 100755 new mode 100644 index e29ba163d..9881136be --- a/_datafiles/world/default/users/1.yaml +++ b/_datafiles/world/default/users/1.yaml @@ -17,9 +17,9 @@ character: other adventurers, granting them strength, wisdom, or fortune beyond their wildest dreams. Conversely, he can also mete out justice to those who would seek to disrupt the balance of the world, swiftly and decisively. - roomid: 487 + roomid: 1 roomidonreset: 0 - zone: Frostfang Slums + zone: Frostfang raceid: 2 stats: strength: @@ -64,15 +64,15 @@ character: list: - buffid: 28 permabuff: true - roundcounter: 22 + roundcounter: 9 triggersleft: 1000000000 - buffid: 29 permabuff: true - roundcounter: 15 + roundcounter: 32 triggersleft: 1000000000 - buffid: 39 permabuff: true - roundcounter: 4 + roundcounter: 6 triggersleft: 1000000000 equipment: weapon: @@ -147,6 +147,23 @@ character: - itemid: 30002 uses: 1 created: 2024-10-31T13:28:45.391415-07:00 + zonesvisited: + Frostfang: + "0": "0xFFFFFFFEFFFFFFFE" + "1": "0x00000000000017FF" + "2": "0x000000C000000000" + "4": "0x0002EFFFFFDFFFFC" + "6": "0x0003000000000000" + "9": "0x0000000800000000" + "10": "0x0000000000000004" + "11": "0x0000000018000000" + "12": "0x2DFFFFF8FFBFFF00" + "13": "0x0000800000000000" + "15": "0x00000C0000000000" + Frostfang Slums: + "6": "0x0080000000000000" + Whispering Wastes: + "2": "0x00000F0000000000" itemstorage: items: - itemid: 20011 diff --git a/_datafiles/world/empty/keywords.yaml b/_datafiles/world/empty/keywords.yaml index 9e0834a95..5e967f727 100644 --- a/_datafiles/world/empty/keywords.yaml +++ b/_datafiles/world/empty/keywords.yaml @@ -68,6 +68,7 @@ help: - races - who - history + - visited items: - drop - drink @@ -125,6 +126,7 @@ help: - buff - build - command + - copyover - deafen - item - grant @@ -145,6 +147,7 @@ help: - syslogs - zap - zone + - visit # Aliases for keywords when typing: help # Key is the target keyword, value is the list of aliases help-aliases: diff --git a/internal/characters/character.go b/internal/characters/character.go index 21613a23d..1b93f0633 100644 --- a/internal/characters/character.go +++ b/internal/characters/character.go @@ -88,6 +88,7 @@ type Character struct { Pet pets.Pet `yaml:"pet,omitempty"` // Do they have a pet? Created time.Time `yaml:"created"` // When this character was created Timers map[string]gametime.RoundTimer `yaml:"timers,omitempty"` // any special timers added to this character + ZonesVisited map[string]RoomBitset `yaml:"zonesvisited,omitempty"` // permanent record of every room visited, keyed by zone name roomHistory []int // A stack FILO of the last X rooms the character has been in PlayerDamage map[int]int `yaml:"-"` // key = who, value = how much LastPlayerDamage uint64 `yaml:"-"` // last round a player damaged this character @@ -984,6 +985,70 @@ func (c *Character) RememberRoom(roomId int) { c.roomHistory = append(c.roomHistory, roomId) } +// MarkVisitedRoom permanently records that this character has visited roomId +// in the given zone. Safe to call every time a player enters a room. +// Returns true only if this specific call completed the zone (i.e. every room +// in validRoomIds is now visited). Returns false if the room was already +// visited, if validRoomIds is empty, or if the zone is still incomplete. +func (c *Character) MarkVisitedRoom(roomId int, zone string, validRoomIds map[int]struct{}) bool { + if c.ZonesVisited == nil { + c.ZonesVisited = make(map[string]RoomBitset) + } + if _, ok := c.ZonesVisited[zone]; !ok { + c.ZonesVisited[zone] = make(RoomBitset) + } + + // If the bit was already set this call cannot be the completing visit. + if c.ZonesVisited[zone].Has(roomId) { + return false + } + + c.ZonesVisited[zone].Set(roomId) + + if len(validRoomIds) == 0 { + return false + } + + return c.ZonesVisited[zone].IsComplete(validRoomIds) +} + +// HasVisitedRoom reports whether this character has ever visited roomId in zone. +func (c *Character) HasVisitedRoom(roomId int, zone string) bool { + if c.ZonesVisited == nil { + return false + } + bs, ok := c.ZonesVisited[zone] + if !ok { + return false + } + return bs.Has(roomId) +} + +// ZoneVisitProgress returns how many rooms the character has visited in zone +// and the total number of rooms in that zone, allowing callers to compute a +// completion percentage. validRoomIds should come from ZoneConfig.RoomIds. +func (c *Character) ZoneVisitProgress(zone string, validRoomIds map[int]struct{}) (visited int, total int) { + total = len(validRoomIds) + if c.ZonesVisited == nil { + return 0, total + } + bs, ok := c.ZonesVisited[zone] + if !ok { + return 0, total + } + return bs.CountIn(validRoomIds), total +} + +// ZoneVisitPercent returns the percentage (0–100) of rooms in zone that the +// character has visited. Returns 0 when the zone has no rooms. +func (c *Character) ZoneVisitPercent(zone string, validRoomIds map[int]struct{}) int { + visited, total := c.ZoneVisitProgress(zone, validRoomIds) + if total == 0 { + return 0 + } + return int(float64(visited) / float64(total) * 100) +} + func (c *Character) IsQuestDone(questToken string) bool { testQuestId, _ := quests.TokenToParts(questToken) if c.QuestProgress == nil { diff --git a/internal/characters/context.md b/internal/characters/context.md index 67083f24e..eb2dd61db 100644 --- a/internal/characters/context.md +++ b/internal/characters/context.md @@ -12,6 +12,13 @@ The `internal/characters` package is the core character system for GoMud, handli - **Experience and leveling**: Level progression and TNL (To Next Level) calculations - **Persistence**: Character data serialization/deserialization +### Room Visit Tracking (`roombitset.go`) +- **RoomBitset**: Chunked bitset type (`map[uint16]uint64`) for memory-efficient permanent room visit tracking +- **Block-based storage**: Each map key is `roomId/64`; each value is a `uint64` bitmask covering that 64-room window +- **Zone-sharded on Character**: `ZonesVisited map[string]RoomBitset` persisted to YAML under `zonesvisited` +- **Human-readable serialization**: Blocks serialize as hex strings (e.g. `"0x000000000000003F"`) for debuggable save files +- **Pruning**: `RoomBitset.Prune(validRoomIds)` clears bits for deleted rooms and removes empty blocks + ### Character Statistics System - **Six core stats**: Strength, Speed, Smarts, Vitality, Mysticism, Perception - **Stat scaling**: Stats over 100 use `SQRT(overage)*2` formula for diminishing returns @@ -46,7 +53,8 @@ The `internal/characters` package is the core character system for GoMud, handli - YAML-based character data storage - Automatic saving with configurable intervals - Character creation timestamps and history tracking -- Room history for movement tracking +- Short-term room history for map rendering (`roomHistory`, capped by memory capacity) +- Permanent room visit tracking via `ZonesVisited` (chunked bitset, persisted to YAML) ### Dynamic Stat System - Base stats from race definitions @@ -91,6 +99,8 @@ The `internal/characters` package is the core character system for GoMud, handli - Equipment management through worn item slots - State management through adjectives and flags - Combat integration through aggro and damage tracking +- Room visit tracking via `MarkVisitedRoom(roomId, zone)` and queried with `HasVisitedRoom(roomId, zone)` +- Zone exploration progress via `ZoneVisitProgress(zone, validRoomIds)` returning `(visited, total int)` ## Testing Comprehensive test coverage in `*_test.go` files covering: @@ -101,5 +111,8 @@ Comprehensive test coverage in `*_test.go` files covering: - Shop mechanics and restocking - Kill/death tracking - Cooldown management +- `RoomBitset` set/has/count/prune operations +- `RoomBitset` YAML round-trip serialization +- `MarkVisitedRoom`, `HasVisitedRoom`, and `ZoneVisitProgress` integration This package serves as the foundation for all character-related functionality in GoMud, providing a rich and flexible character model that supports both player and NPC needs. \ No newline at end of file diff --git a/internal/characters/roombitset.go b/internal/characters/roombitset.go new file mode 100644 index 000000000..f9a653eb7 --- /dev/null +++ b/internal/characters/roombitset.go @@ -0,0 +1,149 @@ +package characters + +import ( + "fmt" + "math/bits" + "strconv" + + "gopkg.in/yaml.v2" +) + +// RoomBitset is a memory-efficient set of visited room IDs using a chunked +// bitset. The map key is roomId/64 (the block index) and the value is a uint64 +// where bit (roomId%64) represents that room. Blocks are only allocated for +// room ID ranges that have been visited, so sparse zones cost nothing for +// unvisited regions. +// +// YAML serialization uses hex strings ("0x...") so the data is human-readable +// in character save files. +type RoomBitset map[uint16]uint64 + +// Set marks a room as visited. Room IDs must be positive; non-positive IDs +// are silently ignored because they represent special sentinel values (e.g. +// -1 for the character-creation room, 0 for StartRoomIdAlias) that are +// always considered visited. +func (rb RoomBitset) Set(roomId int) { + if roomId < 1 { + return + } + block := uint16(roomId / 64) + bit := uint64(1) << (roomId % 64) + rb[block] |= bit +} + +// Has reports whether a room has been visited. Non-positive room IDs always +// return true because they are sentinel values that are considered visited +// by definition. +func (rb RoomBitset) Has(roomId int) bool { + if roomId < 1 { + return true + } + block := uint16(roomId / 64) + bit := uint64(1) << (roomId % 64) + return rb[block]&bit != 0 +} + +// Count returns the total number of visited rooms across all blocks. +func (rb RoomBitset) Count() int { + total := 0 + for _, word := range rb { + total += bits.OnesCount64(word) + } + return total +} + +// CountIn returns how many rooms from the provided set have been visited. +func (rb RoomBitset) CountIn(roomIds map[int]struct{}) int { + count := 0 + for roomId := range roomIds { + if rb.Has(roomId) { + count++ + } + } + return count +} + +// IsComplete reports whether every room in the provided set has been visited. +func (rb RoomBitset) IsComplete(roomIds map[int]struct{}) bool { + return rb.CountIn(roomIds) == len(roomIds) +} + +// Prune clears any bits that do not correspond to a live room in validRoomIds, +// then removes blocks that become empty. This handles the case where rooms are +// deleted from a zone after a player has already visited them. +func (rb RoomBitset) Prune(validRoomIds map[int]struct{}) { + // Build a valid-bits mask per block from the live room set. + validMasks := make(map[uint16]uint64, len(validRoomIds)/32+1) + for roomId := range validRoomIds { + block := uint16(roomId / 64) + bit := uint64(1) << (roomId % 64) + validMasks[block] |= bit + } + + for block, word := range rb { + masked := word & validMasks[block] + if masked == 0 { + delete(rb, block) + } else { + rb[block] = masked + } + } +} + +// ToSet expands the bitset into a map[int]struct{} of all visited room IDs. +func (rb RoomBitset) ToSet() map[int]struct{} { + out := make(map[int]struct{}, rb.Count()) + for block, word := range rb { + base := int(block) * 64 + for bit := 0; bit < 64; bit++ { + if word&(uint64(1)<= 0 { - if !targetRoom.HasVisited(c.UserId, rooms.VisitorUser) { + user := users.GetByUserId(c.UserId) + if user == nil || !user.Character.HasVisitedRoom(exitInfo.RoomId, targetRoom.Zone) { continue } } diff --git a/internal/rooms/roomdetails.go b/internal/rooms/roomdetails.go index abf706fef..3ed58a5d0 100644 --- a/internal/rooms/roomdetails.go +++ b/internal/rooms/roomdetails.go @@ -305,10 +305,10 @@ func GetDetails(r *Room, user *users.UserRecord, tinymap ...[]string) RoomTempla for exitStr, exitInfo := range r.Exits { - // If it's a secret room we need to make sure the player has recently been there before including it in the exits + // If it's a secret room we need to make sure the player has been there before including it in the exits if exitInfo.Secret { if targetRm := LoadRoom(exitInfo.RoomId); targetRm != nil { - if targetRm.HasVisited(user.UserId, VisitorUser) { + if user.Character.HasVisitedRoom(exitInfo.RoomId, targetRm.Zone) { details.VisibleExits[exitStr] = exitInfo } } diff --git a/internal/rooms/roommanager.go b/internal/rooms/roommanager.go index b80a03da3..37660f41f 100644 --- a/internal/rooms/roommanager.go +++ b/internal/rooms/roommanager.go @@ -363,6 +363,11 @@ func MoveToRoom(userId int, toRoomId int, isSpawn ...bool) error { user.Character.RoomId = newRoom.RoomId user.Character.Zone = newRoom.Zone user.Character.RememberRoom(newRoom.RoomId) // Mark this room as remembered. + if zCfg := GetZoneConfig(newRoom.Zone); zCfg != nil { + user.Character.MarkVisitedRoom(newRoom.RoomId, newRoom.Zone, zCfg.RoomIds) // Permanently record the visit. + } else { + user.Character.MarkVisitedRoom(newRoom.RoomId, newRoom.Zone, nil) // Permanently record the visit. + } playerCt := newRoom.AddPlayer(userId) roomManager.roomsWithUsers[newRoom.RoomId] = playerCt diff --git a/internal/scripting/actor_func.go b/internal/scripting/actor_func.go index 93b478c55..5b900a772 100644 --- a/internal/scripting/actor_func.go +++ b/internal/scripting/actor_func.go @@ -381,6 +381,47 @@ func (a ScriptActor) AddEventLog(category string, message string) { } } +// MarkVisitedRoom marks one or more rooms as visited for this actor. +// Only applies to user actors; mobs do not track room visits. +// Each roomId argument is recorded under the zone that room belongs to. +func (a ScriptActor) MarkVisitedRoom(roomIds ...int) { + if a.userRecord == nil { + return + } + for _, roomId := range roomIds { + room := rooms.LoadRoom(roomId) + if room == nil { + continue + } + zCfg := rooms.GetZoneConfig(room.Zone) + var validRoomIds map[int]struct{} + if zCfg != nil { + validRoomIds = zCfg.RoomIds + } + a.characterRecord.MarkVisitedRoom(roomId, room.Zone, validRoomIds) + } +} + +// MarkVisitedZone marks every room in the named zone as visited for this actor. +// Only applies to user actors; mobs do not track room visits. +// Uses FindZoneName for partial/best-match zone name resolution. +func (a ScriptActor) MarkVisitedZone(zoneName string) { + if a.userRecord == nil { + return + } + resolvedZone := rooms.FindZoneName(zoneName) + if resolvedZone == `` { + return + } + zCfg := rooms.GetZoneConfig(resolvedZone) + if zCfg == nil { + return + } + for roomId := range zCfg.RoomIds { + a.characterRecord.MarkVisitedRoom(roomId, resolvedZone, nil) + } +} + func (a ScriptActor) GiveItem(itm any) { var sItem *ScriptItem diff --git a/internal/scripting/party_func.go b/internal/scripting/party_func.go index b66dc7957..ee62781cf 100644 --- a/internal/scripting/party_func.go +++ b/internal/scripting/party_func.go @@ -238,3 +238,15 @@ func (p ScriptParty) TimerSet(name string, period string) { a.TimerSet(name, period) }) } + +func (p ScriptParty) MarkVisitedRoom(roomIds ...int) { + p.each(func(a ScriptActor) { + a.MarkVisitedRoom(roomIds...) + }) +} + +func (p ScriptParty) MarkVisitedZone(zoneName string) { + p.each(func(a ScriptActor) { + a.MarkVisitedZone(zoneName) + }) +} diff --git a/internal/usercommands/admin.visit.go b/internal/usercommands/admin.visit.go new file mode 100644 index 000000000..fff4c6a73 --- /dev/null +++ b/internal/usercommands/admin.visit.go @@ -0,0 +1,182 @@ +package usercommands + +import ( + "fmt" + "sort" + "strings" + + "github.com/GoMudEngine/GoMud/internal/events" + "github.com/GoMudEngine/GoMud/internal/rooms" + "github.com/GoMudEngine/GoMud/internal/templates" + "github.com/GoMudEngine/GoMud/internal/users" + "github.com/GoMudEngine/GoMud/internal/util" +) + +/* +* Role Permissions: +* visit (All) + */ +func Visit(rest string, user *users.UserRecord, room *rooms.Room, flags events.EventFlag) (bool, error) { + + args := util.SplitButRespectQuotes(rest) + + if len(args) == 0 { + infoOutput, _ := templates.Process("admincommands/help/command.visit", nil, user.UserId) + user.SendText(infoOutput) + return true, nil + } + + subCmd := strings.ToLower(args[0]) + args = args[1:] + + switch subCmd { + case "list": + return visit_List(args, user) + case "set": + return visit_SetUnset(args, user, true) + case "unset": + return visit_SetUnset(args, user, false) + default: + infoOutput, _ := templates.Process("admincommands/help/command.visit", nil, user.UserId) + user.SendText(infoOutput) + } + + return true, nil +} + +func visit_resolveTarget(args []string, invoker *users.UserRecord) (*users.UserRecord, bool, error) { + if len(args) == 0 { + return invoker, false, nil + } + + username := args[0] + + if online := users.GetByCharacterName(username); online != nil { + return online, false, nil + } + + return nil, false, fmt.Errorf(`user "%s" not found`, username) +} + +func visit_List(args []string, user *users.UserRecord) (bool, error) { + + targetUser, isOffline, err := visit_resolveTarget(args, user) + if err != nil { + user.SendText(err.Error()) + return true, nil + } + + allZoneNames := rooms.GetAllZoneNames() + sort.Strings(allZoneNames) + + headers := []string{"Zone", "Visited", "Unvisited", "% Complete"} + rows := [][]string{} + + for _, zoneName := range allZoneNames { + zCfg := rooms.GetZoneConfig(zoneName) + if zCfg == nil { + continue + } + + visited, total := targetUser.Character.ZoneVisitProgress(zoneName, zCfg.RoomIds) + unvisited := total - visited + + pct := 0 + if total > 0 { + pct = (visited * 100) / total + } + + rows = append(rows, []string{ + zoneName, + fmt.Sprintf(`%d`, visited), + fmt.Sprintf(`%d`, unvisited), + fmt.Sprintf(`%d%%`, pct), + }) + } + + title := fmt.Sprintf(`Visit Progress for %s`, targetUser.Character.Name) + if isOffline { + title += ` (offline)` + } + + tableData := templates.GetTable(title, headers, rows) + tplTxt, _ := templates.Process("tables/generic", tableData, user.UserId) + user.SendText(tplTxt) + + return true, nil +} + +func visit_SetUnset(args []string, user *users.UserRecord, markVisited bool) (bool, error) { + + if len(args) == 0 { + infoOutput, _ := templates.Process("admincommands/help/command.visit", nil, user.UserId) + user.SendText(infoOutput) + return true, nil + } + + zonePart := args[0] + userArgs := args[1:] + + targetUser, isOffline, err := visit_resolveTarget(userArgs, user) + if err != nil { + user.SendText(err.Error()) + return true, nil + } + + action := "marked visited" + if !markVisited { + action = "reset to unvisited" + } + + if strings.ToLower(zonePart) == "all" { + allZoneNames := rooms.GetAllZoneNames() + for _, zoneName := range allZoneNames { + visit_applyZone(targetUser, zoneName, markVisited) + } + + if isOffline { + users.SaveUser(*targetUser) + } + + user.SendText(fmt.Sprintf(`All zones %s for %s.`, action, targetUser.Character.Name)) + return true, nil + } + + allZoneNames := rooms.GetAllZoneNames() + exactZone, closeZone := util.FindMatchIn(zonePart, allZoneNames...) + resolvedZone := exactZone + if resolvedZone == `` { + resolvedZone = closeZone + } + if resolvedZone == `` { + user.SendText(fmt.Sprintf(`Zone "%s" not found.`, zonePart)) + return true, nil + } + + visit_applyZone(targetUser, resolvedZone, markVisited) + + if isOffline { + users.SaveUser(*targetUser) + } + + user.SendText(fmt.Sprintf(`Zone %s %s for %s.`, resolvedZone, action, targetUser.Character.Name)) + + return true, nil +} + +func visit_applyZone(targetUser *users.UserRecord, zoneName string, markVisited bool) { + zCfg := rooms.GetZoneConfig(zoneName) + if zCfg == nil { + return + } + + if markVisited { + for roomId := range zCfg.RoomIds { + targetUser.Character.MarkVisitedRoom(roomId, zoneName, nil) + } + } else { + if targetUser.Character.ZonesVisited != nil { + delete(targetUser.Character.ZonesVisited, zoneName) + } + } +} diff --git a/internal/usercommands/skill.map.go b/internal/usercommands/skill.map.go index 86527af86..380e22639 100644 --- a/internal/usercommands/skill.map.go +++ b/internal/usercommands/skill.map.go @@ -143,6 +143,17 @@ func Map(rest string, user *users.UserRecord, room *rooms.Room, flags events.Eve } } + if skillLevel <= 4 { + visited := make(map[int]struct{}) + for _, bs := range user.Character.ZonesVisited { + for roomId := range bs.ToSet() { + visited[roomId] = struct{}{} + } + } + visited[user.Character.RoomId] = struct{}{} + c.SetVisitedRooms(visited) + } + if p := parties.Get(user.UserId); p != nil { for _, uid := range p.GetMembers() { if tmpUser := users.GetByUserId(uid); tmpUser != nil { @@ -182,13 +193,19 @@ func Map(rest string, user *users.UserRecord, room *rooms.Room, flags events.Eve } } + zoneCompletePct := 0 + if zCfg := rooms.GetZoneConfig(zone); zCfg != nil { + zoneCompletePct = user.Character.ZoneVisitPercent(zone, zCfg.RoomIds) + } + mapData := map[string]any{ - "Title": room.Zone, - "DisplayLines": displayLines, - "Height": len(displayLines), - "Width": width, - "Legend": legend, - "LegendWidth": width, + "Title": room.Zone, + "ZoneCompletePct": zoneCompletePct, + "DisplayLines": displayLines, + "Height": len(displayLines), + "Width": width, + "Legend": legend, + "LegendWidth": width, "LeftBorder": map[string]any{ "Top": ".-=~=-.", "Mid": []string{"( _ __)", "(__ _)"}, diff --git a/internal/usercommands/usercommands.go b/internal/usercommands/usercommands.go index 8ea237e89..444debae8 100644 --- a/internal/usercommands/usercommands.go +++ b/internal/usercommands/usercommands.go @@ -166,6 +166,8 @@ var ( `dual-wield`: {DualWield, true, false}, `whisper`: {Whisper, true, false}, `who`: {Who, true, false}, + `visit`: {Visit, true, true}, // Admin only + `visited`: {Visited, true, false}, `zap`: {Zap, true, true}, // Admin only `zone`: {Zone, false, true}, // Admin only // Special command only used upon creating a new account diff --git a/internal/usercommands/visited.go b/internal/usercommands/visited.go new file mode 100644 index 000000000..2ef101543 --- /dev/null +++ b/internal/usercommands/visited.go @@ -0,0 +1,67 @@ +package usercommands + +import ( + "fmt" + "math" + "sort" + + "github.com/GoMudEngine/GoMud/internal/events" + "github.com/GoMudEngine/GoMud/internal/rooms" + "github.com/GoMudEngine/GoMud/internal/templates" + "github.com/GoMudEngine/GoMud/internal/users" + "github.com/GoMudEngine/GoMud/internal/util" +) + +func Visited(rest string, user *users.UserRecord, room *rooms.Room, flags events.EventFlag) (bool, error) { + + type ZoneRecord struct { + Name string + Completion string + BarFull string + BarEmpty string + } + + type VisitedInfo struct { + ZonesTotal int + ZonesVisited int + Records []ZoneRecord + } + + allZoneNames := rooms.GetAllZoneNames() + sort.Strings(allZoneNames) + + records := []ZoneRecord{} + + for _, zoneName := range allZoneNames { + zCfg := rooms.GetZoneConfig(zoneName) + if zCfg == nil { + continue + } + + visited, total := user.Character.ZoneVisitProgress(zoneName, zCfg.RoomIds) + if visited == 0 { + continue + } + + pct := int(math.Floor(float64(visited) / float64(total) * 100)) + barFull, barEmpty := util.ProgressBar(float64(visited)/float64(total), 35) + + records = append(records, ZoneRecord{ + Name: zoneName, + Completion: fmt.Sprintf(`%d%%`, pct), + BarFull: barFull, + BarEmpty: barEmpty, + }) + } + + info := VisitedInfo{ + ZonesTotal: len(allZoneNames), + ZonesVisited: len(records), + Records: records, + } + + tplTxt, _ := templates.Process("character/visited", info, user.UserId) + user.SendText(tplTxt) + + return true, nil +} diff --git a/internal/users/context.md b/internal/users/context.md index aef54e9f3..6aa15f8ad 100644 --- a/internal/users/context.md +++ b/internal/users/context.md @@ -472,10 +472,13 @@ type OnlineInfo struct { ### Character System Integration ```go // Users have associated characters -- user.Character // Full character data -- user.Character.Name // Character name -- user.Character.Level // Character progression -- user.Character.RoomId // Current location +- user.Character // Full character data +- user.Character.Name // Character name +- user.Character.Level // Character progression +- user.Character.RoomId // Current location +- user.Character.HasVisitedRoom(roomId, zone) // Permanent room visit check +- user.Character.MarkVisitedRoom(roomId, zone) // Record a room visit +- user.Character.ZoneVisitProgress(zone, validRoomIds) // Visited/total count for a zone ``` ### Connection System Integration diff --git a/modules/gmcp/gmcp.Room.go b/modules/gmcp/gmcp.Room.go index 85b46ed20..f642e34f3 100644 --- a/modules/gmcp/gmcp.Room.go +++ b/modules/gmcp/gmcp.Room.go @@ -373,7 +373,7 @@ func (g *GMCPRoomModule) GetRoomNode(user *users.UserRecord, gmcpModule string) if exitInfo.Secret { if exitRoom := rooms.LoadRoom(exitInfo.RoomId); exitRoom != nil { - if !exitRoom.HasVisited(user.UserId, rooms.VisitorUser) { + if !user.Character.HasVisitedRoom(exitInfo.RoomId, exitRoom.Zone) { continue } }