render.gno
11.22 Kb · 374 lines
1package personal_world
2
3import (
4 "net/url"
5 "strconv"
6 "strings"
7
8 "gno.land/p/jeronimoalbi/pager"
9 "gno.land/r/akkadia/v0/admin"
10)
11
12// Render handles RESTful routing and returns Markdown responses
13func Render(iUrl string) string {
14 assertMigrationStateAvailable()
15 u, err := url.Parse(iUrl)
16 if err != nil {
17 return "404\n"
18 }
19
20 query := u.Query()
21
22 switch u.Path {
23 case "":
24 return renderHome()
25 case "world":
26 idStr := query.Get("id")
27 if idStr == "" {
28 return renderError("World ID required")
29 }
30 return renderWorld(iUrl, idStr)
31 case "worlds":
32 owner := query.Get("owner")
33 if owner != "" {
34 return renderWorldsByOwner(iUrl, owner)
35 }
36 return renderWorldsList(iUrl)
37 case "biomes":
38 return renderBiomes()
39 case "sizes":
40 return renderSizes()
41 default:
42 return renderError("Not found")
43 }
44}
45
46// renderHome renders the home page
47func renderHome() string {
48 output := `# Personal World
49
50Welcome to the Personal World system on Akkadia.
51
52## What is Personal World?
53
54Personal World is a decentralized world creation and management system where users can:
55
56* Create and customize their own worlds
57* Manage world editors and permissions
58* Expand world sizes dynamically
59* Set custom world types and properties
60
61## Getting Started
62
631. **Create Your First World**: World creation costs the selected biome price.
642. **Customize**: Set name, slug and visibility
653. **Manage**: Add editors, expand size, update properties
664. **Explore**: Browse all worlds and discover unique creations
67
68## Config
69
70`
71 metadata := worldConfigStore
72 worlds := worldStore
73 output += "* **Default Biome**: " + metadata.DefaultBiome() + "\n"
74 output += "* **Fee Collector BPS**: " + strconv.Itoa(feeCollectorBPS) + " (" + strconv.Itoa(feeCollectorBPS/100) + "." + strconv.Itoa(feeCollectorBPS%100) + "%)\n"
75 output += "* **List Limit**: " + strconv.Itoa(listLimit) + "\n"
76 output += "* **Batch Limit**: " + strconv.Itoa(batchLimit) + "\n"
77
78 output += `
79## Stats
80
81`
82 output += "* **Total Worlds**: " + strconv.Itoa(worlds.Total()) + "\n"
83
84 output += `
85## Quick Links
86
87* [Browse All Worlds](./personal_world:worlds)
88* [View Biomes](./personal_world:biomes)
89* [View Sizes](./personal_world:sizes)
90
91## Search by Owner
92
93<gno-form path="worlds">
94 <gno-input name="owner" placeholder="Enter owner address" />
95</gno-form>
96`
97
98 return output
99}
100
101// renderBiomes renders the biomes page
102func renderBiomes() string {
103 output := `# Biomes
104
105[← Home](./personal_world:)
106
107The system supports multiple biomes with different creation costs.
108
109## How Pricing Works
110
111* **cost**: The base cost to create a world with this biome
112* **priceMultiplierBPS**: Multiplier applied to world expansion costs, where 10000 means 1x
113 * Example: If expansion base cost is 1000 ugnot and priceMultiplierBPS is 15000, actual cost = 1500 ugnot
114
115---
116
117`
118 metadata := worldConfigStore
119 defaultBiome := metadata.DefaultBiome()
120 for _, biome := range metadata.ListBiomeInfos() {
121 output += "### " + escapeMarkdown(biome["name"]) + "\n\n"
122
123 for k, v := range biome {
124 if k == "name" {
125 if v == defaultBiome {
126 output += "* **" + escapeMarkdown(k) + "**: " + escapeMarkdown(v) + " (default)\n"
127 } else {
128 output += "* **" + escapeMarkdown(k) + "**: " + escapeMarkdown(v) + "\n"
129 }
130 } else if k == "cost" {
131 output += "* **" + escapeMarkdown(k) + "**: " + escapeMarkdown(v) + " ugnot\n"
132 } else {
133 output += "* **" + escapeMarkdown(k) + "**: " + escapeMarkdown(v) + "\n"
134 }
135 }
136 output += "\n---\n\n"
137 }
138
139 return output
140}
141
142// renderSizes renders the sizes page
143func renderSizes() string {
144 output := `# World Sizes
145
146[← Home](./personal_world:)
147
148Worlds can be expanded to larger sizes. Each expansion has a base cost.
149
150## How Expansion Works
151
152* **size**: The world dimension (size x size blocks)
153* **cost**: Base cost to expand to this size level
154* Actual expansion cost = base cost × biome priceMultiplierBPS / 10000
155* New worlds start at size level 0
156
157---
158
159`
160 for _, size := range worldConfigStore.ListSizeInfos() {
161 output += "### Level " + size["id"] + "\n\n"
162 output += "* **Size**: " + escapeMarkdown(size["size"]) + " × " + escapeMarkdown(size["size"]) + " blocks\n"
163 output += "* **Base Cost**: " + escapeMarkdown(size["cost"]) + " ugnot\n"
164 output += "\n---\n\n"
165 }
166
167 return output
168}
169
170// renderWorldsList renders a list of all worlds with pagination
171func renderWorldsList(iUrl string) string {
172 output := `# All Worlds
173
174[← Home](./personal_world:)
175
176`
177
178 worlds := worldStore
179 totalSize := worlds.Total()
180 if totalSize == 0 {
181 output += "*No worlds created yet.*\n"
182 return output
183 }
184
185 pages, err := pager.New(iUrl, totalSize, pager.WithPageSize(10))
186 if err != nil {
187 return renderError("Invalid page")
188 }
189
190 // Get current page number for back links
191 currentPage := (pages.Offset() / pages.PageSize()) + 1
192
193 for _, worldID := range worlds.ListIDs(currentPage, pages.PageSize()) {
194 if world, found := worlds.Get(worldID); found {
195 output += renderWorldItem(world, currentPage)
196 }
197 }
198
199 if pages.HasPages() {
200 output += pager.Picker(pages)
201 }
202
203 return output
204}
205
206// renderWorldsByOwner renders worlds owned by a specific address with pagination
207func renderWorldsByOwner(iUrl string, owner string) string {
208 u, _ := url.Parse(iUrl)
209 page := u.Query().Get("page")
210 ownerAddr := address(owner)
211 if !ownerAddr.IsValid() {
212 return renderError("Invalid owner address")
213 }
214 owner = ownerAddr.String()
215
216 output := "# Personal Worlds by " + escapeMarkdown(owner) + "\n\n"
217 if page != "" {
218 output += "[← Back to List](./personal_world:worlds?page=" + url.QueryEscape(page) + ")\n\n"
219 } else {
220 output += "[← Home](./personal_world:)\n\n"
221 }
222
223 totalOwnerWorlds := GetWorldSizeByOwner(ownerAddr)
224 if totalOwnerWorlds == 0 {
225 output += "*No worlds found for this owner.*\n"
226 return output
227 }
228
229 pages, err := pager.New(iUrl, totalOwnerWorlds, pager.WithPageSize(10))
230 if err != nil {
231 return renderError("Invalid page")
232 }
233
234 currentPage := (pages.Offset() / pages.PageSize()) + 1
235 ownerWorldIDs := worldStore.ListIDsByOwner(ownerAddr, currentPage, pages.PageSize())
236
237 for _, worldID := range ownerWorldIDs {
238 if world, found := worldStore.Get(worldID); found {
239 output += renderWorldItem(world, 0)
240 }
241 }
242
243 if pages.HasPages() {
244 output += pager.Picker(pages)
245 }
246
247 return output
248}
249
250// renderWorldItem renders a single world item
251func renderWorldItem(world map[string]string, page int) string {
252 explorerURL := admin.GetExplorerURL()
253 worldID := world["id"]
254 worldName := escapeMarkdown(world["name"])
255 worldOwner := escapeMarkdown(world["owner"])
256 worldOwnerParam := url.QueryEscape(world["owner"])
257 worldSlugParam := url.QueryEscape(world["slug"])
258 output := ""
259 output += "* **ID**: " + escapeMarkdown(worldID) + "\n"
260 if page > 0 {
261 pageStr := strconv.Itoa(page)
262 output += "* **Name**: [" + worldName + "](./personal_world:world?id=" + url.QueryEscape(worldID) + "&page=" + pageStr + ")\n"
263 output += "* **Owner**: " + worldOwner + " [[View on Explorer](" + explorerURL + "/m/explorer/player?address=" + worldOwnerParam + ")] [[Worlds by Owner](./personal_world:worlds?owner=" + worldOwnerParam + "&page=" + pageStr + ")]\n"
264 } else {
265 output += "* **Name**: [" + worldName + "](./personal_world:world?id=" + url.QueryEscape(worldID) + ")\n"
266 output += "* **Owner**: " + worldOwner + " [[View on Explorer](" + explorerURL + "/m/explorer/player?address=" + worldOwnerParam + ")] [[Worlds by Owner](./personal_world:worlds?owner=" + worldOwnerParam + ")]\n"
267 }
268 output += "* **Biome**: " + escapeMarkdown(world["biome"]) + "\n"
269 output += "* **Size**: " + escapeMarkdown(world["size"]) + "\n"
270 output += "* **Slug**: `" + escapeCodeSpan(world["slug"]) + "` [[View on Explorer](" + explorerURL + "/m/explorer/community-realm?slug=" + worldSlugParam + ")]\n"
271 output += "\n---\n\n"
272 return output
273}
274
275// renderWorld renders a single world detail page
276func renderWorld(iUrl string, idStr string) string {
277 worldID64, err := strconv.ParseUint(idStr, 10, 32)
278 if err != nil {
279 return renderError("Invalid world ID: " + idStr)
280 }
281 worldID := uint32(worldID64)
282
283 world, found := worldStore.Get(worldID)
284 if !found {
285 return renderError("World not found: " + idStr)
286 }
287
288 u, _ := url.Parse(iUrl)
289 pageParam := u.Query().Get("page")
290
291 backLink := "./personal_world:worlds"
292 if pageParam != "" {
293 backLink += "?page=" + pageParam
294 }
295
296 explorerURL := admin.GetExplorerURL()
297
298 output := "# World " + escapeMarkdown(world["name"]) + " (#" + escapeMarkdown(world["id"]) + ")\n\n"
299 output += "[← All Worlds](" + backLink + ")\n"
300 output += "[[View on Explorer](" + explorerURL + "/m/explorer/community-realm?slug=" + url.QueryEscape(world["slug"]) + ")]\n\n"
301
302 output += "## Info\n\n"
303 output += "* **ID**: " + escapeMarkdown(world["id"]) + "\n"
304 output += "* **Name**: " + escapeMarkdown(world["name"]) + "\n"
305 output += "* **Slug**: `" + escapeCodeSpan(world["slug"]) + "`\n"
306 output += "* **Owner**: " + escapeMarkdown(world["owner"]) + " [[View on Explorer](" + explorerURL + "/m/explorer/player?address=" + url.QueryEscape(world["owner"]) + ")] [[Worlds by Owner](./personal_world:worlds?owner=" + url.QueryEscape(world["owner"]) + ")]\n"
307 output += "* **Biome**: " + escapeMarkdown(world["biome"]) + "\n"
308 output += "* **Size**: " + escapeMarkdown(world["size"]) + " (Level " + escapeMarkdown(world["sizeId"]) + ")\n"
309 output += "* **Seed**: " + escapeMarkdown(world["seed"]) + "\n"
310 output += "* **Visible**: " + escapeMarkdown(world["isVisible"]) + "\n"
311 output += "* **Total Paid**: " + escapeMarkdown(world["totalPaid"]) + " ugnot\n"
312 output += "* **Created**: block #" + escapeMarkdown(world["createdAt"]) + "\n"
313 output += "* **Updated**: block #" + escapeMarkdown(world["updatedAt"]) + "\n"
314
315 output += "\n## Roles\n\n"
316 worldKey := formatWorldID(worldID)
317 roles := personalWorldAuthzRBAC.ListRoles(1, listLimit)
318 hasRoles := false
319 for _, role := range roles {
320 users := personalWorldAuthzRBAC.ListRoleUsers(worldKey, role.Name, 1, listLimit)
321 if len(users) != 0 {
322 hasRoles = true
323 break
324 }
325 }
326 if hasRoles {
327 output += "| Address | Roles |\n"
328 output += "|---------|-------|\n"
329 for _, role := range roles {
330 users := personalWorldAuthzRBAC.ListRoleUsers(worldKey, role.Name, 1, listLimit)
331 for _, user := range users {
332 output += "| " + escapeMarkdown(user.String()) + " | " + escapeMarkdown(role.Name) + " |\n"
333 }
334 }
335 } else {
336 output += "*No roles assigned.*\n"
337 }
338
339 return output
340}
341
342// renderError renders an error page
343func renderError(message string) string {
344 output := "# Error\n\n"
345 output += "> " + escapeMarkdown(message) + "\n\n"
346 output += "[← Back to Home](./personal_world:)\n"
347 return output
348}
349
350func escapeMarkdown(s string) string {
351 s = strings.ReplaceAll(s, "\\", "\\\\")
352 s = strings.ReplaceAll(s, "\n", "\\n")
353 s = strings.ReplaceAll(s, "`", "\\`")
354 s = strings.ReplaceAll(s, "*", "\\*")
355 s = strings.ReplaceAll(s, "_", "\\_")
356 s = strings.ReplaceAll(s, "{", "\\{")
357 s = strings.ReplaceAll(s, "}", "\\}")
358 s = strings.ReplaceAll(s, "[", "\\[")
359 s = strings.ReplaceAll(s, "]", "\\]")
360 s = strings.ReplaceAll(s, "(", "\\(")
361 s = strings.ReplaceAll(s, ")", "\\)")
362 s = strings.ReplaceAll(s, "#", "\\#")
363 s = strings.ReplaceAll(s, "+", "\\+")
364 s = strings.ReplaceAll(s, "-", "\\-")
365 s = strings.ReplaceAll(s, ".", "\\.")
366 s = strings.ReplaceAll(s, "!", "\\!")
367 s = strings.ReplaceAll(s, "|", "\\|")
368 s = strings.ReplaceAll(s, ">", "\\>")
369 return s
370}
371
372func escapeCodeSpan(s string) string {
373 return strings.ReplaceAll(s, "`", "\\`")
374}