json.gno
16.95 Kb · 470 lines
1package gnoslinger
2
3import (
4 "strconv"
5 "strings"
6 "time"
7
8 "gno.land/p/g1th0masp67zfs9lg6d34qhghldhg6kjcqjuv7c2/gnoslinger/engine/v0"
9 profiles "gno.land/r/g1th0masp67zfs9lg6d34qhghldhg6kjcqjuv7c2/gnoslinger/profiles/v0"
10)
11
12// JSON views for the web client, read through Render("json/...") so the
13// string comes back raw from vm/qrender. Built by hand: the shapes are fixed
14// and small, and the chain has no encoding/json.
15
16const listLimit = 50
17
18// liveScan is how many of the newest games GamesJSON looks through for the
19// ones being played: what the view costs stays bounded however many games
20// there are.
21const liveScan = 200
22
23// endedLimit is how many of the last finished games GamesJSON lists.
24const endedLimit = 10
25
26// GameJSON is the full view of a game:
27//
28// {"id","rev","phase","turn","maxTurns","timeout" (seconds),"board",
29// "players":[{"addr" ("" for a seat nobody took yet),"name","char","hex" ("" until the seat set up),"facing","down","delay","status","committed","revealed","locked" (its timelocked plan, "" once revealed),"missed" (turns in a row it let time out),"playedFoot","playedRun","ranLastTurn",
30// "aim","aimAt" (the seat the AIM markers follow, -1 when they sit on a hex or there is no aim),"aimHex" (the hex they sit on, "" when they follow a seat),"endurance","serious","gunArm","otherArm","leg",
31// "guns":[{"id","type","name","location","cocked","shells","capacity","jammed","exploded"}]}] (one entry per seat, 2 to 4),
32// "ground":[{"id" (ground id, "g<id>" in a plan),"hex","guns":[{..}]}],
33// "winner","vp":[victory points per seat],"awards":[{"p","reason","points"}] (rule 15, once a played game ends),
34// "stake" (ugnot each player puts in, 0 for a free showdown),"pot" (the stakes paid so far),"payouts":[ugnot each seat got, [] until paid],"houseCut" (stakes.gno),"endReason",
35// "createdAt","updatedAt","timeoutAt" (when the turn in progress can be claimed, 0 unless planning or revealing),
36// "commitUntil" (the turn's deadline),"revealUntil" (when unrevealed seats can be claimed out),"lockRound" (the drand round the plans are timelocked to) (0 unless planning or revealing),
37// "lastTurn":null|{"turn","seed","cards":[result card numbers drawn, in order],"plans":[..],
38// "events":[{"seg","p","kind","action","from","to","facing","down","n","delay","reason","card","result","endurance","target","hit","range","gun","gunId","step","chart","status"}],
39// "start":[{"hex","facing","down","delay","status","aim","aimAt","aimHex","endurance","serious","gunArm","otherArm","leg","guns":[..]},..],
40// "ground":[the weapons on the ground at the turn's start, as "ground" above]}}
41//
42// Times are unix seconds; winner, events[].p and events[].target are seat indexes, -1 for none.
43// The plans of the turn in progress are hidden until revealed: only their
44// commits show, as flags and timelocked plans.
45func GameJSON(id string) string {
46 g := getGame(id)
47 var b strings.Builder
48 b.WriteString(`{"id":` + quote(g.ID))
49 b.WriteString(`,"rev":` + strconv.Itoa(g.Rev))
50 b.WriteString(`,"phase":` + quote(g.Phase.String()))
51 b.WriteString(`,"turn":` + strconv.Itoa(g.Turn))
52 b.WriteString(`,"maxTurns":` + strconv.Itoa(g.MaxTurns))
53 b.WriteString(`,"timeout":` + strconv.FormatInt(int64(g.Timeout/time.Second), 10))
54 b.WriteString(`,"board":"A"`)
55 b.WriteString(`,"players":[`)
56 for i := range g.Players {
57 if i > 0 {
58 b.WriteByte(',')
59 }
60 writePlayer(&b, g, i)
61 }
62 b.WriteByte(']')
63 writeGround(&b, g.State.Ground)
64 b.WriteString(`,"winner":` + strconv.Itoa(g.Winner))
65 writeVP(&b, g)
66 b.WriteString(`,"awards":[`)
67 for i, a := range g.Awards {
68 if i > 0 {
69 b.WriteByte(',')
70 }
71 b.WriteString(`{"p":` + strconv.Itoa(a.Player) + `,"reason":` + quote(a.Reason) + `,"points":` + strconv.Itoa(a.Points) + `}`)
72 }
73 b.WriteByte(']')
74 writeStakes(&b, g)
75 b.WriteString(`,"endReason":` + quote(g.EndReason))
76 b.WriteString(`,"createdAt":` + strconv.FormatInt(g.CreatedAt.Unix(), 10))
77 b.WriteString(`,"updatedAt":` + strconv.FormatInt(g.UpdatedAt.Unix(), 10))
78 writeTimeoutAt(&b, g)
79 commitUntil, revealUntil, lockRound := int64(0), int64(0), int64(0)
80 if g.Phase == PhasePlanning || g.Phase == PhaseRevealing {
81 commitUntil, revealUntil, lockRound = g.commitUntil().Unix(), g.revealUntil().Unix(), g.lockRound()
82 }
83 b.WriteString(`,"commitUntil":` + strconv.FormatInt(commitUntil, 10))
84 b.WriteString(`,"revealUntil":` + strconv.FormatInt(revealUntil, 10))
85 b.WriteString(`,"lockRound":` + strconv.FormatInt(lockRound, 10))
86 b.WriteString(`,"lastTurn":`)
87 writeTurnResult(&b, g.lastTurn())
88 b.WriteByte('}')
89 return b.String()
90}
91
92// PlanJSON is where an address's plan for the turn in progress stands; the
93// plan itself shows once revealed:
94//
95// {"id","turn","seat" (-1 when addr is not a player),"committed","revealed","plan" ("" until revealed)}
96func PlanJSON(id, addr string) string {
97 g := getGame(id)
98 seat := g.seat(address(addr))
99 var b strings.Builder
100 b.WriteString(`{"id":` + quote(g.ID))
101 b.WriteString(`,"turn":` + strconv.Itoa(g.Turn))
102 b.WriteString(`,"seat":` + strconv.Itoa(seat))
103 committed, revealed, plan := false, false, ""
104 if seat >= 0 {
105 committed, revealed, plan = g.committed(seat), g.Revealed[seat], g.Plans[seat]
106 }
107 b.WriteString(`,"committed":` + strconv.FormatBool(committed))
108 b.WriteString(`,"revealed":` + strconv.FormatBool(revealed))
109 b.WriteString(`,"plan":` + quote(plan))
110 b.WriteByte('}')
111 return b.String()
112}
113
114// TurnJSON is one resolved turn of a game (from 1), the turn result as
115// json/game writes its last one: what replays read, a turn per query (the
116// "json/history/{id}/{turn}" view), so that what a query costs does not
117// grow with the game. Each turn is self-contained (its start and ground).
118func TurnJSON(id string, turn int) string {
119 g := getGame(id)
120 if turn < 1 || turn > len(g.Turns) {
121 panic("gnoslinger: turn " + strconv.Itoa(turn) + " is not resolved")
122 }
123 var b strings.Builder
124 writeTurnResult(&b, g.Turns[turn-1])
125 return b.String()
126}
127
128// HistoryJSON is every resolved turn of a game, oldest first, for tests and
129// tools (the tutorial's showdown, make gen-tutorial); no view serves it: a
130// long game's history may cost more than a query can.
131//
132// {"id","turn","phase","players":[{"addr","char"}],"turns":[turn result...]}
133func HistoryJSON(id string) string {
134 g := getGame(id)
135 var b strings.Builder
136 b.WriteString(`{"id":` + quote(g.ID))
137 b.WriteString(`,"turn":` + strconv.Itoa(g.Turn))
138 b.WriteString(`,"phase":` + quote(g.Phase.String()))
139 b.WriteString(`,"players":[`)
140 for i, p := range g.Players {
141 if i > 0 {
142 b.WriteByte(',')
143 }
144 b.WriteString(`{"addr":` + quote(p.Addr.String()) + `,"name":` + quote(profiles.Name(p.Addr)) + `,"char":` + quote(p.Char) + `}`)
145 }
146 b.WriteString(`],"turns":[`)
147 for i, r := range g.Turns {
148 if i > 0 {
149 b.WriteByte(',')
150 }
151 writeTurnResult(&b, r)
152 }
153 b.WriteString("]}")
154 return b.String()
155}
156
157func writePlayer(b *strings.Builder, g *Game, i int) {
158 p, c := g.Players[i], g.State.Chars[i]
159 b.WriteString(`{"addr":` + quote(p.Addr.String()))
160 b.WriteString(`,"name":` + quote(profiles.Name(p.Addr)))
161 b.WriteString(`,"char":` + quote(p.Char))
162 b.WriteString(`,"hex":` + quote(c.Hex))
163 b.WriteString(`,"facing":` + strconv.Itoa(int(c.Facing)))
164 b.WriteString(`,"down":` + strconv.FormatBool(c.Down))
165 b.WriteString(`,"delay":` + strconv.Itoa(c.Delay))
166 b.WriteString(`,"status":` + quote(c.Status.String()))
167 b.WriteString(`,"committed":` + strconv.FormatBool(g.committed(i)))
168 b.WriteString(`,"revealed":` + strconv.FormatBool(g.Revealed[i]))
169 b.WriteString(`,"locked":` + quote(g.Commits[i].Locked))
170 b.WriteString(`,"missed":` + strconv.Itoa(g.Missed[i]))
171 b.WriteString(`,"playedFoot":` + strconv.FormatBool(c.PlayedFoot))
172 b.WriteString(`,"playedRun":` + strconv.FormatBool(c.PlayedRun))
173 b.WriteString(`,"ranLastTurn":` + strconv.FormatBool(c.RanLastTurn))
174 writeWounds(b, c)
175 writeGuns(b, c.Guns)
176 b.WriteByte('}')
177}
178
179// writeWounds writes the aim and wound members of a character.
180func writeWounds(b *strings.Builder, c engine.Character) {
181 b.WriteString(`,"aim":` + strconv.Itoa(c.Aim))
182 aimAt := engine.NoSeat
183 if c.Aim > 0 {
184 aimAt = c.AimAt
185 }
186 b.WriteString(`,"aimAt":` + strconv.Itoa(aimAt))
187 b.WriteString(`,"aimHex":` + quote(c.AimHex))
188 b.WriteString(`,"endurance":` + strconv.Itoa(c.Endurance))
189 b.WriteString(`,"serious":` + strconv.Itoa(c.Serious))
190 b.WriteString(`,"gunArm":` + strconv.Itoa(c.GunArm))
191 b.WriteString(`,"otherArm":` + strconv.Itoa(c.OtherArm))
192 b.WriteString(`,"leg":` + strconv.Itoa(c.Leg))
193}
194
195// writeTimeoutAt writes the ,"timeoutAt" member: when the turn in progress
196// can be claimed, 0 unless the game is planning or revealing.
197func writeTimeoutAt(b *strings.Builder, g *Game) {
198 timeoutAt := int64(0)
199 if g.Phase == PhasePlanning || g.Phase == PhaseRevealing {
200 timeoutAt = g.timeoutAt().Unix()
201 }
202 b.WriteString(`,"timeoutAt":` + strconv.FormatInt(timeoutAt, 10))
203}
204
205// writeGround writes the ,"ground":[...] member: the weapons lying in hexes.
206func writeGround(b *strings.Builder, ground []engine.GroundGun) {
207 b.WriteString(`,"ground":[`)
208 for i, gg := range ground {
209 if i > 0 {
210 b.WriteByte(',')
211 }
212 b.WriteString(`{"id":` + strconv.Itoa(gg.ID))
213 b.WriteString(`,"hex":` + quote(gg.Hex))
214 writeGuns(b, engine.Guns{gg.Gun})
215 b.WriteByte('}')
216 }
217 b.WriteByte(']')
218}
219
220// writeGuns writes the ,"guns":[...] member of a character.
221func writeGuns(b *strings.Builder, guns engine.Guns) {
222 b.WriteString(`,"guns":[`)
223 n := 0
224 for _, g := range guns {
225 if g.Type == engine.NoGun {
226 continue
227 }
228 if n > 0 {
229 b.WriteByte(',')
230 }
231 n++
232 spec := g.Type.Spec()
233 b.WriteString(`{"id":` + strconv.Itoa(g.ID))
234 b.WriteString(`,"type":` + quote(string(g.Type)))
235 b.WriteString(`,"name":` + quote(spec.Name))
236 b.WriteString(`,"location":` + quote(g.Location.String()))
237 b.WriteString(`,"cocked":` + strconv.FormatBool(g.Cocked))
238 b.WriteString(`,"shells":` + strconv.Itoa(g.Shells))
239 b.WriteString(`,"capacity":` + strconv.Itoa(spec.Capacity))
240 b.WriteString(`,"jammed":` + strconv.FormatBool(g.Jammed))
241 b.WriteString(`,"exploded":` + strconv.FormatBool(g.Exploded))
242 b.WriteByte('}')
243 }
244 b.WriteByte(']')
245}
246
247func writeTurnResult(b *strings.Builder, r *TurnResult) {
248 if r == nil {
249 b.WriteString("null")
250 return
251 }
252 b.WriteString(`{"turn":` + strconv.Itoa(r.Turn))
253 b.WriteString(`,"seed":` + quote(r.Seed))
254 b.WriteString(`,"cards":[`)
255 for i, n := range r.Cards() {
256 if i > 0 {
257 b.WriteByte(',')
258 }
259 b.WriteString(strconv.Itoa(n))
260 }
261 b.WriteString(`],"plans":[`)
262 for i, p := range r.Plans {
263 if i > 0 {
264 b.WriteByte(',')
265 }
266 b.WriteString(quote(p))
267 }
268 b.WriteString(`],"events":[`)
269 for i, e := range r.Events() {
270 if i > 0 {
271 b.WriteByte(',')
272 }
273 writeEvent(b, e)
274 }
275 b.WriteString(`],"start":[`)
276 for i, c := range r.Start() {
277 if i > 0 {
278 b.WriteByte(',')
279 }
280 b.WriteString(`{"hex":` + quote(c.Hex))
281 b.WriteString(`,"facing":` + strconv.Itoa(int(c.Facing)))
282 b.WriteString(`,"down":` + strconv.FormatBool(c.Down))
283 b.WriteString(`,"delay":` + strconv.Itoa(c.Delay))
284 b.WriteString(`,"status":` + quote(c.Status.String()))
285 writeWounds(b, c)
286 writeGuns(b, c.Guns)
287 b.WriteByte('}')
288 }
289 b.WriteByte(']')
290 writeGround(b, r.Ground())
291 b.WriteByte('}')
292}
293
294func writeEvent(b *strings.Builder, e engine.Event) {
295 b.WriteString(`{"seg":` + strconv.Itoa(e.Seg))
296 b.WriteString(`,"p":` + strconv.Itoa(e.Player))
297 b.WriteString(`,"kind":` + quote(e.Kind))
298 b.WriteString(`,"action":` + quote(string(e.Action)))
299 b.WriteString(`,"from":` + quote(e.From))
300 b.WriteString(`,"to":` + quote(e.To))
301 b.WriteString(`,"facing":` + strconv.Itoa(int(e.Facing)))
302 b.WriteString(`,"down":` + strconv.FormatBool(e.Down))
303 b.WriteString(`,"n":` + strconv.Itoa(e.N))
304 b.WriteString(`,"delay":` + strconv.Itoa(e.Delay))
305 b.WriteString(`,"reason":` + quote(e.Reason))
306 b.WriteString(`,"card":` + strconv.Itoa(e.Card))
307 b.WriteString(`,"result":` + quote(e.Result))
308 b.WriteString(`,"endurance":` + strconv.Itoa(e.Endurance))
309 b.WriteString(`,"target":` + strconv.Itoa(e.Target))
310 b.WriteString(`,"hit":` + quote(e.Hit))
311 b.WriteString(`,"range":` + strconv.Itoa(e.Range))
312 b.WriteString(`,"gun":` + quote(string(e.Gun)))
313 b.WriteString(`,"gunId":` + strconv.Itoa(e.GunID))
314 b.WriteString(`,"step":` + strconv.Itoa(e.Step))
315 b.WriteString(`,"chart":` + quote(e.Chart))
316 b.WriteString(`,"status":` + quote(e.Status))
317 b.WriteByte('}')
318}
319
320// GamesJSON lists, for an address, the games with a free seat, the games
321// being played (live: planning or revealing, for anyone to watch), the
322// last endedLimit games finished (ended, for anyone to replay) and the
323// games it plays or played, newest first (at most 50 each), with the
324// address's registered name ("" if none). live and ended are found among
325// the liveScan newest games, a running game being recent: a turn times
326// out within days.
327//
328// {"name","open":[summary...],"live":[summary...],"ended":[summary...],"mine":[summary...]}
329// summary = {"id","rev","phase","turn","maxTurns","timeout","winner","endReason","vp","stake","pot","payouts","houseCut","createdAt","updatedAt","timeoutAt","lockRound","players":[{"addr","name","char","status","committed","revealed"}]}
330//
331// lockRound (0 unless planning or revealing) tells the lobby when the
332// timelocked plans of a seat committed and not revealed can be opened.
333//
334// players has one entry per seat, "addr" "" for a seat nobody took yet.
335func GamesJSON(addr string) string {
336 var b strings.Builder
337 b.WriteString(`{"name":` + quote(profiles.Name(address(addr))))
338 b.WriteString(`,"open":[`)
339 n := 0
340 openGames.ReverseIterate("", "", func(_ string, v any) bool {
341 if n > 0 {
342 b.WriteByte(',')
343 }
344 writeSummary(&b, v.(*Game))
345 n++
346 return n >= listLimit
347 })
348 var live, ended strings.Builder
349 nLive, nEnded, scanned := 0, 0, 0
350 games.ReverseIterate("", "", func(_ string, v any) bool {
351 scanned++
352 g := v.(*Game)
353 switch {
354 case (g.Phase == PhasePlanning || g.Phase == PhaseRevealing) && nLive < listLimit:
355 if nLive > 0 {
356 live.WriteByte(',')
357 }
358 writeSummary(&live, g)
359 nLive++
360 case g.Phase == PhaseFinished && nEnded < endedLimit:
361 if nEnded > 0 {
362 ended.WriteByte(',')
363 }
364 writeSummary(&ended, g)
365 nEnded++
366 }
367 return (nLive >= listLimit && nEnded >= endedLimit) || scanned >= liveScan
368 })
369 b.WriteString(`],"live":[` + live.String() + `],"ended":[` + ended.String() + `],"mine":[`)
370 if v := byPlayer.Get(addr); v != nil {
371 for i, id := range v.([]string) {
372 if i >= listLimit {
373 break
374 }
375 if i > 0 {
376 b.WriteByte(',')
377 }
378 writeSummary(&b, games.Get(id).(*Game))
379 }
380 }
381 b.WriteString("]}")
382 return b.String()
383}
384
385func writeSummary(b *strings.Builder, g *Game) {
386 b.WriteString(`{"id":` + quote(g.ID))
387 b.WriteString(`,"rev":` + strconv.Itoa(g.Rev))
388 b.WriteString(`,"phase":` + quote(g.Phase.String()))
389 b.WriteString(`,"turn":` + strconv.Itoa(g.Turn))
390 b.WriteString(`,"maxTurns":` + strconv.Itoa(g.MaxTurns))
391 b.WriteString(`,"timeout":` + strconv.FormatInt(int64(g.Timeout/time.Second), 10))
392 b.WriteString(`,"winner":` + strconv.Itoa(g.Winner))
393 writeVP(b, g)
394 writeStakes(b, g)
395 b.WriteString(`,"endReason":` + quote(g.EndReason))
396 b.WriteString(`,"createdAt":` + strconv.FormatInt(g.CreatedAt.Unix(), 10))
397 b.WriteString(`,"updatedAt":` + strconv.FormatInt(g.UpdatedAt.Unix(), 10))
398 writeTimeoutAt(b, g)
399 lockRound := int64(0)
400 if g.Phase == PhasePlanning || g.Phase == PhaseRevealing {
401 lockRound = g.lockRound()
402 }
403 b.WriteString(`,"lockRound":` + strconv.FormatInt(lockRound, 10))
404 b.WriteString(`,"players":[`)
405 for i, p := range g.Players {
406 if i > 0 {
407 b.WriteByte(',')
408 }
409 b.WriteString(`{"addr":` + quote(p.Addr.String()) + `,"name":` + quote(profiles.Name(p.Addr)) + `,"char":` + quote(p.Char) +
410 `,"status":` + quote(g.State.Chars[i].Status.String()) + `,"committed":` + strconv.FormatBool(g.committed(i)) + `,"revealed":` + strconv.FormatBool(g.Revealed[i]) + `}`)
411 }
412 b.WriteString("]}")
413}
414
415// writeVP writes the victory points of each seat.
416func writeVP(b *strings.Builder, g *Game) {
417 b.WriteString(`,"vp":[`)
418 for i, v := range g.VP {
419 if i > 0 {
420 b.WriteByte(',')
421 }
422 b.WriteString(strconv.Itoa(v))
423 }
424 b.WriteByte(']')
425}
426
427// writeStakes writes the stake, the pot and, once paid, what each seat got.
428func writeStakes(b *strings.Builder, g *Game) {
429 b.WriteString(`,"stake":` + strconv.FormatInt(g.Stake, 10))
430 b.WriteString(`,"pot":` + strconv.FormatInt(g.Pot, 10))
431 b.WriteString(`,"payouts":[`)
432 for i, v := range g.Payouts {
433 if i > 0 {
434 b.WriteByte(',')
435 }
436 b.WriteString(strconv.FormatInt(v, 10))
437 }
438 b.WriteString(`],"houseCut":` + strconv.FormatInt(g.HouseCut, 10))
439}
440
441// quote encodes s as a JSON string.
442func quote(s string) string {
443 var b strings.Builder
444 b.WriteByte('"')
445 for i := 0; i < len(s); i++ {
446 c := s[i]
447 switch {
448 case c == '"' || c == '\\':
449 b.WriteByte('\\')
450 b.WriteByte(c)
451 case c == '\n':
452 b.WriteString(`\n`)
453 case c == '\r':
454 b.WriteString(`\r`)
455 case c == '\t':
456 b.WriteString(`\t`)
457 case c < 0x20:
458 b.WriteString(`\u00`)
459 b.WriteByte("0123456789abcdef"[c>>4])
460 b.WriteByte("0123456789abcdef"[c&0xf])
461 default:
462 b.WriteByte(c)
463 }
464 }
465 b.WriteByte('"')
466 return b.String()
467}
468
469// GameRev is the cheapest way to poll a game for changes.
470func GameRev(id string) int { return getGame(id).Rev }