Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}