--!strict -- PORTFOLIO SAMPLE / ROBLOX STUDIO VALIDATION REQUIRED -- Put this Script in ServerScriptService. -- Isolated example: Daily Rewards + Promo Codes share ONE demo currency store. -- Integrate with your game's existing economy before using in production. local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local DataStoreService = game:GetService("DataStoreService") local STORE = DataStoreService:GetDataStore("NeoPortfolioRewardDemo_v1") local REMOTE_NAME = "NeoPortfolioRewardsAction" local DAILY_REWARD = 150 local DAILY_COOLDOWN = 24 * 60 * 60 local PROMO_CODES: {[string]: number} = { WELCOME100 = 100, TEST50 = 50, } type Profile = { coins: number, lastDaily: number, redeemed: {[string]: boolean}, } local profiles: {[number]: Profile} = {} local loaded: {[number]: boolean} = {} local busy: {[number]: boolean} = {} local remote = ReplicatedStorage:FindFirstChild(REMOTE_NAME) if not remote then remote = Instance.new("RemoteFunction") remote.Name = REMOTE_NAME remote.Parent = ReplicatedStorage end assert(remote:IsA("RemoteFunction"), "Reward remote must be a RemoteFunction") local function sanitizedProfile(value: any): Profile local result: Profile = {coins = 0, lastDaily = 0, redeemed = {}} if type(value) ~= "table" then return result end if type(value.coins) == "number" and value.coins >= 0 and value.coins < 1e12 then result.coins = math.floor(value.coins) end if type(value.lastDaily) == "number" and value.lastDaily >= 0 then result.lastDaily = math.floor(value.lastDaily) end if type(value.redeemed) == "table" then for code, redeemed in pairs(value.redeemed) do if type(code) == "string" and redeemed == true and PROMO_CODES[code] then result.redeemed[code] = true end end end return result end local function getCoinsValue(player: Player): IntValue local stats = player:FindFirstChild("leaderstats") if not stats then stats = Instance.new("Folder") stats.Name = "leaderstats" stats.Parent = player end local existing = stats:FindFirstChild("Coins") if existing and existing:IsA("IntValue") then return existing end local coins = Instance.new("IntValue") coins.Name = "Coins" coins.Value = 0 coins.Parent = stats return coins end local function publicState(profile: Profile): {[string]: any} local secondsLeft = math.max(0, DAILY_COOLDOWN - (os.time() - profile.lastDaily)) return { coins = profile.coins, secondsLeft = secondsLeft, canClaim = secondsLeft == 0, reward = DAILY_REWARD, } end local function response(ok: boolean, message: string, profile: Profile?): {[string]: any} return { ok = ok, message = message, state = if profile then publicState(profile) else nil, } end local function loadPlayer(player: Player) local id = player.UserId getCoinsValue(player) local success, stored = pcall(function() return STORE:GetAsync("user_" .. tostring(id)) end) if player.Parent ~= Players then return end if not success then warn("[RewardDemo] DataStore load failed for " .. id .. ": " .. tostring(stored)) -- Fail closed: do not award coins if the saved state could not be read. loaded[id] = false return end local profile = sanitizedProfile(stored) profiles[id] = profile getCoinsValue(player).Value = profile.coins loaded[id] = true end Players.PlayerAdded:Connect(loadPlayer) Players.PlayerRemoving:Connect(function(player) -- Claims are persisted immediately with UpdateAsync; there is no leave-time write. profiles[player.UserId] = nil loaded[player.UserId] = nil busy[player.UserId] = nil end) for _, player in Players:GetPlayers() do task.spawn(loadPlayer, player) end remote.OnServerInvoke = function(player: Player, action: any, detail: any) local id = player.UserId if not loaded[id] or not profiles[id] then return response(false, "Data is still loading or unavailable. Try again shortly.", nil) end local current = profiles[id] if action == "status" then return response(true, "Ready", current) end if action ~= "daily" and action ~= "promo" then return response(false, "Unknown action", current) end if busy[id] then return response(false, "Please wait for the current action", current) end local now = os.time() local promoCode: string? = nil if action == "promo" then if type(detail) ~= "string" or #detail > 40 then return response(false, "Invalid code", current) end promoCode = string.upper(string.gsub(detail, "%s+", "")) if PROMO_CODES[promoCode] == nil then return response(false, "Code not found", current) end end if action == "daily" and now - current.lastDaily < DAILY_COOLDOWN then return response(false, "Your daily reward is on cooldown", current) end if promoCode and current.redeemed[promoCode] then return response(false, "You already used that code", current) end busy[id] = true local success, stored = pcall(function() return STORE:UpdateAsync("user_" .. tostring(id), function(oldData: any) local nextProfile = sanitizedProfile(oldData) if action == "daily" then if now - nextProfile.lastDaily < DAILY_COOLDOWN then return nil -- Abort an ineligible write, including cross-server duplicates. end nextProfile.coins += DAILY_REWARD nextProfile.lastDaily = now elseif promoCode then if nextProfile.redeemed[promoCode] then return nil end nextProfile.coins += PROMO_CODES[promoCode] nextProfile.redeemed[promoCode] = true end return nextProfile end) end) busy[id] = nil if not success then warn("[RewardDemo] DataStore update failed: " .. tostring(stored)) return response(false, "Could not confirm the save; please rejoin and check", current) end if stored == nil then return response(false, "Already claimed or redeemed. Rejoin to refresh status.", current) end local updated = sanitizedProfile(stored) -- Another PlayerRemoving may have run during the DataStore operation. if player.Parent == Players then profiles[id] = updated getCoinsValue(player).Value = updated.coins end return response(true, if action == "daily" then "+150 coins claimed!" else "Promo code redeemed!", updated) end