Transaction
A1EFDC02A46E13…513C720399C2
Block 556,190 · index 0 · indexed
Summary
- Hash
- A1EFDC02A46E132BF37311845360E0D62C5A898ADB246CBB1834513C720399C2
- Block
- 556,190
- Size
- 30596 bytes
- Gas used
- 40,723,095 / 200,000,000
- Fee
- 200000ugnot
- Status
- success
Messages
- Attached funds
- 25000000ugnot
Arguments · 24
- #1game
- #2admin.gno
- #3package game import "chain" // Roles: one owner, a set of admins, a set of referees. // // - The owner manages the admins and the treasury, and can hand the realm // over. The owner is always an admin. // - Admins tune the game and manage the referees. // - Referees are the game servers. They record kills and nothing else. // callerOf returns the verified caller of a crossing function. rlm must be // the cur of the crossing function calling this helper: a stale or forged // realm value fails IsCurrent, and Previous is only meaningful on a live one. // // It takes the realm in second position on purpose, like p/nt/ownable does: // a function whose first parameter is a realm is a crossing function. func callerOf(_ int, rlm realm) address { if !rlm.IsCurrent() { panic(errSpoofedRealm) } return rlm.Previous().Address() } // assertOwner panics unless the caller is the owner, and returns the caller. func assertOwner(_ int, rlm realm) address { caller := callerOf(0, rlm) if !owner.OwnedBy(caller) { panic(errNotOwner) } return caller } // assertAdmin panics unless the caller is an admin, and returns the caller. func assertAdmin(_ int, rlm realm) address { caller := callerOf(0, rlm) if !IsAdmin(caller) { panic(errNotAdmin) } return caller } // assertReferee panics unless the caller is a referee, and returns the caller. func assertReferee(_ int, rlm realm) address { caller := callerOf(0, rlm) if !IsReferee(caller) { panic(errNotReferee) } return caller } // TransferOwnership hands the realm over to newOwner. Owner only. // // The previous owner keeps no privilege, unless it was also added to the // admin or referee lists. func TransferOwnership(cur realm, newOwner address) { // ownable checks the caller and the address. It would take the address // written in capitals though, and an owner under that key could never be // matched to a caller again: see validAddress. if newOwner.IsValid() && !validAddress(newOwner) { panic(errInvalidAddress) } if err := owner.TransferOwnership(0, cur, newOwner); err != nil { panic(err) } bump() } // AddAdmin adds addr to the admin list. Owner only. func AddAdmin(cur realm, addr address) { assertOwner(0, cur) if !validAddress(addr) { panic(errInvalidAddress) } if admins.Set(addr.String(), nil) { panic(errAlreadyAdmin) } bump() chain.Emit("AdminAdded", "address", addr.String()) } // RemoveAdmin removes addr from the admin list. Owner only. // // The owner is an admin by definition and cannot be demoted this way, use // TransferOwnership. func RemoveAdmin(cur realm, addr address) { assertOwner(0, cur) if _, removed := admins.Remove(addr.String()); !removed { panic(errNoSuchAdmin) } bump() chain.Emit("AdminRemoved", "address", addr.String()) } // SetTreasury sets the address the sales of skins and planes are forwarded // to. Owner only. func SetTreasury(cur realm, addr address) { assertOwner(0, cur) if !validAddress(addr) { panic(errInvalidAddress) } // Coins sent to the realm's own address would be stuck: nothing here // ever spends the realm balance. if addr == cur.Address() { panic(errTreasurySelf) } treasury = addr bump() chain.Emit("TreasurySet", "address", addr.String()) } // AddReferee allows addr to submit kills. Admin only. func AddReferee(cur realm, addr address) { assertAdmin(0, cur) if !validAddress(addr) { panic(errInvalidAddress) } if referees.Set(addr.String(), nil) { panic(errAlreadyReferee) } bump() chain.Emit("RefereeAdded", "address", addr.String()) } // RemoveReferee stops addr from submitting kills. Admin only. func RemoveReferee(cur realm, addr address) { assertAdmin(0, cur) if _, removed := referees.Remove(addr.String()); !removed { panic(errNoSuchReferee) } bump() chain.Emit("RefereeRemoved", "address", addr.String()) } // Owner returns the owner of the realm. func Owner() address { return owner.Owner() } // Treasury returns the address the sales of skins and planes are forwarded // to. func Treasury() address { return treasury } // IsAdmin reports whether addr is an admin. The owner always is. func IsAdmin(addr address) bool { return owner.OwnedBy(addr) || admins.Has(addr.String()) } // IsReferee reports whether addr may submit kills. func IsReferee(addr address) bool { return referees.Has(addr.String()) }
- #4doc.gno
- #5// v0 - Unaudited: This is an initial version that has not yet been formally audited. // Use in production at your own risk. // // Package game is the on-chain part of gnofly, a multiplayer plane game. It is // the source of truth for everything an admin can tune: the game parameters, // the roles and the treasury. It also keeps what the referee attests and what // has lasting value: the kills, on the kill leaderboard. Skins and planes are // tokens of their own realms, gnofly/nfts/skins and gnofly/nfts/planes, which // ask this one who the admins are and where the treasury is. // // Three kinds of callers write to it: // // - the owner and the admins tune the game (SetParam, ...). Every such // write bumps Version, which is how the game server knows it has to read // StateJSON again; // - referees, the game servers, record kills (SubmitKills). // // Challenge runs never reach the chain: the game server defines the // challenges, scores the runs and keeps the session boards. // // 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 game
- #6errors.gno
- #7package game import "errors" 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") errInvalidAddress = errors.New("gnofly: invalid address") errAlreadyAdmin = errors.New("gnofly: address is already an admin") errNoSuchAdmin = errors.New("gnofly: address is not in the admin list") errAlreadyReferee = errors.New("gnofly: address is already a referee") errNoSuchReferee = errors.New("gnofly: address is not a referee") errTreasurySelf = errors.New("gnofly: the treasury cannot be the realm itself") errUnknownParam = errors.New("gnofly: unknown param") errInvalidBounds = errors.New("gnofly: bounds must satisfy 0 <= min <= max") errValueOutOfBounds = errors.New("gnofly: the current value is outside these bounds, set the value first") errLeaderboardMin = errors.New("gnofly: leaderboard_size cannot go below 1") )
- #8game.gno
- #9package game import ( "strconv" "gno.land/p/nt/bptree/v0" "gno.land/p/nt/ownable/v0" ) // The whole state of the realm. Everything is unexported and nothing below is // ever returned by reference: the trees and the ownable are /p/ types with // mutator methods, so handing one out would be handing out write access to // the realm. var ( owner *ownable.Ownable // the single owner, always an admin admins bptree.BPTree // extra admins, managed by the owner: address -> nil referees bptree.BPTree // game servers allowed to submit kills: address -> nil treasury address // where the sales of the skins and planes realms go version int64 // bumps on every admin write, see Version params []*param // fixed registry, in display order pilots bptree.BPTree // player address -> int64 kills, allocated at the first kill killers ranking // the top players by kills recorded on-chain, see rankings.gno webPath string // "/r/g1t2kg2vtr3fukg43eujkn6x53gfdyakhngt4sfd/gnofly/game/v0", used to build links in Render ) // init runs once, when the package is added to the chain. This is the only // moment cur.Previous() is the deployer, so it is recorded here: the deployer // starts as owner, referee and treasury. // // Under `gno test` there is no deployer and the address is empty. The tests // call bootstrap themselves with a real address. func init(cur realm) { bootstrap(cur.Previous().Address(), cur.PkgPath()) } // bootstrap (re)creates the initial state: roles and params. func bootstrap(deployer address, pkgPath string) { owner = ownable.NewWithAddress(deployer) admins = bptree.BPTree{} referees = bptree.BPTree{} referees.Set(deployer.String(), nil) treasury = deployer params = defaultParams() pilots = bptree.BPTree{} killers = ranking{} webPath = webPathOf(pkgPath) version = 1 } // Version returns a counter that bumps on every admin write: params, // treasury and roles. Referee actions (kills) do not bump it. A reader that cached StateJSON only has to read it // again when Version changed. func Version() int64 { return version } // bump marks the admin-tunable state as changed. func bump() { version++ } // webPathOf turns "gno.land/r/g1t2kg2vtr3fukg43eujkn6x53gfdyakhngt4sfd/gnofly/game/v0" into "/r/g1t2kg2vtr3fukg43eujkn6x53gfdyakhngt4sfd/gnofly/game/v0". 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) }
- #10gnomod.toml
- #11module = "gno.land/r/g1t2kg2vtr3fukg43eujkn6x53gfdyakhngt4sfd/gnofly/game/v0" gno = "0.9"
- #12json.gno
- #13package game 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 (see validate.gno): ids, names, models, colors and bech32 addresses // need no escaping. The one string that is not under the realm's control, an // address typed into a query, goes through quote. // // Ordering is deterministic everywhere: params follow the registry and the // leaderboard is best first. // StateJSON returns everything the game needs from the chain, as one JSON // object: // // { // "version": 12, // "treasury": "g1...", // "params": { "plane_speed": 70, "boost_speed": 130 } // } // // The output has no whitespace. It only changes when Version does. The skins // are in their own realm, gnofly/nfts/skins. func StateJSON() string { var b strings.Builder b.WriteString(`{"version":`) b.WriteString(itoa(version)) b.WriteString(`,"treasury":"`) b.WriteString(treasury.String()) b.WriteString(`","params":{`) for i, p := range params { if i > 0 { b.WriteByte(',') } b.WriteByte('"') b.WriteString(p.key) b.WriteString(`":`) b.WriteString(itoa(p.value)) } b.WriteString(`}}`) return b.String() } // PlayerJSON returns one player as a JSON object: // // { "address": "g1...", "kills": 17 } // // The output has no whitespace. An unknown address has zero kills. The skins // and planes a player owns are read from their realms (SkinsOf, PlanesOf). func PlayerJSON(addr address) string { var b strings.Builder b.WriteString(`{"address":`) b.WriteString(quote(addr.String())) b.WriteString(`,"kills":`) b.WriteString(itoa(recordedKills(addr))) b.WriteByte('}') return b.String() } // KillLeaderboardJSON returns the top players by kills recorded on-chain as // a JSON array, best first, at most leaderboard_size entries: // // [ { "address": "g1...", "kills": 17 } ] // // The output has no whitespace. With equal kills, the player who got there // first stays ahead. func KillLeaderboardJSON() string { var b strings.Builder writeRanking(&b, killers, "kills") return b.String() } // 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() }
- #14kills.gno
- #15package game import ( "chain" "errors" ) const ( maxKillEntries = 32 // players per SubmitKills call maxKillCount = 1000 // kills per player per call // maxKillsLen bounds a kills string, so that an oversized input is // refused before it is read. An address is 40 characters, a count 4 at // most, plus the two separators: 64 a player leaves room to spare. maxKillsLen = maxKillEntries * 64 ) var errKillsFormat = errors.New(`gnofly: kills must be 1 to 32 entries "address:count" separated by ";"`) // SubmitKills adds kills to players' on-chain counts. Referee only. // // kills is 1 to 32 entries "address:count" separated by ";", for example // // g1abc...:3;g1def...:1 // // Each address must be valid and listed once, each count from 1 to 1000. // Anything else aborts the transaction and nothing is recorded. The counts // are added to what the players already have, and the kill leaderboard is // updated. Kills are player actions: Version does not move. func SubmitKills(cur realm, kills string) { referee := assertReferee(0, cur) addrs, counts, err := parseKills(kills) if err != nil { panic(err) } size := leaderboardSize() added := int64(0) for i, addr := range addrs { total := recordedKills(addr) + counts[i] pilots.Set(addr.String(), total) if next, changed := killers.raise(addr, total-counts[i], total, size); changed { killers = next } added += counts[i] } chain.Emit("KillsSubmitted", "referee", referee.String(), "players", itoa(int64(len(addrs))), "kills", itoa(added), ) } // parseKills parses and validates a kills string in a single pass. It // returns the players and their counts, in the order of the string. func parseKills(s string) ([]address, []int64, error) { if s == "" || len(s) > maxKillsLen { return nil, nil, errKillsFormat } var addrs []address var counts []int64 seen := make(map[address]bool) // lookups only, never iterated i := 0 for { index := len(addrs) if index == maxKillEntries { return nil, nil, errKillsFormat } colon := indexFrom(s, i, ':') if colon == len(s) { return nil, nil, errKillsFormat } end := indexFrom(s, colon+1, ';') addr := address(s[i:colon]) if !validAddress(addr) { return nil, nil, killsEntryError(index, "invalid address") } if seen[addr] { return nil, nil, killsEntryError(index, "address listed twice") } // At most 4 digits: 1000 is the highest count, and a bounded length // keeps the number from overflowing. if end-colon-1 > 4 { return nil, nil, killsEntryError(index, "count must be a number from 1 to 1000") } count, ok := digits(s[colon+1 : end]) if !ok || count < 1 || count > maxKillCount { return nil, nil, killsEntryError(index, "count must be a number from 1 to 1000") } seen[addr] = true addrs = append(addrs, addr) counts = append(counts, count) if end == len(s) { return addrs, counts, nil } i = end + 1 if i == len(s) { return nil, nil, errKillsFormat // a trailing ";" } } } func killsEntryError(index int, reason string) error { return errors.New("gnofly: kills entry " + itoa(int64(index)) + ": " + reason) }
- #16params.gno
- #17package game import "chain" // param is one tunable number of the game, with the bounds an admin has to // stay within. type param struct { key string value int64 min int64 max int64 doc string } // paramLeaderboardSize is the one param the realm itself consumes. Every // other param is only stored here and applied by the game server. const paramLeaderboardSize = "leaderboard_size" // defaultParams returns the registry with its default values. The order is // the display order, in StateJSON as well as in Render. func defaultParams() []*param { return []*param{ {"plane_speed", 70, 20, 300, "cruise speed, m/s"}, {"boost_speed", 130, 20, 600, "speed while boosting, m/s"}, {"boost_capacity_ms", 4000, 0, 60000, "how long a full boost tank lasts, 0 disables boost"}, {"boost_recharge_ms", 10000, 100, 600000, "time to refill an empty tank while not boosting"}, {"turn_rate", 80, 20, 360, "pitch and turn responsiveness, deg/s"}, {"plane_health", 100, 1, 1000, "hit points"}, {"fire_cooldown_ms", 160, 50, 5000, "minimum time between shots"}, {"bullet_speed", 400, 100, 2000, "m/s"}, {"bullet_ttl_ms", 1500, 100, 10000, "bullet lifetime"}, {"bullet_damage", 20, 1, 1000, "hit points removed per bullet"}, {"respawn_ms", 3000, 0, 60000, "delay before a destroyed plane respawns"}, {"balloon_respawn_ms", 6000, 0, 600000, "delay before a popped balloon comes back"}, {paramLeaderboardSize, 10, 1, 100, "entries a leaderboard keeps, on-chain and on the server"}, {"max_players", 64, 1, 1000, "server capacity"}, {"bot_max", 2, 0, 16, "bots flying with a lone pilot, 0 for no bots"}, {"bot_off_players", 5, 1, 64, "from this many pilots there are no bots"}, {"bot_aim_error", 8, 0, 45, "how far a bot's shot can stray from its aim, degrees"}, {"bot_fire_interval_ms", 900, 100, 10000, "minimum time between a bot's shots"}, } } // findParam returns the param registered under key, or nil. The registry is // a dozen entries, a linear scan is cheaper than any index. func findParam(key string) *param { for _, p := range params { if p.key == key { return p } } return nil } // SetParam changes a game parameter. Admin only. // // The key must exist and the value must be within the bounds of that key, // see Render("params"). Lowering leaderboard_size truncates the kill // leaderboard right away. Raising it does not bring back the lines that were // dropped: the list fills up again as kills come in. func SetParam(cur realm, key string, value int64) { assertAdmin(0, cur) p := findParam(key) if p == nil { panic(errUnknownParam) } if value < p.min || value > p.max { panic("gnofly: " + key + " must be between " + itoa(p.min) + " and " + itoa(p.max)) } p.value = value if key == paramLeaderboardSize { trimRankings() } bump() chain.Emit("ParamSet", "key", key, "value", itoa(value)) } // SetParamBounds changes the range SetParam accepts for a key. Owner only. // // The bounds are the guard rail between the owner and the admins: admins tune // values within them, the owner decides how far tuning may go. The current // value has to fit the new bounds, so that the registry never holds a value // SetParam would refuse. func SetParamBounds(cur realm, key string, min, max int64) { assertOwner(0, cur) p := findParam(key) if p == nil { panic(errUnknownParam) } if min < 0 || min > max { panic(errInvalidBounds) } // An empty leaderboard has no last line to compare a new total with. if key == paramLeaderboardSize && min < 1 { panic(errLeaderboardMin) } if p.value < min || p.value > max { panic(errValueOutOfBounds) } p.min, p.max = min, max bump() chain.Emit("ParamBoundsSet", "key", key, "min", itoa(min), "max", itoa(max)) } // Param returns the value of a game parameter. It panics on an unknown key. func Param(key string) int64 { p := findParam(key) if p == nil { panic(errUnknownParam) } return p.value }
- #18parse.gno
- #19package game // indexFrom returns the index of the first c in s at or after i, or len(s) // when there is none. func indexFrom(s string, i int, c byte) int { for i < len(s) && s[i] != c { i++ } return i } // digits parses a non-empty string of decimal digits. Kill counts are never // negative, so there is no sign to read. func digits(s string) (int64, bool) { if len(s) == 0 { 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 }
- #20pilots.gno
- #21package game // What the realm remembers about a player is their kills, recorded by the // referees: pilots maps an address to an int64, allocated at the first kill. // A player without a record has none. Skins and planes are tokens of their // own realms (gnofly/nfts/skins, gnofly/nfts/planes). // killsOf returns the kills recorded for addr. func recordedKills(addr address) int64 { v := pilots.Get(addr.String()) if v == nil { return 0 } return v.(int64) }
- #22rankings.gno
- #23package game import "strings" // ranking is a leaderboard on a single number that only goes up, the count of // kills: the top players, best first, at most leaderboard_size lines and one // line per player. With equal scores, the player who got there first stays // ahead. // // It is kept sorted at write time, so reading it never has to look at every // player. Scores 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. Measured on // gnodev, a line costs about 190 bytes this way. type ranking struct { addrs []address scores []int64 } // leaderboardSize returns how many lines a leaderboard may hold. func leaderboardSize() int { return int(findParam(paramLeaderboardSize).value) } // 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, which the binary search finds without reading the whole // list: with 32 players credited in one call on a 100 line list, comparing // every address would cost millions of gas. 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 leaderboard 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, and it is built with the append builtin: on a 100 line board a // loop over the lines costs several times more gas. 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:]...) } // cut returns the first size lines of the ranking, in slices of exactly that // length, so that nothing beyond the kept lines stays in storage. func (r ranking) cut(size int) ranking { n := len(r.addrs) if n > size { n = size } out := ranking{addrs: make([]address, n), scores: make([]int64, n)} copy(out.addrs, r.addrs) copy(out.scores, r.scores) return out } // trimRankings cuts the kill leaderboard down to leaderboard_size. func trimRankings() { if size := leaderboardSize(); len(killers.addrs) > size { killers = killers.cut(size) } } // writeRanking writes a ranking as a JSON array of // {"address":"g1...","<key>":<score>} objects, best first. func writeRanking(b *strings.Builder, r ranking, key string) { b.WriteByte('[') for i, addr := range r.addrs { if i > 0 { b.WriteByte(',') } b.WriteString(`{"address":"`) b.WriteString(addr.String()) b.WriteString(`","`) b.WriteString(key) b.WriteString(`":`) b.WriteString(itoa(r.scores[i])) b.WriteByte('}') } b.WriteByte(']') }
- #24render.gno
Result log
msg:0,success:true,log:,events:[]