sonotone2.gno
6.09 Kb · 234 lines
1// Gno realm (smart contract) for Sonotone — version 2.
2// Two separate leaderboards: "classic" and "melomane".
3// Mélomane mode unlocks at 135 points in Classic, and the contract
4// itself enforces it (no cheating by tweaking the browser).
5
6package sonotone2
7
8import (
9 "strconv"
10 "strings"
11
12 "gno.land/p/nt/avl/v0"
13)
14
15const (
16 MaxScore = 1000000
17 MelomaneUnlockAt = 135 // Classic points needed to unlock Mélomane
18)
19
20// Record = best result in one mode.
21type Record struct {
22 Score int
23 Level int
24 Instrument string
25}
26
27// Player = a registered player and their best record per mode.
28type Player struct {
29 Pseudo string
30 Address address
31 Classic Record
32 Melomane Record
33}
34
35var (
36 players avl.Tree // key: wallet address -> *Player
37 pseudos avl.Tree // key: lowercase nickname -> wallet address
38)
39
40// ---------------------------------------------------------------------
41// Functions called by players (transactions signed with Adena)
42// ---------------------------------------------------------------------
43
44// Register links a nickname to the wallet signing the transaction.
45func Register(cur realm, pseudo string) {
46 caller := cur.Previous()
47 if !caller.IsUser() {
48 panic("only a wallet can register")
49 }
50 key := caller.Address().String()
51
52 if players.Has(key) {
53 panic("this wallet already has a nickname")
54 }
55 checkPseudo(pseudo)
56
57 lower := strings.ToLower(pseudo)
58 if pseudos.Has(lower) {
59 panic("this nickname is already taken")
60 }
61
62 players.Set(key, &Player{
63 Pseudo: pseudo,
64 Address: caller.Address(),
65 })
66 pseudos.Set(lower, key)
67}
68
69// SubmitScore saves a new record in a mode ("classic" or "melomane").
70func SubmitScore(cur realm, mode string, score int, level int, instrument string) {
71 key := cur.Previous().Address().String()
72
73 v := players.Get(key)
74 if v == nil {
75 panic("register a nickname first")
76 }
77 if score <= 0 || score > MaxScore {
78 panic("invalid score")
79 }
80 if level < 1 || level > 1000 {
81 panic("invalid level")
82 }
83 if !validInstrument(instrument) {
84 panic("unknown instrument")
85 }
86
87 p := v.(*Player)
88 switch mode {
89 case "classic":
90 if score <= p.Classic.Score {
91 panic("not a new record")
92 }
93 p.Classic = Record{Score: score, Level: level, Instrument: instrument}
94 case "melomane":
95 if p.Classic.Score < MelomaneUnlockAt {
96 panic("Mélomane mode locked: score 135 points in Classic first")
97 }
98 if score <= p.Melomane.Score {
99 panic("not a new record")
100 }
101 p.Melomane = Record{Score: score, Level: level, Instrument: instrument}
102 default:
103 panic("unknown mode")
104 }
105}
106
107// ---------------------------------------------------------------------
108// Read-only functions (free, used by the game)
109// ---------------------------------------------------------------------
110
111// GetPlayerJSON returns an address's full profile as JSON, or "null":
112// {"pseudo":"…","address":"…","melomane_unlocked":true,
113// "classic":{"score":…,"level_max":…,"instrument":"…"},
114// "melomane":{"score":…,"level_max":…,"instrument":"…"}}
115func GetPlayerJSON(addr string) string {
116 v := players.Get(addr)
117 if v == nil {
118 return "null"
119 }
120 p := v.(*Player)
121 unlocked := "false"
122 if p.Classic.Score >= MelomaneUnlockAt {
123 unlocked = "true"
124 }
125 return `{"pseudo":"` + p.Pseudo +
126 `","address":"` + p.Address.String() +
127 `","melomane_unlocked":` + unlocked +
128 `,"classic":` + recordJSON(p.Classic) +
129 `,"melomane":` + recordJSON(p.Melomane) + `}`
130}
131
132// GetTopJSON returns the top n players of a mode, as JSON:
133// [{"pseudo":"…","address":"…","score":…,"level_max":…,"instrument":"…"}, …]
134func GetTopJSON(mode string, n int) string {
135 top := topPlayers(mode, n)
136 out := "["
137 for i, p := range top {
138 if i > 0 {
139 out += ","
140 }
141 r := recordOf(p, mode)
142 out += `{"pseudo":"` + p.Pseudo +
143 `","address":"` + p.Address.String() +
144 `","score":` + strconv.Itoa(r.Score) +
145 `,"level_max":` + strconv.Itoa(r.Level) +
146 `,"instrument":"` + r.Instrument + `"}`
147 }
148 return out + "]"
149}
150
151// Render shows both leaderboards as Markdown on gnoweb.
152func Render(_ string) string {
153 out := "# 🎧 Sonotone — Leaderboards\n\n"
154 out += renderTable("🎵 Classic", "classic")
155 out += renderTable("𝄞 Mélomane", "melomane")
156 return out
157}
158
159// ---------------------------------------------------------------------
160// Internal functions
161// ---------------------------------------------------------------------
162
163func renderTable(title, mode string) string {
164 out := "## " + title + "\n\n"
165 top := topPlayers(mode, 20)
166 if len(top) == 0 {
167 return out + "No scores yet.\n\n"
168 }
169 out += "| # | Nickname | Score | Level | Instrument |\n"
170 out += "|---|--------|-------|--------|------------|\n"
171 for i, p := range top {
172 r := recordOf(p, mode)
173 out += "| " + strconv.Itoa(i+1) +
174 " | " + p.Pseudo +
175 " | " + strconv.Itoa(r.Score) +
176 " | " + strconv.Itoa(r.Level) +
177 " | " + r.Instrument + " |\n"
178 }
179 return out + "\n"
180}
181
182func recordOf(p *Player, mode string) Record {
183 if mode == "melomane" {
184 return p.Melomane
185 }
186 return p.Classic
187}
188
189func recordJSON(r Record) string {
190 return `{"score":` + strconv.Itoa(r.Score) +
191 `,"level_max":` + strconv.Itoa(r.Level) +
192 `,"instrument":"` + r.Instrument + `"}`
193}
194
195// checkPseudo: 3 to 20 characters, letters, digits, _ and - only.
196func checkPseudo(p string) {
197 if len(p) < 3 || len(p) > 20 {
198 panic("nickname must be 3 to 20 characters")
199 }
200 for _, c := range p {
201 ok := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
202 (c >= '0' && c <= '9') || c == '_' || c == '-'
203 if !ok {
204 panic("nickname: only letters (no accents), digits, _ and -")
205 }
206 }
207}
208
209func validInstrument(i string) bool {
210 return i == "piano" || i == "bass" || i == "guitar" || i == "organ"
211}
212
213// topPlayers sorts players with a score in this mode, highest first.
214func topPlayers(mode string, n int) []*Player {
215 var list []*Player
216 players.Iterate("", "", func(_ string, value any) bool {
217 p := value.(*Player)
218 if recordOf(p, mode).Score > 0 {
219 list = append(list, p)
220 }
221 return false
222 })
223
224 for i := 1; i < len(list); i++ {
225 for j := i; j > 0 && recordOf(list[j], mode).Score > recordOf(list[j-1], mode).Score; j-- {
226 list[j], list[j-1] = list[j-1], list[j]
227 }
228 }
229
230 if n > 0 && len(list) > n {
231 list = list[:n]
232 }
233 return list
234}