Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}