CrateRoll.luau
Luau, 89 lines from UI overview
--!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