shots.gno
11.57 Kb · 322 lines
1// Package shots is a pay-to-play penalty shootout against the chain.
2//
3// A creator opens a game by choosing its keeper and ball constants and
4// seeding the pot. A challenger joins an open game by paying its entry fee
5// into the pot, then plays a shootout: in every round they take a kick
6// (TakeShot) and then keep against the chain's kick (SubmitKeeperPlan). Five
7// kicks each, finishing early when one side cannot be caught, then sudden
8// death. Win, and the whole pot is paid out; lose, and the entry fee stays in
9// the pot for the next challenger.
10//
11// Every kick is played out in the transaction that makes it: the player's
12// choices are completed by 32 bytes derived from the block's timestamp (the
13// chain's move and the mishit), and the outcome is penalty.SimulateShot of the
14// ten inputs, recorded so anyone can re-run it. See impl/v1 for what that
15// entropy does and does not guarantee.
16//
17// This realm's path and entry points are permanent. The rules engine is an
18// implementation realm behind an upgradeable proxy
19// (gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/upgradeable/v0); the games and their pots live here
20// and survive every upgrade.
21package shots
22
23import (
24 "chain/runtime/unsafe"
25 "strconv"
26 "strings"
27
28 "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/penalty/v0"
29 "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/shootout/v0"
30 "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/upgradeable/v0"
31)
32
33// Impl is the rules engine. Declared here so every version agrees on one
34// type identity. The methods are non-crossing: this realm threads its own cur
35// in, so an implementation reaching Store is seen as this realm.
36//
37// The caller's address and the coins received are established by the entry
38// points below and passed in; an implementation cannot be invoked with forged
39// ones from elsewhere, because without this realm's live cur it cannot reach
40// the state at all.
41type Impl interface {
42 CreateGame(_ int, rlm realm, creator address, deposit, entryFee, timeoutBlocks int64, config string) uint64
43 JoinGame(_ int, rlm realm, player address, gameID uint64, paid int64)
44 TopUp(_ int, rlm realm, from address, gameID uint64, paid int64)
45 TakeShot(_ int, rlm realm, player address, gameID uint64, shot penalty.ShotInput) shootout.Kick
46 SubmitKeeperPlan(_ int, rlm realm, player address, gameID uint64, plan penalty.KeeperPlan) shootout.Kick
47 Forfeit(_ int, rlm realm, caller address, gameID uint64)
48 CancelGame(_ int, rlm realm, caller address, gameID uint64)
49
50 // SimulateShot is the pure outcome function: six taker inputs (in shot,
51 // mishit seed included) and four keeper inputs, under cfg.
52 SimulateShot(shot penalty.ShotParams, plan penalty.KeeperPlan, cfg penalty.Config) penalty.Result
53
54 // Describe names the implementation for Render.
55 Describe() string
56}
57
58// Denom is the coin pots are held in.
59const Denom = "ugnot"
60
61// Admin may accept, roll back or freeze an implementation. The value is the
62// standard test1 account so the tests run unedited; set your own address
63// before deploying (TransferAuthority moves it afterwards).
64const Admin = address("g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm")
65
66var (
67 proxy *upgradeable.Proxy
68 selfPath string
69)
70
71func init(cur realm) {
72 selfPath = cur.PkgPath()
73 proxy = upgradeable.New(upgradeable.NewAddrAuthority(Admin))
74}
75
76// ---------------------------------------------------------------- playing
77
78// CreateGame opens a game. Send the initial pot with the call (ugnot).
79// config overrides the default constants as "key=value,..." ("" for the
80// defaults; see penalty.ParseConfig). timeoutBlocks 0 picks the default.
81// Returns the game id.
82func CreateGame(cur realm, entryFee, timeoutBlocks int64, config string) uint64 {
83 paid := receivePayment(0, cur)
84 return live().CreateGame(0, cur, cur.Previous().Address(), paid, entryFee, timeoutBlocks, config)
85}
86
87// JoinGame starts a shootout in an open game. Send exactly its entry fee.
88func JoinGame(cur realm, gameID uint64) {
89 paid := receivePayment(0, cur)
90 live().JoinGame(0, cur, cur.Previous().Address(), gameID, paid)
91}
92
93// TopUp adds the coins sent to a game's pot.
94func TopUp(cur realm, gameID uint64) {
95 paid := receivePayment(0, cur)
96 live().TopUp(0, cur, cur.Previous().Address(), gameID, paid)
97}
98
99// TakeShot plays the player's kick for the current round against the chain's
100// keeper: aim point on the goal plane (mm), contact offset on the ball
101// (milli-radii) and power (per-mille). Returns the kick as JSON.
102func TakeShot(cur realm, gameID uint64, aimX, aimY, contactX, contactY, power int64) string {
103 assertFreeDirectCall(0, cur)
104 shot := penalty.ShotInput{AimX: aimX, AimY: aimY, ContactX: contactX, ContactY: contactY, Power: power}
105 return kickJSON(live().TakeShot(0, cur, cur.Previous().Address(), gameID, shot))
106}
107
108// SubmitKeeperPlan plays the player's keeper plan against the chain's kick
109// for the current round: four uint16 (see penalty.DecodePlan). Returns the
110// kick as JSON.
111func SubmitKeeperPlan(cur realm, gameID uint64, direction, speed, jump, reaction uint16) string {
112 assertFreeDirectCall(0, cur)
113 plan := penalty.KeeperPlan{Direction: direction, Speed: speed, Jump: jump, Reaction: reaction}
114 return kickJSON(live().SubmitKeeperPlan(0, cur, cur.Previous().Address(), gameID, plan))
115}
116
117// Forfeit ends the current shootout as a loss: the player may resign at any
118// time, anyone else once the player has been idle for the game's timeout.
119func Forfeit(cur realm, gameID uint64) {
120 assertFreeDirectCall(0, cur)
121 live().Forfeit(0, cur, cur.Previous().Address(), gameID)
122}
123
124// CancelGame refunds the pot to the creator of a game nobody has played yet.
125func CancelGame(cur realm, gameID uint64) {
126 assertFreeDirectCall(0, cur)
127 live().CancelGame(0, cur, cur.Previous().Address(), gameID)
128}
129
130// ---------------------------------------------------------------- reading
131
132// SimulateShot runs the live outcome function on ten inputs, without touching
133// any state: the taker's aim, contact, power and 48-hex-char mishit seed, and
134// the keeper's four uint16, under config ("" for the defaults). Returns JSON.
135func SimulateShot(aimX, aimY, contactX, contactY, power int64, mishitHex string,
136 direction, speed, jump, reaction uint16, config string) string {
137 cfg, err := penalty.ParseConfig(config)
138 if err != nil {
139 panic(err)
140 }
141 m, ok := penalty.ParseMishitHex(mishitHex)
142 if !ok {
143 panic("shots: mishit seed must be 48 hex characters")
144 }
145 shot := penalty.ShotInput{AimX: aimX, AimY: aimY, ContactX: contactX, ContactY: contactY, Power: power}.WithMishit(m)
146 plan := penalty.KeeperPlan{Direction: direction, Speed: speed, Jump: jump, Reaction: reaction}
147 return resultJSON(live().SimulateShot(shot, plan, cfg))
148}
149
150// GameJSON returns game id as JSON ("" if it does not exist).
151func GameJSON(gameID uint64) string {
152 g, ok := getGame(gameID)
153 if !ok {
154 return ""
155 }
156 return gameJSON(g)
157}
158
159// SessionJSON returns session no of a game, kicks included, as JSON.
160func SessionJSON(gameID uint64, no int) string {
161 s, ok := getSession(gameID, no)
162 if !ok {
163 return ""
164 }
165 return sessionJSON(s)
166}
167
168// ListGames returns up to limit games as a JSON array, newest first,
169// optionally filtered by status ("open", "playing", "won", "cancelled"; ""
170// for all).
171func ListGames(status string, offset, limit int) string {
172 if limit <= 0 || limit > 100 {
173 limit = 100
174 }
175 out := []string{}
176 skipped := 0
177 games.ReverseIterate("", "", func(_ string, v any) bool {
178 g := v.(*shootout.Game)
179 if status != "" && g.Status.String() != status {
180 return false
181 }
182 if skipped < offset {
183 skipped++
184 return false
185 }
186 out = append(out, gameJSON(*g))
187 return len(out) >= limit
188 })
189 return "[" + strings.Join(out, ",") + "]"
190}
191
192// VerifySession re-runs every recorded kick of a shootout through the live
193// outcome function and reports whether all of them reproduce, so anyone can
194// check a result from the ten recorded inputs alone.
195func VerifySession(gameID uint64, no int) bool {
196 g, ok := getGame(gameID)
197 if !ok {
198 return false
199 }
200 s, ok := getSession(gameID, no)
201 if !ok {
202 return false
203 }
204 im := live()
205 for _, k := range s.Kicks {
206 if im.SimulateShot(k.Shot, k.Plan, g.Config) != k.Result {
207 return false
208 }
209 }
210 return true
211}
212
213// GameCount returns how many games have been created.
214func GameCount() uint64 { return lastID }
215
216// ---------------------------------------------------------------- guards
217
218// receivePayment returns the ugnot sent with a direct user call. The coin
219// envelope is credited to this realm only when this realm is the message's
220// target, so a payment from any other call shape is refused rather than
221// trusted.
222func receivePayment(_ int, rlm realm) int64 {
223 assertDirect(0, rlm)
224 var amount int64
225 for _, c := range unsafe.OriginSend() {
226 if c.Denom != Denom {
227 panic("shots: only " + Denom + " is accepted, got " + c.Denom)
228 }
229 amount += c.Amount
230 }
231 return amount
232}
233
234// assertFreeDirectCall requires a direct user call that sends no coins.
235func assertFreeDirectCall(_ int, rlm realm) {
236 assertDirect(0, rlm)
237 if len(unsafe.OriginSend()) > 0 {
238 panic("shots: this call does not accept coins")
239 }
240}
241
242func assertDirect(_ int, rlm realm) {
243 if !rlm.IsCurrent() || !rlm.Previous().IsUserCall() {
244 panic("shots: call this directly from an account (no run scripts or other realms)")
245 }
246}
247
248// ---------------------------------------------------------------- upgrades
249
250// Register records the calling realm as a candidate implementation; an
251// implementation calls it from its own init. Nothing serves until Accept.
252func Register(cur realm, impl Impl) { proxy.Propose(0, cur, impl) }
253
254// Accept makes the candidate at pkgPath live. Authority only.
255func Accept(cur realm, pkgPath string) { proxy.Accept(0, cur, pkgPath) }
256
257// Withdraw drops a candidate. Authority only, or the candidate itself.
258func Withdraw(cur realm, pkgPath string) { proxy.Withdraw(0, cur, pkgPath) }
259
260// Rollback restores the previous implementation. Authority only.
261func Rollback(cur realm) { proxy.Rollback(0, cur) }
262
263// Forget drops the rollback history. Authority only.
264func Forget(cur realm) { proxy.Forget(0, cur) }
265
266// Freeze ends upgradeability permanently. Authority only.
267func Freeze(cur realm) { proxy.Freeze(0, cur) }
268
269// AddExtension lets another realm act as this one. Authority only.
270func AddExtension(cur realm, pkgPath string) { proxy.AddExtension(0, cur, pkgPath) }
271
272// DropExtension revokes one. Authority only.
273func DropExtension(cur realm, pkgPath string) { proxy.DropExtension(0, cur, pkgPath) }
274
275// TransferAuthority hands the power to upgrade to another address.
276func TransferAuthority(cur realm, newOwner address) {
277 proxy.TransferAuthority(0, cur, upgradeable.NewAddrAuthority(newOwner))
278}
279
280// TransferAuthorityToRealms hands it to governance realms (comma-separated).
281func TransferAuthorityToRealms(cur realm, csvPaths string) {
282 paths := []string{}
283 for _, p := range strings.Split(csvPaths, ",") {
284 paths = append(paths, strings.TrimSpace(p))
285 }
286 proxy.TransferAuthority(0, cur, upgradeable.NewRealmAuthority(paths...))
287}
288
289func LivePath() string { return proxy.LivePath() }
290func Frozen() bool { return proxy.Frozen() }
291func Authority() string { return proxy.Authority().String() }
292func Extensions() []string { return proxy.Extensions() }
293
294func PendingPaths() []string {
295 out := []string{}
296 for _, r := range proxy.Pending() {
297 out = append(out, r.PkgPath())
298 }
299 return out
300}
301
302func HistoryPaths() []string {
303 out := []string{}
304 for _, r := range proxy.History() {
305 out = append(out, r.PkgPath())
306 }
307 return out
308}
309
310func live() Impl {
311 v, ok := proxy.TryImpl()
312 if !ok {
313 panic("shots: no implementation has been accepted yet")
314 }
315 im, ok := v.(Impl)
316 if !ok {
317 panic("shots: live implementation does not satisfy Impl")
318 }
319 return im
320}
321
322func itoa(n int64) string { return strconv.FormatInt(n, 10) }