game.gno
8.15 Kb · 281 lines
1package gnodice
2
3import (
4 "chain"
5 "chain/banker"
6 "chain/runtime/unsafe"
7 "strconv"
8)
9
10// Les étapes d'une partie : Play -> Resolve -> Reveal, avec deux sorties de
11// secours, Refund (croupier trop lent) et Expire (chiffre jamais dévoilé).
12// Règle d'or suivie partout : on vérifie, puis on met à jour l'état, et on
13// envoie l'argent en tout dernier (contrôles -> effets -> paiement).
14
15// Play mise sur un chiffre caché. Le joueur envoie sa mise (1 à 10 GNOT) avec
16// la transaction et l'empreinte de son chiffre (voir Commitment).
17// Renvoie le numéro de la partie, par exemple "id=42".
18func Play(cur realm, commitment string) string {
19 caller := cur.Previous()
20
21 // La mise est lue dans « l'enveloppe » de la transaction (OriginSend) :
22 // ce n'est fiable que pour un appel direct depuis un wallet (MsgCall).
23 // Un script (MsgRun) ou un autre contrat pourrait détourner ces GNOT.
24 if !caller.IsUserCall() {
25 panic("gnodice: Play doit être appelé directement depuis un wallet")
26 }
27 player := caller.Address()
28 if paused {
29 panic("gnodice: le jeu est en pause")
30 }
31 if isBlocked(player) {
32 panic("gnodice: cette adresse n'est pas autorisée à jouer")
33 }
34 if !isHex64(commitment) {
35 panic("gnodice: empreinte invalide (64 caractères hexadécimaux attendus)")
36 }
37 bet := receivedBet()
38
39 t := now()
40 rollDay(t)
41 if payoutToday >= dailyPayoutLimit {
42 panic("gnodice: limite de gains du jour atteinte, réessaie demain")
43 }
44
45 p := getPlayer(player)
46 if wait := cooldownRemaining(p, t); wait > 0 {
47 panic("gnodice: attends encore " + formatDuration(wait) + " avant de rejouer")
48 }
49
50 // La banque (qui contient déjà la mise reçue) doit pouvoir couvrir le gain
51 // maximal EN PLUS de tout ce qu'elle a déjà promis aux parties en cours.
52 game := &Game{
53 Player: player,
54 Commitment: commitment,
55 Bet: bet,
56 Status: StatusPending,
57 PlacedAt: t,
58 PlacedHeight: height(),
59 }
60 if available() < game.reservation() {
61 panic("gnodice: la banque ne peut pas couvrir ce gain, essaie une mise plus petite")
62 }
63
64 // Effets
65 totalGames++
66 game.ID = totalGames
67 games.Set(gameKey(game.ID), game)
68 openGames.Set(gameKey(game.ID), true)
69 reserved += game.reservation()
70
71 if p == nil {
72 p = &playerData{}
73 players.Set(player.String(), p)
74 }
75 p.LastPlay = t
76 p.Open++
77 p.Wagered += bet
78 totalWagered += bet
79
80 chain.Emit(
81 "GamePlaced",
82 "id", itoa64(game.ID),
83 "player", player.String(),
84 "bet", itoa64(bet),
85 "commitment", commitment,
86 )
87 return "id=" + itoa64(game.ID)
88}
89
90// Resolve tire le dé d'une partie en attente. Réservé au croupier, qui fournit
91// une graine aléatoire de 32 octets (64 caractères hexadécimaux) sans connaître
92// le chiffre du joueur. La graine est publiée : chacun peut refaire le calcul.
93func Resolve(cur realm, id int64, seed string) {
94 caller := cur.Previous()
95 if !caller.IsUserCall() || caller.Address() != croupier {
96 panic("gnodice: seul le croupier peut tirer les dés")
97 }
98 if !isHex64(seed) {
99 panic("gnodice: graine invalide (64 caractères hexadécimaux attendus)")
100 }
101 g := mustGetGame(id)
102 if g.Status != StatusPending {
103 panic("gnodice: cette partie n'attend pas de tirage")
104 }
105 // Fenêtres disjointes : avant la limite, seul le tirage est possible ;
106 // après, seul le remboursement. Sinon un joueur qui voit passer un tirage
107 // tardif (et sa graine) pourrait le devancer avec Refund s'il a perdu.
108 if now() >= g.PlacedAt+ResolveTimeout {
109 panic("gnodice: délai de tirage dépassé, la partie doit être remboursée")
110 }
111
112 g.Seed = seed
113 g.Roll = rollFromSeed(seed, g.Commitment, g.ID)
114 g.Status = StatusRolled
115 g.RolledAt = now()
116
117 chain.Emit("DiceRolled", "id", itoa64(g.ID), "player", g.Player.String(), "roll", strconv.Itoa(g.Roll), "seed", seed)
118}
119
120// Reveal dévoile le chiffre caché et termine la partie. N'importe qui peut
121// l'appeler (le joueur ou le service du site) : le gain va toujours au joueur.
122// Renvoie le résultat, par exemple "roll=4;won=true;payout=50000000".
123func Reveal(cur realm, id int64, guess int, salt string) string {
124 g := mustGetGame(id)
125 if g.Status != StatusRolled {
126 panic("gnodice: cette partie ne peut pas être dévoilée maintenant")
127 }
128 if now() > g.RolledAt+RevealWindow {
129 panic("gnodice: délai dépassé, la partie a expiré")
130 }
131 if guess < 1 || guess > DieFaces {
132 panic("gnodice: le chiffre doit être entre 1 et 6")
133 }
134 if !isHex64(salt) {
135 panic("gnodice: secret invalide (64 caractères hexadécimaux attendus)")
136 }
137 if Commitment(g.Player, guess, salt) != g.Commitment {
138 panic("gnodice: le chiffre ou le secret ne correspond pas à l'empreinte")
139 }
140
141 g.Guess = guess
142 won := guess == g.Roll
143 payout := int64(0)
144 if won {
145 payout = g.reservation()
146 g.Status = StatusWon
147 } else {
148 g.Status = StatusLost
149 }
150 g.Payout = payout
151 finish(g)
152
153 p := getPlayer(g.Player)
154 p.Played++
155 p.Paid += payout
156 if won {
157 p.Wins++
158 totalWins++
159 } else {
160 totalLosses++
161 }
162 totalPaid += payout
163
164 chain.Emit(
165 "GameSettled",
166 "id", itoa64(g.ID),
167 "player", g.Player.String(),
168 "guess", strconv.Itoa(guess),
169 "roll", strconv.Itoa(g.Roll),
170 "bet", itoa64(g.Bet),
171 "payout", itoa64(payout),
172 "won", strconv.FormatBool(won),
173 )
174
175 if won {
176 recordPayout(payout)
177 pay(cur, g.Player, payout) // en dernier
178 }
179 checkLowBankroll()
180 return "roll=" + strconv.Itoa(g.Roll) + ";won=" + strconv.FormatBool(won) + ";payout=" + itoa64(payout)
181}
182
183// Refund rend sa mise au joueur si le croupier n'a pas tiré le dé à temps
184// (ResolveTimeout). N'importe qui peut le déclencher : l'argent va au joueur.
185func Refund(cur realm, id int64) {
186 g := mustGetGame(id)
187 if g.Status != StatusPending {
188 panic("gnodice: seule une partie en attente de tirage peut être remboursée")
189 }
190 if now() < g.PlacedAt+ResolveTimeout {
191 panic("gnodice: le croupier a encore " + formatDuration(g.PlacedAt+ResolveTimeout-now()) + " pour tirer le dé")
192 }
193
194 g.Status = StatusRefunded
195 g.Payout = g.Bet
196 finish(g)
197
198 p := getPlayer(g.Player)
199 p.Wagered -= g.Bet
200 totalWagered -= g.Bet
201 totalRefunded++
202
203 chain.Emit("GameRefunded", "id", itoa64(g.ID), "player", g.Player.String(), "amount", itoa64(g.Bet))
204 pay(cur, g.Player, g.Bet) // en dernier
205}
206
207// Expire termine une partie dont le chiffre n'a jamais été dévoilé
208// (RevealWindow) : elle compte comme perdue et la réserve est libérée.
209// N'importe qui peut l'appeler.
210func Expire(cur realm, id int64) {
211 g := mustGetGame(id)
212 if g.Status != StatusRolled {
213 panic("gnodice: seule une partie tirée et non dévoilée peut expirer")
214 }
215 if now() <= g.RolledAt+RevealWindow {
216 panic("gnodice: le joueur peut encore dévoiler son chiffre")
217 }
218
219 g.Status = StatusExpired
220 finish(g)
221
222 p := getPlayer(g.Player)
223 p.Played++
224 totalExpired++
225
226 chain.Emit("GameExpired", "id", itoa64(g.ID), "player", g.Player.String())
227}
228
229// --- outils internes ---
230
231// receivedBet vérifie la mise envoyée avec la transaction et renvoie son montant.
232func receivedBet() int64 {
233 sent := unsafe.OriginSend()
234 if len(sent) != 1 || sent[0].Denom != Denom {
235 panic("gnodice: envoie ta mise en GNOT uniquement")
236 }
237 amount := sent[0].Amount
238 if amount < MinBet || amount > MaxBet {
239 panic("gnodice: la mise doit être entre 1 et 10 GNOT")
240 }
241 return amount
242}
243
244// finish : partie terminée. Libère la réserve, la retire des parties en cours
245// et l'ajoute aux historiques.
246func finish(g *Game) {
247 g.SettledAt = now()
248 reserved -= g.reservation()
249 openGames.Remove(gameKey(g.ID))
250 p := getPlayer(g.Player)
251 p.Open--
252 p.History = pushID(p.History, g.ID)
253 recent = pushID(recent, g.ID)
254}
255
256// pay envoie des GNOT de la banque vers une adresse.
257func pay(cur realm, to address, amount int64) {
258 bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
259 bk.SendCoins(realmAddr, to, chain.NewCoins(chain.NewCoin(Denom, amount)))
260}
261
262// cooldownRemaining renvoie le nombre de secondes à attendre avant de pouvoir
263// rejouer (0 si le joueur peut jouer maintenant).
264func cooldownRemaining(p *playerData, t int64) int64 {
265 if p == nil || p.LastPlay == 0 {
266 return 0
267 }
268 wait := p.LastPlay + CooldownSeconds - t
269 if wait < 0 {
270 return 0
271 }
272 return wait
273}
274
275// formatDuration transforme un nombre de secondes en texte, ex. "7 min 12 s".
276func formatDuration(seconds int64) string {
277 if seconds < 60 {
278 return itoa64(seconds) + " s"
279 }
280 return itoa64(seconds/60) + " min " + itoa64(seconds%60) + " s"
281}