aboutsummaryrefslogtreecommitdiff
path: root/internal/game/exit_migrate.go
blob: c678591fade782da6337759abb755c216f17c0ad (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
package game

import (
	"strconv"
	"strings"

	"thehouseoficarus/internal/player"
)

// 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, PendingSequence.RoomID, MapSymbols
// keys, and 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
// clobber an entry mid-rewrite.
func rewritePlayerRoomIDs(p *player.Player, idMap map[int]int) bool {
	if p == nil || len(idMap) == 0 {
		return false
	}
	changed := false
	if remapScalar(idMap, &p.RoomID) {
		changed = true
	}
	if p.PendingSequence != nil && p.PendingSequence.RoomID != 0 && remapScalar(idMap, &p.PendingSequence.RoomID) {
		changed = true
	}
	if len(p.Flags) > 0 && rewriteDiscoveredExitFlags(p.Flags, idMap) {
		changed = true
	}
	if len(p.MapSymbols) > 0 && rekeyIntMap(p.MapSymbols, idMap) {
		changed = true
	}
	next, moved := p.Stats.RoomsVisited.Rekey(idMap)
	if moved {
		p.Stats.RoomsVisited = next
		changed = true
	}
	return changed
}

func remapScalar(idMap map[int]int, n *int) bool {
	if newID, ok := idMap[*n]; ok && newID != *n {
		*n = newID
		return true
	}
	return false
}

// rewriteDiscoveredExitFlags rewrites hidden_exit_<roomID>_<dir> flag keys,
// remapping the embedded roomID per idMap. Flag values are preserved.
func rewriteDiscoveredExitFlags(flags map[string]any, idMap map[int]int) bool {
	type move struct {
		oldKey, newKey string
		val            any
	}
	var moves []move
	for k, v := range flags {
		if !strings.HasPrefix(k, hiddenExitFlagPrefix) {
			continue
		}
		rest := k[len(hiddenExitFlagPrefix):] // "<roomID>_<dir>"
		idx := strings.IndexByte(rest, '_')
		if idx <= 0 {
			continue
		}
		n, err := strconv.Atoi(rest[:idx])
		if err != nil {
			continue
		}
		newID, ok := idMap[n]
		if !ok || newID == n {
			continue
		}
		moves = append(moves, move{k, hiddenExitFlagPrefix + strconv.Itoa(newID) + "_" + rest[idx+1:], v})
	}
	if len(moves) == 0 {
		return false
	}
	for _, m := range moves {
		delete(flags, m.oldKey)
	}
	for _, m := range moves {
		flags[m.newKey] = m.val
	}
	return true
}

// rekeyIntMap rekeys a map[int]V per idMap (oldID -> newID), preserving values.
// Uses delete-all-then-set-all so a swap cannot clobber an entry.
func rekeyIntMap[V any](m map[int]V, idMap map[int]int) bool {
	type entry struct {
		key int
		val V
	}
	var saved []entry
	for k, v := range m {
		if newID, ok := idMap[k]; ok && newID != k {
			saved = append(saved, entry{k, v})
		}
	}
	if len(saved) == 0 {
		return false
	}
	for _, e := range saved {
		delete(m, e.key)
	}
	for _, e := range saved {
		m[idMap[e.key]] = e.val
	}
	return true
}

// RewriteRoomIDs is the shared entry point called by every room-ID change
// path (the swapid command and the admin web GUI rename) to migrate room-ID-embedded
// state across all characters. Online players are updated in memory (and
// re-saved); offline characters are loaded, rewritten, and saved. Callers
// must be on the owner goroutine (use MigrateRoomIDs from other goroutines).
func (g *Game) RewriteRoomIDs(idMap map[int]int) {
	if len(idMap) == 0 {
		return
	}
	online := make(map[string]bool)
	if g.Hub != nil {
		for _, sess := range g.Hub.AllSessions() {
			if sess.Player == nil {
				continue
			}
			if rewritePlayerRoomIDs(sess.Player, idMap) {
				g.AccountStore.SaveCharacter(sess.Player)
			}
			online[sess.Player.Name] = true
		}
	}
	names, err := g.AccountStore.ListCharacterNames()
	if err != nil {
		return
	}
	for _, name := range names {
		if online[name] {
			continue
		}
		p, err := g.AccountStore.LoadCharacter(name)
		if err != nil {
			continue
		}
		if rewritePlayerRoomIDs(p, idMap) {
			g.AccountStore.SaveCharacter(p)
		}
	}
}

// MigrateRoomIDs is the complete room-ID migration entry point for other
// goroutines: player state, Hub room
// buckets, casino room keys, world room-keyed runtime state, and live mob
// instances all move together under one owner task. This is what makes a
// live rename leave NO gameplay or broadcast attached to the old location.
func (g *Game) MigrateRoomIDs(idMap map[int]int) {
	if len(idMap) == 0 {
		return
	}
	g.RunOnOwner(func() {
		g.MigrateRoomIDsOnOwner(idMap)
	})
}

// MigrateRoomIDsOnOwner performs the full runtime migration within an existing
// owner task (e.g. the admin editor's file commit + migration + reload task).
// It must not dispatch again: a nested RunOnOwner would deadlock.
func (g *Game) MigrateRoomIDsOnOwner(idMap map[int]int) {
	if len(idMap) == 0 {
		return
	}
	if g.Hub != nil {
		g.Hub.RekeyRooms(idMap)
	}
	if g.Casino != nil {
		g.Casino.RekeyRooms(idMap)
	}
	g.World.RekeyRooms(idMap)
	g.MobStore.RekeyRooms(idMap)
	// The file paths changed under the world: rebuild its room index so
	// lookups agree with the post-migration layout.
	g.World.RebuildRoomIndex(g.DataDir)
	g.RewriteRoomIDs(idMap)
}