fomo3d.gno

9.51 Kb · 358 lines
  1package fomo3d
  2
  3import (
  4	"std"
  5	"strings"
  6
  7	"gno.land/p/demo/avl"
  8	"gno.land/p/demo/ownable"
  9	"gno.land/p/demo/ufmt"
 10
 11	"gno.land/r/demo/users"
 12	"gno.land/r/leon/hof"
 13)
 14
 15// FOMO3D (Fear Of Missing Out 3D) is a blockchain-based game that combines elements
 16// of a lottery and investment mechanics. Players purchase keys using GNOT tokens,
 17// where each key purchase:
 18// - Extends the game timer
 19// - Increases the key price by 1%
 20// - Makes the buyer the potential winner of the jackpot
 21// - Distributes dividends to all key holders
 22//
 23// Game Mechanics:
 24// - The last person to buy a key before the timer expires wins the jackpot (47% of all purchases)
 25// - Key holders earn dividends from each purchase (28% of all purchases)
 26// - 20% of purchases go to the next round's starting pot
 27// - 5% goes to development fee
 28// - Game ends when the timer expires
 29//
 30// Inspired by the original Ethereum FOMO3D game but implemented in Gno.
 31
 32const (
 33	MIN_KEY_PRICE  int64 = 100000 // minimum key price in ugnot
 34	TIME_EXTENSION int64 = 86400  // time extension in blocks when new key is bought (~24 hours @ 1s blocks)
 35
 36	// Distribution percentages (total 100%)
 37	JACKPOT_PERCENT   int64 = 47 // 47% goes to jackpot
 38	DIVIDENDS_PERCENT int64 = 28 // 28% distributed to key holders
 39	NEXT_ROUND_POT    int64 = 20 // 20% goes to next round's starting pot
 40	OWNER_FEE_PERCENT int64 = 5  // 5% goes to contract owner
 41)
 42
 43type PlayerInfo struct {
 44	Keys      int64 // number of keys owned
 45	Dividends int64 // unclaimed dividends in ugnot
 46}
 47
 48// GameState represents the current state of the FOMO3D game
 49type GameState struct { // TODO: Separate GameState and RoundState and save round history tree in GameState
 50	StartBlock         int64       // Block when the game started
 51	EndBlock           int64       // Block when the game will end
 52	LastKeyBlock       int64       // Block of last key purchase
 53	LastBuyer          std.Address // Address of last key buyer
 54	Jackpot            int64       // Current jackpot in ugnot
 55	KeyPrice           int64       // Current price of keys in ugnot
 56	TotalKeys          int64       // Total number of keys in circulation
 57	Ended              bool        // Whether the game has ended
 58	CurrentRound       int64       // Current round number
 59	NextPot            int64       // Next round's starting pot
 60	OwnerFee           int64       // Accumulated owner fees
 61	BuyKeysLink        string      // Link to BuyKeys function
 62	ClaimDividendsLink string      // Link to ClaimDividends function
 63	StartGameLink      string      // Link to StartGame function
 64}
 65
 66var (
 67	gameState GameState
 68	players   *avl.Tree // maps address -> PlayerInfo
 69	Ownable   *ownable.Ownable
 70)
 71
 72func init() {
 73	Ownable = ownable.New()
 74	players = avl.NewTree()
 75	gameState.Ended = true
 76	hof.Register()
 77}
 78
 79// StartGame starts a new game round
 80func StartGame() {
 81	if !gameState.Ended && gameState.StartBlock != 0 {
 82		panic(ErrGameInProgress.Error())
 83	}
 84
 85	gameState.CurrentRound++
 86	gameState.StartBlock = std.ChainHeight()
 87	gameState.EndBlock = gameState.StartBlock + TIME_EXTENSION // Initial 24h window
 88	gameState.LastKeyBlock = gameState.StartBlock
 89	gameState.Jackpot = gameState.NextPot
 90	gameState.NextPot = 0
 91	gameState.Ended = false
 92	gameState.KeyPrice = MIN_KEY_PRICE
 93	gameState.TotalKeys = 0
 94
 95	// Clear previous round's player data
 96	players = avl.NewTree()
 97
 98	emitGameStarted(
 99		gameState.CurrentRound,
100		gameState.StartBlock,
101		gameState.EndBlock,
102		gameState.Jackpot,
103	)
104}
105
106// BuyKeys allows players to purchase keys
107func BuyKeys() {
108	if gameState.Ended {
109		panic(ErrGameEnded.Error())
110	}
111
112	currentBlock := std.ChainHeight()
113	if currentBlock > gameState.EndBlock {
114		panic(ErrGameTimeExpired.Error())
115	}
116
117	// Get sent coins
118	sent := std.OriginSend()
119	if len(sent) != 1 || sent[0].Denom != "ugnot" {
120		panic(ErrInvalidPayment.Error())
121	}
122
123	payment := sent.AmountOf("ugnot")
124	if payment < gameState.KeyPrice {
125		panic(ErrInsufficientPayment.Error())
126	}
127
128	// Calculate number of keys that can be bought and actual cost
129	numKeys := payment / gameState.KeyPrice
130	actualCost := numKeys * gameState.KeyPrice
131	excess := payment - actualCost
132
133	// Update buyer's info
134	buyer := std.PreviousRealm().Address()
135	var buyerInfo PlayerInfo
136	if info, exists := players.Get(buyer.String()); exists {
137		buyerInfo = info.(PlayerInfo)
138	}
139
140	buyerInfo.Keys += numKeys
141	gameState.TotalKeys += numKeys
142
143	// Distribute actual cost
144	jackpotShare := actualCost * JACKPOT_PERCENT / 100
145	dividendShare := actualCost * DIVIDENDS_PERCENT / 100
146	nextPotShare := actualCost * NEXT_ROUND_POT / 100
147	ownerShare := actualCost * OWNER_FEE_PERCENT / 100
148
149	// Update pools
150	gameState.Jackpot += jackpotShare
151	gameState.NextPot += nextPotShare
152	gameState.OwnerFee += ownerShare
153
154	// Return excess payment to buyer if any
155	if excess > 0 {
156		banker := std.NewBanker(std.BankerTypeOriginSend)
157		banker.SendCoins(
158			std.CurrentRealm().Address(),
159			buyer,
160			std.NewCoins(std.NewCoin("ugnot", excess)),
161		)
162	}
163
164	// Distribute dividends to all key holders
165	if players.Size() > 0 && gameState.TotalKeys > 0 {
166		dividendPerKey := dividendShare / gameState.TotalKeys
167		players.Iterate("", "", func(key string, value any) bool {
168			playerInfo := value.(PlayerInfo)
169			playerInfo.Dividends += playerInfo.Keys * dividendPerKey
170			players.Set(key, playerInfo)
171			return false
172		})
173	}
174
175	// Update game state
176	gameState.LastBuyer = buyer
177	gameState.LastKeyBlock = currentBlock
178	gameState.EndBlock = currentBlock + TIME_EXTENSION // Always extend 24h from current block
179	gameState.KeyPrice += (gameState.KeyPrice * numKeys) / 100
180
181	// Save buyer's updated info
182	players.Set(buyer.String(), buyerInfo)
183
184	emitKeysPurchased(
185		buyer,
186		numKeys,
187		gameState.KeyPrice,
188		jackpotShare,
189		dividendShare,
190	)
191}
192
193// ClaimDividends allows players to withdraw their earned dividends
194func ClaimDividends() {
195	caller := std.PreviousRealm().Address()
196
197	info, exists := players.Get(caller.String())
198	if !exists {
199		panic(ErrNoDividendsToClaim.Error())
200	}
201
202	playerInfo := info.(PlayerInfo)
203	if playerInfo.Dividends == 0 {
204		panic(ErrNoDividendsToClaim.Error())
205	}
206
207	// Reset dividends and send coins
208	amount := playerInfo.Dividends
209	playerInfo.Dividends = 0
210	players.Set(caller.String(), playerInfo)
211
212	banker := std.NewBanker(std.BankerTypeRealmSend)
213	banker.SendCoins(
214		std.CurrentRealm().Address(),
215		caller,
216		std.NewCoins(std.NewCoin("ugnot", amount)),
217	)
218
219	emitDividendsClaimed(caller, amount)
220}
221
222// ClaimOwnerFee allows the owner to withdraw accumulated fees
223func ClaimOwnerFee() {
224	Ownable.AssertCallerIsOwner()
225
226	if gameState.OwnerFee == 0 {
227		panic(ErrNoFeesToClaim.Error())
228	}
229
230	amount := gameState.OwnerFee
231	gameState.OwnerFee = 0
232
233	banker := std.NewBanker(std.BankerTypeRealmSend)
234	banker.SendCoins(
235		std.CurrentRealm().Address(),
236		Ownable.Owner(),
237		std.NewCoins(std.NewCoin("ugnot", amount)),
238	)
239
240	emitOwnerFeeClaimed(Ownable.Owner(), amount)
241}
242
243// EndGame ends the current round and distributes the jackpot
244func EndGame() {
245	if gameState.Ended {
246		panic(ErrGameEnded.Error())
247	}
248
249	currentBlock := std.ChainHeight()
250	if currentBlock <= gameState.EndBlock {
251		panic(ErrGameNotInProgress.Error())
252	}
253
254	if gameState.LastBuyer == "" {
255		panic(ErrNoKeysPurchased.Error())
256	}
257
258	gameState.Ended = true
259
260	// Send jackpot to winner
261	banker := std.NewBanker(std.BankerTypeRealmSend)
262	banker.SendCoins(
263		std.CurrentRealm().Address(),
264		gameState.LastBuyer,
265		std.NewCoins(std.NewCoin("ugnot", gameState.Jackpot)),
266	)
267
268	emitGameEnded(
269		gameState.CurrentRound,
270		gameState.LastBuyer,
271		gameState.Jackpot,
272	)
273
274	// Mint NFT for the winner
275	if err := mintRoundWinnerNFT(gameState.LastBuyer, gameState.CurrentRound); err != nil {
276		panic(err.Error())
277	}
278}
279
280// GetGameState returns current game state
281func GetGameState() (int64, int64, int64, std.Address, int64, int64, int64, bool, int64, int64) {
282	return gameState.StartBlock,
283		gameState.EndBlock,
284		gameState.LastKeyBlock,
285		gameState.LastBuyer,
286		gameState.Jackpot,
287		gameState.KeyPrice,
288		gameState.TotalKeys,
289		gameState.Ended,
290		gameState.NextPot,
291		gameState.CurrentRound
292}
293
294// GetOwnerInfo returns the owner address and unclaimed fees
295func GetOwnerInfo() (std.Address, int64) {
296	return Ownable.Owner(), gameState.OwnerFee
297}
298
299// Helper to convert string (address or username) to address
300func stringToAddress(input string) std.Address {
301	// Check if input is valid address
302	addr := std.Address(input)
303	if addr.IsValid() {
304		return addr
305	}
306
307	// Not an address, try to find namespace
308	if user := users.GetUserByName(input); user != nil {
309		return user.Address
310	}
311
312	return ""
313}
314
315func isPlayerInGame(addr std.Address) bool {
316	_, exists := players.Get(addr.String())
317	return exists
318}
319
320// GetPlayerInfo returns a player's keys and dividends
321func GetPlayerInfo(addrOrName string) (int64, int64) {
322	addr := stringToAddress(addrOrName)
323
324	if addr == "" {
325		panic(ErrInvalidAddressOrName.Error())
326	}
327
328	if !isPlayerInGame(addr) {
329		panic(ErrPlayerNotInGame.Error())
330	}
331
332	info, _ := players.Get(addr.String())
333	playerInfo := info.(PlayerInfo)
334	return playerInfo.Keys, playerInfo.Dividends
335}
336
337// Render handles the rendering of game state
338func Render(path string) string {
339	parts := strings.Split(path, "/")
340	c := len(parts)
341
342	switch {
343	case path == "":
344		return RenderHome()
345	case c == 2 && parts[0] == "player":
346		if gameState.Ended {
347			return ufmt.Sprintf("🔴 Game has not started yet.\n\n Call [`StartGame()`](%s) to start a new round.\n\n", gameState.StartGameLink)
348		}
349		addr := stringToAddress(parts[1])
350		if addr == "" || !isPlayerInGame(addr) {
351			return "Address not found in game. You need to buy keys first to view your stats.\n\n"
352		}
353		keys, dividends := GetPlayerInfo(parts[1])
354		return RenderPlayer(addr, keys, dividends)
355	default:
356		return "404: Invalid path\n\n"
357	}
358}