personal_world.gno
13.28 Kb ยท 554 lines
1package personal_world
2
3import (
4 "chain"
5 "chain/banker"
6 "chain/runtime"
7 "math/rand"
8 "strconv"
9 "strings"
10
11 "gno.land/p/demo/entropy"
12 "gno.land/p/nt/avl"
13 "gno.land/p/nt/ufmt"
14)
15
16const (
17 CreateWorldEvent = "CreateWorld"
18 UpdateWorldEvent = "UpdateWorld"
19 ExpandWorldEvent = "ExpandWorld"
20 DeleteWorldEvent = "DeleteWorld"
21)
22
23var (
24 nextWorldID uint32 = 1
25 worlds = avl.NewTree() // worldID -> map[string]string
26
27 slugIndex = make(map[string]uint32) // slug -> worldID
28 nameIndex = make(map[string]uint32) // name -> worldID
29 ownerIndex = avl.NewTree() // owner -> map[uint32]bool
30)
31
32func CreateWorld(cur realm, biomeName string, name string, slug string, seed int, isVisible bool, options string) uint32 {
33 caller := validateUser()
34
35 // Validate inputs
36 validateBiomeName(biomeName)
37 validateName(name)
38 validateSlug(slug)
39 validateSeed(seed)
40
41 // Check uniqueness
42 if _, found := nameIndex[name]; found {
43 panic("name already exists")
44 }
45
46 if _, found := slugIndex[slug]; found {
47 panic("slug already exists")
48 }
49
50 // Get creation info
51 biomeInfo := mustGetBiomeInfo(biomeName)
52 requiredCost, _ := strconv.ParseInt(biomeInfo["cost"], 10, 64)
53
54 // Check if this is user's first world
55 isFirstWorld := !ownerIndex.Has(caller.String())
56
57 // Check payment sent with transaction
58 coinsSent := banker.OriginSend()
59 amountUgnot := coinsSent.AmountOf("ugnot")
60 finalRequiredCost := int64(0)
61 // First world is free only if it's default biome
62 if !isFirstWorld || biomeName != defaultBiome {
63 finalRequiredCost = requiredCost
64 }
65
66 // Validate payment
67 if amountUgnot < finalRequiredCost {
68 panic(ufmt.Sprintf("insufficient payment: required %d, got %d", finalRequiredCost, amountUgnot))
69 }
70
71 id := getNextWorldID()
72
73 // Get default size
74 defaultSizeInfo := mustGetSizeInfo(0)
75 size, _ := strconv.Atoi(defaultSizeInfo["size"])
76 height := ufmt.Sprintf("%d", runtime.ChainHeight())
77
78 // Create world as map[string]string
79 world := map[string]string{
80 "id": ufmt.Sprintf("%d", id),
81 "owner": caller.String(),
82 "name": name,
83 "slug": slug,
84 "sizeId": "0",
85 "size": ufmt.Sprintf("%d", size),
86 "seed": ufmt.Sprintf("%d", seed),
87 "biome": biomeName,
88 "isVisible": ufmt.Sprintf("%t", isVisible),
89 "createdAt": height,
90 "updatedAt": height,
91 "totalPaid": ufmt.Sprintf("%d", finalRequiredCost),
92 "options": options,
93 }
94
95 worlds.Set(worldIDToKey(id), world)
96 slugIndex[slug] = id
97 nameIndex[name] = id
98 addToOwnerIndex(id, caller)
99
100 // Distribute funds to operator and protocol (only if payment was made)
101 var operatorShare, protocolShare int64
102 if finalRequiredCost > 0 {
103 operatorShare, protocolShare = distributeFunds(finalRequiredCost)
104 }
105
106 // Refund excess payment
107 var refundAmount int64
108 if amountUgnot > finalRequiredCost {
109 refundAmount = amountUgnot - finalRequiredCost
110 bnk := banker.NewBanker(banker.BankerTypeRealmSend)
111 realmAddr := runtime.CurrentRealm().Address()
112 refundCoins := chain.NewCoins(chain.NewCoin("ugnot", refundAmount))
113 bnk.SendCoins(realmAddr, caller, refundCoins)
114 }
115
116 // Emit event with distribution details
117 chain.Emit(
118 CreateWorldEvent,
119 "id", ufmt.Sprintf("%d", id),
120 "owner", caller.String(),
121 "name", name,
122 "slug", slug,
123 "seed", ufmt.Sprintf("%d", seed),
124 "isVisible", ufmt.Sprintf("%t", isVisible),
125 "options", options,
126 "isFirstWorld", ufmt.Sprintf("%t", isFirstWorld),
127 "required", ufmt.Sprintf("%d", finalRequiredCost),
128 "sent", ufmt.Sprintf("%d", amountUgnot),
129 "refund", ufmt.Sprintf("%d", refundAmount),
130 "operatorShare", ufmt.Sprintf("%d", operatorShare),
131 "protocolShare", ufmt.Sprintf("%d", protocolShare),
132 )
133
134 return id
135}
136
137func UpdateWorld(cur realm, worldID uint32, name string, slug string, isVisible bool, options string) {
138 caller := validateUser()
139
140 // Get world properties
141 world := mustGetWorld(worldID)
142
143 // Check world:update permission (owner always has this)
144 if !HasPermission(worldID, caller, "world:update") {
145 panic("caller must be admin or world owner to update world " + ufmt.Sprintf("%d", worldID))
146 }
147
148 // Get current values
149 oldName := world["name"]
150 oldSlug := world["slug"]
151
152 if len(name) != 0 && name != oldName {
153 validateName(name)
154 if _, found := nameIndex[name]; found {
155 panic("name already exists")
156 }
157 delete(nameIndex, oldName)
158 nameIndex[name] = worldID
159 world["name"] = name
160 }
161
162 if len(slug) != 0 && slug != oldSlug {
163 validateSlug(slug)
164 if _, found := slugIndex[slug]; found {
165 panic("slug already exists")
166 }
167 delete(slugIndex, oldSlug)
168 slugIndex[slug] = worldID
169 world["slug"] = slug
170 }
171
172 world["isVisible"] = ufmt.Sprintf("%t", isVisible)
173
174 // Update options (empty string is allowed to reset options)
175 world["options"] = options
176
177 world["updatedAt"] = ufmt.Sprintf("%d", runtime.ChainHeight())
178
179 // Emit event
180 chain.Emit(
181 UpdateWorldEvent,
182 "id", ufmt.Sprintf("%d", worldID),
183 "name", name,
184 "slug", slug,
185 "options", options,
186 )
187}
188
189func ExpandWorld(cur realm, worldID uint32) {
190 caller := validateUser()
191
192 world := mustGetWorld(worldID)
193
194 // Check world:expand permission (owner always has this)
195 if !HasPermission(worldID, caller, "world:expand") {
196 panic("caller must be admin or world owner to expand world " + ufmt.Sprintf("%d", worldID))
197 }
198
199 // Get current size ID
200 oldSizeID, _ := strconv.Atoi(world["sizeId"])
201 newSizeID := oldSizeID + 1
202
203 // Get new size info
204 newSizeInfo := mustGetSizeInfo(newSizeID)
205 newSizeCost, _ := strconv.ParseInt(newSizeInfo["cost"], 10, 64)
206
207 // Get biome info
208 biomeInfo := mustGetBiomeInfo(world["biome"])
209 multiple, _ := strconv.ParseFloat(biomeInfo["priceMultiplier"], 64)
210
211 expansionCost := int64(float64(newSizeCost) * multiple)
212
213 coinsSent := banker.OriginSend()
214 amountUgnot := coinsSent.AmountOf("ugnot")
215
216 if amountUgnot < expansionCost {
217 panic(ufmt.Sprintf("insufficient payment: required %d, got %d", expansionCost, amountUgnot))
218 }
219
220 // Get old totalPaid
221 oldTotalPaid, _ := strconv.ParseInt(world["totalPaid"], 10, 64)
222
223 // Update properties
224 newTotalPaid := oldTotalPaid + expansionCost
225 world["sizeId"] = ufmt.Sprintf("%d", newSizeID)
226 world["size"] = newSizeInfo["size"]
227 world["totalPaid"] = ufmt.Sprintf("%d", newTotalPaid)
228 world["updatedAt"] = ufmt.Sprintf("%d", runtime.ChainHeight())
229
230 // Distribute funds
231 operatorShare, protocolShare := distributeFunds(expansionCost)
232
233 // Refund excess payment
234 var refundAmount int64
235 if amountUgnot > expansionCost {
236 refundAmount = amountUgnot - expansionCost
237 bnk := banker.NewBanker(banker.BankerTypeRealmSend)
238 realmAddr := runtime.CurrentRealm().Address()
239 refundCoins := chain.NewCoins(chain.NewCoin("ugnot", refundAmount))
240 bnk.SendCoins(realmAddr, caller, refundCoins)
241 }
242
243 chain.Emit(
244 ExpandWorldEvent,
245 "id", ufmt.Sprintf("%d", worldID),
246 "oldSizeID", ufmt.Sprintf("%d", oldSizeID),
247 "newSizeID", ufmt.Sprintf("%d", newSizeID),
248 "required", ufmt.Sprintf("%d", expansionCost),
249 "sent", ufmt.Sprintf("%d", amountUgnot),
250 "refund", ufmt.Sprintf("%d", refundAmount),
251 "totalPaid", ufmt.Sprintf("%d", newTotalPaid),
252 "operatorShare", ufmt.Sprintf("%d", operatorShare),
253 "protocolShare", ufmt.Sprintf("%d", protocolShare),
254 )
255}
256
257func DeleteWorld(cur realm, worldID uint32) {
258 caller := validateUser()
259 assertIsAdminOrOperator(caller)
260
261 // Get world properties
262 world := mustGetWorld(worldID)
263
264 // Remove from all indexes
265 worlds.Remove(worldIDToKey(worldID))
266 delete(slugIndex, world["slug"])
267 delete(nameIndex, world["name"])
268 removeFromOwnerIndex(worldID, address(world["owner"]))
269
270 // Remove chunk verifiers
271 deleteWorldChunkVerifiers(worldID)
272
273 // Remove all role assignments for this world
274 delete(worldAuthorization, worldID)
275
276 // Emit event
277 chain.Emit(
278 DeleteWorldEvent,
279 "id", ufmt.Sprintf("%d", worldID),
280 "caller", caller.String(),
281 "slug", world["slug"],
282 "name", world["name"],
283 )
284}
285
286func GetWorld(worldID uint32) map[string]string {
287 return mustGetWorld(worldID)
288}
289
290func GetWorldBySlug(slug string) map[string]string {
291 worldID, found := slugIndex[slug]
292 if !found {
293 panic("world not found by slug: " + slug)
294 }
295 return mustGetWorld(worldID)
296}
297
298func GetTotalWorldSize() int {
299 return worlds.Size()
300}
301
302func IsNameAvailable(name string) bool {
303 _, found := nameIndex[name]
304 return !found
305}
306
307func IsSlugAvailable(slug string) bool {
308 _, found := slugIndex[slug]
309 return !found
310}
311
312func ListWorlds(page, count int) []map[string]string {
313 if page < 1 {
314 panic("page must be at least 1")
315 }
316 if count < 1 {
317 panic("count must be at least 1")
318 }
319 if count > listLimit {
320 panic("count exceeds listLimit")
321 }
322
323 var results []map[string]string
324 offset := (page - 1) * count
325 worlds.IterateByOffset(offset, count, func(_ string, value any) bool {
326 results = append(results, value.(map[string]string))
327 return false
328 })
329 return results
330}
331
332func GetWorldSizeByOwner(owner address) int {
333 ownerKey := owner.String()
334 worldIDs, found := ownerIndex.Get(ownerKey)
335 if !found {
336 return 0
337 }
338 return len(worldIDs.(map[uint32]bool))
339}
340
341func ListWorldsByOwner(owner address, page, count int) []map[string]string {
342 if page < 1 {
343 panic("page must be at least 1")
344 }
345 if count < 1 {
346 panic("count must be at least 1")
347 }
348 if count > listLimit {
349 panic("count exceeds listLimit")
350 }
351
352 ownerKey := owner.String()
353 worldIDsVal, found := ownerIndex.Get(ownerKey)
354 if !found {
355 return []map[string]string{}
356 }
357
358 worldIDs := worldIDsVal.(map[uint32]bool)
359 offset := (page - 1) * count
360
361 if offset >= len(worldIDs) {
362 return []map[string]string{}
363 }
364
365 // Convert map keys to slice for pagination
366 var ids []uint32
367 for id := range worldIDs {
368 ids = append(ids, id)
369 }
370
371 end := offset + count
372 if end > len(ids) {
373 end = len(ids)
374 }
375
376 var results []map[string]string
377 for _, worldID := range ids[offset:end] {
378 if world, found := getWorld(worldID); found {
379 results = append(results, world)
380 }
381 }
382
383 return results
384}
385
386func ListRandomWorlds(count int) []map[string]string {
387 if count < 1 {
388 panic("count must be at least 1")
389 }
390 if count > listLimit {
391 panic("count exceeds listLimit")
392 }
393
394 if nextWorldID <= 1 {
395 return []map[string]string{}
396 }
397
398 e := entropy.New()
399 seed := uint64(e.Seed())
400 r := rand.New(rand.NewPCG(seed, 0xdeadbeef))
401
402 var results []map[string]string
403 seen := make(map[uint32]bool)
404
405 for i := 0; i < count; i++ {
406 // Random ID in range [1, nextWorldID)
407 id := uint32(r.IntN(int(nextWorldID-1))) + 1
408
409 if seen[id] {
410 continue
411 }
412 seen[id] = true
413
414 world, found := getWorld(id)
415 if !found {
416 continue
417 }
418 if world["isVisible"] != "true" {
419 continue
420 }
421
422 results = append(results, world)
423 }
424
425 return results
426}
427
428// worldIDToKey converts a world ID to a padded string key for AVL tree
429func worldIDToKey(worldID uint32) string {
430 s := strconv.FormatUint(uint64(worldID), 10)
431 // Pad to 10 digits for proper AVL tree sorting
432 for len(s) < 10 {
433 s = "0" + s
434 }
435 return s
436}
437
438// getNextWorldID generates next world ID
439func getNextWorldID() uint32 {
440 id := nextWorldID
441 nextWorldID++
442 return id
443}
444
445// addToOwnerIndex adds world ID to owner's index
446func addToOwnerIndex(worldID uint32, owner address) {
447 ownerKey := owner.String()
448 existingIDs, found := ownerIndex.Get(ownerKey)
449
450 if !found {
451 ownerIndex.Set(ownerKey, map[uint32]bool{worldID: true})
452 return
453 }
454
455 worldIDs := existingIDs.(map[uint32]bool)
456 worldIDs[worldID] = true
457}
458
459// removeFromOwnerIndex removes world ID from owner's index
460func removeFromOwnerIndex(worldID uint32, owner address) {
461 ownerKey := owner.String()
462 existingIDs, found := ownerIndex.Get(ownerKey)
463 if !found {
464 return
465 }
466
467 worldIDs := existingIDs.(map[uint32]bool)
468 delete(worldIDs, worldID)
469
470 if len(worldIDs) == 0 {
471 ownerIndex.Remove(ownerKey)
472 }
473}
474
475// mustGetWorld gets world properties with panic on not found
476func mustGetWorld(worldID uint32) map[string]string {
477 properties, found := worlds.Get(worldIDToKey(worldID))
478 if !found {
479 panic("world not found: " + ufmt.Sprintf("%d", worldID))
480 }
481 return properties.(map[string]string)
482}
483
484// getWorld gets world properties without panic
485func getWorld(worldID uint32) (map[string]string, bool) {
486 properties, found := worlds.Get(worldIDToKey(worldID))
487 if !found {
488 return nil, false
489 }
490 return properties.(map[string]string), true
491}
492
493// validateName validates name format
494func validateName(name string) {
495 // Empty check
496 if len(name) == 0 {
497 panic("name cannot be empty")
498 }
499
500 // Length check (allow longer than slug for unicode characters)
501 if len(name) > 100 {
502 panic("name too long (max 100)")
503 }
504
505 // Check if name is all whitespace (must be before trim check)
506 trimmed := strings.TrimSpace(name)
507 if len(trimmed) == 0 {
508 panic("name cannot be only whitespace")
509 }
510
511 // Check for leading/trailing whitespace
512 if name != trimmed {
513 panic("name cannot have leading or trailing whitespace")
514 }
515
516 // Check for control characters (0x00-0x1F)
517 for _, ch := range name {
518 if ch < 0x20 {
519 panic("name cannot contain control characters")
520 }
521 }
522}
523
524// validateSlug validates slug format
525func validateSlug(slug string) {
526 if len(slug) == 0 {
527 panic("slug cannot be empty")
528 }
529
530 if len(slug) > 50 {
531 panic("slug too long (max 50)")
532 }
533
534 // Allowed characters: a-z, 0-9, hyphen, underscore
535 for _, ch := range slug {
536 if !((ch >= 'a' && ch <= 'z') ||
537 (ch >= '0' && ch <= '9') ||
538 ch == '-' || ch == '_') {
539 panic("slug contains invalid characters (allowed: a-z, 0-9, -, _)")
540 }
541 }
542
543 // Cannot start or end with hyphen
544 if slug[0] == '-' || slug[len(slug)-1] == '-' {
545 panic("slug cannot start or end with hyphen")
546 }
547}
548
549// validateSeed validates seed value
550func validateSeed(seed int) {
551 if seed <= 0 {
552 panic("seed must be positive")
553 }
554}