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

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}