Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}