Transaction
DD4895EE775FFF…BD44EDC710D4
Block 677,885 · index 0 · indexed
Summary
- Hash
- DD4895EE775FFF2B67DBB695842832E81AC0E4882605C1ACCCB0BD44EDC710D4
- Block
- 677,885
- Size
- 61667 bytes
- Gas used
- 80,353,190 / 200,000,000
- Fee
- 200000ugnot
- Status
- success
Messages
- Attached funds
- 40000000ugnot
Arguments · 24
- #1arena
- #2arena.gno
- #3package arena import ( "strconv" "time" "gno.land/p/nt/bptree/v0" ) // The whole state of the realm. Everything is unexported and nothing below is // ever returned by reference: the trees are /p/ types with mutator methods, // so handing one out would be handing out write access to the realm. var ( version int64 // bumps on every admin write, see Version // Arenas exist from the first paid entry until their game is over, or // their lobby is empty. An arena nobody paid into is never stored. arenas bptree.BPTree // arena id -> *arena, with its lobby and its round // The last games played, for readers: their result, by arena id, and the // ids in the order they closed, oldest first. results bptree.BPTree // arena id -> encoded result, see results.gno closed []string refundDelay int64 // seconds after a game's time limit before anyone may refund it escrowed int64 // ugnot owed to players: the stakes of every lobby and running game, and their unclaimed winnings // Winnings not claimed yet, see claims.gno. claims bptree.BPTree // player address -> encoded credits // What players won, see earnings.gno. earnings bptree.BPTree // player address -> encoded totals, allocated on a first settled game winners ranking // the top players by winnings webPath string // "/r/g1t2kg2vtr3fukg43eujkn6x53gfdyakhngt4sfd/gnofly/arena/v0", used to build links in Render ) const defaultRefundDelay = 600 // seconds // init runs once, when the package is added to the chain. Unlike the game // realm this one has nothing to remember about its deployer: it has no role // of its own. func init(cur realm) { bootstrap(cur.PkgPath()) } // bootstrap (re)creates the initial state. The tests call it to start from a // clean slate. func bootstrap(pkgPath string) { arenas = bptree.BPTree{} results = bptree.BPTree{} closed = nil refundDelay = defaultRefundDelay escrowed = 0 claims = bptree.BPTree{} earnings = bptree.BPTree{} winners = ranking{} webPath = webPathOf(pkgPath) version = 1 } // Version returns a counter that bumps on every admin write: the refund // delay. Player and referee actions do not bump it. func Version() int64 { return version } // now returns the block time in Unix seconds. func now() int64 { return time.Now().Unix() } // webPathOf turns "gno.land/r/..." into "/r/...". func webPathOf(pkgPath string) string { for i := 0; i < len(pkgPath); i++ { if pkgPath[i] == '/' { return pkgPath[i:] } } return "" } func itoa(n int64) string { return strconv.FormatInt(n, 10) }
- #4claims.gno
- #5package arena import ( "chain" "strings" ) // Winnings are not sent when a game settles. They are credited to each // winner, stay in the escrow, and the winner takes them with Claim, every // game at once. A settlement then moves no coin to a player, and a player // who never comes back costs nobody anything. // // Refunds (Leave, CancelRound, RefundRound, ExpireLobby) still send the // stakes back straight away: a refund is not a win. // claimCredit is what one game owes one player. type claimCredit struct { id string amount int64 } // The credits of a player are stored as one string, oldest first: // // <arena id>:<amount>,<arena id>:<amount> func creditsOf(addr address) []claimCredit { v := claims.Get(addr.String()) if v == nil { return nil } var out []claimCredit for _, entry := range strings.Split(v.(string), ",") { colon := indexFrom(entry, 0, ':') if colon == 0 || colon == len(entry) { panic(errCorruptClaims) } amount, ok := digits(entry[colon+1:]) if !ok || amount <= 0 { panic(errCorruptClaims) } out = append(out, claimCredit{id: entry[:colon], amount: amount}) } return out } // credit owes amount to a player for the game of an arena. The coins are // already in the realm: they stay in the escrow until claimed. func credit(addr address, id string, amount int64) { if amount <= 0 { return } entry := id + ":" + itoa(amount) if v := claims.Get(addr.String()); v != nil { entry = v.(string) + "," + entry } claims.Set(addr.String(), entry) hold(amount) } // Claim sends the caller everything the realm owes them from settled // games, and returns the amount in ugnot. It panics when there is nothing // to claim. func Claim(cur realm) int64 { player := callerOf(0, cur) list := creditsOf(player) if len(list) == 0 { panic(errNothingToClaim) } total := int64(0) for _, c := range list { total += c.amount } // State first: nothing is owed any more before the coins leave. claims.Remove(player.String()) release(total) pay(0, cur, player, total) assertSolvent(0, cur) chain.Emit("Claimed", "player", player.String(), "amount", itoa(total), "games", itoa(int64(len(list)))) return total } // Claimable returns what a player can claim, in ugnot. func Claimable(addr address) int64 { total := int64(0) for _, c := range creditsOf(addr) { total += c.amount } return total } // ClaimsJSON returns what a player can claim, game by game, oldest first: // // { "address": "g1...", "total": 19000000, "games": [ { "id": "...", "amount": 19000000 } ] } // // The output has no whitespace. An address with nothing to claim returns // the same shape with a zero total and no game. func ClaimsJSON(addr address) string { list := creditsOf(addr) var b strings.Builder total := int64(0) for _, c := range list { total += c.amount } b.WriteString(`{"address":` + quote(addr.String()) + `,"total":` + itoa(total) + `,"games":[`) for i, c := range list { if i > 0 { b.WriteByte(',') } b.WriteString(`{"id":"` + c.id + `","amount":` + itoa(c.amount) + `}`) } b.WriteString("]}") return b.String() }
- #6doc.gno
- #7// v0 - Unaudited: This is an initial version that has not yet been formally audited. // Use in production at your own risk. // // Package arena holds the paid games of gnofly, a multiplayer plane game. A // player pays an entry price in ugnot to join a game, the realm keeps the // stakes until the game is over, then credits them by a rule it applies // itself. Winners take what they are owed with Claim. // // Games are created off chain, by the game server at a player's request, and // described by a spec (see spec.gno): mode, entry price, seats, lives, game // time and kill target. The realm only stores a game once someone paid for // it, and forgets it when it is over or its lobby empties: no game nobody // played is ever left on chain. // // Four kinds of callers write to it: // // - players enter a game with their stake attached, the first one handing // the realm its spec, and may leave again until it starts (Enter, Leave); // - referees, the game servers, start a game with the players of its lobby, // and report who died by whose hand when it is over (StartRound, // SettleRound, CancelRound); // - anyone can send the stakes of a game back to its players once the // referee is long overdue, and close a lobby whose game never started an // hour after it was created (RefundRound, ExpireLobby); // - the admins of the game realm set the refund delay (SetRefundDelay). // // The realm keeps no roles of its own: the owner, the admins, the referees // and the treasury are those of gno.land/r/g1t2kg2vtr3fukg43eujkn6x53gfdyakhngt4sfd/gnofly/game/v0, asked at // the time of each call. // // The referee is trusted for facts only: who died, and by whom. What these // facts are worth is decided by a rule of // gno.land/p/g1t2kg2vtr3fukg43eujkn6x53gfdyakhngt4sfd/gnofly/arena/payout/v0, picked by the mode of the game, // and the realm checks what the rule returns before it sends anything. // // # Money // // The realm owes exactly the stakes of its lobbies and of its running // games, and the winnings not claimed yet, and counts them in one number, // see Escrowed. Every coin that leaves // goes to a player of the game it came from, or to the treasury of the game // realm: // // Enter +stake the envelope of the call stays in the realm // Leave -stake back to the player // ExpireLobby -stakes back to the players of the lobby // StartRound 0 stakes move from the lobby to the game // SettleRound -fee the fee leaves, the rest is credited to the winners // Claim -owed everything credited to the caller, every game at once // CancelRound -pot back to the players of the game // RefundRound -pot back to the players of the game // // A game is closed by exactly one of SettleRound, CancelRound and // RefundRound, once. // // What a settlement does not pay to the players is the house's cut, a fee of // 5% sent to the treasury of the game realm, rounding included. // // Reads are plain functions meant for vm/qeval. The ones ending in JSON // return a single string holding a JSON document. Render exposes the same // data as markdown for gnoweb. package arena
- #8earnings.gno
- #9package arena import "gno.land/p/g1t2kg2vtr3fukg43eujkn6x53gfdyakhngt4sfd/gnofly/arena/payout/v0" // Earnings are what players won, added up round after round. Like the // results they are a record for readers: no payment is ever computed from // them. // // For one settled round a player's winnings are what they were paid above // their own stake, and never less than zero: losing a stake does not take // anything back from what was won before. Cancelled and refunded rounds // count for nothing, nobody won or lost in them. // topSize is how many players the list of top winners holds. const topSize = 10 // totals is what the realm remembers about one player. type totals struct { won int64 // ugnot won above the stakes, over every settled round rounds int64 // settled rounds played } // Totals are stored as a single string, "won:rounds", for the same reason // as the pilots of the game realm: a struct would be one more stored object // per player, and the referee pays for up to 16 new players per settlement. // encode returns the stored form of t. func (t totals) encode() string { return itoa(t.won) + ":" + itoa(t.rounds) } // totalsOf returns the totals of a player, zero for a player who never // finished a round. func totalsOf(addr address) totals { v := earnings.Get(addr.String()) if v == nil { return totals{} } s := v.(string) colon := indexFrom(s, 0, ':') if colon == len(s) { panic(errCorruptEarnings) } won, ok := digits(s[:colon]) if !ok { panic(errCorruptEarnings) } rounds, ok := digits(s[colon+1:]) if !ok { panic(errCorruptEarnings) } return totals{won: won, rounds: rounds} } // creditRound records a settled round for each of its players: one more // round played, and what they were paid above their stake, if anything. func creditRound(stake int64, players []address, payouts []payout.Payout) { for _, p := range players { gain := int64(0) for _, paid := range payouts { if paid.Address == p && paid.Amount > stake { gain = paid.Amount - stake } } t := totalsOf(p) before := t.won t.won += gain t.rounds++ earnings.Set(p.String(), t.encode()) if gain > 0 { if next, changed := winners.raise(p, before, t.won, topSize); changed { winners = next } } } } // ranking is the list of top winners: the players who won the most, best // first, at most topSize lines and one line per player. With equal winnings, // the player who got there first stays ahead. // // It is the ranking of the game realm. It is kept sorted at write time, so // reading it never has to look at every player. Winnings only going up is // what makes that exact: a player outside the list can only enter it through // raise. // // The two slices are parallel: slices of plain values are one stored object // each, where a slice of structs would be one object per line. type ranking struct { addrs []address scores []int64 } // raise puts a player at their place after their score went up from old to // score. It returns the ranking to store, and changed == false when it is // left as it is: the player is not on a full list and still not good enough // to get on. func (r ranking) raise(addr address, old, score int64, size int) (next ranking, changed bool) { from := r.lineOf(addr, old) to := r.search(score - 1) // after every line with this score or more if from < 0 && to >= size { return r, false } if from >= 0 && to > from { to = from // cannot happen: a score only goes up } next = ranking{ addrs: spliceAddrs(r.addrs, from, to, addr, size), scores: spliceInts(r.scores, from, to, score, size), } return next, true } // search returns the index of the first line with a score of limit or less, // the number of lines when there is none. The list is sorted, so this is a // binary search. func (r ranking) search(limit int64) int { lo, hi := 0, len(r.scores) for lo < hi { mid := (lo + hi) / 2 if r.scores[mid] <= limit { hi = mid } else { lo = mid + 1 } } return lo } // lineOf returns the index of a player's line, -1 when they have none. old // is the player's score before the change being applied. // // A line always carries the current score of its player, because every // change of a score goes through raise. So the line can only be among those // with exactly old. A player whose score was zero has never been through // raise and has no line. func (r ranking) lineOf(addr address, old int64) int { if old <= 0 { return -1 } for i := r.search(old); i < len(r.scores) && r.scores[i] == old; i++ { if r.addrs[i] == addr { return i } } return -1 } // spliceAddrs returns a copy of a ranking column with v inserted at index // to. With from >= 0 the element at that index, the player's previous line, // is left out; to must not be past it. With from < 0 the result is kept to // size elements by dropping the tail; to must be below size. // // The copy has exactly the length of its content, so that nothing else stays // in storage. func spliceAddrs(list []address, from, to int, v address, size int) []address { if from < 0 { if len(list) >= size { list = list[:size-1] } out := make([]address, 0, len(list)+1) out = append(out, list[:to]...) out = append(out, v) return append(out, list[to:]...) } out := make([]address, 0, len(list)) out = append(out, list[:to]...) out = append(out, v) out = append(out, list[to:from]...) return append(out, list[from+1:]...) } // spliceInts is spliceAddrs for a column of numbers. func spliceInts(list []int64, from, to int, v int64, size int) []int64 { if from < 0 { if len(list) >= size { list = list[:size-1] } out := make([]int64, 0, len(list)+1) out = append(out, list[:to]...) out = append(out, v) return append(out, list[to:]...) } out := make([]int64, 0, len(list)) out = append(out, list[:to]...) out = append(out, v) out = append(out, list[to:from]...) return append(out, list[from+1:]...) }
- #10errors.gno
- #11package arena import "errors" // Errors are values created once, at package init: a panic with one of them // costs no allocation. Messages that need a value are built at the call. var ( errSpoofedRealm = errors.New("gnofly: spoofed realm") errNotOwner = errors.New("gnofly: caller is not the owner") errNotAdmin = errors.New("gnofly: caller is not an admin") errNotReferee = errors.New("gnofly: caller is not a referee") errInvalidSpec = errors.New("gnofly: invalid arena spec") errInvalidMode = errors.New("gnofly: mode must be one of: battle-royale, deathmatch") errInvalidStake = errors.New("gnofly: stake must be between 1000000 and 1000000000 ugnot") errInvalidSeats = errors.New("gnofly: seats must satisfy 2 <= minSeats <= maxSeats <= 8") errInvalidLives = errors.New("gnofly: lives must be between 1 and 3") errInvalidSeconds = errors.New("gnofly: game time must be between 300 and 1800 seconds") errInvalidKillTarget = errors.New("gnofly: the kill target must be between 0 and 20") errRoyaleTarget = errors.New("gnofly: battle-royale has no kill target: the last one flying wins") errInvalidAddress = errors.New("gnofly: invalid address") errInvalidRefundDelay = errors.New("gnofly: refund delay must be between 60 and 86400 seconds") errUnknownArena = errors.New("gnofly: unknown arena") errNotUserCall = errors.New("gnofly: Enter must be called directly by a user (maketx call)") errEntryClosed = errors.New("gnofly: this arena no longer takes entries") errNotYetCreated = errors.New("gnofly: this arena was created in the future") errInvalidTicket = errors.New("gnofly: ticket must be 64 lowercase hex characters") errAlreadyInLobby = errors.New("gnofly: already in the lobby of this arena") errLobbyFull = errors.New("gnofly: the lobby is full") errNotInLobby = errors.New("gnofly: not in the lobby of this arena") errNotExpired = errors.New("gnofly: this arena still takes entries") errRoundRunning = errors.New("gnofly: the game of this arena has started") errTooFewPlayers = errors.New("gnofly: fewer players than the arena requires") errNoRound = errors.New("gnofly: the game of this arena is not running") errRefundTooEarly = errors.New("gnofly: the game cannot be refunded yet") errBadSettlement = errors.New("gnofly: settlement refused: the rule pays someone who did not play, twice, or nothing") errNotConserved = errors.New("gnofly: settlement refused: payouts and fee do not add up to the pot") errEscrow = errors.New("gnofly: escrow accounting error") errInsolvent = errors.New("gnofly: the realm holds less than the stakes it owes") errNothingToClaim = errors.New("gnofly: nothing to claim") errCorruptClaims = errors.New("gnofly: corrupt claims record") errCorruptResult = errors.New("gnofly: corrupt result record") errCorruptEarnings = errors.New("gnofly: corrupt earnings record") )
- #12escrow.gno
- #13package arena import ( "chain" "chain/banker" ) // The money of the realm goes through the four functions below and nowhere // else. // // escrowed is what the realm owes: the stakes of every lobby and of every // running round, in ugnot. It goes up in hold, when a player enters, and // down in release, before coins leave. The balance of the realm's address is // what it has. The rule of this file is // // balance >= escrowed, and balance == escrowed unless someone gave coins away // // Coins can only come in through Enter: the chain refuses a call that // attaches coins to a function that does not read them. Someone can still // send coins straight to the realm's address with a bank transfer. Such // coins are owed to nobody, no function spends them, and they stay there. // // Every function that moves money ends with assertSolvent. If a mistake in // this realm ever made a path send more than it released, the realm would be // paying one player with the stake of another: the check turns that into an // aborted transaction, which takes the payments back with it. // payDenom is the only coin the realm deals in. const payDenom = "ugnot" // hold records that the realm received amount in stakes. func hold(amount int64) { if amount <= 0 { panic(errEscrow) } escrowed += amount } // release takes amount out of what the realm owes. It is called before the // coins are sent, and it panics rather than let the counter go below zero. func release(amount int64) { if amount <= 0 || amount > escrowed { panic(errEscrow) } escrowed -= amount } // pay sends amount ugnot from the realm's own balance to an address. // // A RealmSend banker can spend everything the realm holds, which is why it // is built here and only here, from the live cur of the crossing function // (rlm), and never kept. The bank aborts the transaction if the realm does // not have the coins. func pay(_ int, rlm realm, to address, amount int64) { if amount <= 0 { panic(errEscrow) } coins := chain.NewCoins(chain.NewCoin(payDenom, amount)) banker.NewBanker(banker.BankerTypeRealmSend, rlm).SendCoins(rlm.Address(), to, coins) } // assertSolvent panics unless the realm holds at least what it owes. func assertSolvent(_ int, rlm realm) { if banker.NewReadonlyBanker().GetCoin(rlm.Address(), payDenom) < escrowed { panic(errInsolvent) } } // Escrowed returns what the realm owes to players, in ugnot: the stakes of // every lobby and of every running round. The balance of the realm's // address is never below it. func Escrowed() int64 { return escrowed }
- #14gnomod.toml
- #15module = "gno.land/r/g1t2kg2vtr3fukg43eujkn6x53gfdyakhngt4sfd/gnofly/arena/v0" gno = "0.9"
- #16json.gno
- #17package arena import "strings" // The JSON documents are built by hand. That is safe because every string // that reaches them was validated against a JSON-safe charset when it was // written: ids are hex, modes and nonces [a-z0-9-], tickets hex, addresses // bech32. The one string that is not under the realm's control, an address // typed into a query, goes through quote. // // Ordering is deterministic everywhere: arenas follow their id (the tree // iterates in key order), a lobby and a round the order of entry, results // are newest first and earnings best first. // ArenasJSON returns every arena someone paid into and whose game is not // over, as a JSON array sorted by id: // // [ // { "id": "3f2a...", "mode": "deathmatch", "stake": 10000000, // "minSeats": 2, "maxSeats": 8, "lives": 3, "seconds": 900, "killTarget": 5, // "creator": "g1...", "created": 1790950000, "nonce": "k3x9", // "lobby": [ { "address": "g1...", "ticket": "9f86d0...00a08" } ], // "round": { "startedAt": 1790950300, "players": [ "g1...", "g1..." ] } } // ] // // The output has no whitespace. "round" is null until the game starts. // The fields from "mode" to "nonce" are the arena's spec: the game server // rebuilds the spec string, and so the id, from them. func ArenasJSON() string { var b strings.Builder b.WriteByte('[') first := true arenas.Iterate("", "", func(id string, v any) bool { if !first { b.WriteByte(',') } first = false writeArena(&b, id, v.(*arena)) return false }) b.WriteByte(']') return b.String() } // ArenaJSON returns one arena in the shape of ArenasJSON, or null. func ArenaJSON(id string) string { a := getArena(id) if a == nil { return "null" } var b strings.Builder writeArena(&b, id, a) return b.String() } func writeArena(b *strings.Builder, id string, a *arena) { b.WriteString(`{"id":"` + id + `","mode":"` + a.mode + `"`) for _, f := range []struct { k string v int64 }{{"stake", a.stake}, {"minSeats", a.minSeats}, {"maxSeats", a.maxSeats}, {"lives", a.lives}, {"seconds", a.seconds}, {"killTarget", a.killTarget}} { b.WriteString(`,"` + f.k + `":` + itoa(f.v)) } b.WriteString(`,"creator":"` + a.creator.String() + `","created":` + itoa(a.created) + `,"nonce":"` + a.nonce + `"`) b.WriteString(`,"lobby":[`) for i, p := range a.lobby { if i > 0 { b.WriteByte(',') } b.WriteString(`{"address":"` + p.String() + `","ticket":"` + a.tickets[i] + `"}`) } b.WriteString(`],"round":`) if a.round == nil { b.WriteString("null}") return } b.WriteString(`{"startedAt":` + itoa(a.round.startedAt) + `,"players":[`) for i, p := range a.round.players { if i > 0 { b.WriteByte(',') } b.WriteString(`"` + p.String() + `"`) } b.WriteString("]}}") } // ResultsJSON returns the last closed games, newest first, at most 50: // // [ { "id": "3f2a...", "mode": "deathmatch", "stake": 10000000, // "outcome": "settled", "players": 2, "pot": 20000000, "fee": 500000, // "closedAt": 1790951000, // "payouts": [ { "address": "g1...", "amount": 19500000 } ] } ] func ResultsJSON() string { var b strings.Builder b.WriteByte('[') for i := len(closed) - 1; i >= 0; i-- { if i < len(closed)-1 { b.WriteByte(',') } r, _ := resultOf(closed[i]) writeResult(&b, closed[i], r) } b.WriteByte(']') return b.String() } // ResultJSON returns the result of one closed game in the shape of // ResultsJSON, or null when the realm does not have it (still running, or // too old). func ResultJSON(id string) string { r, ok := resultOf(id) if !ok { return "null" } var b strings.Builder writeResult(&b, id, r) return b.String() } func writeResult(b *strings.Builder, id string, r result) { b.WriteString(`{"id":"` + id + `","mode":"` + r.mode + `","stake":` + itoa(r.stake) + `,"outcome":"` + r.outcome + `","players":` + itoa(r.players) + `,"pot":` + itoa(r.pot) + `,"fee":` + itoa(r.fee) + `,"closedAt":` + itoa(r.closedAt) + `,"payouts":[`) for i, p := range r.payouts { if i > 0 { b.WriteByte(',') } b.WriteString(`{"address":"` + p.Address.String() + `","amount":` + itoa(p.Amount) + `}`) } b.WriteString("]}") } // EarningsJSON returns the top winners as a JSON array, best first, at most // 10 entries: // // [ { "address": "g1...", "won": 19000000, "rounds": 3 } ] // // The output has no whitespace. "won" adds up, round after round, what the // player was paid above their own stake, never less than zero for a round. // "rounds" is how many settled rounds they played. With equal winnings, the // player who got there first stays ahead. func EarningsJSON() string { var b strings.Builder b.WriteByte('[') for i, addr := range winners.addrs { if i > 0 { b.WriteByte(',') } writeTotals(&b, `"`+addr.String()+`"`, totalsOf(addr)) } b.WriteByte(']') return b.String() } // PlayerJSON returns one player's totals as a JSON object: // // { "address": "g1...", "won": 19000000, "rounds": 3 } // // The output has no whitespace. An unknown address returns the same shape // with zero won and zero rounds. func PlayerJSON(addr address) string { var b strings.Builder writeTotals(&b, quote(addr.String()), totalsOf(addr)) return b.String() } // writeTotals writes the totals of a player. quoted is the player's address // as a JSON string literal, quotes included. func writeTotals(b *strings.Builder, quoted string, t totals) { b.WriteString(`{"address":`) b.WriteString(quoted) b.WriteString(`,"won":`) b.WriteString(itoa(t.won)) b.WriteString(`,"rounds":`) b.WriteString(itoa(t.rounds)) b.WriteByte('}') } // quote returns s as a JSON string literal, quotes included. It is for the // strings the realm did not validate. Quotes and backslashes are escaped and // anything outside printable ASCII is replaced by "?", so the result is // valid JSON whatever s holds. func quote(s string) string { var b strings.Builder b.WriteByte('"') for i := 0; i < len(s); i++ { c := s[i] switch { case c == '"' || c == '\\': b.WriteByte('\\') b.WriteByte(c) case c < 0x20 || c > 0x7e: b.WriteByte('?') default: b.WriteByte(c) } } b.WriteByte('"') return b.String() }
- #18lobby.gno
- #19package arena import ( "chain" // Only for OriginSend, in Enter. Caller identity always comes from // cur.Previous(), never from this package. "chain/runtime/unsafe" ) // arena is one game: its spec, the players who paid and wait, and once it // started, its round. It is stored from the first paid entry and deleted // when its round is closed or its lobby empties. type arena struct { spec lobby []address tickets []string // SHA-256 of each player's secret, 64 hex characters round *round // nil until the game starts } func getArena(id string) *arena { v := arenas.Get(id) if v == nil { return nil } return v.(*arena) } func mustArena(id string) *arena { a := getArena(id) if a == nil { panic(errUnknownArena) } return a } // Enter puts the caller in the lobby of the arena that spec describes, and // returns its id. The exact stake must be attached to the transaction // (-send "<stake>ugnot") and stays in the realm until the player leaves or // the game is over. // // The first entry creates the arena, the next ones join it: the realm // recomputes the id from the spec every time. An arena takes entries for an // hour after it was created, and until its game starts. // // ticket is 64 lowercase hex characters, the SHA-256 of a secret the // player's client made up: the game server gives the seat to whoever shows // the secret. // // It must be called directly by a user (maketx call): a call relayed by // another realm or by a maketx run script is refused. func Enter(cur realm, spec string, ticket string) string { player := callerOf(0, cur) // Paid-call guard: two checks that are only correct together. // // (a) The caller is a plain user account. Only then is the -send // envelope guaranteed to have landed at this realm's address. A // relaying realm or a maketx run script can attach coins to the // transaction and keep them, while OriginSend still reports them. if !cur.Previous().IsUserCall() { panic(errNotUserCall) } sp := parseSpec(spec) id := idOf(spec) t := now() if t > sp.created+entryWindow { panic(errEntryClosed) } if sp.created > t+clockSkew { panic(errNotYetCreated) } if !validTicket(ticket) { panic(errInvalidTicket) } a := getArena(id) if a == nil { a = &arena{spec: sp} } if a.round != nil { panic(errRoundRunning) } if indexOfAddr(a.lobby, player) >= 0 { panic(errAlreadyInLobby) } if int64(len(a.lobby)) >= a.maxSeats { panic(errLobbyFull) } // (b) The envelope is exactly the stake: one coin, the right denom, the // right amount. Anything else would either underpay or leave coins // in the realm that it owes to nobody. sent := unsafe.OriginSend() if len(sent) != 1 || sent[0].Denom != payDenom || sent[0].Amount != a.stake { panic("gnofly: wrong payment, send exactly " + itoa(a.stake) + payDenom) } // The coins are already at the realm's address: the chain moved them // before the call. All that is left is to write down who they belong to. a.lobby = withAddr(a.lobby, player) a.tickets = withString(a.tickets, ticket) arenas.Set(id, a) hold(a.stake) // The guard above says the coins arrived. This looks at the balance. assertSolvent(0, cur) chain.Emit("Entered", "arena", id, "player", player.String(), "stake", itoa(a.stake), "ticket", ticket, "lobby", itoa(int64(len(a.lobby))), ) return id } // Leave takes the caller out of the lobby of an arena and sends their stake // back. It works until the game starts. The last player out deletes the // arena: nothing is left on chain of a game nobody played. func Leave(cur realm, id string) { player := callerOf(0, cur) a := mustArena(id) seat := indexOfAddr(a.lobby, player) if seat < 0 { panic(errNotInLobby) } a.lobby = withoutAddr(a.lobby, seat) a.tickets = withoutString(a.tickets, seat) if len(a.lobby) == 0 { arenas.Remove(id) } release(a.stake) pay(0, cur, player, a.stake) assertSolvent(0, cur) chain.Emit("Left", "arena", id, "player", player.String(), "stake", itoa(a.stake), ) } // ExpireLobby closes an arena whose game never started: every player in the // lobby gets their stake back, and the arena is deleted. Anyone can call it // once the arena stopped taking entries, an hour after it was created. func ExpireLobby(cur realm, id string) { caller := callerOf(0, cur) a := mustArena(id) if a.round != nil { panic(errRoundRunning) } if now() <= a.created+entryWindow { panic(errNotExpired) } players := a.lobby arenas.Remove(id) release(a.stake * int64(len(players))) for _, p := range players { pay(0, cur, p, a.stake) } assertSolvent(0, cur) chain.Emit("LobbyExpired", "arena", id, "players", itoa(int64(len(players))), "caller", caller.String(), ) } // The four helpers below return a new slice of exactly the length of its // content, so that nothing else stays in storage. They return nil for an // empty list. // withAddr returns list with v added at the end. func withAddr(list []address, v address) []address { out := make([]address, len(list), len(list)+1) copy(out, list) return append(out, v) } // withString returns list with v added at the end. func withString(list []string, v string) []string { out := make([]string, len(list), len(list)+1) copy(out, list) return append(out, v) } // withoutAddr returns list without the element at index at. func withoutAddr(list []address, at int) []address { if len(list) == 1 { return nil } out := make([]address, 0, len(list)-1) out = append(out, list[:at]...) return append(out, list[at+1:]...) } // withoutString returns list without the element at index at. func withoutString(list []string, at int) []string { if len(list) == 1 { return nil } out := make([]string, 0, len(list)-1) out = append(out, list[:at]...) return append(out, list[at+1:]...) }
- #20render.gno
- #21package arena import ( "strings" "time" "gno.land/p/g1t2kg2vtr3fukg43eujkn6x53gfdyakhngt4sfd/gnofly/arena/payout/v0" ) // Render returns the state of the arenas as markdown, for gnoweb: // // "" the open arenas and the last games played // "arena/<id>" one arena: its rules, lobby and game, or its result // "earnings" the top winners // // Everything printed here was validated against a narrow charset when it was // written (see spec.gno and validate.gno), or is an address the chain gave // the realm. The only caller-supplied value, the id of an arena page, is // printed only if it is a valid id. Nothing needs markdown escaping. func Render(path string) string { switch { case path == "": return renderHome() case path == "earnings": return renderEarnings() case strings.HasPrefix(path, "arena/"): id := strings.TrimPrefix(path, "arena/") if a := getArena(id); a != nil { return renderArena(id, a) } if r, ok := resultOf(id); ok { return renderResult(id, r) } } return "# gnofly arenas\n\nPage not found. Go back to the [overview](" + link("") + ").\n" } // link returns the gnoweb URL of a Render path of this realm. func link(path string) string { if path == "" { return webPath } return webPath + ":" + path } func renderHome() string { var b strings.Builder b.WriteString("# gnofly arenas\n\n") b.WriteString("Paid games of gnofly, a multiplayer plane game, created by players at [gnofly.xyz/arena](https://gnofly.xyz/arena).\n") b.WriteString("A pilot pays the entry price to join, the realm keeps the stakes until the game is over, then credits them by the rule of its mode. Winners claim what they are owed with Claim.\n") b.WriteString("The house takes " + percent(feeBps) + " of what a settled game pays out.\n\n") b.WriteString("## Open arenas\n\n") if arenas.Size() == 0 { b.WriteString("No arena has players right now.\n\n") } else { b.WriteString("| Arena | Mode | Entry | Seats | Lives | Time | Kill target | Pilots |\n|---|---|---|---|---|---|---|---|\n") arenas.Iterate("", "", func(id string, v any) bool { a := v.(*arena) pilots := itoa(int64(len(a.lobby))) + " waiting" if a.round != nil { pilots = itoa(int64(len(a.round.players))) + " playing" } b.WriteString("| [" + id[:8] + "](" + link("arena/"+id) + ") | " + a.mode + " | " + gnot(a.stake) + " | " + itoa(a.minSeats) + " to " + itoa(a.maxSeats) + " | " + itoa(a.lives) + " | " + itoa(a.seconds/60) + " min | " + targetCell(a.killTarget) + " | " + pilots + " |\n") return false }) b.WriteByte('\n') } b.WriteString("## Last games\n\n") if len(closed) == 0 { b.WriteString("No game is over yet.\n\n") } else { b.WriteString("| Arena | Mode | Outcome | Pilots | Pot | Fee |\n|---|---|---|---|---|---|\n") for i := len(closed) - 1; i >= 0 && i >= len(closed)-10; i-- { r, _ := resultOf(closed[i]) b.WriteString("| [" + closed[i][:8] + "](" + link("arena/"+closed[i]) + ") | " + r.mode + " | " + r.outcome + " | " + itoa(r.players) + " | " + gnot(r.pot) + " | " + gnot(r.fee) + " |\n") } b.WriteByte('\n') } b.WriteString("| | |\n|---|---|\n") b.WriteString("| In escrow | " + gnot(escrowed) + " |\n") b.WriteString("| Refund delay | " + itoa(refundDelay) + " s |\n\n") b.WriteString("- [Earnings](" + link("earnings") + "): " + itoa(int64(len(winners.addrs))) + " ranked pilots\n") return b.String() } func renderArena(id string, a *arena) string { var b strings.Builder b.WriteString("# Arena " + id[:8] + "\n\n") b.WriteString("Mode `" + a.mode + "`: " + modeDoc(a.mode) + "\n\n") b.WriteString("| | |\n|---|---|\n") b.WriteString("| Id | `" + id + "` |\n") b.WriteString("| Entry | " + gnot(a.stake) + " |\n") b.WriteString("| Seats | " + itoa(a.minSeats) + " to " + itoa(a.maxSeats) + " |\n") b.WriteString("| Lives | " + itoa(a.lives) + " |\n") b.WriteString("| Game time | " + itoa(a.seconds/60) + " min |\n") b.WriteString("| Kill target | " + targetCell(a.killTarget) + " |\n") b.WriteString("| Created by | " + a.creator.String() + " |\n") b.WriteString("| Takes entries until | " + clock(a.created+entryWindow) + " |\n\n") if a.round == nil { b.WriteString("## Lobby\n\n") b.WriteString("Pilots who paid and wait for the game. They can leave with `Leave` until it starts.\n\n") b.WriteString("| Seat | Pilot |\n|---|---|\n") for i, player := range a.lobby { b.WriteString("| " + itoa(int64(i+1)) + " | " + player.String() + " |\n") } } else { r := a.round b.WriteString("## Game\n\n") b.WriteString("Started at " + clock(r.startedAt) + " with " + itoa(int64(len(r.players))) + " pilots and a pot of " + gnot(a.stake*int64(len(r.players))) + ".\n") b.WriteString("If the referee does not settle it, anyone can send the stakes back with `RefundRound` from " + clock(r.startedAt+a.seconds+refundDelay) + ".\n\n") b.WriteString("| Pilot |\n|---|\n") for _, player := range r.players { b.WriteString("| " + player.String() + " |\n") } } b.WriteString("\n[Earnings](" + link("earnings") + ") | [Overview](" + link("") + ")\n") return b.String() } func renderResult(id string, r result) string { var b strings.Builder b.WriteString("# Arena " + id[:8] + "\n\n") b.WriteString("Mode `" + r.mode + "`, " + itoa(r.players) + " pilots, closed at " + clock(r.closedAt) + ": " + r.outcome + ".\n\n") b.WriteString("| | |\n|---|---|\n") b.WriteString("| Entry | " + gnot(r.stake) + " |\n") b.WriteString("| Pot | " + gnot(r.pot) + " |\n") b.WriteString("| Fee | " + gnot(r.fee) + " |\n") b.WriteString("| Paid | " + paidCell(r) + " |\n") b.WriteString("\n[Overview](" + link("") + ")\n") return b.String() } // targetCell formats a kill target: most arenas end on the clock only. func targetCell(n int64) string { if n == 0 { return "none" } return itoa(n) + " kills" } // paidCell lists who was paid what in a round, for a table cell. func paidCell(r result) string { if r.outcome != outcomeSettled { return "every stake sent back" } if len(r.payouts) == 0 { return "nobody" } parts := make([]string, len(r.payouts)) for i, p := range r.payouts { parts[i] = p.Address.String() + " " + gnot(p.Amount) } return strings.Join(parts, ", ") } func renderEarnings() string { var b strings.Builder b.WriteString("# gnofly arena earnings\n\n") b.WriteString("The top " + itoa(topSize) + " pilots by what they won above their own stakes, over every settled round.\n\n") if len(winners.addrs) == 0 { b.WriteString("Nobody won anything yet.\n") } else { b.WriteString("| Rank | Pilot | Won | Rounds |\n|---|---|---|---|\n") for i, addr := range winners.addrs { t := totalsOf(addr) b.WriteString("| " + itoa(int64(i+1)) + " | " + addr.String() + " | " + gnot(t.won) + " | " + itoa(t.rounds) + " |\n") } } b.WriteString("\n[Back to the overview](" + link("") + ")\n") return b.String() } // modeDoc is one sentence about the payout rule of a mode. func modeDoc(mode string) string { switch mode { case payout.ModeBattleRoyale: return "the pot, less the fee, goes to the last pilot flying; when the clock runs out, to the pilot still in with the most kills." case payout.ModeDeathmatch: return "the pot, less the fee, goes to the top three by kills: 60%, 30% and 10%." } return "see the payout package." } // gnot formats an amount of ugnot in GNOT, without trailing zeros: // 10000000 gives "10 GNOT", 9500000 gives "9.5 GNOT", 1 gives "0.000001 GNOT". func gnot(ugnot int64) string { whole, frac := ugnot/1000000, ugnot%1000000 if frac == 0 { return itoa(whole) + " GNOT" } decimals := itoa(1000000 + frac)[1:] // all six of them, zeros included end := len(decimals) for decimals[end-1] == '0' { end-- } return itoa(whole) + "." + decimals[:end] + " GNOT" } // percent formats basis points as a percentage, without trailing zeros: // 500 gives "5%", 250 gives "2.5%", 1 gives "0.01%". func percent(bps int64) string { whole, frac := bps/100, bps%100 switch { case frac == 0: return itoa(whole) + "%" case frac%10 == 0: return itoa(whole) + "." + itoa(frac/10) + "%" case frac < 10: return itoa(whole) + ".0" + itoa(frac) + "%" } return itoa(whole) + "." + itoa(frac) + "%" } // clock formats a block time in Unix seconds, in UTC. func clock(unix int64) string { return time.Unix(unix, 0).UTC().Format("2006-01-02 15:04:05") + " UTC" }
- #22results.gno
- #23package arena import ( "strings" "gno.land/p/g1t2kg2vtr3fukg43eujkn6x53gfdyakhngt4sfd/gnofly/arena/payout/v0" ) // result is how a game ended, kept for readers: no payment is ever computed // from it. type result struct { mode string stake int64 outcome string // settled, cancelled or refunded players int64 // how many played pot int64 // their stakes fee int64 closedAt int64 // block time, Unix seconds payouts []payout.Payout // who was paid what, in the order of the players } const ( outcomeSettled = "settled" outcomeCancelled = "cancelled" outcomeRefunded = "refunded" ) // maxResults is how many closed games the realm remembers. const maxResults = 50 // A result is stored as one string, its fields separated by "|", the payouts // last as "address:amount" separated by ",": // // mode|stake|outcome|players|pot|fee|closedAt|g1...:19500000,g1...:500000 func (r result) encode() string { var b strings.Builder b.WriteString(r.mode) for _, n := range []string{itoa(r.stake), r.outcome, itoa(r.players), itoa(r.pot), itoa(r.fee), itoa(r.closedAt)} { b.WriteByte('|') b.WriteString(n) } b.WriteByte('|') b.WriteString(joinPayouts(r.payouts)) return b.String() } func decodeResult(s string) result { f := strings.Split(s, "|") if len(f) != 8 { panic(errCorruptResult) } num := func(i int) int64 { n, ok := digits(f[i]) if !ok { panic(errCorruptResult) } return n } r := result{mode: f[0], stake: num(1), outcome: f[2], players: num(3), pot: num(4), fee: num(5), closedAt: num(6)} if r.outcome != outcomeSettled && r.outcome != outcomeCancelled && r.outcome != outcomeRefunded { panic(errCorruptResult) } if f[7] == "" { return r } for _, entry := range strings.Split(f[7], ",") { colon := indexFrom(entry, 0, ':') if colon == 0 || colon == len(entry) { panic(errCorruptResult) } amount, ok := digits(entry[colon+1:]) if !ok { panic(errCorruptResult) } r.payouts = append(r.payouts, payout.Payout{Address: address(entry[:colon]), Amount: amount}) } return r } // record keeps the result of a closed game, and forgets the oldest one past // maxResults. func record(id string, r result) { results.Set(id, r.encode()) closed = append(closed, id) if len(closed) > maxResults { results.Remove(closed[0]) next := make([]string, len(closed)-1) copy(next, closed[1:]) closed = next } } // resultOf returns the result of a closed game, if the realm still has it. func resultOf(id string) (result, bool) { v := results.Get(id) if v == nil { return result{}, false } return decodeResult(v.(string)), true } // indexFrom returns the index of the first c in s at or after i, or len(s). func indexFrom(s string, i int, c byte) int { for i < len(s) && s[i] != c { i++ } return i } // digits parses a non-negative decimal of at most 18 digits. func digits(s string) (int64, bool) { if len(s) == 0 || len(s) > 18 { return 0, false } n := int64(0) for i := 0; i < len(s); i++ { c := s[i] if c < '0' || c > '9' { return 0, false } n = n*10 + int64(c-'0') } return n, true }
- #24roles.gno
Result log
msg:0,success:true,log:,events:[]