data.gno
12.26 Kb · 385 lines
1package golf
2
3import (
4 "chain"
5 "chain/runtime"
6 "crypto/sha256"
7 "encoding/hex"
8 "math"
9 "strconv"
10 "strings"
11 "unicode"
12 "unicode/utf8"
13
14 "gno.land/p/nym-alexiscolin000/gnogolf/course"
15)
16
17// Holes as data: a version is a GG1 string, checked once at publish and kept
18// as bytes; each call decodes it afresh and stores nothing of it. A version
19// is never edited or removed: a new one takes its alias, and the old one is
20// archived, still playable, its records kept.
21
22// wearGrid is the entry's wear: a count of balls at rest per cell of the
23// WearW by WearH grid, row-major.
24func (e *entry) wearGrid() []int {
25 out := make([]int, wearCells)
26 if b := e.wear; b != nil {
27 for i := range out {
28 out[i] = int(uint32(b[4*i]) | uint32(b[4*i+1])<<8 | uint32(b[4*i+2])<<16 | uint32(b[4*i+3])<<24)
29 }
30 }
31 return out
32}
33
34const wearCells = course.WearW * course.WearH
35
36// mark counts a ball come to rest in wear cell i.
37func (e *entry) mark(i int) {
38 if i < 0 || i >= wearCells {
39 return // course.WearIndexOn never gives one
40 }
41 if e.wear == nil {
42 e.wear = make([]byte, 4*wearCells)
43 }
44 b := e.wear
45 n := uint32(b[4*i]) | uint32(b[4*i+1])<<8 | uint32(b[4*i+2])<<16 | uint32(b[4*i+3])<<24
46 n++
47 b[4*i], b[4*i+1], b[4*i+2], b[4*i+3] = byte(n), byte(n>>8), byte(n>>16), byte(n>>24)
48}
49
50// maxData bounds a version's data: a hole at every limit of the format is
51// about 21 KB.
52const maxData = 32 << 10
53
54// Publish puts a course hole's data into its slot as its next version and
55// returns the new id ("garden/7/v2"). Only the owner can. hexData is the hole
56// as course.Encode wrote it, in hex. slot must be the data's own world and
57// order ("garden/7", order 1 to 999), so a mis-edited file cannot replace the
58// wrong hole; the course holds at most 9999 slots (maxCourse). Data identical
59// to the current version's is refused. The version it replaces is archived:
60// its records stay, its bests leave the course standings. note (at most 140
61// characters, cleaned) says why.
62func Publish(cur realm, slot, hexData, note string) string {
63 by := cur.Previous().Address()
64 if !isOwner(by) {
65 panic("golf: only the owner can publish a course hole")
66 }
67 h, data, sum := decodeData(hexData)
68 if h.Order < 1 || h.Order != math.Trunc(h.Order) {
69 panic("golf: a course hole's order is a whole number from 1 to 999")
70 }
71 own := h.World + "/" + strconv.Itoa(int(h.Order))
72 if slot != own {
73 panic("golf: this data is " + own + ", not " + slot)
74 }
75 key, ok := slotKeyOf(slot)
76 if !ok {
77 panic("golf: " + slot + " is not a course slot") // its data's own world/order: cannot happen
78 }
79 var old *entry
80 n := 1
81 if v := slots.Get(key); v != nil {
82 old = find(v.(string))
83 if old.sha == sum {
84 panic(nothingNew(old))
85 }
86 n = old.version + 1
87 } else if slots.Size() >= maxCourse {
88 panic("golf: the course is full: 9999 slots")
89 }
90 e := dataEntry(slot, n, h, by, sum, note)
91 e.official = true
92 courseHoles.Set(e.id, e)
93 holeData.Set(e.id, data)
94 slots.Set(key, e.id)
95 chain.Emit(EventHolePublished, "hole", e.id, "slot", slot, "sha", sum, "by", by.String(), "official", "true")
96 if old != nil {
97 retire(old, e.id)
98 }
99 return e.id
100}
101
102// PublishMine publishes a community hole of the caller's own and returns its
103// id: "<caller>/<slug>/v1", then v2 and on. Anyone can while publishing is
104// open (SetPublishing); only the same address adds versions. slug is 1 to 32
105// of a-z, 0-9 and "-". The data is checked as Publish checks it, its world
106// and order aside; its name and note carry no web address. It is playable and
107// recorded, in no cup and not in the course ranking; a new version of a
108// hidden hole stays hidden. The publisher pays the storage deposit (about
109// half a GNOT a version), and nothing published can be deleted.
110func PublishMine(cur realm, slug, hexData, note string) string {
111 if !publishing {
112 panic("golf: publishing community holes is not open yet")
113 }
114 by := cur.Previous().Address()
115 if !validSlug(slug) {
116 panic("golf: a slug is 1 to 32 of a-z, 0-9 and -")
117 }
118 alias := by.String() + "/" + slug
119 h, data, sum := decodeData(hexData)
120 var old *entry
121 n := 1
122 if v := aliases.Get(alias); v != nil {
123 old = find(v.(string))
124 if old.sha == sum {
125 panic(nothingNew(old))
126 }
127 n = old.version + 1
128 }
129 e := dataEntry(alias, n, h, by, sum, note)
130 if linkish(e.name) || linkish(e.note) {
131 panic("golf: a community hole's name and note carry no web address")
132 }
133 community.Set(e.id, e)
134 holeData.Set(e.id, data)
135 aliases.Set(alias, e.id)
136 if old != nil {
137 old.next = e.id // archived; it never counted anywhere
138 }
139 if !hidden.Has(alias) { // a hidden hole has no row (Hide)
140 a := by.String()
141 // the newest row: the author's own moves to its height
142 moveAuthor(a, newestRow(a), pad(int(e.height), 12))
143 if old != nil {
144 byAuthor.Remove(a + " " + recentKey(old.height, slug))
145 }
146 byAuthor.Set(a+" "+recentKey(e.height, slug), e.id)
147 }
148 chain.Emit(EventHolePublished, "hole", e.id, "slot", alias, "sha", sum, "by", by.String(), "official", "false")
149 return e.id
150}
151
152// newestRow is the height of an author's newest row in byAuthor
153// ("<address> <height> <slug>"), "" if they have none.
154func newestRow(a string) string {
155 latest := ""
156 byAuthor.ReverseIterate(a+" ", a+"!", func(k string, _ any) bool {
157 latest = k[len(a)+1 : len(a)+13]
158 return true
159 })
160 return latest
161}
162
163// moveAuthor moves an author's own row in byAuthor (recentKey(height,
164// address)) from the height was to now, "" for none: it stays at their
165// newest row's, and goes with their last.
166func moveAuthor(a, was, now string) {
167 if was == now {
168 return
169 }
170 if was != "" {
171 byAuthor.Remove(was + " " + a)
172 }
173 if now != "" {
174 byAuthor.Set(now+" "+a, nil)
175 }
176}
177
178// linkish spots a web address in a community hole's name or note: "www.", a
179// dot between a letter or digit and two letters ("claim.xyz", "t.me"; not
180// "e.g."), or an IP address, look-alike dots (.。․﹒) read as dots, letters in
181// any case. Basic (the owner can Hide what gets past) and strict on purpose
182// ("St.Andrews" is refused). It reads s a byte at a time: every mark is
183// ASCII, so a non-ASCII character is decoded only to tell İ and the Kelvin
184// sign (which lower-case to ASCII), the look-alike dots and spaces apart.
185func linkish(s string) bool {
186 var c1, c2, c3 byte // the three characters before c, lower-cased
187 // the word so far (as strings.Fields splits): an IP is digits, three dots
188 word, ip, dots := false, true, 0
189 for i := 0; i < len(s); {
190 c, size, space := s[i], 1, false
191 switch {
192 case c >= 'A' && c <= 'Z':
193 c += 'a' - 'A'
194 case c >= utf8.RuneSelf:
195 var r rune
196 r, size = utf8.DecodeRuneInString(s[i:])
197 switch r {
198 case 'İ':
199 c = 'i'
200 case '\u212a': // the Kelvin sign
201 c = 'k'
202 case '.', '。', '․', '﹒':
203 c = '.'
204 default:
205 c, space = utf8.RuneSelf, unicode.IsSpace(r) // none of the marks
206 }
207 case c == ' ' || c >= '\t' && c <= '\r':
208 space = true
209 }
210 i += size
211 switch {
212 case c == '.' && c1 == 'w' && c2 == 'w' && c3 == 'w':
213 return true
214 case isLower(c) && isLower(c1) && c2 == '.' && (isLower(c3) || isDigit(c3)):
215 return true
216 }
217 c1, c2, c3 = c, c1, c2
218 if space {
219 if word && ip && dots == 3 {
220 return true
221 }
222 word, ip, dots = false, true, 0
223 continue
224 }
225 word = true
226 if c == '.' {
227 dots++
228 } else if !isDigit(c) {
229 ip = false
230 }
231 }
232 return word && ip && dots == 3
233}
234
235func isLower(c byte) bool { return c >= 'a' && c <= 'z' }
236func isDigit(c byte) bool { return c >= '0' && c <= '9' }
237
238// decodeData checks a version's data as it arrives: hex, GG1 within the
239// format's limits, stored lengths bit-exact (DecodeChecked: every later
240// Decode trusts them), and in the one form Encode gives.
241func decodeData(hexData string) (*course.Simple, string, string) {
242 if len(hexData) > 2*maxData {
243 panic("golf: the data is too long")
244 }
245 b, err := hex.DecodeString(hexData)
246 if err != nil {
247 panic("golf: the data is not hex")
248 }
249 data := string(b)
250 h, err := course.DecodeChecked(data)
251 if err != nil {
252 panic("golf: " + err.Error())
253 }
254 // one hole, one string: data that decodes to a hole Encode would write
255 // otherwise (a skin never used, a style run split, a stored length a
256 // zero-length wall never reads) is refused, so the same hole cannot come
257 // back under another sha
258 if course.Encode(h) != data {
259 panic("golf: the data is not as course.Encode writes it")
260 }
261 // refused if a transaction could not replay its heaviest shot, or if in
262 // its dearest weather a commit's first shot would be capped below
263 // minShotWork: every commit decodes the hole and draws its forecast first
264 if newWork(h, nil).bound > maxShotGas {
265 panic(errShotTooHeavy)
266 }
267 if worstGas(h, len(data)) > workBudget {
268 panic(errTooStormy)
269 }
270 sum := sha256.Sum256(b)
271 return h, data, hex.EncodeToString(sum[:])
272}
273
274// nothingNew refuses data identical to the alias's current version: it would
275// reset the hole's ranking for nothing.
276func nothingNew(old *entry) string {
277 return "golf: this data is already " + old.id + ": nothing to publish"
278}
279
280func dataEntry(alias string, n int, h *course.Simple, by address, sum, note string) *entry {
281 e := newEntry(alias+"/v"+strconv.Itoa(n), cleanName(h.Title), h.Par(), h.World, h.Order)
282 e.slot, e.version, e.by, e.height, e.sha, e.note = alias, n, by, runtime.ChainHeight(), sum, cleanText(note, maxNote)
283 return e
284}
285
286// worlds is the course's worlds in play order, with their names on the page.
287var worlds = []struct{ id, name string }{
288 {"garden", "Garden Cup"},
289 {"island", "Island Cup"},
290 {"town", "Mushroom Town"},
291 {"mountain", "Mountain Cup"},
292 {"mines", "Crystal Mines"},
293}
294
295// worldRank is a world's place among worlds; any other comes after.
296func worldRank(w string) int {
297 for i, x := range worlds {
298 if x.id == w {
299 return i
300 }
301 }
302 return len(worlds)
303}
304
305// slotKey is a slot's key: its world's rank, the world, and the order in
306// three digits, so the slots tree is in course order.
307func slotKey(world string, order int) string {
308 return string(rune('0'+worldRank(world))) + world + "/" + pad(order, 3)
309}
310
311// slotKeyOf reads a slot, "world/order" (as "garden/7"), into its key.
312func slotKeyOf(slot string) (string, bool) {
313 i := strings.Index(slot, "/")
314 if i < 0 || !course.IsWorld(slot[:i]) {
315 return "", false
316 }
317 n, err := strconv.Atoi(slot[i+1:])
318 if err != nil || n < 1 || n > course.MaxOrder || strconv.Itoa(n) != slot[i+1:] {
319 return "", false
320 }
321 return slotKey(slot[:i], n), true
322}
323
324func validSlug(s string) bool {
325 if len(s) < 1 || len(s) > 32 {
326 return false
327 }
328 for i := 0; i < len(s); i++ {
329 if c := s[i]; !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') {
330 return false
331 }
332 }
333 return true
334}
335
336// HoleData is a version's GG1, in hex, as it was published (an alias gives
337// its current version's): what a golf/v2 or an auditor reads back.
338func HoleData(hole string) string {
339 e := readHole(hole)
340 return hex.EncodeToString([]byte(holeData.Get(e.id).(string)))
341}
342
343// Current is the id of the version an alias ("garden/7", "<address>/<slug>")
344// plays now, "" if the alias has none.
345func Current(alias string) string { return current(alias) }
346
347// Versions is every version of an alias, oldest first (the newest pageMax if
348// there are more): its id, number, the height it was published at, by whom,
349// its data's sha256, its note, and the version that took its place. slot is
350// the alias, "" (and no versions) for one that has none.
351func Versions(alias string) string {
352 now := find(current(alias))
353 slot := ""
354 if now != nil {
355 slot = now.slot // never the caller's own bytes
356 }
357 var sb strings.Builder
358 sb.WriteString("{" + versionJSON + `,"slot":` + jstr(slot) + `,"versions":[`)
359 if now != nil {
360 from := firstVersion(now)
361 for n := from; n <= now.version; n++ {
362 e := find(now.slot + "/v" + strconv.Itoa(n))
363 sb.WriteString(sep(n-from) + `{"id":` + jstr(e.id) + `,"v":` + strconv.Itoa(e.version) +
364 `,"height":` + strconv.FormatInt(e.height, 10) + `,"by":` + jstr(e.by.String()) +
365 `,"sha":` + jstr(e.sha) + `,"note":` + jstr(e.note) + `,"next":` + jstr(e.next) + "}")
366 }
367 }
368 sb.WriteString("]}")
369 return sb.String()
370}
371
372// firstVersion is the oldest of now's versions a list shows: the newest
373// pageMax are (now is its alias's current version).
374func firstVersion(now *entry) int {
375 if now.version > pageMax {
376 return now.version - pageMax + 1
377 }
378 return 1
379}
380
381// BestOf is a player's best finished round on a hole in a mode, 0 if none.
382func BestOf(hole, mode string, player address) int {
383 e, m := readHole(hole), modeOf(mode)
384 return bestStrokes(e.bests(m).Get(player.String()))
385}