impl.gno
23.49 Kb · 645 lines
1// Package impl is the third rules engine for gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/shots:
2// impl/v2 with a chain that plays well, a pot that must be won by two goals,
3// and a share of it for the game's creator.
4//
5// It owns the game state machine and pot rules; the state itself, and the
6// coins, stay in the shots realm, reached through shots.Store.
7//
8// THE CHAIN'S PLAY. impl/v1 and v2 read the chain's keeper plan and kick as
9// uniform draws from the seed, which a player who has learnt the best shot
10// and keeper plan beats about 97% of the time. Here the seed's first two bytes
11// pick the chain's move from equilibrium mixes, worked out with the physics
12// engine for the default constants (chainKeeper, chainKicks): against them
13// no shot scores more than about 79%, and no keeper plan concedes less than
14// about 77%, roughly the conversion rate of real penalties. Games with other
15// constants use the same mixes, kicks scaled to their goal and plans kept in
16// their mode (see keeperFor and kickFor), so they are only near equilibrium;
17// a game may therefore change only four constants, a little (see
18// checkConstants).
19//
20// THE POT. Each game's creator chooses how much a challenger must win by
21// (winBy in the config string, 1..3, DefaultWinBy if left out; WinBy reads
22// it). Win by 1 is a normal shootout: five kicks each, ending as soon as one
23// side cannot be caught, then sudden death. Win by 2 or 3 is five kicks each
24// at most with no sudden death, won only by that many goals, ending as soon as
25// that is certain either way. Against the chain above, a player who plays the
26// best moves wins about 47%, 11% or 2.3% of shootouts, one who plays at
27// random about 9.5%, 1.5% or 0.2%: the larger the margin, the longer a pot
28// grows before it is won. The winner is paid all of it but CreatorPercent,
29// which goes to the game's creator for having put up the seed pot. Games
30// created before this implementation keep the rules they were made with: win
31// by 1.
32//
33// SETTLING. Every kick is recorded in the session while it is played (its ten
34// inputs, seed and result: about 3 KB, paid for by the player's storage
35// deposit). When the shootout ends (won, lost or forfeited) the session is
36// rewritten without its kicks, keeping player, entry fee, score, status and
37// heights, and a won game keeps its pot (the prize it paid) and winner. The
38// deposit for the bytes freed goes back to the account that made the final
39// move: on the deciding kick, the player (on a chain whose ugnot is
40// transfer-restricted, the storage fee collector instead). Each kick remains
41// in the transaction that played it, as that call's return value and Kick
42// event, so it can still be re-run from the chain's history; shots'
43// VerifySession has nothing left to re-run for a settled shootout.
44//
45// Game: open --join--> playing --player wins--> won (pot paid out, closed)
46//
47// ^ |
48// +----player loses-+ (entry fee stays in the pot)
49// open --creator cancels, nobody played yet--> cancelled (pot refunded)
50//
51// Session: each round the player shoots, then keeps. Every move is played
52// out in the transaction that makes it, from 32 bytes derived from that
53// block's timestamp (see entropy).
54//
55// ENTROPY. The seed is SHA-256 of the block time (unix nanoseconds) and the
56// kick's identity (game, session, kick number), so two kicks in one block
57// still differ. The block time is not known when the player signs, but it is
58// not secret either: the proposer chooses it within consensus bounds, and
59// anything else running in the same transaction can read the result. In
60// particular a transaction that makes a move and then, in a later message,
61// reads the session and fails on a bad outcome reverts the whole kick, so a
62// player can retry until they like the result (for the price of gas). Fine
63// for play money; a commit-reveal or a VRF is needed before real stakes, and
64// an upgraded implementation can switch without touching games or pots.
65package impl
66
67import (
68 "chain"
69 "chain/runtime"
70 "crypto/sha256"
71 "strconv"
72 "strings"
73 "time"
74
75 "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/penalty/physics/v0"
76 "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/penalty/v0"
77 "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/shootout/v0"
78 "gno.land/p/nt/avl/v0"
79 "gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/shots"
80)
81
82const (
83 RegulationKicks = 5 // kicks each side takes before sudden death (win by 1), or at most
84 DefaultWinBy = 2 // goals a challenger must win by, unless the creator chose 1 or 3
85 MaxWinBy = 3
86 CreatorPercent = 20 // of the pot, paid to the game's creator when it is won
87
88 MinDeposit = 1000000 // ugnot to seed a pot (1 GNOT)
89 MinEntryFee = 100000 // ugnot (0.1 GNOT)
90 DefaultTimeout = 1000 // blocks a challenger may sit idle
91 MinTimeout = 30
92 MaxTimeout = 200000
93)
94
95type engine struct{}
96
97var instance = &engine{}
98
99// Instance returns the implementation, for a caller that registers it
100// explicitly rather than relying on init.
101func Instance() shots.Impl { return instance }
102
103func init(cur realm) {
104 shots.Register(cross(cur), instance)
105}
106
107func (e *engine) Describe() string {
108 return "impl/v3: Spot Kick physics (penalty/physics/v0, bit-identical to the browser engine), seed = SHA-256 of the block time; " +
109 "the chain plays equilibrium mixes; each game's pot is won by 1, 2 or 3 goals (its creator's choice), " + itoa(CreatorPercent) +
110 "% of it to the creator; an ended shootout keeps only its score"
111}
112
113func (e *engine) SimulateShot(shot penalty.ShotParams, plan penalty.KeeperPlan, cfg penalty.Config) penalty.Result {
114 return physics.SimulateShot(shot, plan, cfg)
115}
116
117// ---------------------------------------------------------------- games
118
119func (e *engine) CreateGame(_ int, rlm realm, creator address, deposit, entryFee, timeoutBlocks int64, config string) uint64 {
120 winBy, config := takeWinBy(config)
121 cfg, err := penalty.ParseConfig(config)
122 if err != nil {
123 panic(err)
124 }
125 if msg := checkConstants(cfg); msg != "" {
126 panic("shots: " + msg)
127 }
128 if deposit < MinDeposit {
129 panic("shots: send at least " + itoa(MinDeposit) + "ugnot to seed the pot")
130 }
131 if entryFee < MinEntryFee {
132 panic("shots: the entry fee must be at least " + itoa(MinEntryFee) + "ugnot")
133 }
134 if timeoutBlocks == 0 {
135 timeoutBlocks = DefaultTimeout
136 }
137 if timeoutBlocks < MinTimeout || timeoutBlocks > MaxTimeout {
138 panic("shots: timeoutBlocks must be within " + itoa(MinTimeout) + ".." + itoa(MaxTimeout))
139 }
140 st := shots.Store(0, rlm)
141 g := shootout.Game{
142 ID: st.NewGameID(),
143 Creator: creator,
144 Config: cfg,
145 EntryFee: entryFee,
146 TimeoutBlocks: timeoutBlocks,
147 Pot: deposit,
148 Status: shootout.GameOpen,
149 CreatedHeight: runtime.ChainHeight(),
150 }
151 st.PutGame(g)
152 setWinBy(cross(rlm), g.ID, winBy)
153 chain.Emit("GameCreated", "game", u64(g.ID), "creator", string(creator), "pot", itoa(deposit),
154 "entryFee", itoa(entryFee), "config", cfg.String(), "winBy", strconv.Itoa(winBy))
155 return g.ID
156}
157
158// winBys holds each game's margin (the shots realm's Game has no field for
159// it, and its type cannot change); a later implementation reads it through
160// WinBy.
161var winBys = avl.NewTree() // game id -> int
162
163// WinBy is how many goals a challenger must win game gameID by: as chosen at
164// creation, or 1 for a game created before this implementation.
165func WinBy(gameID uint64) int {
166 if v := winBys.Get(u64(gameID)); v != nil {
167 return v.(int)
168 }
169 return 1
170}
171
172// setWinBy records a new game's margin. It crosses into this realm: the
173// engine's methods run as the shots realm, which may not write this realm's
174// table.
175func setWinBy(cur realm, gameID uint64, winBy int) {
176 winBys.Set(u64(gameID), winBy)
177}
178
179// takeWinBy removes "winBy=N" from a config string, returning N (DefaultWinBy
180// if absent) and the rest for penalty.ParseConfig.
181func takeWinBy(config string) (int, string) {
182 winBy := DefaultWinBy
183 rest := []string{}
184 for _, kv := range strings.Split(config, ",") {
185 k := strings.TrimSpace(kv)
186 if !strings.HasPrefix(k, "winBy=") {
187 if k != "" {
188 rest = append(rest, k)
189 }
190 continue
191 }
192 n, err := strconv.Atoi(strings.TrimSpace(k[len("winBy="):]))
193 if err != nil || n < 1 || n > MaxWinBy {
194 panic("shots: winBy must be 1, 2 or 3")
195 }
196 winBy = n
197 }
198 return winBy, strings.Join(rest, ",")
199}
200
201func (e *engine) TopUp(_ int, rlm realm, from address, gameID uint64, paid int64) {
202 st := shots.Store(0, rlm)
203 g := mustGame(st, gameID)
204 if g.Status != shootout.GameOpen && g.Status != shootout.GamePlaying {
205 panic("shots: game " + u64(gameID) + " is closed")
206 }
207 if paid <= 0 {
208 panic("shots: send ugnot to top up the pot")
209 }
210 g.Pot += paid
211 st.PutGame(g)
212 chain.Emit("PotToppedUp", "game", u64(gameID), "from", string(from), "amount", itoa(paid), "pot", itoa(g.Pot))
213}
214
215func (e *engine) CancelGame(_ int, rlm realm, caller address, gameID uint64) {
216 st := shots.Store(0, rlm)
217 g := mustGame(st, gameID)
218 if caller != g.Creator {
219 panic("shots: only the creator can cancel game " + u64(gameID))
220 }
221 if g.Status != shootout.GameOpen || g.Sessions > 0 {
222 panic("shots: a game can only be cancelled before anyone has played it")
223 }
224 refund := g.Pot
225 g.Pot = 0
226 g.Status = shootout.GameCancelled
227 g.ClosedHeight = runtime.ChainHeight()
228 st.PutGame(g)
229 st.Pay(g.Creator, refund)
230 chain.Emit("GameCancelled", "game", u64(gameID), "refund", itoa(refund))
231}
232
233// ---------------------------------------------------------------- sessions
234
235func (e *engine) JoinGame(_ int, rlm realm, player address, gameID uint64, paid int64) {
236 st := shots.Store(0, rlm)
237 g := mustGame(st, gameID)
238 if g.Status != shootout.GameOpen {
239 panic("shots: game " + u64(gameID) + " is not open (" + g.Status.String() + ")")
240 }
241 if paid != g.EntryFee {
242 panic("shots: send exactly the entry fee, " + itoa(g.EntryFee) + "ugnot")
243 }
244 h := runtime.ChainHeight()
245 g.Pot += paid
246 g.Sessions++
247 g.Status = shootout.GamePlaying
248 st.PutGame(g)
249 st.PutSession(shootout.Session{
250 GameID: gameID,
251 No: g.Sessions,
252 Player: player,
253 Paid: paid,
254 Status: shootout.SessionPlaying,
255 JoinHeight: h,
256 LastActionHeight: h,
257 })
258 chain.Emit("GameJoined", "game", u64(gameID), "session", strconv.Itoa(g.Sessions), "player", string(player),
259 "pot", itoa(g.Pot))
260}
261
262func (e *engine) TakeShot(_ int, rlm realm, player address, gameID uint64, shot penalty.ShotInput) shootout.Kick {
263 st := shots.Store(0, rlm)
264 g, s := mustPlayersSession(st, gameID, player)
265 if err := shot.Validate(g.Config); err != nil {
266 panic(err)
267 }
268 return e.kick(st, g, s, penalty.SidePlayer, shot, penalty.KeeperPlan{})
269}
270
271func (e *engine) SubmitKeeperPlan(_ int, rlm realm, player address, gameID uint64, plan penalty.KeeperPlan) shootout.Kick {
272 st := shots.Store(0, rlm)
273 g, s := mustPlayersSession(st, gameID, player)
274 return e.kick(st, g, s, penalty.SideChain, penalty.ShotInput{}, plan)
275}
276
277// kick plays the player's move against the chain's, both completed by this
278// block's seed, records it, and ends the session if the shootout is decided.
279func (e *engine) kick(st *shots.StateRef, g shootout.Game, s shootout.Session, kicker penalty.Side,
280 shot penalty.ShotInput, plan penalty.KeeperPlan) shootout.Kick {
281 if want := s.Score.Next(); want != kicker {
282 if want == penalty.SidePlayer {
283 panic("shots: it is your turn to shoot (TakeShot)")
284 }
285 panic("shots: it is your turn to keep (SubmitKeeperPlan)")
286 }
287 h := runtime.ChainHeight()
288 now := time.Now().UnixNano()
289 seed := entropy(g, s, now)
290 k := shootout.Kick{
291 Round: s.Score.Round(),
292 Kicker: kicker,
293 Seed: seed,
294 Height: h,
295 Time: now,
296 }
297 if kicker == penalty.SidePlayer {
298 // The player shoots; the seed picks the chain's keeper plan and is
299 // the mishit.
300 k.Shot = shot.WithMishit(seed.Mishit())
301 k.Plan = keeperFor(seed, g.Config)
302 } else {
303 // The player keeps; the seed picks the chain's kick and its mishit.
304 k.Shot = kickFor(seed, g.Config).WithMishit(seed.Mishit())
305 k.Plan = plan
306 }
307 k.Result = e.SimulateShot(k.Shot, k.Plan, g.Config)
308 s.Kicks = append(s.Kicks, k)
309 // Scored here rather than with penalty.Shootout.Record, whose rules (a
310 // win by one, sudden death) would call some win-by-2 or 3 shootouts over
311 // that are not.
312 if kicker == penalty.SidePlayer {
313 s.Score.PlayerKicks++
314 if k.Result.Outcome.Scored() {
315 s.Score.PlayerGoals++
316 }
317 } else {
318 s.Score.ChainKicks++
319 if k.Result.Outcome.Scored() {
320 s.Score.ChainGoals++
321 }
322 }
323 s.LastActionHeight = h
324 chain.Emit("Kick", "game", u64(g.ID), "session", strconv.Itoa(s.No), "round", strconv.Itoa(k.Round),
325 "kicker", kicker.String(), "outcome", k.Result.Outcome.String(), "seed", seed.Hex())
326
327 switch decided(s.Score, WinBy(g.ID)) {
328 case penalty.SidePlayer:
329 // The pot is paid out, CreatorPercent of it to the creator, and kept
330 // on the closed game as the prize it paid (see SETTLING).
331 prize := g.Pot
332 cut := prize * CreatorPercent / 100
333 s.Status = shootout.SessionWon
334 s.EndHeight = h
335 g.Status = shootout.GameWon
336 g.Winner = s.Player
337 g.ClosedHeight = h
338 st.PutGame(g)
339 st.PutSession(settled(s))
340 st.Pay(s.Player, prize-cut)
341 st.Pay(g.Creator, cut)
342 chain.Emit("PotWon", "game", u64(g.ID), "session", strconv.Itoa(s.No), "player", string(s.Player),
343 "prize", itoa(prize), "winnerShare", itoa(prize-cut), "creator", string(g.Creator), "creatorShare", itoa(cut))
344 return k
345 case penalty.SideChain:
346 s.Status = shootout.SessionLost
347 s.EndHeight = h
348 g.Status = shootout.GameOpen
349 st.PutGame(g)
350 st.PutSession(settled(s))
351 chain.Emit("SessionLost", "game", u64(g.ID), "session", strconv.Itoa(s.No), "player", string(s.Player),
352 "pot", itoa(g.Pot))
353 return k
354 }
355 st.PutGame(g)
356 st.PutSession(s)
357 return k
358}
359
360// checkConstants allows a game four small changes from the default
361// constants, and nothing else: the chain's mixes are the equilibrium of the
362// default game, and every change opens a gap a skilled player can use. Within
363// these ranges a player who plays the best moves wins 11-14% of shootouts
364// (against 11% under the defaults); outside them up to 40%, which no pot
365// survives. The ranges, as the web client offers them:
366//
367// goal width goalWidth 6400..7000 mm
368// keeper size keeperScale 1117..1235 (95..105% of 1176)
369// keeper agility diveMin and diveMax at 90..105% of 2600 and 5200, together
370// (diveMax = 2 x diveMin)
371// shot speed ballMaxSpeed 34000..37400 mm/s (100..110%)
372//
373// It returns why cfg is refused, or "".
374func checkConstants(cfg penalty.Config) string {
375 def := penalty.DefaultConfig()
376 if cfg.GoalWidth < 6400 || cfg.GoalWidth > 7000 {
377 return "goal width must be 6400..7000 mm"
378 }
379 if cfg.KeeperScale < 1117 || cfg.KeeperScale > 1235 {
380 return "keeper size must be 1117..1235 (95..105%)"
381 }
382 if cfg.DiveMax < 4680 || cfg.DiveMax > 5460 || cfg.DiveMin*2 != cfg.DiveMax {
383 return "keeper agility: diveMax must be 4680..5460 mm/s and diveMin half of it"
384 }
385 if cfg.BallMaxSpeed < 34000 || cfg.BallMaxSpeed > 37400 {
386 return "shot speed: ballMaxSpeed must be 34000..37400 mm/s"
387 }
388 rest := cfg
389 rest.GoalWidth, rest.KeeperScale, rest.DiveMin, rest.DiveMax, rest.BallMaxSpeed =
390 def.GoalWidth, def.KeeperScale, def.DiveMin, def.DiveMax, def.BallMaxSpeed
391 if rest != def {
392 return "only goal width, keeper size, keeper agility and shot speed may differ from the defaults (" + def.String() + ")"
393 }
394 return ""
395}
396
397// decided says whether a shootout is over, and who won it. Win by 1 is a
398// normal shootout (penalty.Shootout's rules, sudden death included). For a
399// larger margin it is SidePlayer once the player is sure to finish winBy goals
400// ahead after RegulationKicks each, SideChain once they no longer can, and 0
401// while it is open.
402func decided(sc penalty.Shootout, winBy int) penalty.Side {
403 if winBy <= 1 {
404 return sc.Winner()
405 }
406 playerLeft := RegulationKicks - sc.PlayerKicks
407 chainLeft := RegulationKicks - sc.ChainKicks
408 if sc.PlayerGoals-(sc.ChainGoals+chainLeft) >= winBy {
409 return penalty.SidePlayer
410 }
411 if sc.PlayerGoals+playerLeft-sc.ChainGoals < winBy {
412 return penalty.SideChain
413 }
414 return 0
415}
416
417// settled is an ended session as it stays on chain: everything but its
418// kicks (see SETTLING).
419func settled(s shootout.Session) shootout.Session {
420 s.Kicks = nil
421 return s
422}
423
424func (e *engine) Forfeit(_ int, rlm realm, caller address, gameID uint64) {
425 st := shots.Store(0, rlm)
426 g := mustGame(st, gameID)
427 if g.Status != shootout.GamePlaying {
428 panic("shots: nobody is playing game " + u64(gameID))
429 }
430 s, _ := session(st, gameID, g.Sessions)
431 h := runtime.ChainHeight()
432 if caller != s.Player && h-s.LastActionHeight <= g.TimeoutBlocks {
433 panic("shots: the player has " + itoa(g.TimeoutBlocks-(h-s.LastActionHeight)+1) +
434 " more blocks before they can be forfeited")
435 }
436 s.Status = shootout.SessionForfeited
437 s.EndHeight = h
438 g.Status = shootout.GameOpen
439 st.PutGame(g)
440 st.PutSession(settled(s))
441 chain.Emit("SessionForfeited", "game", u64(gameID), "session", strconv.Itoa(s.No),
442 "player", string(s.Player), "by", string(caller), "pot", itoa(g.Pot))
443}
444
445// entropy is the kick's 32-byte seed: SHA-256 of the block time and the
446// kick's identity (see the package comment for what that does and does not
447// guarantee).
448func entropy(g shootout.Game, s shootout.Session, blockTime int64) penalty.Seed {
449 var b [8]byte
450 for i := 0; i < 8; i++ {
451 b[i] = byte(uint64(blockTime) >> (56 - 8*uint(i)))
452 }
453 id := "|" + u64(g.ID) + "|" + strconv.Itoa(s.No) + "|" + strconv.Itoa(len(s.Kicks))
454 return penalty.Seed(sha256.Sum256(append(b[:], []byte(id)...)))
455}
456
457// ---------------------------------------------------------------- helpers
458
459func mustGame(st *shots.StateRef, id uint64) shootout.Game {
460 g, ok := st.Game(id)
461 if !ok {
462 panic("shots: no game " + u64(id))
463 }
464 return ownGame(g)
465}
466
467// session reads the live session of a game as this realm's own copy.
468func session(st *shots.StateRef, id uint64, no int) (shootout.Session, bool) {
469 s, ok := st.Session(id, no)
470 if !ok {
471 return s, false
472 }
473 return ownSession(s), true
474}
475
476// ownGame and ownSession rebuild values read through shots.Store in this
477// realm. What Store hands back was allocated by the shots realm, and the VM
478// refuses writes to another realm's objects (a pointer-method update such as
479// Shootout.Record trips its cross-realm check), so the engine only ever
480// mutates copies it built itself; PutGame/PutSession copy them back.
481func ownGame(g shootout.Game) shootout.Game {
482 return shootout.Game{
483 ID: g.ID,
484 Creator: g.Creator,
485 Config: g.Config,
486 EntryFee: g.EntryFee,
487 TimeoutBlocks: g.TimeoutBlocks,
488 Pot: g.Pot,
489 Status: g.Status,
490 Sessions: g.Sessions,
491 Winner: g.Winner,
492 CreatedHeight: g.CreatedHeight,
493 ClosedHeight: g.ClosedHeight,
494 }
495}
496
497func ownSession(s shootout.Session) shootout.Session {
498 kicks := make([]shootout.Kick, len(s.Kicks))
499 for i := range s.Kicks {
500 kicks[i] = s.Kicks[i]
501 }
502 return shootout.Session{
503 GameID: s.GameID,
504 No: s.No,
505 Player: s.Player,
506 Paid: s.Paid,
507 Score: penalty.Shootout{
508 PlayerKicks: s.Score.PlayerKicks,
509 PlayerGoals: s.Score.PlayerGoals,
510 ChainKicks: s.Score.ChainKicks,
511 ChainGoals: s.Score.ChainGoals,
512 },
513 Kicks: kicks,
514 Status: s.Status,
515 JoinHeight: s.JoinHeight,
516 LastActionHeight: s.LastActionHeight,
517 EndHeight: s.EndHeight,
518 }
519}
520
521func mustPlayersSession(st *shots.StateRef, gameID uint64, player address) (shootout.Game, shootout.Session) {
522 g := mustGame(st, gameID)
523 if g.Status != shootout.GamePlaying {
524 panic("shots: nobody is playing game " + u64(gameID) + "; join it first")
525 }
526 s, ok := session(st, gameID, g.Sessions)
527 if !ok || s.Player != player {
528 panic("shots: you are not the challenger in game " + u64(gameID))
529 }
530 if s.Status != shootout.SessionPlaying {
531 panic("shots: your shootout in game " + u64(gameID) + " is over")
532 }
533 return g, s
534}
535
536func itoa(n int64) string { return strconv.FormatInt(n, 10) }
537func u64(n uint64) string { return strconv.FormatUint(n, 10) }
538
539// ---------------------------------------------------------------- the chain's play
540
541// chainPlan is a keeper plan of the chain's mix: cum is the cumulative weight
542// out of 65536 (the seed's first two bytes pick the first entry above them),
543// the rest the plan's four uint16 under the default constants.
544type chainPlan struct {
545 cum uint32
546 direction, speed, jump, reaction uint16
547}
548
549// chainKick is a kick of the chain's mix, in the realm's units for the
550// default goal: aim mm, contact milli-radii, power per-mille.
551type chainKick struct {
552 cum uint32
553 aimX, aimY, contactX, contactY, power int64
554}
555
556// The equilibrium mixes for the default constants (a double-oracle solution
557// of the shooter-keeper game on the physics engine; the value is a 78.5%
558// conversion rate). Plans: a third wait and read the shot; the rest dive
559// fast across the goal, mid-height or low. Kicks: low and hard 2.2 m out, top
560// corners, high down the middle.
561var chainKeeper = []chainPlan{
562 {22080, 32768, 11796, 32768, 0}, // 33.7% wait and read
563 {35198, 31457, 58650, 32768, 19661}, // 20.0%
564 {48315, 34078, 58650, 32768, 19661}, // 20.0%
565 {56642, 31457, 65530, 0, 0}, // 12.7%
566 {64968, 34078, 65530, 0, 0}, // 12.7%
567 {65252, 31457, 65530, 32768, 32768}, // 0.4%
568 {65536, 34078, 65530, 32768, 32768}, // 0.4%
569}
570
571var chainKicks = []chainKick{
572 {13504, 2200, 150, 0, -100, 1000}, // 20.6%
573 {27008, -2200, 150, 0, -100, 1000}, // 20.6%
574 {33589, 3200, 2250, 250, -100, 850}, // 10.0%
575 {40169, -3200, 2250, -250, -100, 850}, // 10.0%
576 {46362, 3300, 2150, 250, -100, 850}, // 9.4%
577 {52554, -3300, 2150, -250, -100, 850}, // 9.4%
578 {58145, 100, 2250, 0, 50, 1000}, // 8.5%
579 {63737, -100, 2250, 0, 50, 1000}, // 8.5%
580 {64636, 2100, 1450, 0, -100, 1000}, // 1.4%
581 {65536, -2100, 1450, 0, -100, 1000}, // 1.4%
582}
583
584// pick is the index of the first entry whose cumulative weight is above u.
585func pick(u uint32, cum func(i int) uint32, n int) int {
586 for i := 0; i < n; i++ {
587 if u < cum(i) {
588 return i
589 }
590 }
591 return n - 1
592}
593
594func seedU16(s penalty.Seed) uint32 { return uint32(s[0])<<8 | uint32(s[1]) }
595
596// keeperFor is the chain's keeper plan for a kick. Under constants with other
597// mode bands (StarShare, ReadShare) a plan keeps its mode: a read stays a
598// read, and a dive keeps its place within the dive band.
599func keeperFor(seed penalty.Seed, c penalty.Config) penalty.KeeperPlan {
600 p := chainKeeper[pick(seedU16(seed), func(i int) uint32 { return chainKeeper[i].cum }, len(chainKeeper))]
601 speed := p.speed
602 if c.StarShare != 60 || c.ReadShare != 240 {
603 const defRead = 19660 // 30% of 65535: the default read band ends here
604 star := uint32(c.StarShare) * 65535 / 1000
605 read := uint32(c.StarShare+c.ReadShare) * 65535 / 1000
606 if p.speed < defRead {
607 speed = uint16((star + read) / 2)
608 } else {
609 speed = uint16(read + (uint32(p.speed)-defRead)*(65535-read)/(65535-defRead))
610 }
611 }
612 return penalty.KeeperPlan{Direction: p.direction, Speed: speed, Jump: p.jump, Reaction: p.reaction}
613}
614
615// kickFor is the chain's kick, its aim scaled to the game's goal and its
616// contact kept inside the game's selectable disc.
617func kickFor(seed penalty.Seed, c penalty.Config) penalty.ShotInput {
618 k := chainKicks[pick(seedU16(seed), func(i int) uint32 { return chainKicks[i].cum }, len(chainKicks))]
619 in := penalty.ShotInput{
620 AimX: k.aimX * c.GoalWidth / 6555,
621 AimY: k.aimY * c.GoalHeight / 2424,
622 ContactX: k.contactX,
623 ContactY: k.contactY,
624 Power: k.power,
625 }
626 if r2 := in.ContactX*in.ContactX + in.ContactY*in.ContactY; r2 > c.MaxContact*c.MaxContact {
627 r := isqrt(r2)
628 in.ContactX = in.ContactX * c.MaxContact / r
629 in.ContactY = in.ContactY * c.MaxContact / r
630 }
631 return in
632}
633
634func isqrt(n int64) int64 {
635 if n <= 0 {
636 return 0
637 }
638 x := n
639 y := (x + 1) / 2
640 for y < x {
641 x = y
642 y = (x + n/x) / 2
643 }
644 return x
645}