Join the RoClicks Discord
RoClicks

CrateRoll.luau

Luau, 89 lines from UI overview

Download CrateRoll.luau
--!strict
-- CrateRoll: decide the prize on the SERVER (weights + pity), then build a cosmetic reel plan on
-- the client around that known prize. The near-miss only exists in the reel strip; it never
-- changes the odds. Share the CRATES config with the odds popup so the popup can't lie.

local CrateRoll = {}

-- Shared config (ReplicatedStorage). The odds popup renders straight from this table.
CrateRoll.CRATES = {
	Basic = {
		weights = { Common = 55, Rare = 37, Epic = 5, Legendary = 3 },
		pity = { atLeast = "Epic", within = 15 }, -- guaranteed Epic+ within 15 opens
		prizes = {
			Common = { "CashSmall", "Units5" }, Rare = { "CashMedium", "Boost15" },
			Epic = { "GemSack" }, Legendary = { "SkinGold" },
		},
	},
}
CrateRoll.ORDER = { "Common", "Rare", "Epic", "Legendary" }

local function rank(r: string): number
	return table.find(CrateRoll.ORDER, r) or 1
end

local function weighted(rng: Random, weights: { [string]: number }, minRank: number): string
	local total = 0
	for r, w in weights do
		if rank(r) >= minRank then total += w end
	end
	local x = rng:NextNumber() * total
	for _, r in CrateRoll.ORDER do
		local w = weights[r]
		if w and rank(r) >= minRank then
			x -= w
			if x <= 0 then return r end
		end
	end
	return CrateRoll.ORDER[#CrateRoll.ORDER]
end

-- SERVER: call after charging the price. `pityCount` is saved per player per crate and survives
-- prestige resets. Returns rarity, prize id and the new pity count. Grant through your normal paths.
function CrateRoll.roll(crateId: string, pityCount: number, rng: Random): (string, string, number)
	local c = CrateRoll.CRATES[crateId]
	local floor = 1
	if c.pity and pityCount + 1 >= c.pity.within then
		floor = rank(c.pity.atLeast) -- forced on the last open of the window
	end
	local rarity = weighted(rng, c.weights, floor)
	local pool = c.prizes[rarity]
	local prize = pool[rng:NextInteger(1, #pool)]
	local hit = c.pity and rank(rarity) >= rank(c.pity.atLeast)
	return rarity, prize, if hit then 0 else pityCount + 1
end

-- CLIENT: a reel strip with the prize at `landing` and a one-better near-miss right after it.
-- The reel eases past the prize toward the near-miss, then settles back onto the prize.
export type Tile = { rarity: string, prize: string }
function CrateRoll.reelPlan(crateId: string, rarity: string, prize: string, length: number?): ({ Tile }, number)
	local c = CrateRoll.CRATES[crateId]
	local n = length or 40
	local landing = n - 6
	local rng = Random.new()
	local strip: { Tile } = {}
	for i = 1, n do
		local r = weighted(rng, c.weights, 1) -- flavour only
		local pool = c.prizes[r]
		strip[i] = { rarity = r, prize = pool[rng:NextInteger(1, #pool)] }
	end
	strip[landing] = { rarity = rarity, prize = prize }
	-- near-miss: the next tile is one rarity better (or Epic when the prize is the top rarity)
	local better = if rank(rarity) < #CrateRoll.ORDER then CrateRoll.ORDER[rank(rarity) + 1] else CrateRoll.ORDER[#CrateRoll.ORDER - 1]
	local pool = c.prizes[better]
	strip[landing + 1] = { rarity = better, prize = pool[1] }
	return strip, landing
end

-- Reel motion: overshoot by ~0.35 tile toward the near-miss, then settle. tileW in px.
-- First spin ~5.2 s, later spins of a multi-open ~3.6 s. A tap jumps straight to the landing.
function CrateRoll.reelOffsets(landing: number, tileW: number): (number, number)
	local final = (landing - 1) * tileW
	return final + tileW * 0.35, final -- tween to the first with Quint Out, then Back Out to the second
end

-- Before sale: respect paid-random-item policy for this player.
--   local ok, info = pcall(PolicyService.GetPolicyInfoForPlayerAsync, PolicyService, player)
--   if not ok or info.ArePaidRandomItemsRestricted then -- hide paid crates / show a non-random offer
return CrateRoll