/* Primal Rampage — profile & persistence v2 (localStorage). v2 adds: gems (hard currency), permanent upgrades, bestiary kills, daily quests + login streak, achievements, lifetime stats, pet, tutorial flag, rewarded-ad daily cap, shake amount + quality setting. Server-backed leaderboards/accounts plug in later via US.net. */ (function () { 'use strict'; var KEY = 'neonrampage.profile.v1'; var US = (window.US = window.US || {}); var DEFAULTS = { name: 'Rookie', lang: 'en', coins: 0, bombs: 1, weapons: ['pistol'], heroes: ['recruit'], skins: ['default'], weapon: 'pistol', hero: 'recruit', skin: 'default', bestScore: 0, bestWave: 0, // --- v2 --- gems: 0, // hard currency "ngọn lửa" upgrades: {}, // id -> level bestiary: {}, // speciesId -> {kills, done} quests: { date: '', items: [], lastDay: '', streak: 0 }, achievements: {}, // id -> true stats: { totalKills: 0, totalAmber: 0, runs: 0 }, pet: null, // {name, color} once hatched tut: false, adDay: { date: '', count: 0 }, // rewarded ads watched today (cap 3) settings: { sfx: 0.8, music: 0.5, autofire: true, autoAim: false, onehit: false, shake: true, shakeAmt: 1, quality: 'high' }, board: [], // [{name,score,wave,ts}] // --- v3: retention systems --- login: { last: '', streak: 0, claimedToday: false }, wheel: { last: '' }, chest: { keys: 0, pending: 0 }, eggs: { hatched: 0, pity: 0 }, prestige: { count: 0, ngoc: 0, perks: {} }, // perks: {dmg:lv,hp:lv,gold:lv} mastery: {}, // weaponKind -> kills dailyChal: { date: '', bestScore: 0, board: [] }, // [{name,score,wave,ts}] top5 season: { week: '', points: 0, claimedMilestones: [] }, raid: { week: '', damage: 0, claimedTiers: [] } }; function isoWeekKey(d) { var dt = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); var day = dt.getUTCDay() || 7; dt.setUTCDate(dt.getUTCDate() + 4 - day); var y0 = new Date(Date.UTC(dt.getUTCFullYear(), 0, 1)); var wk = Math.ceil(((dt - y0) / 864e5 + 1) / 7); return dt.getUTCFullYear() + '-W' + wk; } var PRESTIGE_PERKS = { dmg: { per: 0.04, cost: [1, 2, 4, 8] }, hp: { per: 8, cost: [1, 2, 4, 8] }, gold: { per: 0.03, cost: [1, 2, 4, 8] } }; var SEASON_RANKS = [ { at: 0, en: 'Unranked', vi: 'Chưa Xếp Hạng', icon: '·' }, { at: 1000, en: 'Bronze', vi: 'Đồng', icon: '🥉' }, { at: 3000, en: 'Silver', vi: 'Bạc', icon: '🥈' }, { at: 8000, en: 'Gold', vi: 'Vàng', icon: '🥇' }, { at: 20000, en: 'Diamond', vi: 'Kim Cương', icon: '💎' } ]; var BESTIARY_MS = [50, 200, 500]; var BESTIARY_MS_GEMS = [5, 12, 30]; var RAID_TIERS = [ { dmg: 1000, gems: 20 }, { dmg: 5000, gems: 50 }, { dmg: 20000, gems: 120 } ]; function clone(o) { return JSON.parse(JSON.stringify(o)); } function isPlain(o) { return o && typeof o === 'object' && !(o instanceof Array); } // deep-merge stored data over defaults so new fields appear after updates function mergeDefaults(dst, def) { Object.keys(def).forEach(function (k) { if (!(k in dst)) dst[k] = clone(def[k]); else if (isPlain(def[k]) && isPlain(dst[k])) mergeDefaults(dst[k], def[k]); }); return dst; } var state = null; function load() { try { var raw = localStorage.getItem(KEY); state = raw ? mergeDefaults(JSON.parse(raw), DEFAULTS) : clone(DEFAULTS); state.settings = Object.assign(clone(DEFAULTS.settings), state.settings || {}); state.stats = Object.assign(clone(DEFAULTS.stats), state.stats || {}); state.quests = Object.assign(clone(DEFAULTS.quests), state.quests || {}); state.adDay = Object.assign(clone(DEFAULTS.adDay), state.adDay || {}); if (!state.weapons.length) state.weapons = ['pistol']; if (!state.heroes.length) state.heroes = ['recruit']; if (!state.skins.length) state.skins = ['default']; } catch (e) { state = clone(DEFAULTS); } return state; } US.save = { get: function () { return state || load(); }, save: function () { try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) { /* private mode */ } }, reset: function () { state = clone(DEFAULTS); US.save.save(); }, addCoins: function (n) { state.coins = Math.max(0, (state.coins | 0) + n); US.save.save(); }, /* ---------- hard currency ---------- */ addGems: function (n) { state.gems = Math.max(0, (state.gems | 0) + n); US.save.save(); }, spendGems: function (n) { if ((state.gems | 0) < n) return false; state.gems -= n; US.save.save(); return true; }, ownWeapon: function (id) { if (state.weapons.indexOf(id) < 0) { state.weapons.push(id); US.save.save(); } }, ownHero: function (id) { if (state.heroes.indexOf(id) < 0) { state.heroes.push(id); US.save.save(); } }, ownSkin: function (id) { if (state.skins.indexOf(id) < 0) { state.skins.push(id); US.save.save(); } }, equip: function (slot, id) { state[slot] = id; US.save.save(); }, /* ---------- permanent upgrades ---------- */ upLevel: function (id) { return state.upgrades[id] | 0; }, buyUpgrade: function (id, cost) { var lv = state.upgrades[id] | 0; if ((state.coins | 0) < cost) return false; state.coins -= cost; state.upgrades[id] = lv + 1; US.save.save(); return true; }, /* ---------- bestiary ---------- */ bumpBestiary: function (speciesId, n) { var e = state.bestiary[speciesId]; if (!e) e = state.bestiary[speciesId] = { kills: 0, done: false }; e.kills += (n || 1); US.save.save(); return e; }, bestiaryDone: function (speciesId, rewardFn) { var e = state.bestiary[speciesId]; if (e && !e.done) { e.done = true; if (rewardFn) rewardFn(); US.save.save(); return true; // newly completed } return false; }, /* ---------- lifetime stats ---------- */ stat: function (k, n) { if (n === undefined) return state.stats[k] | 0; state.stats[k] = (state.stats[k] | 0) + n; US.save.save(); return state.stats[k]; }, /* ---------- achievements ---------- */ hasAch: function (id) { return !!state.achievements[id]; }, unlockAch: function (id) { if (state.achievements[id]) return false; state.achievements[id] = true; US.save.save(); return true; }, /* ---------- rewarded ads (stub; swap with real SDK later) ---------- */ ADS_DAILY_CAP: 3, canWatchAd: function () { var today = US.save.today(); if (state.adDay.date !== today) { state.adDay = { date: today, count: 0 }; } return state.adDay.count < US.save.ADS_DAILY_CAP; }, consumeAd: function () { var today = US.save.today(); if (state.adDay.date !== today) state.adDay = { date: today, count: 0 }; state.adDay.count++; US.save.save(); }, /* ---------- live-ops ---------- */ isoWeekKey: function (d) { return isoWeekKey(d || new Date()); }, // ---- eggs with rarity + pity ---- hatchEgg: function (forceRoll) { var st = this.get(); st.eggs.hatched++; st.eggs.pity++; var rar = 'common'; var roll = (forceRoll !== undefined) ? forceRoll : Math.random() * 100; if (roll >= 95 || st.eggs.pity >= 30) rar = 'legendary'; else if (roll >= 70) rar = 'rare'; if (rar !== 'common') st.eggs.pity = 0; var pool = US.PETS[rar]; var pick = pool[(Math.random() * pool.length) | 0]; st.pet = { name: pick.name, color: pick.color, rarity: rar, perk: pick.perk || null }; this.save(); return { pet: clone(st.pet), rarity: rar }; }, // ---- 7-day login calendar ---- claimLogin: function () { var st = this.get(); var today = this.today(); if (st.login.claimedToday && st.login.last === today) return { ok: false, reason: 'claimed' }; var yest = new Date(); yest.setDate(yest.getDate() - 1); var yKey = [yest.getFullYear(), String(yest.getMonth() + 1).padStart(2, '0'), String(yest.getDate()).padStart(2, '0')].join('-'); st.login.streak = (st.login.last === yKey) ? st.login.streak + 1 : 1; st.login.last = today; st.login.claimedToday = true; var dayIdx = Math.min(6, st.login.streak - 1); var rw = US.LOGIN_REWARDS[dayIdx]; if (rw.coins) st.coins += rw.coins; if (rw.gems) this.addGems(rw.gems); if (rw.chestKey) st.chest.keys += rw.chestKey; var hatched = null; if (rw.egg) hatched = this.hatchEgg(rw.egg === 'rare' ? 80 : 96); this.save(); return { ok: true, day: dayIdx + 1, streak: st.login.streak, reward: rw, pet: hatched && hatched.pet }; }, // ---- lucky wheel, one free spin per day ---- WHEEL: [ { coins: 250 }, { gems: 5 }, { coins: 500 }, { chestKey: 1 }, { coins: 750 }, { gems: 15 }, { coins: 1200 }, { egg: 1 } ], canSpin: function () { var st = this.get(); return st.wheel.last !== this.today(); }, spinWheel: function (idx) { if (!this.canSpin()) return { ok: false, reason: 'spun' }; var st = this.get(); st.wheel.last = this.today(); var seg = (idx !== undefined) ? idx : (Math.random() * this.WHEEL.length) | 0; var rw = this.WHEEL[seg % this.WHEEL.length]; var petRes = null; if (rw.coins) st.coins += rw.coins; if (rw.gems) this.addGems(rw.gems); if (rw.chestKey) st.chest.keys += rw.chestKey; if (rw.egg) petRes = this.hatchEgg(); this.save(); return { ok: true, seg: seg % this.WHEEL.length, reward: rw, pet: petRes && petRes.pet }; }, // ---- seeded daily challenge ---- DAILY_MODS: null, // filled below after US.DAILY_MOD_POOL exists dailyInfo: function () { var date = this.today(); var seed = 0; for (var i = 0; i < date.length; i++) seed = (seed * 31 + date.charCodeAt(i)) >>> 0; // mulberry32 function rnd() { seed |= 0; seed = (seed + 0x6D2B79F5) | 0; var t2 = Math.imul(seed ^ (seed >>> 15), 1 | seed); t2 = (t2 + Math.imul(t2 ^ (t2 >>> 7), 61 | t2)) ^ t2; return ((t2 ^ (t2 >>> 14)) >>> 0) / 4294967296; } var pool = US.DAILY_MOD_POOL; var a = pool[(rnd() * pool.length) | 0]; var b = pool[(rnd() * pool.length) | 0]; var mods = [a, b !== a ? b : pool[(pool.indexOf(a) + 1) % pool.length]]; return { date: date, mods: mods, seed: seed }; }, submitChallengeScore: function (score, wave) { var st = this.get(); var info = this.dailyInfo(); if (st.dailyChal.date !== info.date) { st.dailyChal.date = info.date; st.dailyChal.bestScore = 0; st.dailyChal.board = []; } st.dailyChal.bestScore = Math.max(st.dailyChal.bestScore, score | 0); st.dailyChal.board.push({ name: st.name, score: score | 0, wave: wave | 0, ts: Date.now() }); st.dailyChal.board.sort(function (p, q2) { return q2.score - p.score; }); st.dailyChal.board = st.dailyChal.board.slice(0, 5); this.save(); return { best: st.dailyChal.bestScore, rank: st.dailyChal.board.length }; }, // ---- weekly raid ---- raidPoolBase: 50000, raidInfo: function () { var st = this.get(); var wk = isoWeekKey(new Date()); if (st.raid.week !== wk) { st.raid.week = wk; st.raid.damage = 0; st.raid.claimedTiers = []; } var weekNum = parseInt(wk.split('-W')[1], 10) || 1; return { week: wk, pool: this.raidPoolBase * weekNum, damage: st.raid.damage, tiers: RAID_TIERS, claimedTiers: st.raid.claimedTiers.slice() }; }, addRaidDamage: function (dmg) { var st = this.get(); var wk = isoWeekKey(new Date()); if (st.raid.week !== wk) { st.raid.week = wk; st.raid.damage = 0; st.raid.claimedTiers = []; } st.raid.damage += Math.round(dmg); this.save(); }, claimRaidTier: function (idx) { var st = this.get(); var wk = isoWeekKey(new Date()); if (st.raid.week !== wk) return { ok: false, reason: 'week' }; if (st.raid.claimedTiers.indexOf(idx) >= 0) return { ok: false, reason: 'claimed' }; var tier = RAID_TIERS[idx]; if (!tier || st.raid.damage < tier.dmg) return { ok: false, reason: 'locked' }; st.raid.claimedTiers.push(idx); this.addGems(tier.gems); this.save(); return { ok: true, gems: tier.gems }; }, // ---- prestige / rebirth ---- prestigeInfo: function () { var st = this.get(); var gain = st.bestWave >= 50 ? Math.max(1, Math.floor((st.bestWave - 40) / 5)) : 0; return { canPrestige: st.bestWave >= 50, bestWave: st.bestWave, ngocGain: gain, ngoc: st.prestige.ngoc, count: st.prestige.count, perks: clone(st.prestige.perks), perkDefs: PRESTIGE_PERKS }; }, doPrestige: function () { var st = this.get(); var info = this.prestigeInfo(); if (!info.canPrestige) return { ok: false, reason: 'wave50' }; st.prestige.ngoc += info.ngocGain; st.prestige.count++; st.upgrades = {}; st.coins = 0; st.bombs = 1; this.save(); return { ok: true, gained: info.ngocGain }; }, buyPerk: function (id) { var def = PRESTIGE_PERKS[id]; if (!def) return { ok: false, reason: 'noid' }; var st = this.get(); var lv = st.prestige.perks[id] || 0; if (lv >= def.cost.length) return { ok: false, reason: 'max' }; var cost = def.cost[lv]; if (st.prestige.ngoc < cost) return { ok: false, reason: 'poor' }; st.prestige.ngoc -= cost; st.prestige.perks[id] = lv + 1; this.save(); return { ok: true, level: lv + 1 }; }, // ---- weapon mastery ---- masteryLevel: function (kind) { var kills = (this.get().mastery || {})[kind] || 0; var lvl = Math.min(15, Math.floor(Math.sqrt(kills / 5))); return { level: lvl, bonus: lvl * 0.02, kills: kills }; }, // ---- bestiary milestones ---- bestiaryMilestoneCheck: function (spKey) { var st = this.get(); var entry = st.bestiary[spKey]; if (!entry) return 0; entry.ms = entry.ms || []; var granted = 0; BESTIARY_MS.forEach(function (m4, i4) { if (entry.kills >= m4 && entry.ms.indexOf(i4) < 0) { entry.ms.push(i4); granted += BESTIARY_MS_GEMS[i4]; } }); if (granted > 0) this.addGems(granted); return granted; }, // ---- season points & rank ---- addSeasonPoints: function (n) { var st = this.get(); var wk = isoWeekKey(new Date()); if (st.season.week !== wk) { st.season.week = wk; st.season.points = 0; } st.season.week = wk; st.season.points += n | 0; this.save(); return st.season.points; }, seasonRank: function () { var st = this.get(); var pts = st.season.points || 0; var tier = SEASON_RANKS[0]; for (var i5 = SEASON_RANKS.length - 1; i5 >= 0; i5--) { if (pts >= SEASON_RANKS[i5].at) { tier = SEASON_RANKS[i5]; break; } } return { points: pts, week: st.season.week, tier: clone(tier) }; }, // ---- boss chest ---- openChest: function () { var st = this.get(); if (st.chest.keys < 1) return { ok: false, reason: 'nokey' }; st.chest.keys--; var r = Math.random(); var reward; var petRes = null; if (r < 0.55) { var c2 = 450 + ((Math.random() * 500) | 0); st.coins += c2; reward = { coins: c2 }; } else if (r < 0.85) { var g2 = 10 + ((Math.random() * 12) | 0); this.addGems(g2); reward = { gems: g2 }; } else { petRes = this.hatchEgg(); reward = { egg: true }; } this.save(); return { ok: true, reward: reward, pet: petRes && petRes.pet }; }, isWeekend: function () { var d = new Date().getDay(); return d === 0 || d === 6; }, /* ---------- misc ---------- */ today: function () { var d = new Date(); return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2); }, submitRun: function (score, wave) { var isRecord = score > state.bestScore; if (isRecord) state.bestScore = Math.floor(score); if (wave > state.bestWave) state.bestWave = wave; state.board.push({ name: state.name || 'Rookie', score: Math.floor(score), wave: wave, ts: Date.now() }); state.board.sort(function (a, b) { return b.score - a.score; }); state.board = state.board.slice(0, 10); US.save.save(); // Plug-in point for a real server leaderboard: // if (US.net && US.net.submitRun) US.net.submitRun(state.board[0]); return isRecord; } }; load(); })();