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