[["main.lua","\n\n\n\n\n\n\n\n\n\nlocal AUTOPLAY = false\n\nlocal C = require \"ringcore.config\"\nlocal G = require \"ringcore.state\"\nlocal Layout = require \"ringcore.layout\"\nlocal Audio = require \"ringcore.audio\"\nlocal Fx = require \"ringcore.fx\"\nlocal Ring = require \"ringcore.ring\"\nlocal Link = require \"ringcore.interlock\"\nlocal Board = require \"ringcore.activeboard\"\nlocal Flow = require \"ringcore.flow\"\nlocal Input = require \"ringcore.input\"\nlocal Bot = require \"ringcore.autoplay\"\nlocal World = require \"ringcore.world\"\nlocal Screens = require \"ringcore.ui.screens\"\nlocal W = require \"ringcore.ui.widgets\"\nlocal Danger = require \"ringcore.danger\"\nlocal Progress = require \"ringcore.progress\"\nlocal Goals = require \"ringcore.goals\"\nlocal Monetize = require \"ringcore.monetize\"\nlocal Funnel = require \"ringcore.funnel\"\nlocal IMG, T, LEVELS = C.IMG, C.T, C.LEVELS\n\nlocal DESIGN_W, DESIGN_H = C.DESIGN_W, C.DESIGN_H\nlocal function designW() return C.DESIGN_W end\nlocal function designH() return C.DESIGN_H end\nlocal vg = nil\nlocal touchUsed = false\n\nfunction HandleMouseButtonDown(eventType, eventData)\n    if touchUsed then return end\n    if eventData[\"Button\"]:GetInt() ~= MOUSEB_LEFT then return end\n    local mp = input:GetMousePosition()\n    Input.pointerDown(Layout.toDesign(mp.x, mp.y))\nend\n\nfunction HandleMouseMove(eventType, eventData)\n    if touchUsed then return end\n    local mp = input:GetMousePosition()\n    Input.pointerMove(Layout.toDesign(mp.x, mp.y))\nend\n\nfunction HandleMouseButtonUp(eventType, eventData)\n    if touchUsed then return end\n    local mp = input:GetMousePosition()\n    Input.pointerUp(Layout.toDesign(mp.x, mp.y))\nend\n\nfunction HandleTouchBegin(eventType, eventData)\n    touchUsed = true\n    Input.pointerDown(Layout.toDesign(eventData[\"X\"]:GetInt(), eventData[\"Y\"]:GetInt()))\nend\n\nfunction HandleTouchMove(eventType, eventData)\n    touchUsed = true\n    Input.pointerMove(Layout.toDesign(eventData[\"X\"]:GetInt(), eventData[\"Y\"]:GetInt()))\nend\n\nfunction HandleTouchEnd(eventType, eventData)\n    touchUsed = true\n    Input.pointerUp(Layout.toDesign(eventData[\"X\"]:GetInt(), eventData[\"Y\"]:GetInt()))\nend\n\nfunction HandleKeyDown(eventType, eventData)\n    local key = eventData[\"Key\"]:GetInt()\n    if key == KEY_R then\n        Flow.startLevel(G.levelIdx)\n    elseif key == KEY_N then\n        Flow.startLevel(G.levelIdx % #LEVELS + 1)\n    elseif key == KEY_M then\n        Audio.toggleBgm()\n    elseif key == KEY_P then\n        if G.state == \"playing\" then Flow.pause() elseif G.state == \"paused\" then Flow.resume() end\n    elseif key == KEY_ESCAPE or (KEY_AC_BACK and key == KEY_AC_BACK) then\n        Input.back()\n    end\nend\n\nfunction HandleScreenMode()\n    Layout.recalcLayout()\nend\n\nfunction HandleUpdate(eventType, eventData)\n    local dt = eventData[\"TimeStep\"]:GetFloat()\n    Layout.updatePending()\n    G.animT = G.animT + dt\n    Fx.updateFx(dt)\n    Audio.setTrack(Audio.trackFor(G.state, G.mode, G.levelIdx))\n    Screens.update(dt)\n    Monetize.update(dt)\n    if AUTOPLAY then Bot.autoplay(dt) end\n    if G.state ~= \"playing\" then return end\n    if G.hitstop > 0 then\n        G.hitstop = G.hitstop - dt\n        return\n    end\n    G.now = G.now + dt\n\n    G.stats.time = (G.stats.time or 0) + dt\n\n    G.saveClock = (G.saveClock or 0) + dt\n    if G.saveClock >= 10 then\n        G.saveClock = 0\n        pcall(function() require(\"ringcore.savegame\").saveRun() end)\n    end\n    Ring.updateRing(dt)\n    Danger.update(dt)\n    if G.flying then\n        G.flying.x = G.flying.x + G.flying.vx * dt\n        G.flying.y = G.flying.y + G.flying.vy * dt\n        Ring.tryInsertFlying()\n        if G.flying and (G.flying.x < -40 or G.flying.x > designW() + 40 or G.flying.y < -40 or G.flying.y > designH() + 40) then\n            G.flying = nil\n        end\n    end\n    Link.updatePulses(dt)\n    Link.updateLocks(dt)\n    Board.updateBoard(dt)\n    Goals.update(dt)\n    if G.now - G.lastHitTime > T.COMBO_WINDOW then G.combo = 1 end\n    Flow.checkWinLose()\nend\n\nfunction HandleRender(eventType, eventData)\n    if vg == nil then return end\n    nvgBeginFrame(vg, G.logicalW, G.logicalH, G.dpr)\n    nvgScale(vg, G.scale, G.scale)\n    nvgTranslate(vg, G.offX, G.offY)\n    if Screens.FULLSCREEN[G.state] then\n        Screens.drawFull(vg)\n        nvgEndFrame(vg)\n        return\n    end\n    nvgBeginPath(vg)\n    nvgRect(vg, -G.offX, -G.offY, G.screenDW, G.screenDH)\n    nvgFillPaint(vg, nvgLinearGradient(vg, 0, 0, 0, designH(), nvgRGBA(14, 22, 48, 255), nvgRGBA(5, 9, 22, 255)))\n    nvgFill(vg)\n\n\n    local theme = (G.mode == \"hex\") and C.hexPageOf(G.levelIdx or 1) or C.chapterOf(math.max(1, G.levelIdx or 1))\n    local bg = (G.mode == \"hex\" and G.img.gameBgHex) or G.img[theme.bg or \"gameBg\"]\n    if W.cover(vg, bg or G.img.gameBg, 1) then\n        if theme.tint then W.tint(vg, theme.tint) end\n        W.dim(vg, 40)\n    end\n    if G.mode ~= \"hex\" and theme.mist then W.mist(vg, 1) end\n    World.drawBackdrop(vg)\n\n\n    local shx, shy, shrot, shscale = Fx.shakeOffset()\n    nvgSave(vg)\n    if shx ~= 0 or shy ~= 0 or shrot ~= 0 then\n        local ccx, ccy = designW() / 2, designH() * 0.46\n        nvgTranslate(vg, ccx + shx, ccy + shy)\n        nvgRotate(vg, shrot)\n        nvgScale(vg, 1 + shscale, 1 + shscale)\n        nvgTranslate(vg, -ccx, -ccy)\n    end\n    for i = 1, 14 do\n        local fx = (i * 97 + math.sin(G.animT * 0.4 + i) * 30) % designW()\n        local fy = 90 + (i * 53) % 380 + math.cos(G.animT * 0.5 + i * 2) * 12\n        nvgBeginPath(vg)\n        nvgCircle(vg, fx, fy, 1.6)\n        nvgFillColor(vg, nvgRGBA(255, 240, 160, 60 + math.floor(math.sin(G.animT * 2 + i) * 50)))\n        nvgFill(vg)\n    end\n    World.drawConduit(vg)\n    World.drawRing(vg)\n    World.drawBoard(vg)\n    World.drawPreview(vg)\n    World.drawStrip(vg)\n    World.drawFlying(vg)\n    nvgRestore(vg)\n\n\n    local tr2 = G.shakeTrauma or 0\n    if tr2 > 0.45 and G.shakeOn ~= false then\n        local a = math.floor(72 * math.min(1, (tr2 - 0.45) / 0.55))\n        local dw, dh = designW(), designH()\n        nvgBeginPath(vg)\n        nvgRect(vg, 0, 0, dw, dh)\n        nvgFillPaint(vg, nvgBoxGradient(vg, 0, 0, dw, dh, dw * 0.42, dw * 0.34,\n            nvgRGBA(255, 236, 180, 0), nvgRGBA(255, 236, 180, a)))\n        nvgFill(vg)\n    end\n    Screens.drawHUD(vg, C.ORIENT == \"landscape\")\n    Screens.drawOverlay(vg)\n    nvgEndFrame(vg)\nend\n\nfunction Start()\n    Layout.recalcLayout()\n    vg = nvgCreate(1)\n    if vg == nil then\n        print(\"ERROR: NanoVG create failed\")\n        return\n    end\n    G.fontId = nvgCreateFont(vg, \"sans\", \"fonts/NotoSansSC-Regular.subset.woff2\")\n    for k, ipath in pairs(IMG) do\n        local h = nvgCreateImage(vg, ipath, 0)\n        if h and h >= 0 then\n            G.img[k] = h\n            print(\"[Ringcore] loaded \" .. ipath)\n        else\n            print(\"[Ringcore] missing image \" .. ipath)\n        end\n    end\n    math.randomseed(os.time() % 100000)\n    Audio.initAudio()\n    Ring.buildPath()\n    G.noSave = AUTOPLAY\n    Progress.load()\n    Audio.applyGain()\n    Monetize.load()\n    Funnel.load()\n    graphics.windowTitle = require(\"ringcore.i18n\").t(\"app.window\")\n    Flow.startLevel(1)\n    if not AUTOPLAY then Flow.openTitle() end\n    SubscribeToEvent(vg, \"NanoVGRender\", \"HandleRender\")\n    SubscribeToEvent(\"Update\", \"HandleUpdate\")\n    SubscribeToEvent(\"MouseButtonDown\", \"HandleMouseButtonDown\")\n    SubscribeToEvent(\"MouseButtonUp\", \"HandleMouseButtonUp\")\n    SubscribeToEvent(\"MouseMove\", \"HandleMouseMove\")\n    SubscribeToEvent(\"TouchBegin\", \"HandleTouchBegin\")\n    SubscribeToEvent(\"TouchMove\", \"HandleTouchMove\")\n    SubscribeToEvent(\"TouchEnd\", \"HandleTouchEnd\")\n    SubscribeToEvent(\"KeyDown\", \"HandleKeyDown\")\n    SubscribeToEvent(\"ScreenMode\", \"HandleScreenMode\")\n    print(\"=== Ringcore round 4 UI started === path=\" .. math.floor(G.pathLen))\nend\n\nfunction Stop()\n\n    pcall(function() require(\"ringcore.savegame\").saveRun() end)\n    if vg ~= nil then\n        nvgDelete(vg)\n        vg = nil\n    end\nend\n"],["ringcore/activeboard.lua","\nlocal G = require \"ringcore.state\"\nlocal Classic = require \"ringcore.board\"\nlocal Hex = require \"ringcore.hexboard\"\nlocal M = {}\nlocal function cur() return G.mode == \"hex\" and Hex or Classic end\nfunction M.cellXY(...) return cur().cellXY(...) end\nfunction M.cellAt(...) return cur().cellAt(...) end\nfunction M.findMove(...) return cur().findMove(...) end\nfunction M.generateBoard(...) return cur().generateBoard(...) end\nfunction M.previewSwap(...) return cur().previewSwap(...) end\nfunction M.adjacent(...) return cur().adjacent(...) end\nfunction M.trySwap(...) return cur().trySwap(...) end\nfunction M.updateBoard(...) return cur().updateBoard(...) end\nfunction M.isHex() return G.mode == \"hex\" end\nfunction M.draw(ctx) if G.mode == \"hex\" then Hex.draw(ctx) end end\nreturn M\n"],["ringcore/audio.lua","\nif type(rawget(_G, \"__ringcore_audio\")) == \"table\" then\n    return rawget(_G, \"__ringcore_audio\")\nend\nlocal M = {}\nlocal C = require \"ringcore.config\"\nlocal G = require \"ringcore.state\"\nlocal BGM_PATH, BGM_GAIN, SFX_GAIN = C.BGM_PATH, C.BGM_GAIN, C.SFX_GAIN\nlocal scene_ = nil\nlocal sfxNode = nil\nlocal SFX = {}\nlocal sfxPool = {}\nlocal sfxIdx = 1\nlocal bgmSound = nil\n\n\n\n\n\nlocal function initAudio()\n    local ok = pcall(function()\n        scene_ = Scene()\n        sfxNode = scene_:CreateChild(\"sfx\")\n        sfxPool = {}\n        for i = 1, 12 do sfxPool[i] = sfxNode:CreateComponent(\"SoundSource\") end\n\n\n        local S7 = \"audio/s7/sfx_\"\n        local files = {\n            swap = S7 .. \"swap.ogg\", clear = S7 .. \"clear.ogg\",\n            shoot = \"audio/sfx/sfx_shoot.mp3\", insert = S7 .. \"insert.ogg\",\n            win = S7 .. \"win.ogg\", lose = S7 .. \"lose.ogg\",\n            pulse = \"audio/sfx/sfx_pulse.mp3\", pulse_hit = \"audio/sfx/sfx_pulse_hit.mp3\",\n            ammo = S7 .. \"ammo.ogg\", cannon = S7 .. \"cannon.ogg\",\n            special = S7 .. \"special.ogg\", lock = S7 .. \"lock.ogg\",\n            unlock = S7 .. \"unlock.ogg\", stun = S7 .. \"stun.ogg\",\n            click = S7 .. \"click.ogg\", open = S7 .. \"open.ogg\", back = S7 .. \"back.ogg\", star = S7 .. \"star.ogg\",\n        }\n        for k, fpath in pairs(files) do\n            local snd = cache:GetResource(\"Sound\", fpath)\n            if snd then SFX[k] = snd end\n        end\n    end)\n    if not ok then print(\"[Ringcore] audio init skipped\") end\n    pcall(function()\n        G.bgmSrc = scene_:CreateChild(\"bgm\"):CreateComponent(\"SoundSource\")\n        pcall(function() G.bgmSrc.soundType = \"Music\" end)\n    end)\n    M.setTrack(\"title\")\nend\n\n\nlocal bgmCache = {}\nlocal curTrack = nil\nfunction M.setTrack(name)\n    if name == curTrack or not G.bgmSrc then return end\n    local path = (C.BGM_TRACKS and C.BGM_TRACKS[name]) or BGM_PATH\n    pcall(function()\n        local snd = bgmCache[path] or cache:GetResource(\"Sound\", path)\n        if not snd then return end\n        bgmCache[path] = snd\n        snd.looped = true\n        G.bgmSrc:Play(snd)\n        G.bgmSrc.gain = G.bgmOn == false and 0 or (C.BGM_GAIN or BGM_GAIN)\n        curTrack = name\n        print(\"[Ringcore] bgm \" .. name)\n    end)\nend\n\n\n\nfunction M.applyGain()\n    if not G.bgmSrc then return end\n    pcall(function()\n        G.bgmSrc.gain = G.bgmOn == false and 0 or (C.BGM_GAIN or BGM_GAIN)\n    end)\nend\n\nfunction M.trackFor(state, mode, levelIdx)\n    if state == \"playing\" or state == \"paused\" or state == \"won\" or state == \"lost\" or state == \"allclear\" or state == \"runover\" then\n\n        if mode ~= \"hex\" and (math.floor(((levelIdx or 1) - 1) / 10) % 2 == 1) then return \"level2\" end\n        return \"level1\"\n    end\n    return \"title\"\nend\n\nlocal function applyBgmGain()\n    if G.bgmSrc then pcall(function() G.bgmSrc.gain = G.bgmOn and (C.BGM_GAIN or BGM_GAIN) or 0 end) end\nend\nlocal function toggleBgm()\n    G.bgmOn = not G.bgmOn\n    applyBgmGain()\nend\nlocal function setBgmGain(v)\n    C.BGM_GAIN = math.max(0, math.min(1, v))\n    applyBgmGain()\nend\nlocal function setSfxGain(v)\n    C.SFX_GAIN = math.max(0, math.min(1, v))\nend\n\nlocal function toggleSfx()\n    G.sfxOn = not G.sfxOn\nend\n\nlocal function playSfx(name, pitch, gain)\n    if not G.sfxOn then return end\n    local s = SFX[name]\n    if not s or #sfxPool == 0 then return end\n    pcall(function()\n        local src = sfxPool[sfxIdx]\n        sfxIdx = sfxIdx % #sfxPool + 1\n        local g = (gain or 0.7) * (C.SFX_GAIN or SFX_GAIN or 0.7)\n        src:Play(s, s:GetFrequency() * (pitch or 1), g)\n    end)\nend\n\nM.initAudio = initAudio\nM.toggleBgm = toggleBgm\nM.toggleSfx = toggleSfx\nM.setBgmGain = setBgmGain\nM.setSfxGain = setSfxGain\nM.applyBgmGain = applyBgmGain\nM.playSfx = playSfx\n\nrawset(_G, \"__ringcore_audio\", M)\nreturn M\n"],["ringcore/autoplay.lua","\nlocal M = {}\nlocal C = require \"ringcore.config\"\nlocal G = require \"ringcore.state\"\nlocal Ring = require \"ringcore.ring\"\nlocal Link = require \"ringcore.interlock\"\nlocal Board = require \"ringcore.activeboard\"\nlocal Flow = require \"ringcore.flow\"\nlocal Input = require \"ringcore.input\"\nlocal Goals = require \"ringcore.goals\"\nlocal autoTimer = 0\nfunction M.reset() autoTimer = 0; nextInt = nil end\n\n\nlocal function lineLen(B, n, r, c, dr, dc, colr)\n    local k = 0\n    local rr, cc = r + dr, c + dc\n    while rr >= 1 and rr <= n and cc >= 1 and cc <= n and B[rr][cc] and B[rr][cc].color == colr and not B[rr][cc].special do\n        k = k + 1; rr, cc = rr + dr, cc + dc\n    end\n    return k\nend\nlocal function cellsFor(B, n, r, c, out)\n    local cell = B[r][c]\n    if not cell or cell.special then return end\n    local colr = cell.color\n    local h1, h2 = lineLen(B, n, r, c, 0, -1, colr), lineLen(B, n, r, c, 0, 1, colr)\n    local v1, v2 = lineLen(B, n, r, c, -1, 0, colr), lineLen(B, n, r, c, 1, 0, colr)\n    if h1 + h2 + 1 >= 3 then for k = -h1, h2 do out[r .. \",\" .. (c + k)] = { r, c + k } end end\n    if v1 + v2 + 1 >= 3 then for k = -v1, v2 do out[(r + k) .. \",\" .. c] = { r + k, c } end end\nend\nlocal function stepPick()\n    local B, n = G.board, #G.board\n    local want = {}\n    for _, t in ipairs(G.lvl.targets or {}) do\n        local cur, need = Goals.targetProgress(t)\n        if cur < need then want[t.kind .. (t.color or \"\")] = true end\n    end\n    local best, bestS = nil, -1\n    for r = 1, n do for c = 1, n do\n        for _, d in ipairs({ { 0, 1 }, { 1, 0 } }) do\n            local r2, c2 = r + d[1], c + d[2]\n            local a, b = B[r] and B[r][c], B[r2] and B[r2][c2]\n            if a and b and not a.locked and not b.locked and a.color ~= b.color and not a.special and not b.special then\n                B[r][c], B[r2][c2] = b, a\n                local set = {}\n                cellsFor(B, n, r, c, set); cellsFor(B, n, r2, c2, set)\n                B[r][c], B[r2][c2] = a, b\n                local sc, cnt = 0, 0\n                for _, rc in pairs(set) do\n                    local cell = (rc[1] == r and rc[2] == c) and b or ((rc[1] == r2 and rc[2] == c2) and a or B[rc[1]][rc[2]])\n                    cnt = cnt + 1\n                    sc = sc + 1\n                    if cell.locked and want.unlock then sc = sc + 3 end\n                    if want[\"color\" .. cell.color] then sc = sc + 1.5 end\n                end\n                if cnt >= 3 then\n                    sc = sc + math.random() * 1.5\n                    if sc > bestS then best, bestS = { r, c, r2, c2 }, sc end\n                end\n            end\n        end\n    end end\n    return best\nend\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nM.PROFILES = {\n\n\n\n\n    skilled = { name = \"skilled\", int = { 0.6, 1.2 }, pause = { 0.12, 1.0, 2.5 }, rand = 0.06, invalid = 0.05,\n                fireAt = { 0.55, 0.80 }, panic = 0.12, aimErr = 12, aimFront = 0.25,\n                planOnly = true, planErr = 0.10, chainW = 1.8, ammoKeep = 0.45 },\n\n\n\n    casual  = { name = \"casual\", int = { 0.6, 1.2 }, search = 1.0, pause = { 0.15, 1.0, 3.0 }, rand = 0.10, invalid = 0.06,\n                fireAt = { 0.55, 0.85 }, panic = 0.15, aimErr = 14, aimFront = 0.30,\n                planOnly = true, planErr = 0.18, chainW = 1.0, ammoKeep = 0.60 },\n    novice  = { name = \"novice\", int = { 0.8, 1.6 }, search = 1.4, pause = { 0.22, 1.5, 3.5 }, rand = 0.15, invalid = 0.10,\n                fireAt = { 0.60, 0.88 }, panic = 0.25, aimErr = 20, aimFront = 0.40,\n                planOnly = true, planErr = 0.30, chainW = 0.4, ammoKeep = 0.80 },\n}\nM.PROFILES.average = M.PROFILES.skilled\n\nfor _, k in ipairs({ 0, 5 }) do\n    local p = {}\n    for key, v in pairs(M.PROFILES.casual) do p[key] = v end\n    p.name, p.search = \"casual_s\" .. k, k / 10\n    M.PROFILES[p.name] = p\nend\nlocal nextInt = nil\nlocal function urand(a, b) return a + (b - a) * math.random() end\nlocal function gauss() return math.sqrt(-2 * math.log(1 - math.random())) * math.cos(2 * math.pi * math.random()) end\nlocal function profInterval(P)\n    local t = urand(P.int[1], P.int[2])\n    if P.pause and math.random() < P.pause[1] then t = t + urand(P.pause[2], P.pause[3]) end\n    if P.search then\n        local n = 12\n        if not Board.isHex() then n = #M._legal(true) end\n        t = t + P.search * math.min(3, math.sqrt(12 / math.max(1, n)))\n    end\n    return t\nend\n\nlocal function legalMoves(wantLegal)\n    local B, n = G.board, #G.board\n    local out = {}\n    for r = 1, n do for c = 1, n do\n        for _, d in ipairs({ { 0, 1 }, { 1, 0 } }) do\n            local r2, c2 = r + d[1], c + d[2]\n            local a, b = B[r] and B[r][c], B[r2] and B[r2][c2]\n            if a and b and not a.locked and not b.locked and a.color ~= b.color and not a.special and not b.special then\n                B[r][c], B[r2][c2] = b, a\n                local set = {}\n                cellsFor(B, n, r, c, set); cellsFor(B, n, r2, c2, set)\n                B[r][c], B[r2][c2] = a, b\n                local legal = next(set) ~= nil\n                if legal == wantLegal then out[#out + 1] = { r, c, r2, c2 } end\n            end\n        end\n    end end\n    return out\nend\nM._legal = legalMoves\n\n\n\nlocal function planPick(P)\n    local B, n = G.board, #G.board\n    local want, wantColor = {}, {}\n    for _, t in ipairs((G.lvl and G.lvl.targets) or {}) do\n        local cur, need = Goals.targetProgress(t)\n        if cur < need then\n            want[t.kind] = true\n            if t.kind == \"color\" then wantColor[t.color] = true end\n        end\n    end\n\n\n    local near = {}\n    local total = #G.balls\n    if total > 0 then\n        for _, r in ipairs(Ring.runs()) do\n            if G.balls[r.i2] and G.balls[r.i2].vdist >= 0 then\n                local f = 1 - r.i2 / total\n                if f > (near[r.color] or -1) then near[r.color] = f end\n            end\n        end\n    end\n    local cw = P and P.chainW or 0\n    local best, bestS = nil, -1\n    for r = 1, n do for c = 1, n do\n        for _, d in ipairs({ { 0, 1 }, { 1, 0 } }) do\n            local r2, c2 = r + d[1], c + d[2]\n            local a, b = B[r] and B[r][c], B[r2] and B[r2][c2]\n            if a and b and not a.locked and not b.locked and a.color ~= b.color and not a.special and not b.special then\n                B[r][c], B[r2][c2] = b, a\n                local set = {}\n                cellsFor(B, n, r, c, set); cellsFor(B, n, r2, c2, set)\n                B[r][c], B[r2][c2] = a, b\n                local cnt = 0\n                for _ in pairs(set) do cnt = cnt + 1 end\n                if cnt >= 3 then\n                    local sc = cnt\n                    if cnt >= 5 then sc = sc + 3 elseif cnt >= 4 then sc = sc + 1.5 end\n                    for _, rc in pairs(set) do\n                        local cell = (rc[1] == r and rc[2] == c) and b or ((rc[1] == r2 and rc[2] == c2) and a or B[rc[1]][rc[2]])\n                        if cell.locked and want.unlock then sc = sc + 2 end\n                        if wantColor[cell.color] then sc = sc + 1.5 end\n                        if cell.special then sc = sc + 0.5 end\n                        local f = near[cell.color]\n                        if f then sc = sc + cw * f * 3 end\n                    end\n                    sc = sc + math.random() * 2\n                    if sc > bestS then best, bestS = { r, c, r2, c2 }, sc end\n                end\n            end\n        end\n    end end\n    return best\nend\n\nlocal function profMove(P)\n    if Board.isHex() then return nil end\n    local roll = math.random()\n    if roll < P.invalid then\n        local l = legalMoves(false)\n        if #l > 0 then return l[math.random(#l)] end\n    elseif roll < P.invalid + P.rand then\n        local l = legalMoves(true)\n        if #l > 0 then return l[math.random(#l)] end\n    end\n    if P.planOnly then\n        if math.random() >= (P.planErr or 0) then\n            local m = planPick(P)\n            if m then return m end\n        end\n        local l = legalMoves(true)\n        if #l > 0 then return l[math.random(#l)] end\n    end\n    return nil\nend\nlocal function profFire(P, head)\n    local f = head.dist / G.pathLen\n    if f < urand(P.fireAt[1], P.fireAt[2]) then return false end\n    if f > 0.80 and math.random() < P.panic then return false end\n\n\n    local headCol = head.color\n    local look = P.aimFront or 0.3\n    local n = #G.balls\n    local bg = G.balls[math.max(1, n - math.floor(n * look))]\n    if bg then headCol = bg.color end\n    if G.cannonAmmo[1] ~= headCol and math.random() < (P.ammoKeep or 0) then\n        Link.rotateAmmo()\n        return false\n    end\n\n    local k = math.max(1, n - math.floor(math.random() * look * n))\n    local b = G.balls[k] or head\n    local x, y = Ring.pointOnPath(b.vdist or b.dist)\n    Link.fireCannon(x + gauss() * P.aimErr, y + gauss() * P.aimErr)\n    return true\nend\n\n\n\n\n\n\nlocal function liveColor(color)\n    for _, r in ipairs(Ring.runs()) do\n        if r.color == color and G.balls[r.i2] and G.balls[r.i2].vdist >= 0 then return true end\n    end\n    return false\nend\n\nlocal function swapColor(r1, c1, r2, c2)\n    local B, n = G.board, #G.board\n    local a, b = B[r1] and B[r1][c1], B[r2] and B[r2][c2]\n    if not a or not b or a.color == b.color then return nil end\n    B[r1][c1], B[r2][c2] = b, a\n    local found = nil\n    for _, rc in ipairs({ { r1, c1 }, { r2, c2 } }) do\n        local r, c = rc[1], rc[2]\n        local col = B[r][c] and B[r][c].color\n        if col then\n            local h, v = 1, 1\n            for k = 1, 2 do\n                local cc = c - k\n                if cc >= 1 and B[r][cc] and B[r][cc].color == col then h = h + 1 else break end\n            end\n            for k = 1, 2 do\n                local cc = c + k\n                if cc <= n and B[r][cc] and B[r][cc].color == col then h = h + 1 else break end\n            end\n            for k = 1, 2 do\n                local rr = r - k\n                if rr >= 1 and B[rr][c] and B[rr][c].color == col then v = v + 1 else break end\n            end\n            for k = 1, 2 do\n                local rr = r + k\n                if rr <= n and B[rr][c] and B[rr][c].color == col then v = v + 1 else break end\n            end\n            if h >= 3 or v >= 3 then found = col break end\n        end\n    end\n    B[r1][c1], B[r2][c2] = a, b\n    return found\nend\n\nlocal function feedsRing(m)\n    if Board.isHex() then return true end\n    local col = swapColor(m[1], m[2], m[3], m[4])\n    return col ~= nil and liveColor(col)\nend\n\n\nlocal function movementMove()\n    local last = nil\n    for _ = 1, 8 do\n        local m = (G.goal and G.goal.kind == \"step\" and not Board.isHex()) and stepPick() or nil\n        m = m or Board.findMove()\n        if not m then return nil end\n        last = m\n        if feedsRing(m) then return m end\n    end\n    return last\nend\n\n\n\nlocal function autoplay(dt)\n    if G.state == \"ready\" and G.animT > 0.5 then Flow.beginPlay() end\n    if G.state ~= \"playing\" then\n        if BOT_SINGLE and G.state ~= \"ready\" then\n            if engine and engine.Exit then engine:Exit() end\n            return\n        end\n        endWait = (endWait or 0) + dt\n        if endWait > 1.2 then\n            endWait = 0\n            if G.state == \"allclear\" then\n                if engine and engine.Exit then engine:Exit() end\n                return\n            end\n            if G.state == \"won\" then Flow.startLevel(G.levelIdx + 1)\n            elseif G.state == \"lost\" then Flow.startLevel(G.levelIdx) end\n        end\n        return\n    end\n    autoTimer = autoTimer + dt\n    if BOT_DEBUG then\n        DBG_T = (DBG_T or 0) + dt\n        if DBG_T > 3 then\n            DBG_T = 0\n            local parts = {}\n            for i, b in ipairs(G.balls) do\n                parts[#parts + 1] = (i > 1 and not Ring.touching(G.balls[i - 1], b)) and (\"|\" .. b.color) or tostring(b.color)\n            end\n            print(string.format(\"[DBG] t=%.1f n=%d head=%.0f chain=%d %s\", G.now, #G.balls, G.balls[#G.balls] and G.balls[#G.balls].dist or -1, G.chain or 0, table.concat(parts, \"\")))\n        end\n    end\n    local P = BOT_PROFILE\n    if P then\n        nextInt = nextInt or profInterval(P)\n        if autoTimer > nextInt and G.boardState == \"idle\" then\n            autoTimer, nextInt = 0, nil\n            local head = Ring.headBall()\n            if G.lvl.cannon and #G.cannonAmmo > 0 and head and not G.flying and profFire(P, head) then return end\n            local m = profMove(P)\n            if not m then\n                m = (G.goal and G.goal.kind == \"step\" and not Board.isHex()) and stepPick() or nil\n                m = m or Board.findMove()\n            end\n            if m then Board.trySwap(m[1], m[2], m[3], m[4]) end\n        end\n        return\n    end\n    local interval = AUTO_INT or 2.0\n    if autoTimer > interval and G.boardState == \"idle\" then\n        autoTimer = 0\n\n        local head = Ring.headBall()\n        if G.lvl.cannon and #G.cannonAmmo > 0 and head and head.dist > G.pathLen * 0.55 and not G.flying then\n            local hx, hy = Ring.pointOnPath(head.vdist)\n            Link.fireCannon(hx, hy)\n        else\n            local m = movementMove()\n            if m then Board.trySwap(m[1], m[2], m[3], m[4]) end\n        end\n    end\nend\n\nM.autoplay = autoplay\n\nreturn M\n"],["ringcore/board.lua","\nlocal M = {}\nlocal C = require \"ringcore.config\"\nlocal G = require \"ringcore.state\"\nlocal Rng = require \"ringcore.rng\"\nlocal Goals = require \"ringcore.goals\"\nlocal Audio = require \"ringcore.audio\"\nlocal Fx = require \"ringcore.fx\"\nlocal Haptics = require \"ringcore.haptics\"\nlocal Ring = require \"ringcore.ring\"\nlocal Link = require \"ringcore.interlock\"\nlocal tr = require(\"ringcore.i18n\").t\nlocal T, N = C.T, C.N\n\n\n\n\nlocal function cellXY(r, c)\n    return T.BOARD_X + (c - 0.5) * T.CELL, T.BOARD_Y + (r - 0.5) * T.CELL\nend\n\nlocal function cellAt(x, y)\n    local c = math.floor((x - T.BOARD_X) / T.CELL) + 1\n    local r = math.floor((y - T.BOARD_Y) / T.CELL) + 1\n    if r >= 1 and r <= N and c >= 1 and c <= N then return r, c end\n    return nil\nend\n\nlocal function newCell(color)\n    return { color = color or Ring.randColor(), locked = false, special = nil, oy = 0 }\nend\n\nlocal function colorAt(r, c)\n    if r < 1 or r > N or c < 1 or c > N then return nil end\n    local cell = G.board[r][c]\n    if not cell or cell.clearing then return nil end\n    return cell.color\nend\n\nlocal function makesMatch(r, c)\n    local col = colorAt(r, c)\n    if not col then return false end\n    local h = 1\n    local k = c - 1\n    while colorAt(r, k) == col do h = h + 1; k = k - 1 end\n    k = c + 1\n    while colorAt(r, k) == col do h = h + 1; k = k + 1 end\n    if h >= 3 then return true end\n    local v = 1\n    k = r - 1\n    while colorAt(k, c) == col do v = v + 1; k = k - 1 end\n    k = r + 1\n    while colorAt(k, c) == col do v = v + 1; k = k + 1 end\n    return v >= 3\nend\n\nlocal function findGroups()\n    local mark = {}\n    for r = 1, N do mark[r] = {} end\n    for r = 1, N do\n        local c = 1\n        while c <= N do\n            local col = colorAt(r, c)\n            local e = c\n            while col and colorAt(r, e + 1) == col do e = e + 1 end\n            if col and e - c + 1 >= 3 then for k = c, e do mark[r][k] = true end end\n            c = e + 1\n        end\n    end\n    for c = 1, N do\n        local r = 1\n        while r <= N do\n            local col = colorAt(r, c)\n            local e = r\n            while col and colorAt(e + 1, c) == col do e = e + 1 end\n            if col and e - r + 1 >= 3 then for k = r, e do mark[k][c] = true end end\n            r = e + 1\n        end\n    end\n    local seen, groups = {}, {}\n    for r = 1, N do seen[r] = {} end\n    for r = 1, N do\n        for c = 1, N do\n            if mark[r][c] and not seen[r][c] then\n                local col = G.board[r][c].color\n                local g = { color = col, cells = {} }\n                local stack = { { r, c } }\n                seen[r][c] = true\n                while #stack > 0 do\n                    local cur = table.remove(stack)\n                    g.cells[#g.cells + 1] = cur\n                    for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do\n                        local rr, cc = cur[1] + d[1], cur[2] + d[2]\n                        if rr >= 1 and rr <= N and cc >= 1 and cc <= N and mark[rr][cc]\n                            and not seen[rr][cc] and G.board[rr][cc].color == col then\n                            seen[rr][cc] = true\n                            stack[#stack + 1] = { rr, cc }\n                        end\n                    end\n                end\n                groups[#groups + 1] = g\n            end\n        end\n    end\n    return groups\nend\n\n\nlocal function classifySpecial(cells)\n    if not G.lvl.specials then return nil end\n    local set = {}\n    for _, rc in ipairs(cells) do set[rc[1] * 10 + rc[2]] = true end\n    local maxH, maxV = 0, 0\n    local bestH, bestV = nil, nil\n    for _, rc in ipairs(cells) do\n        local r, c = rc[1], rc[2]\n        local c1, c2 = c, c\n        while set[r * 10 + (c1 - 1)] do c1 = c1 - 1 end\n        while set[r * 10 + (c2 + 1)] do c2 = c2 + 1 end\n        local h = c2 - c1 + 1\n        if h > maxH then maxH, bestH = h, { r, math.floor((c1 + c2) / 2) } end\n        local r1, r2 = r, r\n        while set[(r1 - 1) * 10 + c] do r1 = r1 - 1 end\n        while set[(r2 + 1) * 10 + c] do r2 = r2 + 1 end\n        local v = r2 - r1 + 1\n        if v > maxV then maxV, bestV = v, { math.floor((r1 + r2) / 2), c } end\n    end\n    local allow5 = G.lvl.specials == \"full\"\n    local allowLT = G.lvl.specials == \"full\"\n    if allow5 and math.max(maxH, maxV) >= 5 then\n        local sr = maxH >= maxV and bestH or bestV\n        return \"nova\", sr[1], sr[2]\n    end\n    if allowLT and maxH >= 3 and maxV >= 3 and #cells >= 5 then\n\n        for _, rc in ipairs(cells) do\n            local r, c = rc[1], rc[2]\n            local c1, c2, r1, r2 = c, c, r, r\n            while set[r * 10 + (c1 - 1)] do c1 = c1 - 1 end\n            while set[r * 10 + (c2 + 1)] do c2 = c2 + 1 end\n            while set[(r1 - 1) * 10 + c] do r1 = r1 - 1 end\n            while set[(r2 + 1) * 10 + c] do r2 = r2 + 1 end\n            if (c2 - c1 + 1) >= 3 and (r2 - r1 + 1) >= 3 then\n                return \"quake\", r, c\n            end\n        end\n        return \"quake\", cells[1][1], cells[1][2]\n    end\n    if maxH >= 4 or maxV >= 4 then\n        if maxH >= maxV then return \"line_h\", bestH[1], bestH[2] end\n        return \"line_v\", bestV[1], bestV[2]\n    end\n    return nil\nend\n\nlocal function swappable(r, c)\n    local cell = G.board[r] and G.board[r][c]\n    return cell and not cell.locked\nend\n\nlocal function rawSwap(r1, c1, r2, c2)\n    G.board[r1][c1], G.board[r2][c2] = G.board[r2][c2], G.board[r1][c1]\nend\n\nlocal function findMove()\n\n    for r = 1, N do\n        for c = 1, N do\n            for _, d in ipairs({ { 0, 1 }, { 1, 0 } }) do\n                local r2, c2 = r + d[1], c + d[2]\n                if r2 <= N and c2 <= N and swappable(r, c) and swappable(r2, c2)\n                    and G.board[r][c].special and G.board[r2][c2].special then\n                    return { r, c, r2, c2 }\n                end\n            end\n        end\n    end\n    for r = 1, N do\n        for c = 1, N do\n            for _, d in ipairs({ { 0, 1 }, { 1, 0 } }) do\n                local r2, c2 = r + d[1], c + d[2]\n                if r2 <= N and c2 <= N and swappable(r, c) and swappable(r2, c2) then\n                    local a, b = G.board[r][c], G.board[r2][c2]\n\n                    if a.special == \"nova\" or b.special == \"nova\" then\n                        return { r, c, r2, c2 }\n                    end\n                    if a.color ~= b.color then\n                        rawSwap(r, c, r2, c2)\n                        local ok = makesMatch(r, c) or makesMatch(r2, c2)\n                        rawSwap(r, c, r2, c2)\n                        if ok then return { r, c, r2, c2 } end\n                    end\n                end\n            end\n        end\n    end\n    return nil\nend\n\nlocal function anyMatch()\n    for r = 1, N do for c = 1, N do if makesMatch(r, c) then return true end end end\n    return false\nend\n\nlocal function generateBoard()\n    G.boardRows, G.boardCols = nil, nil\n    for _ = 1, 200 do\n        G.board = {}\n        for r = 1, N do\n            G.board[r] = {}\n            for c = 1, N do\n                local col\n                for _ = 1, 20 do\n                    col = Ring.randColor()\n                    local h = c >= 3 and G.board[r][c - 1].color == col and G.board[r][c - 2].color == col\n                    local v = r >= 3 and G.board[r - 1][c].color == col and G.board[r - 2][c].color == col\n                    if not h and not v then break end\n                end\n                G.board[r][c] = newCell(col)\n            end\n        end\n        if not anyMatch() and findMove() then return end\n    end\nend\n\nlocal function shuffleBoard()\n    local cells = {}\n    for r = 1, N do for c = 1, N do\n        local cell = G.board[r][c]\n        if cell and not cell.locked and not cell.special then cells[#cells + 1] = cell end\n    end end\n    for _ = 1, 60 do\n        local cols = {}\n        for i, cell in ipairs(cells) do cols[i] = cell.color end\n        for i = #cols, 2, -1 do\n            local j = Rng.board(1, i)\n            cols[i], cols[j] = cols[j], cols[i]\n        end\n        for i, cell in ipairs(cells) do cell.color = cols[i] end\n        if not anyMatch() and findMove() then return end\n    end\n    for _ = 1, 200 do\n        for _, cell in ipairs(cells) do cell.color = Ring.randColor() end\n        if not anyMatch() and findMove() then return end\n    end\nend\n\nlocal function applyGravity()\n    for c = 1, N do\n        local r = N\n        while r >= 1 do\n            if G.board[r][c] and G.board[r][c].locked then\n                r = r - 1\n            else\n                local bottom, top = r, r\n                while top - 1 >= 1 and not (G.board[top - 1][c] and G.board[top - 1][c].locked) do top = top - 1 end\n                local write = bottom\n                for rr = bottom, top, -1 do\n                    local cell = G.board[rr][c]\n                    if cell then\n                        if rr ~= write then\n                            G.board[write][c] = cell\n                            G.board[rr][c] = nil\n                            cell.oy = cell.oy - (write - rr) * T.CELL\n                        end\n                        write = write - 1\n                    end\n                end\n                local k = write - top + 1\n                for rr = write, top, -1 do\n                    local cell = newCell()\n                    cell.oy = -k * T.CELL\n                    G.board[rr][c] = cell\n                end\n                r = top - 1\n            end\n        end\n    end\nend\n\nlocal chained = {}\n\nlocal function markClear(r, c, fromSpecial)\n    local cell = G.board[r] and G.board[r][c]\n    if not cell or cell.clearing then return false end\n    if fromSpecial and cell.special then chained[#chained + 1] = { r, c } end\n    cell.clearing = true\n    cell.fromSpecial = fromSpecial or false\n    Goals.onTile(cell.color)\n    do local x, y = cellXY(r, c); Fx.crystal(x, y, cell.color, fromSpecial) end\n    if cell.locked then\n        G.stats.unlocks = G.stats.unlocks + 1\n        Audio.playSfx(\"unlock\", 1.0, 0.55)\n    end\n    return true\nend\n\nlocal quietPopups = false\n\nlocal function activateSpecial(r, c, partnerColor, noRing)\n    local cell = G.board[r][c]\n    if not cell or not cell.special then return end\n    local kind = cell.special\n    G.stats.specialsFired = G.stats.specialsFired + 1\n    cell.special = nil\n    markClear(r, c, true)\n    local sx, sy = cellXY(r, c)\n    local ring = not noRing\n\n    if kind == \"line_h\" then\n        for cc = 1, N do markClear(r, cc, true) end\n        if ring then Ring.removeHeadBeads(T.ARC_BEADS, 40, tr(\"fx.arc\")) end\n        if not quietPopups then Fx.popup(sx, sy - 20, tr(\"fx.meteorH\"), true) end\n        Fx.ringBurst(sx, sy, cell.color, 55, 0.35)\n        Fx.sparkle(sx, sy, 12)\n        Audio.playSfx(\"special\", 1.0, 0.8)\n        Fx.shake(5, 0.18)\n        Haptics.pulse(\"heavy\", G.now)\n        G.hitstop = T.HITSTOP_SPECIAL\n    elseif kind == \"line_v\" then\n        for rr = 1, N do markClear(rr, c, true) end\n        if ring then Ring.removeHeadBeads(T.ARC_BEADS, 40, tr(\"fx.arc\")) end\n        if not quietPopups then Fx.popup(sx, sy - 20, tr(\"fx.meteorV\"), true) end\n        Fx.ringBurst(sx, sy, cell.color, 55, 0.35)\n        Fx.sparkle(sx, sy, 12)\n        Audio.playSfx(\"special\", 1.0, 0.8)\n        Fx.shake(5, 0.18)\n        Haptics.pulse(\"heavy\", G.now)\n        G.hitstop = T.HITSTOP_SPECIAL\n    elseif kind == \"quake\" then\n        for rr = r - 1, r + 1 do\n            for cc = c - 1, c + 1 do\n                if rr >= 1 and rr <= N and cc >= 1 and cc <= N then\n                    markClear(rr, cc, true)\n                end\n            end\n        end\n        if ring then Ring.stunRing() end\n        Fx.ringBurst(sx, sy, cell.color, 50, 0.35)\n    elseif kind == \"nova\" then\n        local col = partnerColor or cell.color\n        for rr = 1, N do for cc = 1, N do\n            if G.board[rr][cc] and G.board[rr][cc].color == col then\n                markClear(rr, cc, true)\n            end\n        end end\n        if ring then Ring.removeColorFromRing(col, T.NOVA_MAX) end\n        Fx.popup(sx, sy - 20, tr(\"fx.nova\"), true)\n        Fx.ringBurst(sx, sy, col, 70, 0.4)\n        Fx.sparkle(sx, sy, 16)\n        Audio.playSfx(\"special\", 0.9, 0.9)\n        Fx.shake(8, 0.25)\n        Haptics.pulse(\"heavy\", G.now)\n        G.hitstop = T.HITSTOP_SPECIAL\n    end\nend\n\n\n\nlocal function flushChained()\n    local guard = 0\n    while #chained > 0 and guard < 40 do\n        guard = guard + 1\n        local rc = table.remove(chained, 1)\n        local cell = G.board[rc[1]][rc[2]]\n        if cell and cell.special then\n            local x, y = cellXY(rc[1], rc[2])\n            if not quietPopups then Fx.popup(x, y - 16, tr(\"fx.chainBlast\"), false) end\n            activateSpecial(rc[1], rc[2], nil, true)\n        end\n    end\n    chained = {}\nend\n\nlocal function isLine(k) return k == \"line_h\" or k == \"line_v\" end\n\nlocal function clearRect(r1, c1, r2, c2)\n    for rr = math.max(1, r1), math.min(N, r2) do\n        for cc = math.max(1, c1), math.min(N, c2) do markClear(rr, cc, true) end\n    end\nend\n\n\nlocal function comboSwap(r1, c1, r2, c2)\n    local a, b = G.board[r1][c1], G.board[r2][c2]\n    local ka, kb = a.special, b.special\n    local cr, cc = r2, c2\n    local cx, cy = cellXY(cr, cc)\n    G.stats.combos = (G.stats.combos or 0) + 1\n    G.stats.specialsFired = G.stats.specialsFired + 2\n    a.special, b.special = nil, nil\n    markClear(r1, c1, true)\n    markClear(r2, c2, true)\n    local label, col = nil, a.color\n    if ka == \"nova\" and kb == \"nova\" then\n        clearRect(1, 1, N, N)\n        Ring.removeHeadBeads(T.ARC_BEADS * 3, 40, tr(\"fx.fullBlast\"))\n        Ring.stunRing(1.0)\n        label = tr(\"fx.doubleNova\")\n    elseif ka == \"nova\" or kb == \"nova\" then\n        local other = (ka == \"nova\") and kb or ka\n        col = (ka == \"nova\") and b.color or a.color\n        local targets = {}\n        for rr = 1, N do for c = 1, N do\n            local cell = G.board[rr][c]\n            if cell and not cell.clearing and cell.color == col then\n                cell.special = isLine(other) and (Rng.board() < 0.5 and \"line_h\" or \"line_v\") or other\n                targets[#targets + 1] = { rr, c }\n            end\n        end end\n        quietPopups = true\n        for _, rc in ipairs(targets) do activateSpecial(rc[1], rc[2], nil, true) end\n        Ring.removeColorFromRing(col, T.NOVA_MAX)\n        if isLine(other) then Ring.removeHeadBeads(T.ARC_BEADS, 40, tr(\"fx.meteorShower\")) else Ring.stunRing(1.3) end\n        label = tr(isLine(other) and \"fx.novaMeteor\" or \"fx.novaQuake\", { n = #targets })\n    elseif isLine(ka) and isLine(kb) then\n        clearRect(cr, 1, cr, N)\n        clearRect(1, cc, N, cc)\n        Ring.removeHeadBeads(T.ARC_BEADS * 2, 40, tr(\"fx.crossMeteor\"))\n        label = tr(\"fx.crossMeteor\")\n    elseif isLine(ka) or isLine(kb) then\n        clearRect(cr - 1, 1, cr + 1, N)\n        clearRect(1, cc - 1, N, cc + 1)\n        Ring.removeHeadBeads(T.ARC_BEADS * 2, 40, tr(\"fx.giantMeteor\"))\n        Ring.stunRing(0.8)\n        label = tr(\"fx.giantMeteor\")\n    else\n        clearRect(cr - 2, cc - 2, cr + 2, cc + 2)\n        Ring.stunRing(1.6)\n        label = tr(\"fx.doubleQuake\")\n    end\n    flushChained()\n    quietPopups = false\n    Fx.popup(cx, cy - 26, label, true)\n    Fx.ringBurst(cx, cy, col, 90, 0.5)\n    Fx.sparkle(cx, cy, 24)\n    Audio.playSfx(\"special\", 0.8, 1.0)\n    Fx.shake(9, 0.3)\n    Haptics.long()\n    G.hitstop = T.HITSTOP_SPECIAL * 1.5\nend\n\nlocal function startClear(groups)\n    G.boardState, G.stateTimer = \"clearing\", T.CLEAR_TIME\n    G.clearDur = T.CLEAR_TIME\n\n    for _, g in ipairs(groups) do\n        for _, rc in ipairs(g.cells) do\n            local cell = G.board[rc[1]][rc[2]]\n            if cell and cell.special then\n                activateSpecial(rc[1], rc[2], g.color)\n            end\n        end\n    end\n    flushChained()\n    local mult = 1 + 0.5 * G.cascade\n\n    local pulseGi = 0\n    for _, g in ipairs(groups) do\n        local kind, sr, sc = classifySpecial(g.cells)\n        local spawnRC = nil\n        if kind and sr and G.board[sr][sc] and not G.board[sr][sc].clearing then\n            spawnRC = { sr, sc, kind }\n        end\n        local sx, sy, n = 0, 0, 0\n        for _, rc in ipairs(g.cells) do\n            local r, c = rc[1], rc[2]\n            if spawnRC and r == spawnRC[1] and c == spawnRC[2] then\n\n            else\n                if markClear(r, c, false) then\n                    local x, y = cellXY(r, c)\n                    sx, sy, n = sx + x, sy + y, n + 1\n                    Fx.burst(x, y, g.color, 7, 150)\n                end\n            end\n        end\n        if spawnRC then\n            local cell = G.board[spawnRC[1]][spawnRC[2]]\n            cell.special = spawnRC[3]\n            cell.locked = false\n            cell.clearing = false\n            G.stats.specialsSpawned = G.stats.specialsSpawned + 1\n            if (spawnRC[3] == \"line_h\" or spawnRC[3] == \"line_v\") and G.lvl.cannon then\n                Link.addAmmo(g.color)\n            end\n            local x, y = cellXY(spawnRC[1], spawnRC[2])\n            Fx.popup(x, y - 18, tr(({ line_h = \"fx.meteorH\", line_v = \"fx.meteorV\", quake = \"fx.quake\", nova = \"fx.nova\" })[spawnRC[3]]), true)\n            Audio.playSfx(\"special\", 1.15, 0.7)\n            Fx.sparkle(x, y, 10)\n            Fx.ringBurst(x, y, g.color, 36, 0.3)\n\n            sx, sy, n = sx + x, sy + y, n + 1\n        end\n        if n > 0 then\n            G.score = G.score + math.floor(10 * n * mult + 0.5)\n\n            Fx.impact(sx / n, sy / n, g.color, n)\n            Fx.cue(\"clear\", sx / n, sy / n, g.color)\n            pulseGi = pulseGi + 1\n            G.pendingPulses[#G.pendingPulses + 1] = {\n                color = g.color, x = sx / n, y = sy / n,\n                delay = (pulseGi - 1) * T.PULSE_STAGGER, mult = mult,\n            }\n        end\n    end\n    if G.cascade > 0 then\n        G.stats.cascades = G.stats.cascades + 1\n\n\n        Fx.popup(T.BOARD_X+N*T.CELL/2, C.ORIENT==\"landscape\" and 440 or T.BOARD_Y-4, tr(\"fx.cascade\", { x = string.format(\"%.1f\", mult) }), G.cascade >= 2, 20 + 3 * math.min(5, G.cascade))\n    end\n\n    if G.cascade >= T.PW_CASCADE and not G.powerGranted then\n        G.powerGranted = Ring.grantPower(T.BOARD_X+N*T.CELL/2, T.BOARD_Y + 3 * T.CELL)\n    end\n\n    local cz = math.min(6, G.cascade)\n    Audio.playSfx(\"clear\", 1 + 0.09 * cz, 0.6 + 0.05 * cz)\n    if cz >= 2 then Audio.playSfx(\"star\", 0.9 + 0.08 * cz, 0.35) end\n\n\n    Fx.shake(1.2 + 0.55 * cz, 0.07 + 0.02 * cz)\n\n    Haptics.pulse(cz >= 3 and \"heavy\" or (cz >= 1 and \"medium\" or \"light\"), G.now)\nend\n\nlocal function applyQueuedLocks()\n    for _, q in ipairs(G.lockQueue) do\n        local cell = G.board[q.r][q.c]\n        if cell and not cell.locked and not cell.special then\n            cell.locked = true\n        else\n            local placed = false\n            for r = 1, N do for c = 1, N do\n                if not placed and G.board[r][c] and not G.board[r][c].locked and not G.board[r][c].special then\n                    G.board[r][c].locked = true\n                    placed = true\n                end\n            end end\n        end\n        Fx.shake(2, 0.1)\n    end\n    G.lockQueue = {}\n    if not findMove() then shuffleBoard() end\nend\n\nlocal function settleIdle()\n    G.boardState = \"idle\"\n    G.cascade = 0\n    G.powerGranted = false\n    if #G.lockQueue > 0 then applyQueuedLocks() end\n    if not findMove() then\n        G.stats.shuffles = (G.stats.shuffles or 0) + 1\n        shuffleBoard()\n        Fx.popup(C.ORIENT==\"landscape\" and T.BOARD_X+N*T.CELL/2 or 270, C.ORIENT==\"landscape\" and T.BOARD_Y+3*T.CELL or 715, tr(\"fx.reshuffle\"), true)\n    end\nend\n\n\n\nfunction M.adjacent(r1,c1,r2,c2)\n    return r2 ~= nil and c2 ~= nil and math.abs(r1-r2)+math.abs(c1-c2)==1\nend\nfunction M.previewSwap(r1,c1,r2,c2)\n    if G.state ~= \"playing\" or G.boardState ~= \"idle\" or not M.adjacent(r1,c1,r2,c2) then return nil end\n    if not swappable(r1,c1) or not swappable(r2,c2) then return nil end\n    if Goals.kind()==\"step\" and Goals.movesLeft()<=0 then return nil end\n    local a,b=G.board[r1][c1],G.board[r2][c2]\n\n    if a.special==\"nova\" or b.special==\"nova\" or (a.special and b.special) then return nil end\n    G.board[r1][c1],G.board[r2][c2]=b,a\n    local ok,groups=pcall(findGroups)\n    G.board[r1][c1],G.board[r2][c2]=a,b\n    if not ok then error(groups) end\n    if #groups==0 then return nil end\n    return groups\nend\n\nlocal function trySwap(r1, c1, r2, c2)\n    if G.state ~= \"playing\" or G.boardState ~= \"idle\" then return end\n    if not (r2 and c2) or r2 < 1 or r2 > N or c2 < 1 or c2 > N then return end\n    if Goals.kind() == \"step\" and Goals.movesLeft() <= 0 then return end\n    if math.abs(r1 - r2) + math.abs(c1 - c2) ~= 1 then return end\n    if not swappable(r1, c1) or not swappable(r2, c2) then\n        Fx.shake(1.5, 0.08)\n        return\n    end\n    local a, b = G.board[r1][c1], G.board[r2][c2]\n    if a.special and b.special then\n        G.boardState, G.stateTimer = \"clearing\", T.CLEAR_TIME * 1.6\n        G.clearDur = T.CLEAR_TIME * 1.6\n        comboSwap(r1, c1, r2, c2)\n        G.idleTime, G.hint, G.selected = 0, nil, nil\n        G.stats.swaps = G.stats.swaps + 1\n        Goals.onMove()\n        G.cascade = 0\n        return\n    end\n\n    if a.special == \"nova\" or b.special == \"nova\" then\n        local nr, nc = a.special == \"nova\" and r1 or r2, a.special == \"nova\" and c1 or c2\n        local pr, pc = a.special == \"nova\" and r2 or r1, a.special == \"nova\" and c2 or c1\n        local partner = G.board[pr][pc].color\n        rawSwap(r1, c1, r2, c2)\n\n        local novaR, novaC = pr, pc\n        if G.board[r1][c1].special == \"nova\" then novaR, novaC = r1, c1 end\n        G.boardState, G.stateTimer = \"clearing\", T.CLEAR_TIME\n        G.clearDur = T.CLEAR_TIME\n        activateSpecial(novaR, novaC, partner)\n        flushChained()\n        G.idleTime, G.hint, G.selected = 0, nil, nil\n        G.stats.swaps = G.stats.swaps + 1\n        Goals.onMove()\n        G.cascade = 0\n        Audio.playSfx(\"swap\", 1.0, 0.5)\n        return\n    end\n\n\n    rawSwap(r1, c1, r2, c2)\n    G.swapAnim = { r1 = r1, c1 = c1, r2 = r2, c2 = c2, t = 0 }\n    G.boardState = \"swapping\"\n    G.idleTime, G.hint = 0, nil\n    G.stats.swaps = G.stats.swaps + 1\n    Audio.playSfx(\"swap\", 1.0, 0.5)\nend\n\nlocal function updateBoard(dt)\n    if G.boardState == \"idle\" then\n        G.idleTime = G.idleTime + dt\n        if G.idleTime > T.HINT_DELAY and not G.hint then G.hint = findMove() end\n        if #G.lockQueue > 0 then applyQueuedLocks() end\n    elseif G.boardState == \"swapping\" or G.boardState == \"swapback\" then\n        G.swapAnim.t = G.swapAnim.t + dt\n        if G.swapAnim.t >= T.SWAP_TIME then\n            local a = G.swapAnim\n            if G.boardState == \"swapping\" then\n                local groups = findGroups()\n\n                if #groups > 0 then\n                    G.swapAnim = nil\n                    G.cascade = 0\n                    Goals.onMove()\n                    startClear(groups)\n                else\n\n                    rawSwap(a.r1, a.c1, a.r2, a.c2)\n                    G.swapAnim = { r1 = a.r1, c1 = a.c1, r2 = a.r2, c2 = a.c2, t = 0 }\n                    G.boardState = \"swapback\"\n                    Audio.playSfx(\"swap\", 0.8, 0.35)\n                end\n            else\n                G.swapAnim = nil\n                G.boardState = \"idle\"\n            end\n        end\n    elseif G.boardState == \"clearing\" then\n        G.stateTimer = G.stateTimer - dt\n        if G.stateTimer <= 0 then\n            for r = 1, N do for c = 1, N do\n                if G.board[r][c] and G.board[r][c].clearing then G.board[r][c] = nil end\n            end end\n            applyGravity()\n            G.boardState = \"falling\"\n        end\n    elseif G.boardState == \"falling\" then\n        local moving = false\n        for r = 1, N do for c = 1, N do\n            local cell = G.board[r][c]\n            if cell and cell.oy < 0 then\n                cell.oy = math.min(0, cell.oy + T.FALL_SPEED * dt)\n                moving = true\n            end\n        end end\n        if not moving then\n            local groups = findGroups()\n            if #groups > 0 then\n                G.cascade = G.cascade + 1\n                startClear(groups)\n            else\n                settleIdle()\n            end\n        end\n    end\nend\n\nM.cellXY = cellXY\nM.cellAt = cellAt\nM.findMove = findMove\nM.generateBoard = generateBoard\nM.trySwap = trySwap\nM.updateBoard = updateBoard\n\nreturn M\n"],["ringcore/config.lua","\n\n\nif type(rawget(_G, \"__ringcore_config\")) == \"table\" then\n    return rawget(_G, \"__ringcore_config\")\nend\nlocal C = {}\nlocal Levels = require \"ringcore.levels\"\n\nC.IMG = {\n    core = \"image/yinghe_core_clean_20261030190000.png\",\n    lantern = \"image/yinghe_lantern_clean_20261030190000.png\",\n    lock = \"image/yinghe_lock_20261030133118.png\",\n\n    orb1 = \"image/s7/orb_1.png\", orb2 = \"image/s7/orb_2.png\", orb3 = \"image/s7/orb_3.png\",\n    orb4 = \"image/s7/orb_4.png\", orb5 = \"image/s7/orb_5.png\",\n    meteor = \"image/s7/sp_line.png\", quake = \"image/s7/sp_quake.png\", nova = \"image/s7/sp_nova.png\",\n    titleBg = \"image/s7/bg_title.jpg\",\n    mapBg = \"image/s7/bg_map1.jpg\", mapBg2 = \"image/s7/bg_map2.jpg\", mapBgHex = \"image/s7/bg_hex.jpg\",\n    gameBg = \"image/s7/bg_ch1.jpg\", gameBg2 = \"image/s7/bg_ch2.jpg\", gameBgHex = \"image/s7/bg_hex.jpg\",\n    frameGold = \"image/s7/frame_gold.png\", frameCyan = \"image/s7/frame_cyan.png\",\n    starOn = \"image/s7/star_on.png\", starOff = \"image/s7/star_off.png\",\n    icons = \"image/s7/icons.png\",\n    fxSparkle = \"image/s7/fx_sparkle.png\", fxParticles = \"image/s7/fx_particles.png\",\n    fxRing = \"image/s7/fx_ring.png\", fxSmoke = \"image/s7/fx_smoke.png\",\n    mistA = \"image/s7/mist_a.png\", mistB = \"image/s7/mist_b.png\",\n}\n\nC.ATLAS = {\n    icons = { w = 1024, h = 64, cell = 64, names = { \"pause\", \"home\", \"return\", \"musicOn\", \"musicOff\", \"audioOn\",\n        \"audioOff\", \"locked\", \"right\", \"left\", \"information\", \"trophy\", \"star\", \"gear\", \"forward\", \"cross\" } },\n    sparkle = { w = 768, h = 320, cell = 64, frames = 12 },\n    particles = { w = 288, h = 96, cell = 48 },\n    smoke = { w = 512, h = 64, cell = 64, frames = 8 },\n    frame = { w = 96, h = 96, edge = 32 },\n\n    mist = {\n        { key = \"mistA\", w = 854, h = 320, y = 250, speed = 9, alpha = 0.42, pulse = 0.12, rate = 0.35 },\n        { key = \"mistB\", w = 640, h = 240, y = 40, speed = -15, alpha = 0.32, pulse = 0.10, rate = 0.5 },\n    },\n}\n\n\nC.DESIGN_W, C.DESIGN_H = 540, 960\n\n\n\n\nC.T = {\n    BALL_R = 15,\n    ROLL_IN_SPEED = 160, ROLL_IN_TIME = 2.0, ROLL_IN_HEAD = 0.36,\n    WARN_AT = 0.72, LOCK_MAX = 8, LOCK_FALL_TIME = 0.4,\n    PULSE_TRAVEL = 0.25, PULSE_RETARGET = 0.15, PULSE_STAGGER = 0.12,\n    REVERSE_SPEED = -90, REVERSE_TIME = 0.35,\n    END_SLOW_AT = 0.85, END_SLOW_MUL = 0.8,\n    CELL = 66, BOARD_X = 72, BOARD_Y = 508, N = 6,\n    CELL_MIN = 56, CELL_MAX = 72,\n\n\n    SWAP_TIME = 0.16, CLEAR_TIME = 0.26, FALL_SPEED = 1150,\n    HINT_DELAY = 5, COMBO_WINDOW = 1.5, COMBO_MAX = 4,\n    DRAG_THRESHOLD = 24, HITSTOP = 0.06, HITSTOP_SPECIAL = 0.10,\n    ARC_BEADS = 5, STUN_TIME = 2.5, STUN_PUSHBACK = 60, NOVA_MAX = 12,\n    CANNON_MAX = 3, FIRE_SPEED = 720,\n    CANNON_X = 270, CANNON_Y = 485, CANNON_R = 28,\n    TOP_FIRE_MAX_Y = 500,\n\n    GAP_CLOSE_SPEED = 300, MAGNET_SPEED = 380, MAGNET_PER_CHAIN = 90,\n    DANGER_AT = 0.80,\n    PW_REVERSE_TIME = 2.0, PW_REVERSE_SPEED = -80, PW_SLOW_TIME = 4.0, PW_SLOW_MUL = 0.55,\n    PW_CASCADE = 3,\n    STAR3_AT = 0.72, STAR2_AT = 0.88,\n    PUSH_RATE = 120,\n}\nC.BALL_D = C.T.BALL_R * 2\nC.N = C.T.N\nC.CELL = C.T.CELL\n\n\n\n\n\n\n\nC.ORIENT = \"portrait\"\nC.LAYOUTS = {\n    portrait = {\n        designW = 540, designH = 960,\n        ringX = 270, ringY = 290, ringRX = 232, ringRY = 188,\n        boardX = nil, boardY = 506,\n        hudX = 0, hudY = 0, hudW = 540,\n        stripX = 270,\n        cannonX = 270, cannonY = 485,\n    },\n    landscape = {\n        designW = 960, designH = 540,\n\n        ringX = 268, ringY = 262, ringRX = 214, ringRY = 174,\n\n        boardX = nil, boardY = 86,\n        hudX = 0, hudY = 0, hudW = 960,\n        stripX = 748,\n        cannonX = 748, cannonY = 470,\n    },\n}\nfunction C.applyLayout(name)\n    local L = C.LAYOUTS[name] or C.LAYOUTS.portrait\n    C.ORIENT = C.LAYOUTS[name] and name or \"portrait\"\n    C.DESIGN_W, C.DESIGN_H = L.designW, L.designH\n    local t = C.T\n    t.LAYOUT = L\n    t.CANNON_X, t.CANNON_Y = L.cannonX, L.cannonY\n    return L\nend\n\nfunction C.setCell(px)\n    local t = C.T\n    local L = t.LAYOUT or C.LAYOUTS.portrait\n    local v = math.max(t.CELL_MIN or 56, math.min(t.CELL_MAX or 72, math.floor(px + 0.5)))\n    t.CELL = v\n    C.CELL = v\n\n    t.BOARD_X = L.boardX or math.floor((C.DESIGN_W - C.N * v) / 2)\n    t.BOARD_Y = L.boardY or C.BOARD_TOP\n    return v\nend\n\n\nC.BOARD_TOP = 506\nC.SAFE_TOP, C.SAFE_BOTTOM = 44, 34\nC.FRAME_BELOW = 12\nfunction C.layoutBoard(barY)\n\n\n    if C.ORIENT == \"landscape\" then\n        local L = C.T.LAYOUT or {}\n\n        local colX = (L.ringX or 268) + (L.ringRX or 214) + 44\n        local availW = (L.designW or 960) - 36 - colX\n        local availH = (L.designH or 540) - (L.boardY or 86) - 116\n        local cell = math.floor(math.min(availW, availH) / C.N)\n        local v = C.setCell(cell)\n\n        C.T.BOARD_X = (L.designW or 960) - 36 - C.N * v\n        C.T.CANNON_X=C.T.BOARD_X+C.N*v/2\n        C.T.CANNON_Y=math.min(C.T.BOARD_Y+C.N*v+58,C.DESIGN_H-34)\n        return v\n    end\n    local avail = (barY or 902) - 6 - C.FRAME_BELOW - C.BOARD_TOP\n    return C.setCell(math.floor(avail / C.N))\nend\nC.layoutBoard(902)\n\n\n\n\n\nC.CHAPTERS = {\n    { id = 1,  nameKey = \"chapter.1\",  first = 1,   last = 10,  bg = \"gameBg\",    map = \"mapBg\",    layout = \"ch1\" },\n    { id = 2,  nameKey = \"chapter.2\",  first = 11,  last = 20,  bg = \"gameBg2\",   map = \"mapBg2\",   layout = \"ch2\", mist = true },\n    { id = 3,  nameKey = \"chapter.3\",  first = 21,  last = 30,  bg = \"gameBg\",    map = \"mapBg\",    layout = \"ch3\", tint = { 120, 60, 200, 70 } },\n    { id = 4,  nameKey = \"chapter.4\",  first = 31,  last = 40,  bg = \"gameBg2\",   map = \"mapBg2\",   layout = \"ch4\", tint = { 40, 120, 210, 70 }, mist = true },\n    { id = 5,  nameKey = \"chapter.5\",  first = 41,  last = 50,  bg = \"gameBgHex\", map = \"mapBgHex\", layout = \"ch5\", tint = { 255, 170, 60, 46 } },\n    { id = 6,  nameKey = \"chapter.6\",  first = 51,  last = 60,  bg = \"titleBg\",   map = \"mapBg2\",   layout = \"ch6\", tint = { 30, 20, 90, 96 } },\n\n\n    { id = 7,  nameKey = \"chapter.7\",  first = 61,  last = 70,  bg = \"gameBg\",    map = \"mapBg\",    layout = \"ch2\", tint = { 255, 110, 90, 80 } },\n    { id = 8,  nameKey = \"chapter.8\",  first = 71,  last = 80,  bg = \"gameBg2\",   map = \"mapBg2\",   layout = \"ch1\", tint = { 90, 255, 200, 70 } },\n    { id = 9,  nameKey = \"chapter.9\",  first = 81,  last = 90,  bg = \"gameBg\",    map = \"mapBg\",    layout = \"ch5\", tint = { 200, 120, 255, 90 }, mist = true },\n    { id = 10, nameKey = \"chapter.10\", first = 91,  last = 100, bg = \"gameBgHex\", map = \"mapBgHex\", layout = \"ch6\", tint = { 255, 240, 140, 60 } },\n    { id = 11, nameKey = \"chapter.11\", first = 101, last = 110, bg = \"gameBg2\",   map = \"mapBg2\",   layout = \"ch3\", tint = { 60, 180, 255, 95 } },\n    { id = 12, nameKey = \"chapter.12\", first = 111, last = 120, bg = \"titleBg\",   map = \"mapBg\",    layout = \"ch4\", tint = { 20, 10, 60, 140 }, mist = true },\n}\n\nC.HEX_PAGES = {\n    { id = 1, nameKey = \"hexpage.1\", first = 1,  last = 6,  layout = \"hex\" },\n    { id = 2, nameKey = \"hexpage.2\", first = 7,  last = 12, layout = \"hex2\", tint = { 255, 140, 40, 40 } },\n    { id = 3, nameKey = \"hexpage.3\", first = 13, last = 18, layout = \"hex\", tint = { 60, 20, 80, 80 } },\n\n    { id = 4, nameKey = \"hexpage.4\", first = 19, last = 24, layout = \"hex2\", tint = { 255, 90, 60, 70 } },\n    { id = 5, nameKey = \"hexpage.5\", first = 25, last = 30, layout = \"hex\", tint = { 30, 160, 150, 80 } },\n    { id = 6, nameKey = \"hexpage.6\", first = 31, last = 36, layout = \"hex2\", tint = { 140, 60, 220, 100 } },\n}\nfunction C.chapterOf(i)\n    for _, ch in ipairs(C.CHAPTERS) do if i >= ch.first and i <= ch.last then return ch end end\n    return C.CHAPTERS[1]\nend\nfunction C.hexPageOf(i)\n    for _, pg in ipairs(C.HEX_PAGES) do if i >= pg.first and i <= pg.last then return pg end end\n    return C.HEX_PAGES[1]\nend\nC.BOOSTERS = {\n\n    { id = \"reverse\", nameKey = \"booster.reverse\", descKey = \"booster.reverse.desc\" },\n    { id = \"slow\",    nameKey = \"booster.slow\", descKey = \"booster.slow.desc\" },\n    { id = \"hint\",    nameKey = \"booster.hint\", descKey = \"booster.hint.desc\" },\n}\n\nC.LEVELS = Levels.allClassic()\n\n\n\n\nC.ARENA_TIER_KEYS = { \"tier.1\", \"tier.2\", \"tier.3\" }\nC.ARENA_TIER_AT = { 15, 30, 45 }\nC.ARENA = {}\n\nlocal AR_ENDLESS = { { 14, 0.10, 18, 4.4, 50, 240, 0.15 }, { 17, 0.12, 22, 3.8, 58, 240, 0.14 }, { 20, 0.14, 24, 3.4, 64, 240, 0.13 } }\nlocal AR_BLITZ = { { 16, 5.2, 0.03, 50 }, { 20, 4.4, 0.025, 62 }, { 24, 3.4, 0.02, 74 } }\nfor t = 1, 3 do\n    local e = AR_ENDLESS[t]\n    C.ARENA[\"endless\" .. t] = { name = \"endless\" .. t, goal = \"endless\", speed = e[1], ramp = e[2], rampMax = e[3],\n        quota = 0, seed = 18 + 2 * t, colors = 5, locks = true, lockInterval = e[4], specials = \"full\", cannon = true, powerChance = 0.02, tier = t,\n        push = e[5], lateAt = e[6], lateRamp = e[7] }\n    local bl = AR_BLITZ[t]\n    C.ARENA[\"blitz\" .. t] = { name = \"blitz\" .. t, goal = \"blitz\", time = 90, speed = bl[1], quota = 0, seed = 18 + 2 * t,\n        colors = 5, locks = true, lockInterval = bl[2], specials = \"full\", cannon = true, powerChance = bl[3], tier = t,\n        push = bl[4] }\nend\n\n\nC.HEX_LEVELS = Levels.allHex()\n\n\n\nC.DIFFICULTY_TIERS = Levels.ORDER\nC.DIFFICULTY_SET = {}\nfor _, t in ipairs(Levels.ORDER) do C.DIFFICULTY_SET[t] = true end\n\n\n\nC.COLORS = {\n    { 242, 153, 0 },\n    { 29, 149, 217 },\n    { 217, 45, 71 },\n    { 117, 41, 204 },\n    { 181, 255, 77 },\n}\nC.GLYPHS = { \"dot\", \"diamond\", \"tri\", \"cross\", \"square\" }\n\n\nC.BGM_PATH = \"audio/s7/bgm_title.ogg\"\nC.BGM_TRACKS = { title = \"audio/s7/bgm_title.ogg\", level1 = \"audio/s7/bgm_level1.ogg\", level2 = \"audio/s7/bgm_level2.ogg\" }\nC.BGM_GAIN = 0.35\nC.SFX_GAIN = 0.70\n\nrawset(_G, \"__ringcore_config\", C)\nreturn C\n"],["ringcore/daily.lua","\n\n\nlocal M = {}\nlocal tr = require(\"ringcore.i18n\").t\n\nlocal BJ = 8 * 3600\nM.WEEK_KEYS = { \"week.1\", \"week.2\", \"week.3\", \"week.4\", \"week.5\", \"week.6\", \"week.7\" }\nfunction M.weekName(wday) return tr(M.WEEK_KEYS[wday] or \"week.1\") end\n\nlocal ROTATE = { \"classic\", \"step\", \"timed\" }\n\n\nfunction M.now(t)\n    t = t or os.time()\n    local d = os.date(\"!*t\", t + BJ)\n    return {\n        key = d.year * 10000 + d.month * 100 + d.day,\n        year = d.year, month = d.month, day = d.day, wday = d.wday,\n        secLeft = 86400 - ((d.hour * 60 + d.min) * 60 + d.sec),\n    }\nend\n\nfunction M.fmtCountdown(sec)\n    sec = math.max(0, math.floor(sec))\n    return string.format(\"%02d:%02d:%02d\", math.floor(sec / 3600), math.floor(sec / 60) % 60, sec % 60)\nend\n\nfunction M.goalFor(wday) return ROTATE[((wday - 1) % 3) + 1] end\n\n\nlocal function rng(seed)\n    local s = seed % 2147483647\n    return function(a, b)\n        s = (s * 48271) % 2147483647\n        return a + (s % (b - a + 1))\n    end\nend\n\n\n\n\nM.VARIANTS = {\n    classic = { \"variant.classic.1\", \"variant.classic.2\", \"variant.classic.3\" },\n    step    = { \"variant.step.1\", \"variant.step.2\", \"variant.step.3\" },\n    timed   = { \"variant.timed.1\", \"variant.timed.2\", \"variant.timed.3\" },\n}\n\nM.TUNE = { energy = 4000, q1 = 64, p1 = 116, q2 = 46, p2 = 110, q3 = 90, p3 = 118, moves = 15 }\nlocal DT = M.TUNE\nfunction M.level(day)\n    day = day or M.now()\n    local r = rng(day.key)\n    local goal = M.goalFor(day.wday)\n    local v = r(1, 3)\n    local lv = {\n        name = string.format(\"Daily %d-%02d\", day.month, day.day), nameKey = \"daily.name\", nameMonth = day.month, nameDay = day.day,\n        colors = 5, locks = true, specials = \"full\", cannon = true,\n        powerChance = 0.016, daily = true, dayKey = day.key,\n    }\n    if goal == \"step\" then\n        local col = r(1, 5)\n\n        lv.goal, lv.moves, lv.speed, lv.quota, lv.seed, lv.push = \"step\", DT.moves + r(0, 2), 6, 400, 26, 36\n        lv.pushRate = 240\n        lv.lockInterval = 6.5\n        if v == 1 then\n            lv.preLocks = 6 + r(0, 3); lv.moves = lv.moves + 1\n            lv.targets = { { kind = \"color\", color = col, n = 14 + r(0, 4) }, { kind = \"unlock\", n = lv.preLocks - 2 }, { kind = \"beads\", n = 26 + r(0, 6) } }\n        elseif v == 2 then\n            lv.preLocks = 8 + r(0, 3); lv.moves = lv.moves + 4\n            lv.targets = { { kind = \"unlock\", n = lv.preLocks - 2 }, { kind = \"beads\", n = 24 + r(0, 6) } }\n        else\n            local col2 = (col % 5) + 1\n            lv.moves = lv.moves + 4\n            lv.targets = { { kind = \"color\", color = col, n = 17 + r(0, 3) }, { kind = \"color\", color = col2, n = 17 + r(0, 3) }, { kind = \"beads\", n = 24 + r(0, 6) } }\n        end\n    elseif goal == \"timed\" then\n        lv.goal, lv.speed, lv.quota, lv.seed, lv.push = \"timed\", 9 + r(0, 3) * 0.5, 0, 22, 44\n        lv.lockInterval = 3.8\n        local base = DT.energy + r(0, 6) * 100\n        if v == 1 then lv.time, lv.energy = 90, base\n        elseif v == 2 then lv.time, lv.energy, lv.speed = 60, math.floor(base * 0.66 / 100) * 100, lv.speed + 3\n        else lv.time, lv.energy, lv.speed = 120, math.floor(base * 1.35 / 100) * 100, lv.speed - 1 end\n    else\n        lv.goal, lv.lockInterval = nil, 3.6 - r(0, 3) * 0.1\n\n        if v == 1 then lv.speed, lv.quota, lv.seed, lv.push = 9 + r(0, 4) * 0.5, DT.q1 + r(0, 20) // 2, 22 + r(0, 2), DT.p1\n        elseif v == 2 then lv.speed, lv.quota, lv.seed, lv.push = 14 + r(0, 4) * 0.5, DT.q2 + r(0, 15) // 2, 24, DT.p2\n        else lv.speed, lv.quota, lv.seed, lv.push = 8 + r(0, 3) * 0.5, DT.q3 + r(0, 20) // 2, 20, DT.p3 end\n    end\n    lv.kind = goal\n    lv.variant = M.VARIANTS[goal][v]\n    return lv\nend\n\nreturn M\n"],["ringcore/danger.lua","\n\nlocal M = {}\nlocal C = require \"ringcore.config\"\nlocal G = require \"ringcore.state\"\nlocal Audio = require \"ringcore.audio\"\nlocal Ring = require \"ringcore.ring\"\nlocal T = C.T\n\nlocal function update(dt)\n    local head = Ring.headBall()\n    local p = 0\n    if head and G.pathLen > 0 then\n        p = (head.dist / G.pathLen - T.DANGER_AT) / (1 - T.DANGER_AT)\n        p = math.max(0, math.min(1, p))\n    end\n\n    G.danger = G.danger + (p - G.danger) * math.min(1, dt * 6)\n    if G.heartBeat > 0 then G.heartBeat = math.max(0, G.heartBeat - dt * 3) end\n    if G.heartDub > 0 then\n        G.heartDub = G.heartDub - dt\n        if G.heartDub <= 0 then\n            Audio.playSfx(\"pulse_hit\", 0.45, 0.25 + 0.3 * G.danger)\n        end\n    end\n    if p <= 0 then\n        G.heartT = 0\n        return\n    end\n    G.heartT = G.heartT - dt\n    if G.heartT <= 0 then\n\n        Audio.playSfx(\"pulse_hit\", 0.55, 0.3 + 0.35 * p)\n        G.heartDub = 0.15\n        G.heartBeat = 1\n        G.heartT = 1.1 - 0.75 * p\n    end\nend\n\nM.update = update\n\nreturn M\n"],["ringcore/draw.lua","\nlocal M = {}\nlocal C = require \"ringcore.config\"\nlocal G = require \"ringcore.state\"\nlocal COLORS, GLYPHS = C.COLORS, C.GLYPHS\n\n\n\n\nlocal function rgba(c, a) return nvgRGBA(c[1], c[2], c[3], a or 255) end\n\nlocal function drawImage(ctx, handle, x, y, w, h, alpha)\n    if not handle or handle < 0 then return false end\n    alpha = alpha or 1\n    nvgBeginPath(ctx)\n    nvgRect(ctx, x - w / 2, y - h / 2, w, h)\n    nvgFillPaint(ctx, nvgImagePattern(ctx, x - w / 2, y - h / 2, w, h, 0, handle, alpha))\n    nvgFill(ctx)\n    return true\nend\n\n\nlocal function drawSub(ctx, handle, aw, ah, sx, sy, sw, sh, dx, dy, dw, dh, alpha)\n    if not handle or handle < 0 then return false end\n    local kx, ky = dw / sw, dh / sh\n    nvgBeginPath(ctx)\n    nvgRect(ctx, dx, dy, dw, dh)\n    nvgFillPaint(ctx, nvgImagePattern(ctx, dx - sx * kx, dy - sy * ky, aw * kx, ah * ky, 0, handle, alpha or 1))\n    nvgFill(ctx)\n    return true\nend\n\n\nlocal function nine(ctx, handle, aw, ah, e, x, y, w, h, alpha, de)\n    if not handle or handle < 0 then return false end\n    de = math.min(de or e, w / 2, h / 2)\n    local sxs = { 0, e, aw - e }\n    local sws = { e, aw - 2 * e, e }\n    local sys = { 0, e, ah - e }\n    local shs = { e, ah - 2 * e, e }\n    local dxs = { x, x + de, x + w - de }\n    local dws = { de, w - 2 * de, de }\n    local dys = { y, y + de, y + h - de }\n    local dhs = { de, h - 2 * de, de }\n    for j = 1, 3 do\n        for i = 1, 3 do\n            if dws[i] > 0.5 and dhs[j] > 0.5 then\n                drawSub(ctx, handle, aw, ah, sxs[i], sys[j], sws[i], shs[j], dxs[i], dys[j], dws[i], dhs[j], alpha)\n            end\n        end\n    end\n    return true\nend\n\nlocal function glyphPath(ctx, x, y, s, kind)\n    nvgBeginPath(ctx)\n    if kind == \"dot\" then\n        nvgCircle(ctx, x, y, s * 0.42)\n    elseif kind == \"diamond\" then\n        nvgMoveTo(ctx, x, y - s * 0.6); nvgLineTo(ctx, x + s * 0.5, y)\n        nvgLineTo(ctx, x, y + s * 0.6); nvgLineTo(ctx, x - s * 0.5, y); nvgClosePath(ctx)\n    elseif kind == \"tri\" then\n        nvgMoveTo(ctx, x, y - s * 0.55); nvgLineTo(ctx, x + s * 0.55, y + s * 0.42)\n        nvgLineTo(ctx, x - s * 0.55, y + s * 0.42); nvgClosePath(ctx)\n    elseif kind == \"cross\" then\n        nvgRect(ctx, x - s * 0.55, y - s * 0.17, s * 1.1, s * 0.34)\n        nvgRect(ctx, x - s * 0.17, y - s * 0.55, s * 0.34, s * 1.1)\n    else\n        nvgRect(ctx, x - s * 0.4, y - s * 0.4, s * 0.8, s * 0.8)\n    end\nend\nlocal function drawGlyph(ctx, x, y, s, kind, a)\n    glyphPath(ctx, x, y, s, kind)\n    nvgFillColor(ctx, nvgRGBA(20, 24, 48, a or 150))\n    nvgFill(ctx)\nend\n\nlocal function drawGlyphOnOrb(ctx, x, y, s, kind, af)\n    glyphPath(ctx, x, y, s, kind)\n    nvgStrokeColor(ctx, nvgRGBA(16, 20, 40, math.floor(190 * af)))\n    nvgStrokeWidth(ctx, math.max(1.6, s * 0.16))\n    nvgLineJoin(ctx, NVG_ROUND)\n    nvgStroke(ctx)\n    nvgFillColor(ctx, nvgRGBA(255, 255, 255, math.floor(205 * af)))\n    nvgFill(ctx)\nend\n\n\n\nlocal drawSpecialMark\nlocal function shapePath(ctx, shape, x, y, s, inset)\n    local h = s / 2 - (inset or 0)\n    nvgBeginPath(ctx)\n    if shape == \"round\" then\n        nvgCircle(ctx, x, y, h)\n    elseif shape == \"hex\" then\n        for i = 0, 5 do\n            local a = -math.pi / 2 + i * math.pi / 3\n            local px, py = x + math.cos(a) * h, y + math.sin(a) * h\n            if i == 0 then nvgMoveTo(ctx, px, py) else nvgLineTo(ctx, px, py) end\n        end\n        nvgClosePath(ctx)\n    else\n        nvgRoundedRect(ctx, x - h, y - h, h * 2, h * 2, h * 0.6)\n    end\nend\n\nlocal function orbHandle(colorIdx)\n    local h = G.img[\"orb\" .. tostring(colorIdx)]\n    if h and h >= 0 then return h end\nend\n\n\n\n\n\nlocal GLOW_W = { 1.0, 1.0, 1.0, 1.0, 2.0 }\nlocal function crystalPop(ctx, x, y, c, s, af, ci)\n    if s < 14 or af < 0.2 then return end\n    local gw = GLOW_W[ci] or 1\n    local lighter = nvgGlobalCompositeOperation and NVG_LIGHTER and NVG_SOURCE_OVER\n    if lighter then nvgGlobalCompositeOperation(ctx, NVG_LIGHTER) end\n    nvgBeginPath(ctx); nvgCircle(ctx, x, y, s * 0.44)\n    nvgFillPaint(ctx, nvgRadialGradient(ctx, x, y - s * 0.04, s * 0.04, s * 0.44,\n        nvgRGBA(c[1], c[2], c[3], math.floor(math.min(255, 100 * gw * af))), nvgRGBA(c[1], c[2], c[3], 0)))\n    nvgFill(ctx)\n    if lighter then nvgGlobalCompositeOperation(ctx, NVG_SOURCE_OVER) end\n\n    local f = s * 0.5\n    nvgBeginPath(ctx)\n    nvgMoveTo(ctx, x - f * 0.58, y - f * 0.18); nvgLineTo(ctx, x - f * 0.30, y - f * 0.58)\n    nvgLineTo(ctx, x - f * 0.12, y - f * 0.52); nvgLineTo(ctx, x - f * 0.44, y - f * 0.10); nvgClosePath(ctx)\n    nvgFillColor(ctx, nvgRGBA(255, 255, 255, math.floor(150 * af))); nvgFill(ctx)\n    nvgBeginPath(ctx); nvgCircle(ctx, x + f * 0.38, y - f * 0.44, s * 0.035)\n    nvgFillColor(ctx, nvgRGBA(255, 255, 255, math.floor(230 * af))); nvgFill(ctx)\nend\n\nlocal function drawGem(ctx, x, y, colorIdx, s, shape, alpha, special)\n    local c = COLORS[colorIdx] or COLORS[1]\n    alpha = alpha or 255\n    local af = alpha / 255\n    local orb = orbHandle(colorIdx)\n    local h = s / 2\n    if orb and shape == \"round\" then\n\n        nvgBeginPath(ctx)\n        nvgEllipse(ctx, x, y + h * 0.55, h * 0.8, h * 0.32)\n        nvgFillColor(ctx, nvgRGBA(0, 0, 0, math.floor(70 * af)))\n        nvgFill(ctx)\n        drawImage(ctx, orb, x, y, s * 1.16, s * 1.16, af)\n        crystalPop(ctx, x, y, c, s, af, colorIdx)\n        if special then drawSpecialMark(ctx, x, y, special, s)\n        else drawGlyphOnOrb(ctx, x, y + s * 0.02, s * 0.30, GLYPHS[colorIdx], af) end\n        return\n    end\n    local lip = s * 0.08\n    shapePath(ctx, shape, x, y + lip + 2, s)\n    nvgFillColor(ctx, nvgRGBA(0, 0, 0, math.floor(90 * af)))\n    nvgFill(ctx)\n    shapePath(ctx, shape, x, y + lip * 0.5, s + 3 + lip * 0.5)\n    nvgFillColor(ctx, nvgRGBA(14, 16, 30, alpha))\n    nvgFill(ctx)\n    shapePath(ctx, shape, x, y + lip, s)\n    nvgFillColor(ctx, nvgRGBA(math.floor(c[1] * 0.45), math.floor(c[2] * 0.45), math.floor(c[3] * 0.5), alpha))\n    nvgFill(ctx)\n    shapePath(ctx, shape, x, y, s)\n    if orb then\n\n        nvgFillPaint(ctx, nvgLinearGradient(ctx, x, y - h, x, y + h,\n            nvgRGBA(math.floor(c[1] * 0.95), math.floor(c[2] * 0.95), math.floor(c[3] * 0.95), alpha),\n            nvgRGBA(math.floor(c[1] * 0.58), math.floor(c[2] * 0.58), math.floor(c[3] * 0.60), alpha)))\n    else\n        nvgFillPaint(ctx, nvgLinearGradient(ctx, x, y - h, x, y + h,\n            nvgRGBA(math.min(255, c[1] + 60), math.min(255, c[2] + 60), math.min(255, c[3] + 60), alpha),\n            nvgRGBA(math.floor(c[1] * 0.78), math.floor(c[2] * 0.78), math.floor(c[3] * 0.78), alpha)))\n    end\n    nvgFill(ctx)\n    if orb then\n        drawImage(ctx, orb, x, y + s * 0.01, s * 0.90, s * 0.90, af)\n        crystalPop(ctx, x, y, c, s * 0.9, af, colorIdx)\n        if special then drawSpecialMark(ctx, x, y, special, s)\n        else drawGlyphOnOrb(ctx, x, y + s * 0.02, s * 0.24, GLYPHS[colorIdx], af) end\n        return\n    end\n\n    nvgBeginPath(ctx)\n    nvgEllipse(ctx, x, y - h * 0.42, h * 0.68, h * 0.3)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, x, y - h * 0.75, x, y - h * 0.1,\n        nvgRGBA(255, 255, 255, math.floor(130 * af)), nvgRGBA(255, 255, 255, math.floor(10 * af))))\n    nvgFill(ctx)\n    if special then\n        drawSpecialMark(ctx, x, y, special, s)\n    else\n        drawGlyph(ctx, x, y + s * 0.03, s * 0.4, GLYPHS[colorIdx], math.floor(150 * alpha / 255))\n    end\n    nvgBeginPath(ctx)\n    nvgCircle(ctx, x + h * 0.5, y - h * 0.5, s * 0.045)\n    nvgFillColor(ctx, nvgRGBA(255, 255, 255, math.floor(200 * af)))\n    nvgFill(ctx)\nend\n\n\nlocal function drawBead(ctx, x, y, colorIdx, r, alpha)\n    drawGem(ctx, x, y, colorIdx, r * 2, \"round\", alpha)\nend\n\n\nlocal function drawPowerMark(ctx, x, y, kind, r, t)\n    local glow = 0.6 + 0.4 * math.sin(t * 6)\n    nvgBeginPath(ctx)\n    nvgCircle(ctx, x, y, r + 3)\n    nvgStrokeColor(ctx, nvgRGBA(255, 255, 255, math.floor(150 + 100 * glow)))\n    nvgStrokeWidth(ctx, 2.5)\n    nvgStroke(ctx)\n    nvgStrokeColor(ctx, nvgRGBA(20, 20, 40, 230))\n    nvgFillColor(ctx, nvgRGBA(20, 20, 40, 230))\n    nvgStrokeWidth(ctx, 2.2)\n    if kind == \"reverse\" then\n        nvgBeginPath(ctx)\n        nvgArc(ctx, x, y, r * 0.48, -math.pi * 0.15, math.pi * 1.25, NVG_CW)\n        nvgStroke(ctx)\n        local ax, ay = x + math.cos(-math.pi * 0.15) * r * 0.48, y + math.sin(-math.pi * 0.15) * r * 0.48\n        nvgBeginPath(ctx)\n        nvgMoveTo(ctx, ax + 4, ay - 1); nvgLineTo(ctx, ax - 3, ay - 4); nvgLineTo(ctx, ax - 1, ay + 4)\n        nvgClosePath(ctx)\n        nvgFill(ctx)\n    else\n        local s = r * 0.5\n        nvgBeginPath(ctx)\n        nvgMoveTo(ctx, x - s, y - s); nvgLineTo(ctx, x + s, y - s); nvgLineTo(ctx, x - s, y + s)\n        nvgLineTo(ctx, x + s, y + s); nvgClosePath(ctx)\n        nvgStroke(ctx)\n    end\nend\n\ndrawSpecialMark = function(ctx, x, y, kind, s)\n    local handle = (kind == \"line_h\" or kind == \"line_v\") and G.img.meteor\n        or (kind == \"quake\" and G.img.quake) or (kind == \"nova\" and G.img.nova)\n    if handle then\n        local size = s * 0.84\n        local pulse = 0.85 + 0.15 * math.sin((G.animT or 0) * 5)\n        nvgBeginPath(ctx)\n        nvgCircle(ctx, x, y, s * 0.42)\n        nvgFillPaint(ctx, nvgRadialGradient(ctx, x, y, s * 0.08, s * 0.42,\n            nvgRGBA(255, 250, 220, math.floor(170 * pulse)), nvgRGBA(255, 250, 220, 0)))\n        nvgFill(ctx)\n        nvgSave(ctx)\n        nvgTranslate(ctx, x, y)\n        if kind == \"line_v\" then nvgRotate(ctx, math.pi / 2)\n        elseif kind == \"nova\" then nvgRotate(ctx, math.sin((G.animT or 0) * 2) * 0.25) end\n        drawImage(ctx, handle, 0, 0, size, size, 1)\n        nvgRestore(ctx)\n        if kind == \"line_h\" or kind == \"line_v\" then\n\n            local vx, vy = (kind == \"line_h\") and 1 or 0, (kind == \"line_v\") and 1 or 0\n            nvgStrokeColor(ctx, nvgRGBA(255, 250, 220, 230)); nvgStrokeWidth(ctx, 2)\n            for sgn = -1, 1, 2 do\n                local tx, ty = x + vx * sgn * s * 0.42, y + vy * sgn * s * 0.42\n                local px, py = -vy * s * 0.1, vx * s * 0.1\n                nvgBeginPath(ctx)\n                nvgMoveTo(ctx, tx - vx * sgn * s * 0.1 + px, ty - vy * sgn * s * 0.1 + py)\n                nvgLineTo(ctx, tx, ty)\n                nvgLineTo(ctx, tx - vx * sgn * s * 0.1 - px, ty - vy * sgn * s * 0.1 - py)\n                nvgStroke(ctx)\n            end\n        end\n        return\n    end\n    nvgStrokeWidth(ctx, 2.5)\n    nvgStrokeColor(ctx, nvgRGBA(255, 255, 230, 230))\n    if kind == \"line_h\" then\n        nvgBeginPath(ctx)\n        nvgMoveTo(ctx, x - s * 0.35, y); nvgLineTo(ctx, x + s * 0.35, y)\n        nvgMoveTo(ctx, x + s * 0.2, y - s * 0.15); nvgLineTo(ctx, x + s * 0.35, y); nvgLineTo(ctx, x + s * 0.2, y + s * 0.15)\n        nvgStroke(ctx)\n    elseif kind == \"line_v\" then\n        nvgBeginPath(ctx)\n        nvgMoveTo(ctx, x, y - s * 0.35); nvgLineTo(ctx, x, y + s * 0.35)\n        nvgMoveTo(ctx, x - s * 0.15, y + s * 0.2); nvgLineTo(ctx, x, y + s * 0.35); nvgLineTo(ctx, x + s * 0.15, y + s * 0.2)\n        nvgStroke(ctx)\n    elseif kind == \"quake\" then\n        nvgBeginPath(ctx)\n        nvgCircle(ctx, x, y, s * 0.28)\n        nvgStroke(ctx)\n        nvgBeginPath(ctx)\n        nvgCircle(ctx, x, y, s * 0.14)\n        nvgFillColor(ctx, nvgRGBA(255, 240, 180, 200))\n        nvgFill(ctx)\n    elseif kind == \"nova\" then\n        for i = 0, 3 do\n            local a = i * math.pi / 2 + G.animT\n            nvgBeginPath(ctx)\n            nvgMoveTo(ctx, x, y)\n            nvgLineTo(ctx, x + math.cos(a) * s * 0.35, y + math.sin(a) * s * 0.35)\n            nvgStroke(ctx)\n        end\n    end\nend\n\n\nlocal function drawTile(ctx, x, y, colorIdx, s, alpha, special)\n    drawGem(ctx, x, y, colorIdx, s, \"square\", alpha, special)\nend\n\nlocal function text(ctx, x, y, str, size, align, col)\n    if G.fontId == -1 then return end\n    nvgFontFaceId(ctx, G.fontId)\n    nvgFontSize(ctx, size)\n    nvgTextAlign(ctx, align or (NVG_ALIGN_CENTER + NVG_ALIGN_MIDDLE))\n    nvgFillColor(ctx, col or nvgRGBA(235, 238, 255, 240))\n    nvgText(ctx, x, y, str, nil)\nend\n\nlocal function drawStar(ctx, x, y, r, filled)\n    nvgBeginPath(ctx)\n    for k = 0, 9 do\n        local a = -math.pi / 2 + k * math.pi / 5\n        local rr = (k % 2 == 0) and r or r * 0.45\n        if k == 0 then nvgMoveTo(ctx, x + math.cos(a) * rr, y + math.sin(a) * rr)\n        else nvgLineTo(ctx, x + math.cos(a) * rr, y + math.sin(a) * rr) end\n    end\n    nvgClosePath(ctx)\n    if filled then\n        nvgFillColor(ctx, nvgRGBA(255, 215, 90, 255))\n        nvgFill(ctx)\n    else\n        nvgStrokeColor(ctx, nvgRGBA(150, 150, 190, 180))\n        nvgStrokeWidth(ctx, 1.5)\n        nvgStroke(ctx)\n    end\nend\n\nlocal function button(ctx, x, y, w, h, label, enabled)\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x - w / 2, y - h / 2, w, h, 14)\n    nvgFillColor(ctx, enabled == false and nvgRGBA(30, 36, 60, 220) or nvgRGBA(40, 90, 96, 235))\n    nvgFill(ctx)\n    nvgStrokeColor(ctx, nvgRGBA(120, 220, 190, enabled == false and 60 or 160))\n    nvgStrokeWidth(ctx, 1.5)\n    nvgStroke(ctx)\n    if label then text(ctx, x, y, label, 18, nil, nvgRGBA(230, 245, 240, enabled == false and 120 or 255)) end\nend\n\nlocal function strokePath(ctx, width, col)\n    nvgBeginPath(ctx)\n    nvgMoveTo(ctx, G.path[1].x, G.path[1].y)\n    for i = 2, #G.path, 2 do nvgLineTo(ctx, G.path[i].x, G.path[i].y) end\n    nvgLineTo(ctx, G.path[#G.path].x, G.path[#G.path].y)\n    nvgStrokeColor(ctx, col)\n    nvgStrokeWidth(ctx, width)\n    nvgLineCap(ctx, NVG_ROUND)\n    nvgLineJoin(ctx, NVG_ROUND)\n    nvgStroke(ctx)\nend\n\nM.rgba = rgba\nM.drawImage = drawImage\nM.drawSub = drawSub\nM.nine = nine\nM.drawBead = drawBead\nM.drawTile = drawTile\nM.drawGem = drawGem\nM.drawGlyphOnOrb = drawGlyphOnOrb\nM.drawSpecialMark = function(...) return drawSpecialMark(...) end\nM.text = text\nM.strokePath = strokePath\nM.drawStar = drawStar\nM.button = button\nM.drawPowerMark = drawPowerMark\n\nreturn M\n"],["ringcore/flow.lua","\nlocal M = {}\nlocal C = require \"ringcore.config\"\nlocal G = require \"ringcore.state\"\nlocal Haptics = require \"ringcore.haptics\"\nlocal Rng = require \"ringcore.rng\"\nlocal Audio = require \"ringcore.audio\"\nlocal Fx = require \"ringcore.fx\"\nlocal Ring = require \"ringcore.ring\"\nlocal Board = require \"ringcore.activeboard\"\nlocal Progress = require \"ringcore.progress\"\nlocal Goals = require \"ringcore.goals\"\nlocal Daily = require \"ringcore.daily\"\nlocal Levels = require \"ringcore.levels\"\nlocal Monetize = require \"ringcore.monetize\"\nlocal Funnel = require \"ringcore.funnel\"\nlocal SaveGame = require \"ringcore.savegame\"\nlocal tr = require(\"ringcore.i18n\").t\nlocal T, BALL_D, LEVELS, HEX_LEVELS = C.T, C.BALL_D, C.LEVELS, C.HEX_LEVELS\n\nlocal function levelTable()\n    return G.mode == \"hex\" and HEX_LEVELS or LEVELS\nend\n\nlocal function resetStats()\n    G.stats = { pulses = 0, hits = 0, fizzles = 0, warnTime = 0, locks = 0, unlocks = 0,\n              swaps = 0, cascades = 0, maxCombo = 1, time = 0,\n              cannonShots = 0, specialsSpawned = 0, specialsFired = 0,\n              powers = 0, maxChain = 0, combos = 0, shuffles = 0 }\nend\nlocal function starsFor()\n    local gs = Goals.stars()\n    if gs then return gs end\n    local frac = G.pathLen > 0 and G.maxHead / G.pathLen or 1\n    if frac < T.STAR3_AT then return 3 end\n    if frac < T.STAR2_AT then return 2 end\n    return 1\nend\n\nlocal function endLevel(result)\n    if result == \"lost\" then\n        print(string.format(\"[CAUSE] silt=%s queueLeft=%d balls=%d headDist=%.0f pathLen=%.0f\",\n            tostring(G.siltLost), G.queueLeft or -1, #G.balls,\n            (G.balls[#G.balls] and G.balls[#G.balls].dist) or -1, G.pathLen or -1))\n    end\n    G.state = result\n\n    if result == \"won\" or result == \"allclear\" then Haptics.long()\n    elseif result == \"lost\" then Haptics.pulse(\"light\", G.now) end\n    G.lastStars, G.newBest = 0, false\n    G.progressFeedback = {}\n    SaveGame.clearRun()\n    local levels = levelTable()\n    if G.runKind then\n\n        G.runResult = result\n        G.state = \"runover\"\n        if result == \"won\" then G.lastStars = starsFor() end\n        if G.runKind == \"daily\" then\n            if not G.runPractice then\n                G.newBest = Progress.dailyFinish(G.lvl.dayKey, G.score, result == \"won\")\n            end\n        else\n            G.newBest = Progress.recordArena(G.runKind .. (G.runTier or 1), G.score, G.goal and G.goal.survived or 0)\n        end\n    elseif result == \"lost\" then\n        Progress.recordLoss(G.mode, G.levelIdx)\n        Funnel.level(G.mode, G.levelIdx, \"fail\")\n    elseif result == \"won\" or result == \"allclear\" then\n        Progress.clearLoss(G.mode, G.levelIdx)\n        Funnel.level(G.mode, G.levelIdx, \"win\")\n        G.lastStars = starsFor()\n        if G.mode == \"hex\" then\n            G.newBest, G.progressFeedback = Progress.recordHex(G.levelIdx, G.lastStars, G.score, #levels, G.stats.maxChain)\n        else\n            G.newBest, G.progressFeedback = Progress.record(G.levelIdx, G.lastStars, G.score, #levels, G.stats.maxChain)\n        end\n    end\n    print(string.format(\"[Ringcore] %s %s score=%d time=%.1f swaps=%d pulses=%d hits=%d fiz=%d \" ..\n        \"cascades=%d warn=%.1f locks=%d unl=%d cannon=%d specials=%d/%d maxCombo=%.1f \" ..\n        \"chain=%d powers=%d spcombos=%d stars=%d mode=%s goal=%s used=%d beads=%d left=%.1f\",\n        G.lvl.name, result, G.score, G.stats.time, G.stats.swaps, G.stats.pulses, G.stats.hits, G.stats.fizzles,\n        G.stats.cascades, G.stats.warnTime, G.stats.locks, G.stats.unlocks,\n        G.stats.cannonShots, G.stats.specialsFired, G.stats.specialsSpawned, G.stats.maxCombo,\n        G.stats.maxChain, G.stats.powers, G.stats.combos, G.lastStars, G.mode or \"classic\",\n        G.goal and G.goal.kind or \"-\", G.goal and G.goal.used or 0, G.goal and G.goal.beads or 0, G.goal and G.goal.left or 0))\nend\n\nlocal function finishGoal(result)\n    if result == \"won\" then\n        local bonus, label = Goals.bonus()\n        if bonus > 0 then\n            G.score = G.score + bonus\n            Fx.popup(270, 300, tr(label) .. \" +\" .. bonus, true)\n        end\n        Audio.playSfx(\"win\", 1, 0.9)\n        local levels = levelTable()\n        endLevel((not G.runKind and G.levelIdx >= #levels) and \"allclear\" or \"won\")\n    elseif result == \"over\" then\n        Audio.playSfx(\"win\", 1, 0.9)\n        endLevel(\"over\")\n    else\n        print(string.format(\"[WHY] chain t=%.1f lv=%d\", G.stats and G.stats.time or -1, G.levelIdx))\n        Audio.playSfx(\"lose\", 1, 0.9)\n        Fx.shake(8, 0.3)\n        endLevel(\"lost\")\n    end\nend\n\nlocal function checkWinLose()\n    local gr = Goals.check()\n    if gr then finishGoal(gr) return end\n\n    if G.mode == \"hex\" and G.siltLost then\n        print(string.format(\"[WHY] silt t=%.1f lv=%d\", G.stats and G.stats.time or -1, G.levelIdx))\n        Audio.playSfx(\"lose\", 1, 0.9)\n        Fx.shake(9, 0.35)\n        endLevel(\"lost\")\n        return\n    end\n    if not Goals.infinite() and Goals.kind() == \"classic\" and G.queueLeft <= 0 and #G.balls == 0 then\n        local bonus = math.floor((1 - G.maxHead / G.pathLen) * 1000)\n        G.score = G.score + math.max(0, bonus)\n        Fx.popup(270, 300, tr(\"fx.levelBonus\", { n = math.max(0, bonus) }), true)\n        Audio.playSfx(\"win\", 1, 0.9)\n        local levels = levelTable()\n        endLevel(G.levelIdx >= #levels and \"allclear\" or \"won\")\n        return\n    end\n    local head = Ring.headBall()\n    if head and head.dist >= G.pathLen - 6 then\n        local k = Goals.kind()\n        if k == \"endless\" or k == \"blitz\" then\n            Fx.shake(8, 0.3)\n            Audio.playSfx(\"lose\", 1, 0.9)\n            endLevel(\"over\")\n            return\n        end\n        Audio.playSfx(\"lose\", 1, 0.9)\n        Fx.shake(8, 0.3)\n        endLevel(\"lost\")\n    end\nend\n\nlocal function resetCommon(i, lvl)\n\n    Rng.seed(G.nextSeed or math.random(1, 2147483646))\n    G.nextSeed = nil\n    G.levelIdx = i\n    G.lvl = lvl\n    G.state = \"ready\"\n    G.progressFeedback, G.ui.tutorial = {}, nil\n    G.score, G.combo, G.lastHitTime = 0, 1, -99\n    G.balls, G.pulses, G.pendingPulses, G.reserved = {}, {}, {}, {}\n    G.lockDrops, G.lockQueue = {}, {}\n    G.warnActive, G.lockTimer = false, 0\n    G.particles, G.popups, G.flashes, G.ringsFX, G.sparks, G.impacts = {}, {}, {}, {}, {}, {}\n    G.shards, G.motes, G.conduitGlow = {}, {}, 0\n    G.anims, G.causalFX = {}, {}\n    G.ui.ammoFocusUntil, G.ui.ammoHint = nil, nil\n    G.reverseTimer, G.rollInTimer, G.stunTimer, G.maxHead = 0, 0, 0, 0\n    G.boardState, G.swapAnim, G.cascade, G.idleTime, G.hint = \"idle\", nil, 0, 0, nil\n    G.press, G.selected = nil, nil\n    G.cannonAmmo, G.flying, G.cannonShake = {}, nil, 0\n\n    G.aimX, G.aimY = T.CANNON_X, T.CANNON_Y - 80\n    G.hitstop, G.shakeAmp, G.shakeT, G.shakeTrauma, G.stateTimer, G._swapBack = 0, 0, 0, 0, 0, nil\n    G.clearDur = nil\n    G.chain, G.chainFlash, G.powerReverse, G.slowTimer, G.beams, G.powerGranted = 0, nil, 0, 0, {}, false\n    G.danger, G.heartT, G.heartDub, G.heartBeat = 0, 0, 0, 0\n    G.boosterPick = { reverse = false, slow = false, hint = false }\n    G.silt, G.siltT, G.siltLost, G.siltColor, G.siltColBottom = nil, 0, nil, nil, nil\n\n    G.runContinues, G.winDoubled = 0, 0\n    G.pushQ = 0\n    G.armedBoost, G.roundBoost = nil, nil\n    Monetize.newRun()\n    resetStats()\n    Goals.reset(lvl)\n    G.queueLeft = Goals.infinite(lvl) and 1e9 or G.lvl.quota\n    for k = 1, G.lvl.seed do Ring.spawnTail(-(k - 1) * BALL_D) end\n    Ring.sortBalls()\n    Board.generateBoard()\n\n    if (lvl.preLocks or 0) > 0 and G.mode ~= \"hex\" then\n        local cells = {}\n        for r = 1, #G.board do for c = 1, #G.board[r] do\n            local cell = G.board[r][c]\n            if cell and not cell.special then cells[#cells + 1] = cell end\n        end end\n        for k = 1, math.min(lvl.preLocks, #cells) do\n            local j = Rng.board(k, #cells)\n            cells[k], cells[j] = cells[j], cells[k]\n            cells[k].locked = true\n        end\n    end\nend\n\n\n\nfunction M.ddaLevel(mode, i, lv)\n    if G.noDda or not lv then return lv end\n    if Progress.fails(mode, i) >= Levels.DDA_AFTER then return Levels.eased(lv) end\n    return lv\nend\n\n\n\nlocal function tiered(lv)\n    return Levels.forTier(lv, G.difficulty)\nend\n\nlocal function startLevel(i)\n    SaveGame.clearRun()\n    G.runKind, G.runPractice = nil, false\n    G.mode = \"classic\"\n    if i > #LEVELS then i = 1 end\n\n    for _, ch in ipairs(C.CHAPTERS) do\n        if i >= ch.first and i <= ch.last then G.chapter = ch.id break end\n    end\n    G.lastMode = \"classic\"\n    resetCommon(i, tiered(M.ddaLevel(\"classic\", i, LEVELS[i])))\nend\n\nlocal function startHexLevel(i)\n    G.runKind, G.runPractice = nil, false\n    G.mode = \"hex\"\n    if i > #HEX_LEVELS then i = 1 end\n    G.hexPage = C.hexPageOf(i).id\n    G.lastMode = \"hex\"\n    resetCommon(i, tiered(M.ddaLevel(\"hex\", i, HEX_LEVELS[i])))\nend\n\n\nlocal function startDaily(practice, dayOverride)\n    local day = dayOverride or Daily.now()\n    local lvl = Daily.level(day)\n    G.mode = \"classic\"\n    G.runKind = \"daily\"\n    G.runPractice = practice or Progress.dailyPlayed(day.key)\n    G.nextSeed = day.key\n    resetCommon(0, tiered(lvl))\nend\n\n\nlocal function startArena(id, tier)\n    tier = tier or G.runTier or 1\n    G.mode = \"classic\"\n    G.runKind = id\n    G.runTier = tier\n    G.runPractice = false\n    G.lastMode = \"arena\"\n    resetCommon(0, tiered(C.ARENA[id .. tier]))\nend\n\nlocal function restartRun()\n    if G.runKind == \"daily\" then startDaily(true)\n    elseif G.runKind then startArena(G.runKind) end\nend\n\nlocal function pickedCount()\n    local n = 0\n    for _, v in pairs(G.boosterPick or {}) do if v then n = n + 1 end end\n    return n\nend\n\nlocal function toggleBooster(id)\n    if not G.boosterPick then G.boosterPick = {} end\n    if G.boosterPick[id] then\n        G.boosterPick[id] = false\n        return\n    end\n    if Progress.stock(id) <= 0 then return end\n    if pickedCount() >= 2 then return end\n    G.boosterPick[id] = true\nend\n\nlocal RoundBoost = require \"ringcore.roundboost\"\nlocal function beginPlay()\n\n    RoundBoost.begin(G.boosterPick)\n    if not G.runKind and G.boosterPick and G.boosterPick.hint and Progress.consume(\"hint\") then\n        G.hint = Board.findMove()\n        G.idleTime = T.HINT_DELAY + 1\n        G.stats.powers = (G.stats.powers or 0) + 1\n        Funnel.count(\"booster:hint\")\n    end\n    G.boosterPick = { reverse = false, slow = false, hint = false }\n    if G.runKind == \"daily\" and not G.runPractice then\n        Progress.dailyBegin(G.lvl.dayKey)\n    elseif G.runKind == \"daily\" then\n        Monetize.notePractice()\n    end\n    if not G.runKind then Funnel.level(G.mode, G.levelIdx, \"start\") end\n    G.state = \"playing\"\n    G.rollInTimer = T.ROLL_IN_TIME\n    G.rollInTail = true\nend\n\n\nfunction M.retryLevel()\n    local old, mode, index = G.roundBoost or {}, G.mode, G.levelIdx\n    if mode == \"hex\" then startHexLevel(index) else startLevel(index) end\n    for _, id in ipairs({ \"reverse\", \"slow\" }) do\n        G.boosterPick[id] = old[id] and old[id].allowed == true or false\n    end\n    beginPlay()\n    for _, id in ipairs({ \"reverse\", \"slow\" }) do\n        G.roundBoost[id].prepaid = old[id] and old[id].prepaid == true or false\n    end\nend\n\nlocal function openSelect()\n    G.ui.peek, G.ui.hiveTip = nil, nil\n    if G.mode ~= \"hex\" then G.mode = \"classic\" end\n    G.state = \"select\"\nend\n\n\n\nlocal function openTitle() G.ui.peek, G.ui.hiveTip = nil, nil; G.state = \"title\" end\nlocal function openDaily() G.ui.peek = nil; G.mode = \"classic\"; G.state = \"daily\" end\nlocal function openArena() G.ui.peek = nil; G.mode = \"classic\"; G.state = \"arena\" end\nlocal function openModeSel() G.ui.peek, G.ui.hiveTip = nil, nil; G.state = \"modesel\" end\n\nlocal function pause()\n    if G.state ~= \"playing\" then return end\n    G.state = \"paused\"\n    G.press,G.selected=nil,nil\n    G.ui.press=nil\n    SaveGame.saveRun()\nend\nlocal function resume()\n    if G.state ~= \"paused\" then return end\n    G.state = \"playing\"\n    SaveGame.clearRun()\nend\n\nlocal function setChapter(id)\n    for _, ch in ipairs(C.CHAPTERS) do\n        if ch.id == id then G.chapter = id; return end\n    end\nend\nlocal function nextChapter()\n    if G.mode == \"hex\" then G.hexPage = math.min(#C.HEX_PAGES, (G.hexPage or 1) + 1) return end\n    G.chapter = math.min(#C.CHAPTERS, (G.chapter or 1) + 1)\nend\nlocal function prevChapter()\n    if G.mode == \"hex\" then G.hexPage = math.max(1, (G.hexPage or 1) - 1) return end\n    G.chapter = math.max(1, (G.chapter or 1) - 1)\nend\n\n\n\nfunction M.adCtx()\n    return { mode = G.mode, level = G.levelIdx, runKind = G.runKind, practice = G.runPractice,\n             progress = Progress.unlocked() }\nend\n\n\n\n\n\n\n\n\nM.CONT_FRAC, M.CONT_MOVES, M.CONT_TIME, M.CONT_SLOW = 0.84, 5, 15, 3.0\nfunction M.continueRun(source)\n    if G.state ~= \"lost\" or G.runKind or not Monetize.canContinue() then return false end\n    local g = G.goal\n    if g and g.kind == \"step\" and Goals.movesLeft() <= 0 then g.moves = g.moves + M.CONT_MOVES end\n    if g and g.kind == \"timed\" and g.left <= 0 then g.left = M.CONT_TIME end\n    Ring.sortBalls()\n    local n = 0\n    for k = #G.balls, 1, -1 do\n        if G.balls[k].dist > G.pathLen * M.CONT_FRAC then n = n + 1 else break end\n    end\n    for _ = 1, n do\n        local b = table.remove(G.balls)\n        local x, y = Ring.pointOnPath(b.vdist or b.dist)\n        Fx.burst(x, y, b.color, 6, 160)\n    end\n    if n > 0 then G.reverseTimer = math.max(G.reverseTimer or 0, T.REVERSE_TIME * 2) end\n    G.combo, G.chain = 1, 0\n    G.slowTimer = math.max(G.slowTimer or 0, M.CONT_SLOW)\n    G.powerReverse = math.max(G.powerReverse or 0, 1.0)\n    Progress.undoLoss(G.mode, G.levelIdx)\n    G.runContinues = (G.runContinues or 0) + 1\n\n\n\n    if G.siltLost then\n        G.siltLost = nil\n        for c = 1, (#G.silt or 0) do G.silt[c] = math.max(0, (G.silt[c] or 0) - 2) end\n    end\n    Funnel.count(\"continue:\" .. (source or \"ad\"))\n    Fx.popup(270, 300, tr(\"fx.rescued\"), true)\n    Audio.playSfx(\"unlock\", 1.0, 0.7)\n    G.state = \"playing\"\n    return true\nend\n\n\n\n\n\nfunction M.claimWinDouble()\n    for _, id in ipairs({ \"reverse\", \"slow\", \"hint\" }) do Progress.grant(id, 1) end\n    G.winDoubled = (G.winDoubled or 0) + 1\n    return true\nend\n\n\nfunction M.grantBooster(id)\n    Progress.grant(id, 1)\n    if pickedCount() < 2 then G.boosterPick[id] = true end\nend\n\n\nfunction M.quitLevel()\n    if not G.runKind and (G.state == \"paused\" or G.state == \"playing\") then\n        Funnel.level(G.mode, G.levelIdx, \"quit\")\n        SaveGame.clearRun()\n    end\nend\n\n\nfunction M.resumeSaved()\n    if not SaveGame.loadRun() then return false end\n    G.runKind, G.runPractice = nil, false\n    return true\nend\n\nM.checkWinLose = checkWinLose\nM.openSelect = openSelect\nM.openTitle = openTitle\nM.openDaily = openDaily\nM.openArena = openArena\nM.startDaily = startDaily\nM.startArena = startArena\nM.restartRun = restartRun\nM.openModeSel = openModeSel\nM.pause = pause\nM.resume = resume\nM.startLevel = startLevel\nM.startHexLevel = startHexLevel\nM.beginPlay = beginPlay\nM.toggleBooster = toggleBooster\nM.pickedCount = pickedCount\nM.setChapter = setChapter\nM.nextChapter = nextChapter\nM.prevChapter = prevChapter\n\nreturn M\n"],["ringcore/funnel.lua","\n\n\n\n\nlocal G = require \"ringcore.state\"\nif type(rawget(_G, \"__ringcore_funnel\")) == \"table\" then\n    return rawget(_G, \"__ringcore_funnel\")\nend\nlocal M = {}\nM.LEVELS = { 3, 7, 15 }\nlocal TRACK = {}\nfor _, i in ipairs(M.LEVELS) do TRACK[i] = true end\nM.STEPS = { \"start\", \"win\", \"fail\", \"quit\" }\nlocal SAVE, EXPORT = \"ringcore_funnel.json\", \"ringcore_funnel_export.json\"\nlocal c = {}\nlocal loaded = false\n\nlocal function save()\n    if G.noSave then return end\n    pcall(function()\n        local f = File(SAVE, FILE_WRITE)\n        if f:IsOpen() then f:WriteString(cjson.encode({ v = 1, c = c })); f:Close() end\n    end)\nend\nfunction M.load()\n    loaded = true\n    if G.noSave then return end\n    pcall(function()\n        if not fileSystem:FileExists(SAVE) then return end\n        local f = File(SAVE, FILE_READ)\n        if not f:IsOpen() then return end\n        local ok, d = pcall(cjson.decode, f:ReadString())\n        f:Close()\n        if ok and type(d) == \"table\" and type(d.c) == \"table\" then\n            for k, v in pairs(d.c) do if type(k) == \"string\" then c[k] = tonumber(v) or 0 end end\n        end\n    end)\nend\n\nfunction M.count(key, n)\n    c[key] = (c[key] or 0) + (n or 1)\n    save()\nend\nfunction M.get(key) return c[key] or 0 end\n\n\nfunction M.level(mode, i, step)\n    if mode ~= \"classic\" or not TRACK[i] then return end\n    M.count(\"c\" .. i .. \":\" .. step)\nend\n\n\nfunction M.levelRows()\n    local out = {}\n    for _, i in ipairs(M.LEVELS) do\n        out[#out + 1] = { i, M.get(\"c\" .. i .. \":start\"), M.get(\"c\" .. i .. \":win\"), M.get(\"c\" .. i .. \":fail\"), M.get(\"c\" .. i .. \":quit\") }\n    end\n    return out\nend\n\nfunction M.otherRows()\n    local keys = {}\n    for k in pairs(c) do if not k:match(\"^c%d+:\") then keys[#keys + 1] = k end end\n    table.sort(keys)\n    local out = {}\n    for _, k in ipairs(keys) do out[#out + 1] = { k, c[k] } end\n    return out\nend\n\nfunction M.snapshot()\n    local t = {}\n    for k, v in pairs(c) do t[k] = v end\n    return { format = \"ringcore-funnel-1\", exported = os.date(\"!%Y-%m-%dT%H:%M:%SZ\"), counters = t }\nend\n\nfunction M.export()\n    local js = cjson.encode(M.snapshot())\n    print(\"[Funnel] \" .. js)\n    local ok = pcall(function()\n        local f = File(EXPORT, FILE_WRITE)\n        if f:IsOpen() then f:WriteString(js); f:Close() else error(\"open\") end\n    end)\n    if rawget(_G, \"__js_export\") then pcall(__js_export, EXPORT, js) end\n    return ok and EXPORT or nil\nend\nfunction M.reset() c = {}; save() end\n\nrawset(_G, \"__ringcore_funnel\", M)\nreturn M\n"],["ringcore/fx.lua","\nlocal M = {}\nlocal C = require \"ringcore.config\"\nlocal Rng = require \"ringcore.rng\"\nlocal G = require \"ringcore.state\"\nlocal COLORS = C.COLORS\n\n\n\n\nlocal function burst(x, y, colorIdx, n, speed)\n    local c = COLORS[colorIdx] or { 255, 255, 255 }\n    for _ = 1, n do\n        local a = Rng.vis() * math.pi * 2\n        local v = speed * (0.4 + Rng.vis() * 0.8)\n        G.particles[#G.particles + 1] = { x = x, y = y, vx = math.cos(a) * v, vy = math.sin(a) * v,\n            life = 0.45 + Rng.vis() * 0.25, max = 0.7, c = c, ci = colorIdx, r = 2 + Rng.vis() * 2.5 }\n    end\nend\n\n\nlocal MAX_ANIMS = 40\nlocal function anim(kind, x, y, colorIdx, size, dur)\n    if #G.anims >= MAX_ANIMS then table.remove(G.anims, 1) end\n    G.anims[#G.anims + 1] = { kind = kind, x = x, y = y, row = math.max(1, math.min(5, colorIdx or 1)),\n        size = size, t = 0, dur = dur, rot = Rng.vis() * 6.28 }\nend\n\nlocal function ringBurst(x, y, colorIdx, rad, life)\n    local c = COLORS[colorIdx] or { 255, 240, 180 }\n    rad = rad or 48\n    G.ringsFX[#G.ringsFX + 1] = { x = x, y = y, r0 = 4, r1 = rad, life = life or 0.35, max = life or 0.35, c = c,\n        ci = colorIdx }\n\n    anim(\"sparkle\", x, y, colorIdx, rad * 2.2, 0.5)\n    if rad >= 70 then anim(\"smoke\", x, y, colorIdx, rad * 1.7, 0.55) end\nend\n\nlocal function sparkle(x, y, n)\n    for _ = 1, (n or 6) do\n        local a = Rng.vis() * math.pi * 2\n        local v = 40 + Rng.vis() * 90\n        G.sparks[#G.sparks + 1] = { x = x, y = y, vx = math.cos(a) * v, vy = math.sin(a) * v - 40,\n            life = 0.35 + Rng.vis() * 0.25, max = 0.6, rot = Rng.vis() * 6 }\n    end\nend\n\nlocal function popup(x, y, str, big, size)\n    G.popups[#G.popups + 1] = { x = x, y = y, text = str, life = 0.9, big = big, size = size, age = 0 }\nend\n\n\n\n\nlocal function impact(x, y, colorIdx, n)\n    G.impacts = G.impacts or {}\n    if #G.impacts >= 24 then table.remove(G.impacts, 1) end\n    local k = math.min(1, n / 5)\n    G.impacts[#G.impacts + 1] = {\n        x = x, y = y, ci = colorIdx,\n        r0 = 10 + 26 * k, r1 = 26 + 54 * k,\n        life = 0.10 + 0.06 * k, max = 0.10 + 0.06 * k,\n    }\nend\n\n\n\n\n\n\nlocal SHAKE_CAP = 9\nlocal TRAUMA_DECAY = 3.4\nlocal SHAKE_MAX_PX = 20\nlocal function shake(amp, t)\n    if G.shakeOn == false then return end\n    amp = math.min(SHAKE_CAP, amp)\n    local add = math.sqrt(amp / SHAKE_CAP) * 0.75\n    G.shakeTrauma = math.min(1.35, (G.shakeTrauma or 0) + add)\n    G.shakeAmp, G.shakeT = amp, math.max(G.shakeT or 0, t)\nend\n\n\nlocal function shakeNoise(t, phase)\n    return math.sin(t * 41.0 + phase) * 0.55\n         + math.sin(t * 27.3 + phase * 2.1) * 0.30\n         + math.sin(t * 63.7 + phase * 3.7) * 0.15\nend\n\n\n\n\nfunction M.shakeOffset()\n    local tr = G.shakeTrauma or 0\n    if G.shakeOn == false or tr <= 0 then return 0, 0, 0, 0 end\n    local e = tr * tr\n    local amp = SHAKE_MAX_PX * e\n    if amp < 0.12 then return 0, 0, 0, 0 end\n    local c = G.animT\n\n    return shakeNoise(c, 0.0) * amp, shakeNoise(c, 7.3) * amp * 0.82,\n           shakeNoise(c, 15.1) * e * 0.0045, e * 0.0035\nend\n\n\n\nlocal vseed = 20261101\nlocal function vrand()\n    vseed = (vseed * 1103515245 + 12345) % 2147483648\n    return vseed / 2147483648\nend\nM.vrand = vrand\nlocal MAX_SHARDS = 140\n\nlocal function shards(x, y, colorIdx, n)\n    G.shards = G.shards or {}\n    for _ = 1, n do\n        if #G.shards >= MAX_SHARDS then table.remove(G.shards, 1) end\n        local a = vrand() * math.pi * 2\n        local v = 110 + vrand() * 170\n        G.shards[#G.shards + 1] = { x = x, y = y, vx = math.cos(a) * v, vy = math.sin(a) * v - 70,\n            rot = vrand() * 6.28, vr = (vrand() - 0.5) * 18, life = 0.42 + vrand() * 0.2, max = 0.62,\n            ci = colorIdx, sz = 4 + vrand() * 5 }\n    end\nend\nlocal function moteAt(m, u)\n    local a, b, c = (1 - u) * (1 - u), 2 * u * (1 - u), u * u\n    return a * m.x0 + b * m.cx + c * m.tx, a * m.y0 + b * m.cy + c * m.ty\nend\nM.moteAt = moteAt\n\n\n\nlocal function crystal(x, y, colorIdx, fromSpecial)\n    shards(x, y, colorIdx, fromSpecial and 4 or 6)\nend\n\n\n\nlocal cannonOutcome = { ammo = true, loaded = true, full = true, noTarget = true }\nfunction M.cue(stage, x, y, color)\n    G.causalFX = G.causalFX or {}\n    for _, v in ipairs(G.causalFX) do\n        local sameCue = v.stage == stage and v.ci == color\n        local sameCannonSlot = cannonOutcome[v.stage] and cannonOutcome[stage]\n        if (sameCue or sameCannonSlot) and math.abs(v.x - x) < 64 and math.abs(v.y - y) < 64 then\n            v.stage, v.ci, v.life = stage, color, v.max\n            return\n        end\n    end\n    if #G.causalFX >= 12 then table.remove(G.causalFX, 1) end\n    G.causalFX[#G.causalFX + 1] = { stage = stage, x = x, y = y, ci = color, life = 0.85, max = 0.85 }\nend\n\nlocal function updateFx(dt)\n    if G.state == \"playing\" then\n        for i = #(G.causalFX or {}), 1, -1 do\n            local v = G.causalFX[i]\n            v.life = v.life - dt\n            if v.life <= 0 then table.remove(G.causalFX, i) end\n        end\n    end\n    for i = #G.particles, 1, -1 do\n        local p = G.particles[i]\n        p.life = p.life - dt\n        p.x, p.y = p.x + p.vx * dt, p.y + p.vy * dt\n        p.vx, p.vy = p.vx * 0.92, p.vy * 0.92 + 120 * dt\n        if p.life <= 0 then table.remove(G.particles, i) end\n    end\n    for i = #G.sparks, 1, -1 do\n        local p = G.sparks[i]\n        p.life = p.life - dt\n        p.x, p.y = p.x + p.vx * dt, p.y + p.vy * dt\n        p.vy = p.vy + 180 * dt\n        p.rot = p.rot + dt * 8\n        if p.life <= 0 then table.remove(G.sparks, i) end\n    end\n    for i = #G.anims, 1, -1 do\n        local a = G.anims[i]\n        a.t = a.t + dt\n        if a.t >= a.dur then table.remove(G.anims, i) end\n    end\n    for i = #G.ringsFX, 1, -1 do\n        local r = G.ringsFX[i]\n        r.life = r.life - dt\n        if r.life <= 0 then table.remove(G.ringsFX, i) end\n    end\n    for i = #G.popups, 1, -1 do\n        local p = G.popups[i]\n        p.life = p.life - dt\n        p.y = p.y - 40 * dt\n        if p.life <= 0 then table.remove(G.popups, i) end\n    end\n    for i = #G.flashes, 1, -1 do\n        G.flashes[i].life = G.flashes[i].life - dt\n        if G.flashes[i].life <= 0 then table.remove(G.flashes, i) end\n    end\n    for i = #(G.impacts or {}), 1, -1 do\n        local p = G.impacts[i]\n        p.life = p.life - dt\n        if p.life <= 0 then table.remove(G.impacts, i) end\n    end\n    for i = #G.beams, 1, -1 do\n        G.beams[i].life = G.beams[i].life - dt\n        if G.beams[i].life <= 0 then table.remove(G.beams, i) end\n    end\n    if G.chainFlash then\n        G.chainFlash.life = G.chainFlash.life - dt\n        if G.chainFlash.life <= 0 then G.chainFlash = nil end\n    end\n    for i = #(G.shards or {}), 1, -1 do\n        local p = G.shards[i]\n        p.life = p.life - dt\n        p.x, p.y = p.x + p.vx * dt, p.y + p.vy * dt\n        p.vx, p.vy = p.vx * 0.96, p.vy + 520 * dt\n        p.rot = p.rot + p.vr * dt\n        if p.life <= 0 then table.remove(G.shards, i) end\n    end\n    for i = #(G.motes or {}), 1, -1 do\n        local m = G.motes[i]\n        m.t = m.t + dt\n        if m.t >= m.dur then\n            table.remove(G.motes, i)\n            G.conduitGlow = math.min(1, (G.conduitGlow or 0) + 0.18)\n        end\n    end\n    if (G.conduitGlow or 0) > 0 then G.conduitGlow = math.max(0, G.conduitGlow - dt * 1.6) end\n    for _, p in ipairs(G.popups) do p.age = (p.age or 0) + dt end\n    if G.shakeTrauma and G.shakeTrauma > 0 then\n        G.shakeTrauma = math.max(0, G.shakeTrauma - TRAUMA_DECAY * dt)\n    end\n    if G.shakeT > 0 then G.shakeT = G.shakeT - dt end\n    if G.cannonShake > 0 then G.cannonShake = G.cannonShake - dt end\nend\n\nM.burst = burst\nM.ringBurst = ringBurst\nM.anim = anim\nM.sparkle = sparkle\nM.popup = popup\nM.impact = impact\nM.shake = shake\nM.shards = shards\nM.energy = energy\nM.crystal = crystal\nM.updateFx = updateFx\n\nreturn M\n"],["ringcore/goals.lua","\n\n\nlocal C = require \"ringcore.config\"\nlocal G = require \"ringcore.state\"\nlocal tr = require(\"ringcore.i18n\").t\nlocal M = {}\n\nM.COLOR_KEYS = { \"color.1\", \"color.2\", \"color.3\", \"color.4\", \"color.5\" }\nM.TAG_KEYS = { step = \"tag.step\", timed = \"tag.timed\", endless = \"tag.endless\", blitz = \"tag.blitz\" }\nfunction M.tag(k) return M.TAG_KEYS[k] and tr(M.TAG_KEYS[k]) or nil end\n\nfunction M.kind(lvl)\n    lvl = lvl or G.lvl\n    return (lvl and lvl.goal) or \"classic\"\nend\n\n\nfunction M.infinite(lvl)\n    local k = M.kind(lvl)\n    return k == \"timed\" or k == \"endless\" or k == \"blitz\"\nend\n\nfunction M.reset(lvl)\n    G.goal = {\n        kind = M.kind(lvl),\n        moves = lvl.moves or 0, used = 0,\n        time = lvl.time or 0, left = lvl.time or 0,\n        cleared = {}, beads = 0, survived = 0,\n    }\n    G.speedBonus = 0\nend\n\n\n\n\nfunction M.onMove()\n    if G.goal then G.goal.used = G.goal.used + 1 end\n    if G.lvl and (G.lvl.push or 0) > 0 then G.pushQ = (G.pushQ or 0) + G.lvl.push end\nend\nfunction M.onTile(color)\n    if G.goal and color then G.goal.cleared[color] = (G.goal.cleared[color] or 0) + 1 end\nend\nfunction M.onBeads(n) if G.goal then G.goal.beads = G.goal.beads + n end end\n\nfunction M.movesLeft()\n    return G.goal and math.max(0, G.goal.moves - G.goal.used) or 0\nend\nfunction M.timeLeft()\n    return G.goal and math.max(0, G.goal.left) or 0\nend\n\n\nfunction M.targetProgress(t)\n    local g = G.goal\n    if not g then return 0, t.n end\n    local cur = 0\n    if t.kind == \"unlock\" then cur = G.stats.unlocks or 0\n    elseif t.kind == \"color\" then cur = g.cleared[t.color] or 0\n    elseif t.kind == \"beads\" then cur = g.beads end\n    return math.min(cur, t.n), t.n\nend\n\nfunction M.targetLabel(t)\n    if t.kind == \"unlock\" then return tr(\"target.unlock\")\n    elseif t.kind == \"color\" then return tr(\"target.color\", { color = M.COLOR_KEYS[t.color] and tr(M.COLOR_KEYS[t.color]) or \"\" })\n    elseif t.kind == \"beads\" then return tr(\"target.beads\") end\n    return \"\"\nend\n\nfunction M.targetsMet(lvl)\n    lvl = lvl or G.lvl\n    for _, t in ipairs(lvl.targets or {}) do\n        local cur, need = M.targetProgress(t)\n        if cur < need then return false end\n    end\n    return true\nend\n\nfunction M.energyGoal(lvl) return (lvl or G.lvl).energy or 0 end\n\n\nfunction M.update(dt)\n    local g = G.goal\n    if not g then return end\n    g.survived = g.survived + dt\n    if g.kind == \"timed\" or g.kind == \"blitz\" then g.left = g.left - dt end\n    if g.kind == \"endless\" then\n        local r = G.lvl.ramp or 0.3\n        G.speedBonus = math.min(G.lvl.rampMax or 45, g.survived * r)\n\n        local late = g.survived - (G.lvl.lateAt or 1e9)\n        if late > 0 then G.speedBonus = G.speedBonus + late * (G.lvl.lateRamp or 0) end\n    end\nend\n\n\nfunction M.settled()\n    return G.boardState == \"idle\" and #G.pendingPulses == 0 and #G.pulses == 0 and (G.chain or 0) == 0\nend\n\n\nfunction M.check()\n    local g = G.goal\n    if not g then return nil end\n    if g.kind == \"step\" then\n        if M.targetsMet() then return \"won\" end\n        if M.movesLeft() <= 0 and M.settled() then return \"lost\" end\n    elseif g.kind == \"timed\" then\n        if G.score >= M.energyGoal() then return \"won\" end\n        if g.left <= 0 then return \"lost\" end\n    elseif g.kind == \"blitz\" then\n        if g.left <= 0 then return \"over\" end\n    end\n    return nil\nend\n\nfunction M.stars()\n    local g = G.goal\n    if g.kind == \"step\" then\n        local f = M.movesLeft() / math.max(1, g.moves)\n        if f >= 0.3 then return 3 elseif f >= 0.12 then return 2 end\n        return 1\n    elseif g.kind == \"timed\" then\n        local l = M.timeLeft()\n        if l >= 30 then return 3 elseif l >= 12 then return 2 end\n        return 1\n    end\n    return nil\nend\n\n\nfunction M.starsNow()\n    if not G.goal or (G.goal.kind ~= \"step\" and G.goal.kind ~= \"timed\") then return nil end\n    return M.stars()\nend\n\n\nfunction M.bonus()\n    local g = G.goal\n    if g.kind == \"step\" then return M.movesLeft() * 150, \"goal.bonusMoves\" end\n    if g.kind == \"timed\" then return math.floor(M.timeLeft()) * 40, \"goal.bonusTime\" end\n    return 0, nil\nend\n\nlocal function fmtTime(s)\n    s = math.max(0, math.ceil(s))\n    return string.format(\"%d:%02d\", math.floor(s / 60), s % 60)\nend\nM.fmtTime = fmtTime\n\n\nfunction M.describe(lvl)\n    local k = M.kind(lvl)\n    if k == \"step\" then\n        local parts = {}\n        for _, t in ipairs(lvl.targets or {}) do parts[#parts + 1] = tr(\"target.item\", { label = M.targetLabel(t), n = t.n }) end\n        return tr(\"goal.stepDesc\", { n = lvl.moves, list = table.concat(parts, tr(\"sep.dot\")) })\n    elseif k == \"timed\" then\n        return tr(\"goal.timedDesc\", { t = lvl.time, e = lvl.energy })\n    elseif k == \"endless\" then\n        return tr(\"goal.endlessDesc\")\n    elseif k == \"blitz\" then\n        return tr(\"goal.rushDesc\", { t = lvl.time })\n    end\n    return tr(\"goal.quotaDesc\", { n = lvl.quota })\nend\n\nreturn M\n"],["ringcore/haptics.lua","\nlocal T = {}\nlocal lastAt, lastKind = 0, nil\nlocal COOLDOWN = { light = 0.09, medium = 0.14, heavy = 0.22 }\nlocal function host()\n    if require(\"ringcore.state\").shakeOn == false then return nil end\n    local h = rawget(_G, \"HAPTICS_HOST\")\n    if type(h) == \"table\" then return h end\nend\nfunction T.pulse(kind, now)\n    local h = host()\n    if not h or type(h.pulse) ~= \"function\" then return end\n    now = now or 0\n    if lastKind == kind and now - lastAt < (COOLDOWN[kind] or 0.14) then return end\n    lastAt, lastKind = now, kind\n    pcall(h.pulse, kind)\nend\nfunction T.long()\n    local h = host()\n    if h and type(h.long) == \"function\" then pcall(h.long) end\nend\nreturn T\n"],["ringcore/hexboard.lua","\n\n\n\nlocal M = {}\nlocal C = require \"ringcore.config\"\nlocal G = require \"ringcore.state\"\nlocal Rng = require \"ringcore.rng\"\nlocal Audio = require \"ringcore.audio\"\nlocal Fx = require \"ringcore.fx\"\nlocal Haptics = require \"ringcore.haptics\"\nlocal Link = require \"ringcore.interlock\"\nlocal Draw = require \"ringcore.draw\"\nlocal Ring = require \"ringcore.ring\"\nlocal tr = require(\"ringcore.i18n\").t\nlocal Goals = require \"ringcore.goals\"\nlocal T, NCOLS, NROWS = C.T, 7, 8\nlocal function radius() return C.ORIENT==\"landscape\" and 26 or 28 end\n\n\nlocal function neigh(r, c)\n    local out\n    if r % 2 == 0 then\n        out = { {r, c-1}, {r, c+1}, {r-1, c}, {r-1, c+1}, {r+1, c}, {r+1, c+1} }\n    else\n        out = { {r, c-1}, {r, c+1}, {r-1, c-1}, {r-1, c}, {r+1, c-1}, {r+1, c} }\n    end\n    local ok = {}\n    for _, rc in ipairs(out) do\n        local rr, cc = rc[1], rc[2]\n        if rr >= 1 and rr <= NROWS and cc >= 1 and cc <= NCOLS and G.board[rr] and G.board[rr][cc] then\n            ok[#ok + 1] = { rr, cc }\n        end\n    end\n    return ok\nend\n\n\nlocal function cellXY(r, c)\n    local size = radius()\n    local w = size * math.sqrt(3)\n    local h = size * 1.5\n    local center=C.ORIENT==\"landscape\" and (T.BOARD_X+C.N*T.CELL/2) or C.DESIGN_W/2\n    local ox = center - (NCOLS - 0.5) * w / 2\n    local oy = C.ORIENT==\"landscape\" and 106 or 538\n    local x = ox + (c - 1) * w + ((r % 2 == 0) and (w * 0.5) or 0)\n    local y = oy + (r - 1) * h\n    return x, y\nend\n\nlocal function cellAt(x, y)\n    local best, bd = nil, radius() * radius()\n    for r = 1, NROWS do\n        for c = 1, NCOLS do\n            if G.board[r] and G.board[r][c] then\n                local cx, cy = cellXY(r, c)\n                local d = (x - cx) ^ 2 + (y - cy) ^ 2\n                if d < bd then best, bd = { r, c }, d end\n            end\n        end\n    end\n    if best and bd <= (radius() * 0.95) ^ 2 then return best[1], best[2] end\nend\n\nlocal function inShape(r, c)\n\n    if r <= 2 and (c <= 2 - r + 1 or c >= NCOLS - (2 - r)) then return false end\n    if r >= NROWS - 1 and (c <= r - (NROWS - 2) or c >= NCOLS - (r - (NROWS - 1)) + 1) then return false end\n    return true\nend\n\nlocal function randColor()\n    return Rng.board(1, G.lvl.colors or 5)\nend\nlocal function newCell(color, oy)\n    return { color = color or randColor(), special = nil, axis = nil, locked = false, clearing = false, oy = oy or 0 }\nend\n\n\nlocal DIRS_EVEN = { {0,-1},{0,1},{-1,0},{-1,1},{1,0},{1,1} }\nlocal DIRS_ODD  = { {0,-1},{0,1},{-1,-1},{-1,0},{1,-1},{1,0} }\n\n\nlocal AXES = { {1, 2}, {3, 6}, {4, 5} }\nlocal AXIS_ANGLE = { 0, math.pi / 3, -math.pi / 3 }\nlocal function dirs(r) return (r % 2 == 0) and DIRS_EVEN or DIRS_ODD end\nlocal function step(r, c, di)\n    local d = dirs(r)[di]\n    return r + d[1], c + d[2]\nend\nlocal function inBounds(r, c) return r >= 1 and r <= NROWS and c >= 1 and c <= NCOLS end\nlocal function valid(r, c)\n    return inBounds(r, c) and G.board[r] and G.board[r][c]\nend\nlocal function key(r, c) return r * 100 + c end\n\nlocal findGroups\nlocal buried\nlocal findMove\nlocal function generateBoard()\n    G.board = {}\n    G.boardRows, G.boardCols = NROWS, NCOLS\n    for r = 1, NROWS do\n        G.board[r] = {}\n        for c = 1, NCOLS do\n            if inShape(r, c) then G.board[r][c] = newCell() end\n        end\n    end\n    for _ = 1, 60 do\n        local groups = findGroups()\n        if #groups == 0 then break end\n        for _, g in ipairs(groups) do\n            local rc = g.cells[1]\n            if G.board[rc[1]][rc[2]] then G.board[rc[1]][rc[2]].color = randColor() end\n        end\n    end\n\n    local guard = 0\n    while not findMove() and guard < 60 do\n        guard = guard + 1\n        for r = 1, NROWS do for c = 1, NCOLS do\n            local cell = G.board[r] and G.board[r][c]\n            if cell and not cell.locked and not cell.special then cell.color = randColor() end\n        end end\n        for _ = 1, 60 do\n            local groups = findGroups()\n            if #groups == 0 then break end\n            for _, g in ipairs(groups) do\n                local rc = g.cells[1]\n                if G.board[rc[1]][rc[2]] then G.board[rc[1]][rc[2]].color = randColor() end\n            end\n        end\n    end\n    G.boardState, G.stateTimer, G.cascade = \"idle\", 0, 0\n\n\nend\n\nlocal function adjacent(r1, c1, r2, c2)\n    for _, n in ipairs(neigh(r1, c1)) do\n        if n[1] == r2 and n[2] == c2 then return true end\n    end\n    return false\nend\n\n\n\nfindGroups = function()\n    local runs = {}\n    local seenRun = {}\n    for r = 1, NROWS do\n        for c = 1, NCOLS do\n            local cell = G.board[r] and G.board[r][c]\n            if cell and not cell.clearing and not buried(r, c) then\n                for ai, ax in ipairs(AXES) do\n\n                    local pr, pc = step(r, c, ax[1])\n                    local prev = valid(pr, pc)\n                    if not (prev and not prev.clearing and prev.color == cell.color and not buried(pr, pc)) then\n                        local run = { { r, c } }\n                        local rr, cc = r, c\n                        while true do\n                            local nr, nc = step(rr, cc, ax[2])\n                            local n = valid(nr, nc)\n                            if not n or n.clearing or n.color ~= cell.color or buried(nr, nc) then break end\n                            run[#run + 1] = { nr, nc }; rr, cc = nr, nc\n                        end\n                        if #run >= 3 then\n                            local sk = key(r, c) * 10 + ai\n                            if not seenRun[sk] then seenRun[sk] = true; runs[#runs + 1] = { color = cell.color, cells = run } end\n                        end\n                    end\n                end\n            end\n        end\n    end\n\n    local parent = {}\n    for i = 1, #runs do parent[i] = i end\n    local function find(i) while parent[i] ~= i do parent[i] = parent[parent[i]]; i = parent[i] end return i end\n    local owner = {}\n    for i, run in ipairs(runs) do\n        for _, rc in ipairs(run.cells) do\n            local k = key(rc[1], rc[2])\n            if owner[k] then parent[find(i)] = find(owner[k]) else owner[k] = i end\n        end\n    end\n    local byRoot, groups = {}, {}\n    for i, run in ipairs(runs) do\n        local root = find(i)\n        local g = byRoot[root]\n        if not g then g = { color = run.color, cells = {}, set = {} }; byRoot[root] = g; groups[#groups + 1] = g end\n        for _, rc in ipairs(run.cells) do\n            local k = key(rc[1], rc[2])\n            if not g.set[k] then g.set[k] = true; g.cells[#g.cells + 1] = rc end\n        end\n    end\n    return groups\nend\n\n\n\nlocal function axisRun(set, r, c, ai)\n    local n = 1\n    for side = 1, 2 do\n        local rr, cc = r, c\n        while true do\n            rr, cc = step(rr, cc, AXES[ai][side])\n            if not inBounds(rr, cc) or not set[key(rr, cc)] then break end\n            n = n + 1\n        end\n    end\n    return n\nend\nlocal function classifySpecial(g)\n    if not G.lvl.specials then return nil end\n    local full = G.lvl.specials == \"full\"\n    local best = { run = 0 }\n    local cross\n    for _, rc in ipairs(g.cells) do\n        local axes3 = 0\n        for ai = 1, 3 do\n            local n = axisRun(g.set, rc[1], rc[2], ai)\n            if n >= 3 then axes3 = axes3 + 1 end\n            if n > best.run then best = { run = n, r = rc[1], c = rc[2], axis = ai } end\n        end\n        if axes3 >= 2 and not cross then cross = rc end\n    end\n    if full and best.run >= 5 then return \"nova\", best.r, best.c end\n    if full and cross and #g.cells >= 5 then return \"quake\", cross[1], cross[2] end\n    if best.run >= 4 then return \"line\", best.r, best.c, best.axis end\n    return nil\nend\n\n\n\n\n\n\n\n\n\nlocal function colTop(c)\n    for r = 1, NROWS do if inShape(r, c) then return r end end\n    return 0\nend\nlocal function colBottom(c)\n    for r = NROWS, 1, -1 do if inShape(r, c) then return r end end\n    return 0\nend\nlocal function colRows(c)\n    local t, b = colTop(c), colBottom(c)\n    return (t > 0) and (b - t + 1) or 0\nend\n\n\nlocal function siltEvery() return math.max(7.0, 18 - 5 * (G.lvl.k or 0.5)) end\n\nlocal function initSilt()\n    G.silt, G.siltT, G.siltLost = {}, 0, nil\n    G.siltColor = G.lvl.siltColor or 5\n    for c = 1, NCOLS do\n        G.silt[c] = math.min(colRows(c), G.lvl.siltStart or 1)\n    end\nend\n\nlocal function siltRows(c) return (G.silt and G.silt[c]) or 0 end\n\n\nlocal FLOOD_ROWS = 6\nlocal function siltFull(c)\n    local n, rows = siltRows(c), colRows(c)\n    return rows > 0 and n >= math.min(FLOOD_ROWS, rows)\nend\n\n\n\nburied = function(r, c)\n    local n = siltRows(c)\n    if n <= 0 then return false end\n    local b = colBottom(c)\n    return b > 0 and r > (b - n)\nend\n\nlocal function swappable(r, c)\n    local cell = valid(r, c)\n    return cell and not cell.locked and not buried(r, c)\nend\n\nlocal function anySiltFull()\n    for c = 1, NCOLS do if siltFull(c) then return c end end\nend\n\n\n\n\n\nlocal SILT_PUSH_COLS = 3\nlocal function siltPush(col)\n    if not G.silt or col ~= G.siltColor then return end\n    local order = {}\n    for c = 1, NCOLS do\n        if (G.silt[c] or 0) > 0 then order[#order + 1] = c end\n    end\n    table.sort(order, function(a, b) return G.silt[a] > G.silt[b] end)\n    local pushed = false\n    for k = 1, math.min(SILT_PUSH_COLS, #order) do\n        G.silt[order[k]] = G.silt[order[k]] - 1\n        pushed = true\n    end\n    if pushed then\n        Fx.popup(C.ORIENT==\"landscape\" and 750 or 270, C.ORIENT==\"landscape\" and 440 or 640, tr(\"fx.siltPush\"), false, 17)\n        Audio.playSfx(\"unlock\", 1.05, 0.3)\n    end\nend\n\nlocal function updateSilt(dt)\n    if not G.silt then return end\n    G.siltT = (G.siltT or 0) + dt\n    local every = siltEvery()\n    if G.siltT < every then return end\n    G.siltT = G.siltT - every\n    for c = 1, NCOLS do\n        G.silt[c] = math.min(colRows(c), (G.silt[c] or 0) + 1)\n    end\n\n\n    local next_ = G.siltColor + 1\n    if next_ == 4 then next_ = 5 elseif next_ > 5 then next_ = 1 end\n    G.siltColor = next_\n    Fx.shake(2.5, 0.14)\n    Audio.playSfx(\"lock\", 0.75, 0.3)\n    local c = anySiltFull()\n    if c then G.siltLost = c end\nend\n\nfindMove = function()\n    for r = 1, NROWS do\n        for c = 1, NCOLS do\n            if swappable(r, c) then\n                for _, n in ipairs(neigh(r, c)) do\n                    local r2, c2 = n[1], n[2]\n                    if (r2 > r or (r2 == r and c2 > c)) and swappable(r2, c2) then\n                        local a, b = G.board[r][c], G.board[r2][c2]\n                        if a.special == \"nova\" or b.special == \"nova\" then return { r, c, r2, c2 } end\n                        a.color, b.color = b.color, a.color\n                        local g = findGroups()\n                        a.color, b.color = b.color, a.color\n                        if #g > 0 then return { r, c, r2, c2 } end\n                    end\n                end\n            end\n        end\n    end\nend\n\nlocal function shuffleBoard()\n    local cells = {}\n    for r = 1, NROWS do for c = 1, NCOLS do\n        local cell = G.board[r][c]\n        if cell and not cell.locked and not cell.special then cells[#cells + 1] = cell.color end\n    end end\n    for _ = 1, 20 do\n        for i = #cells, 2, -1 do\n            local j = Rng.board(i)\n            cells[i], cells[j] = cells[j], cells[i]\n        end\n        local k = 1\n        for r = 1, NROWS do for c = 1, NCOLS do\n            local cell = G.board[r][c]\n            if cell and not cell.locked and not cell.special then cell.color = cells[k]; k = k + 1 end\n        end end\n        if #findGroups() == 0 and findMove() then break end\n    end\n\n\n    local guard = 0\n    while not findMove() and guard < 30 do\n        guard = guard + 1\n\n\n        for r = 1, NROWS do for c = 1, NCOLS do\n            local cell = G.board[r] and G.board[r][c]\n            if cell and not cell.locked and not cell.special then cell.color = randColor() end\n        end end\n        for _ = 1, 60 do\n            local groups = findGroups()\n            if #groups == 0 then break end\n            for _, g in ipairs(groups) do\n                local rc = g.cells[1]\n                if G.board[rc[1]][rc[2]] then G.board[rc[1]][rc[2]].color = randColor() end\n            end\n        end\n    end\n    G.stats.shuffles = (G.stats.shuffles or 0) + 1\n    Fx.popup(C.ORIENT==\"landscape\" and 750 or 270, C.ORIENT==\"landscape\" and 440 or 500, tr(\"fx.reshuffle\"), true)\nend\n\n\nlocal chained = {}\nlocal function markClear(r, c, fromSpecial)\n    local cell = valid(r, c)\n    if not cell or cell.clearing then return false end\n    if fromSpecial and cell.special then chained[#chained + 1] = { r, c } end\n    cell.clearing = true\n    if cell.locked then\n        G.stats.unlocks = (G.stats.unlocks or 0) + 1\n        Audio.playSfx(\"unlock\", 1.0, 0.55)\n        local x, y = cellXY(r, c)\n        Fx.sparkle(x, y, 6)\n    end\n    local x, y = cellXY(r, c)\n    Fx.burst(x, y, cell.color, fromSpecial and 5 or 7, 150)\n    Fx.crystal(x, y, cell.color, fromSpecial)\n    return true\nend\n\nlocal function activateSpecial(r, c, partnerColor, noRing)\n    local cell = valid(r, c)\n    if not cell or not cell.special then return end\n    local kind, axis = cell.special, cell.axis or 1\n    G.stats.specialsFired = (G.stats.specialsFired or 0) + 1\n    cell.special = nil\n    markClear(r, c, true)\n    local sx, sy = cellXY(r, c)\n    local ring = not noRing\n    if kind == \"line\" then\n        for side = 1, 2 do\n            local rr, cc = r, c\n            for _ = 1, 12 do\n                rr, cc = step(rr, cc, AXES[axis][side])\n                if not inBounds(rr, cc) then break end\n                markClear(rr, cc, true)\n            end\n        end\n        if ring then Ring.removeHeadBeads(T.ARC_BEADS, 40, tr(\"fx.arc\")) end\n        Fx.popup(sx, sy - 20, tr(\"fx.meteor\"), true)\n        Fx.ringBurst(sx, sy, cell.color, 55, 0.35)\n        Fx.sparkle(sx, sy, 12)\n        Audio.playSfx(\"special\", 1.0, 0.8)\n        Fx.shake(5, 0.18)\n        Haptics.pulse(\"heavy\", G.now)\n        G.hitstop = T.HITSTOP_SPECIAL\n    elseif kind == \"quake\" then\n        for _, n in ipairs(neigh(r, c)) do\n            markClear(n[1], n[2], true)\n            for _, m in ipairs(neigh(n[1], n[2])) do markClear(m[1], m[2], true) end\n        end\n        if ring then Ring.stunRing() end\n        Fx.popup(sx, sy - 20, tr(\"fx.quake\"), true)\n        Fx.ringBurst(sx, sy, cell.color, 70, 0.4)\n        Audio.playSfx(\"special\", 0.85, 0.9)\n        Fx.shake(7, 0.22)\n        Haptics.pulse(\"heavy\", G.now)\n        G.hitstop = T.HITSTOP_SPECIAL\n    elseif kind == \"nova\" then\n        local col = partnerColor or cell.color\n        for rr = 1, NROWS do for cc = 1, NCOLS do\n            local o = valid(rr, cc)\n            if o and o.color == col then markClear(rr, cc, true) end\n        end end\n        if ring then Ring.removeColorFromRing(col, T.NOVA_MAX) end\n        Fx.popup(sx, sy - 20, tr(\"fx.nova\"), true)\n        Fx.ringBurst(sx, sy, col, 80, 0.45)\n        Fx.sparkle(sx, sy, 16)\n        Audio.playSfx(\"special\", 0.9, 0.9)\n        Fx.shake(8, 0.25)\n        Haptics.pulse(\"heavy\", G.now)\n        G.hitstop = T.HITSTOP_SPECIAL\n    end\nend\n\nlocal function flushChained()\n    local guard = 0\n    while #chained > 0 and guard < 30 do\n        guard = guard + 1\n        local rc = table.remove(chained, 1)\n        local cell = valid(rc[1], rc[2])\n        if cell and cell.special then\n            cell.clearing = false\n            activateSpecial(rc[1], rc[2], nil, true)\n        end\n    end\n    chained = {}\nend\n\nlocal SPECIAL_NAME = { line = \"fx.meteor\", quake = \"fx.quake\", nova = \"fx.nova\" }\nlocal function startClear(groups, forced)\n    G.boardState, G.stateTimer = \"clearing\", T.CLEAR_TIME\n    G.clearDur = T.CLEAR_TIME\n    for _, g in ipairs(groups) do\n        for _, rc in ipairs(g.cells) do\n            local cell = valid(rc[1], rc[2])\n            if cell and cell.special then activateSpecial(rc[1], rc[2], g.color) end\n        end\n    end\n    flushChained()\n    local mult = 1 + 0.5 * G.cascade\n    local pulseGi = 0\n    for _, g in ipairs(groups) do\n        local kind, sr, sc, axis = classifySpecial(g)\n        local spawn = kind and valid(sr, sc) and not G.board[sr][sc].clearing and { sr, sc, kind, axis } or nil\n        local sx, sy, n = 0, 0, 0\n        for _, rc in ipairs(g.cells) do\n            if not (spawn and rc[1] == spawn[1] and rc[2] == spawn[2]) then\n                if markClear(rc[1], rc[2], false) then\n                    local x, y = cellXY(rc[1], rc[2])\n                    sx, sy, n = sx + x, sy + y, n + 1\n                end\n            end\n        end\n        if spawn then\n            local cell = G.board[spawn[1]][spawn[2]]\n            cell.special, cell.axis, cell.locked, cell.clearing = spawn[3], spawn[4], false, false\n            cell.popT = 0.35\n            G.stats.specialsSpawned = (G.stats.specialsSpawned or 0) + 1\n            if spawn[3] == \"line\" and G.lvl.cannon then Link.addAmmo(g.color) end\n            local x, y = cellXY(spawn[1], spawn[2])\n            Fx.popup(x, y - 18, tr(SPECIAL_NAME[spawn[3]]), true)\n            Audio.playSfx(\"special\", 1.15, 0.7)\n            Fx.sparkle(x, y, 10)\n            Fx.ringBurst(x, y, g.color, 36, 0.3)\n            sx, sy, n = sx + x, sy + y, n + 1\n        end\n        if n > 0 then\n            siltPush(g.color)\n            G.score = G.score + math.floor(10 * n * mult + 0.5)\n            Fx.impact(sx / n, sy / n, g.color, n)\n            Fx.cue(\"clear\", sx / n, sy / n, g.color)\n            pulseGi = pulseGi + 1\n            G.pendingPulses[#G.pendingPulses + 1] = {\n                color = g.color, x = sx / n, y = sy / n,\n                delay = (pulseGi - 1) * T.PULSE_STAGGER, mult = mult,\n            }\n        end\n    end\n    if G.cascade > 0 then\n        G.stats.cascades = (G.stats.cascades or 0) + 1\n        Fx.popup(C.ORIENT==\"landscape\" and 750 or 270, C.ORIENT==\"landscape\" and 440 or 508, tr(\"fx.cascade\", { x = string.format(\"%.1f\", mult) }), G.cascade >= 2, 20 + 3 * math.min(5, G.cascade))\n    end\n    if G.cascade >= T.PW_CASCADE and not G.powerGranted then\n        G.powerGranted = Ring.grantPower(C.ORIENT==\"landscape\" and 750 or 270, C.ORIENT==\"landscape\" and 440 or 640)\n    end\n    local cz = math.min(6, G.cascade)\n    Audio.playSfx(\"clear\", 1 + 0.09 * cz, 0.6 + 0.05 * cz)\n    if cz >= 2 then Audio.playSfx(\"star\", 0.9 + 0.08 * cz, 0.35) end\n    Fx.shake(1.2 + 0.55 * cz, 0.07 + 0.02 * cz)\n    Haptics.pulse(cz >= 3 and \"heavy\" or (cz >= 1 and \"medium\" or \"light\"), G.now)\n    G.cascade = G.cascade + 1\nend\n\n\n\nlocal function settle()\n    for c = 1, NCOLS do\n        local slots = {}\n        for r = NROWS, 1, -1 do if inShape(r, c) then slots[#slots + 1] = r end end\n        local i = 1\n        while i <= #slots do\n            local seg = {}\n            while i <= #slots and not (G.board[slots[i]][c] and G.board[slots[i]][c].locked and not G.board[slots[i]][c].clearing) do\n                seg[#seg + 1] = slots[i]; i = i + 1\n            end\n            local keep = {}\n            for _, r in ipairs(seg) do\n                local cell = G.board[r][c]\n                if cell and not cell.clearing then keep[#keep + 1] = { cell = cell, r = r } end\n            end\n            local ki, spawned = 1, 0\n            for _, r in ipairs(seg) do\n                local k = keep[ki]\n                local _, ty = cellXY(r, c)\n                if k then\n                    local _, fy = cellXY(k.r, c)\n                    k.cell.oy = (k.cell.oy or 0) + (fy - ty)\n                    G.board[r][c] = k.cell\n                    ki = ki + 1\n                else\n                    spawned = spawned + 1\n                    G.board[r][c] = newCell(nil, -radius() * 1.5 * (spawned + 1))\n                end\n            end\n            i = i + 1\n        end\n    end\nend\n\nlocal function applyQueuedLocks()\n    for _, q in ipairs(G.lockQueue) do\n        local cell = valid(q.r, q.c)\n        if cell and not cell.locked and not cell.special then\n            cell.locked = true\n        else\n            local done = false\n            for r = 1, NROWS do for c = 1, NCOLS do\n                local o = valid(r, c)\n                if not done and o and not o.locked and not o.special then o.locked = true; done = true end\n            end end\n        end\n        Fx.shake(2, 0.1)\n    end\n    G.lockQueue = {}\nend\n\nlocal function settleIdle()\n    G.boardState, G.cascade, G.idleTime, G.hint, G.powerGranted = \"idle\", 0, 0, nil, false\n    if #G.lockQueue > 0 then applyQueuedLocks() end\n    if not findMove() then shuffleBoard() end\nend\n\n\n\nfunction M.adjacent(r1,c1,r2,c2)\n    return r2 ~= nil and c2 ~= nil and adjacent(r1,c1,r2,c2)\nend\nfunction M.previewSwap(r1,c1,r2,c2)\n    if G.state ~= \"playing\" or G.boardState ~= \"idle\" or not M.adjacent(r1,c1,r2,c2) then return nil end\n    if not swappable(r1,c1) or not swappable(r2,c2) then return nil end\n    if Goals.kind()==\"step\" and Goals.movesLeft()<=0 then return nil end\n    local a,b=G.board[r1][c1],G.board[r2][c2]\n\n    if a.special==\"nova\" or b.special==\"nova\"  then return nil end\n    G.board[r1][c1],G.board[r2][c2]=b,a\n    local ok,groups=pcall(findGroups)\n    G.board[r1][c1],G.board[r2][c2]=a,b\n    if not ok then error(groups) end\n    if #groups==0 then return nil end\n    return groups\nend\n\nlocal function trySwap(r1, c1, r2, c2)\n    if G.state ~= \"playing\" or G.boardState ~= \"idle\" then return end\n    if not (swappable(r1, c1) and swappable(r2, c2)) then return end\n    if not adjacent(r1, c1, r2, c2) then return end\n    local a, b = G.board[r1][c1], G.board[r2][c2]\n    G.board[r1][c1], G.board[r2][c2] = b, a\n    G.swapAnim = { r1 = r1, c1 = c1, r2 = r2, c2 = c2, t = 0 }\n    G.boardState, G.stateTimer = \"swapping\", T.SWAP_TIME\n    G.stats.swaps = (G.stats.swaps or 0) + 1\n    G.hint, G.idleTime = nil, 0\n    Audio.playSfx(\"swap\", 1, 0.5)\n    G._swapNova = nil\n    if a.special == \"nova\" or b.special == \"nova\" then\n\n        local nova = a.special == \"nova\" and a or b\n        local other = nova == a and b or a\n        G._swapNova = { nova = nova, color = other.color, other = other }\n        G._pendingGroups, G._swapBack = {}, false\n        return\n    end\n    local groups = findGroups()\n    G._pendingGroups = groups\n    G._swapBack = #groups == 0\nend\n\nlocal function locate(cell)\n    for r = 1, NROWS do for c = 1, NCOLS do if G.board[r][c] == cell then return r, c end end end\nend\n\nlocal function updateBoard(dt)\n    for r = 1, NROWS do for c = 1, NCOLS do\n        local cell = G.board[r] and G.board[r][c]\n        if cell then\n            if cell.oy and cell.oy ~= 0 then\n                cell.oy = cell.oy + math.min(-cell.oy, T.FALL_SPEED * dt)\n                if cell.oy > -0.5 then cell.oy = 0 end\n            end\n            if cell.popT then cell.popT = cell.popT - dt; if cell.popT <= 0 then cell.popT = nil end end\n        end\n    end end\n    if G.lvl.silt then\n        if not G.silt then initSilt() end\n        updateSilt(dt)\n    end\n    if G.boardState == \"idle\" then\n        G.idleTime = (G.idleTime or 0) + dt\n        if #G.lockQueue > 0 then applyQueuedLocks() end\n        if G.idleTime > T.HINT_DELAY and not G.hint then\n            G.hint = findMove()\n\n            if not G.hint then shuffleBoard() end\n        end\n        return\n    end\n    if G.boardState == \"swapping\" then\n        G.swapAnim.t = G.swapAnim.t + dt\n        if G.swapAnim.t >= T.SWAP_TIME then\n            local sa = G.swapAnim\n            G.swapAnim = nil\n            if G._swapNova then\n                local sn = G._swapNova\n                G._swapNova = nil\n                local nr, nc = locate(sn.nova)\n                G.boardState, G.stateTimer = \"clearing\", T.CLEAR_TIME\n                G.clearDur = T.CLEAR_TIME\n                if sn.other.special and sn.other.special ~= \"nova\" then\n                    local orr, oc = locate(sn.other)\n                    activateSpecial(orr, oc, nil)\n                end\n                activateSpecial(nr, nc, sn.color)\n                flushChained()\n                G.cascade = 1\n                Goals.onMove()\n            elseif G._swapBack then\n                local a, b = G.board[sa.r1][sa.c1], G.board[sa.r2][sa.c2]\n                G.board[sa.r1][sa.c1], G.board[sa.r2][sa.c2] = b, a\n                G.boardState = \"idle\"\n                Audio.playSfx(\"swap\", 0.8, 0.35)\n            else\n                Goals.onMove()\n                startClear(G._pendingGroups or {})\n            end\n            G._pendingGroups = nil\n        end\n        return\n    end\n    if G.boardState == \"clearing\" then\n        G.stateTimer = G.stateTimer - dt\n        if G.stateTimer <= 0 then\n            settle()\n            G.boardState = \"falling\"\n        end\n        return\n    end\n    if G.boardState == \"falling\" then\n        local moving = false\n        for r = 1, NROWS do for c = 1, NCOLS do\n            local cell = G.board[r][c]\n            if cell and cell.oy and cell.oy < 0 then moving = true end\n        end end\n        if not moving then\n            local groups = findGroups()\n            if #groups > 0 then startClear(groups) else settleIdle() end\n        end\n    end\nend\n\n\nlocal function hexPath(ctx, x, y, r)\n    nvgBeginPath(ctx)\n    for i = 0, 5 do\n        local a = -math.pi / 2 + i * math.pi / 3\n        local px, py = x + math.cos(a) * r, y + math.sin(a) * r\n        if i == 0 then nvgMoveTo(ctx, px, py) else nvgLineTo(ctx, px, py) end\n    end\n    nvgClosePath(ctx)\nend\n\nlocal function drawHexCell(ctx, x, y, cell, alpha, scale)\n\n    local s = (radius() - 2.5) * 2 * (scale or 1)\n    if cell.special == \"line\" then\n        Draw.drawGem(ctx, x, y, cell.color, s, \"hex\", alpha)\n        nvgSave(ctx)\n        nvgTranslate(ctx, x, y); nvgRotate(ctx, AXIS_ANGLE[cell.axis or 1])\n        Draw.drawSpecialMark(ctx, 0, 0, \"line_h\", s)\n        nvgRestore(ctx)\n    else\n        Draw.drawGem(ctx, x, y, cell.color, s, \"hex\", alpha, cell.special)\n    end\n    if cell.locked and not cell.clearing then\n        if not Draw.drawImage(ctx, G.img.lock, x, y, s * 0.92, s * 0.92, 0.92) then\n            hexPath(ctx, x, y, s / 2)\n            nvgFillColor(ctx, nvgRGBA(8, 12, 26, 95)); nvgFill(ctx)\n            nvgStrokeColor(ctx, nvgRGBA(60, 140, 90, 230)); nvgStrokeWidth(ctx, 4); nvgStroke(ctx)\n        end\n    end\nend\n\nlocal function draw(ctx)\n\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, C.ORIENT==\"landscape\" and 552 or 34, C.ORIENT==\"landscape\" and 78 or 496, C.ORIENT==\"landscape\" and 396 or 472, C.ORIENT==\"landscape\" and 356 or 380, 26)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, 0, C.ORIENT==\"landscape\" and 78 or 496, 0, C.ORIENT==\"landscape\" and 434 or 876, nvgRGBA(92, 62, 30, 255), nvgRGBA(52, 34, 16, 255)))\n    nvgFill(ctx)\n    nvgStrokeColor(ctx, nvgRGBA(14, 16, 30, 255)); nvgStrokeWidth(ctx, 3); nvgStroke(ctx)\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, C.ORIENT==\"landscape\" and 560 or 44, C.ORIENT==\"landscape\" and 86 or 504, C.ORIENT==\"landscape\" and 380 or 452, C.ORIENT==\"landscape\" and 340 or 364, 20)\n    nvgFillColor(ctx, nvgRGBA(18, 14, 8, 235)); nvgFill(ctx)\n    for r = 1, NROWS do for c = 1, NCOLS do\n        if inShape(r, c) then\n            local x, y = cellXY(r, c)\n            hexPath(ctx, x, y, radius() - 1)\n            nvgFillColor(ctx, nvgRGBA(255, 200, 90, 14)); nvgFill(ctx)\n            nvgStrokeColor(ctx, nvgRGBA(255, 200, 90, 30)); nvgStrokeWidth(ctx, 1.2); nvgStroke(ctx)\n        end\n    end end\n    nvgSave(ctx)\n    nvgScissor(ctx, C.ORIENT==\"landscape\" and 560 or 44, C.ORIENT==\"landscape\" and 86 or 504, C.ORIENT==\"landscape\" and 380 or 452, C.ORIENT==\"landscape\" and 340 or 364)\n    local hint = G.hint and G.boardState == \"idle\" and G.hint\n    for r = 1, NROWS do\n        for c = 1, NCOLS do\n            local cell = G.board[r] and G.board[r][c]\n            if cell then\n                local x, y = cellXY(r, c)\n                y = y + (cell.oy or 0)\n                if G.swapAnim then\n                    local u = math.min(1, G.swapAnim.t / T.SWAP_TIME)\n                    if r == G.swapAnim.r1 and c == G.swapAnim.c1 then\n                        local ox, oy = cellXY(G.swapAnim.r2, G.swapAnim.c2)\n                        x, y = ox + (x - ox) * u, oy + (y - oy) * u\n                    elseif r == G.swapAnim.r2 and c == G.swapAnim.c2 then\n                        local ox, oy = cellXY(G.swapAnim.r1, G.swapAnim.c1)\n                        x, y = ox + (x - ox) * u, oy + (y - oy) * u\n                    end\n                end\n                local alpha, sc = 255, 1\n                if cell.clearing then\n\n                    local u = math.min(1, math.max(0, G.stateTimer / (G.clearDur or T.CLEAR_TIME)))\n                    local p = 1 - u\n                    if p < 0.25 then\n                        sc = 1 + 0.18 * (p / 0.25)\n                    else\n                        local k = (p - 0.25) / 0.75\n                        sc = 1.18 - 0.95 * k\n                        alpha = math.floor(255 * (1 - k) ^ 1.4)\n                    end\n                end\n                if cell.popT then sc = sc * (1 + 0.25 * math.sin(math.min(1, (0.35 - cell.popT) / 0.35) * math.pi)) end\n                if hint and ((r == hint[1] and c == hint[2]) or (r == hint[3] and c == hint[4])) then\n                    sc = sc * (1 + math.sin(G.animT * 8) * 0.07)\n                end\n                if cell.special and not cell.clearing then\n                    local g = 0.5 + 0.5 * math.sin(G.animT * 5 + r + c)\n                    nvgBeginPath(ctx); nvgCircle(ctx, x, y, radius() * 1.25)\n                    local cc = C.COLORS[cell.color]\n                    nvgFillPaint(ctx, nvgRadialGradient(ctx, x, y, radius() * 0.5, radius() * 1.25,\n                        nvgRGBA(cc[1], cc[2], cc[3], math.floor(60 + 60 * g)), nvgRGBA(cc[1], cc[2], cc[3], 0)))\n                    nvgFill(ctx)\n                end\n                drawHexCell(ctx, x, y, cell, alpha, sc)\n            end\n        end\n    end\n\n    if G.silt then\n        local sc = C.COLORS[G.siltColor or 5]\n        for c = 1, NCOLS do\n            local n = G.silt[c] or 0\n            if n > 0 then\n                local pulse = 0.5 + 0.5 * math.sin(G.animT * 2.2 + c * 0.6)\n                for r = NROWS - n + 1, NROWS do\n                    if inShape(r, c) then\n                        local x, y = cellXY(r, c)\n                        hexPath(ctx, x, y, radius())\n                        nvgFillColor(ctx, nvgRGBA(sc[1], sc[2], sc[3], 208 + math.floor(40 * pulse)))\n                        nvgFill(ctx)\n                        hexPath(ctx, x, y, radius())\n                        nvgStrokeColor(ctx, nvgRGBA(60, 34, 8, 200)); nvgStrokeWidth(ctx, 2); nvgStroke(ctx)\n\n                        if r == NROWS - n + 1 then\n                            hexPath(ctx, x, y, radius() - 3)\n                            nvgStrokeColor(ctx, nvgRGBA(255, 248, 214, math.floor(180 + 70 * pulse)))\n                            nvgStrokeWidth(ctx, 3); nvgStroke(ctx)\n                        end\n                    end\n                end\n            end\n        end\n    end\n    nvgRestore(ctx)\n    if G.selected and valid(G.selected.r, G.selected.c) then\n        local x, y = cellXY(G.selected.r, G.selected.c)\n        hexPath(ctx, x, y, radius() + 2)\n        nvgStrokeColor(ctx, nvgRGBA(255, 245, 200, 220)); nvgStrokeWidth(ctx, 3); nvgStroke(ctx)\n    end\nend\n\nM.cellXY = cellXY\nM.cellAt = cellAt\nM.findMove = findMove\nM.findGroups = function() return findGroups() end\nM.generateBoard = generateBoard\nM.trySwap = trySwap\nM.updateBoard = updateBoard\nM.draw = draw\nM.AXES = AXES\nM.NROWS, M.NCOLS, M.HEX_R = NROWS, NCOLS, 28\nM.radius=radius\nM.siltRows, M.siltFull, M.siltColor = siltRows, siltFull, function() return G.siltColor end\n\nreturn M\n"],["ringcore/i18n.lua","\n\n\n\n\n\n\nlocal M = {}\nlocal TABLES = {\n    en = require \"ringcore.locale.en\",\n    zh_Hans = require \"ringcore.locale.zh_hans\",\n    zh_Hant = require \"ringcore.locale.zh_hant\",\n}\nM.LANGS = { \"zh_Hans\", \"en\", \"zh_Hant\" }\n\n\n\nM.DEFAULT = \"en\"\nM.lang = M.DEFAULT\nM.version = 0\n\nfunction M.has(lang) return TABLES[lang] ~= nil end\nfunction M.set(lang)\n    if TABLES[lang] and lang ~= M.lang then\n        M.lang = lang\n        M.version = M.version + 1\n    end\n    return M.lang\nend\nfunction M.isCJK() return M.lang ~= \"en\" end\n\nfunction M.nativeName(lang) return (TABLES[lang] or {})[\"lang.self\"] or lang end\n\nlocal function fill(s, p)\n    if not p then return s end\n    return (s:gsub(\"{(%w+)}\", function(k)\n        local v = p[k]\n        if v == nil then return \"{\" .. k .. \"}\" end\n        return tostring(v)\n    end))\nend\n\nfunction M.t(key, p)\n    local s = TABLES[M.lang][key]\n    if s == nil then s = TABLES.en[key] end\n    if s == nil then return key end\n    if type(s) == \"table\" then\n        local n = p and tonumber(p.n)\n        s = (n == 1) and s.one or s.other\n    end\n    return fill(s, p)\nend\n\n\nfunction M.date(month, day)\n    return M.t(\"fmt.date\", { m = month, d = day, mon = M.t(\"cal.m\" .. month) })\nend\n\nfunction M.levelName(lv)\n    if not lv then return \"\" end\n    if lv.nameKey == \"daily.name\" then return M.t(\"daily.name\", { date = M.date(lv.nameMonth or 1, lv.nameDay or 1) }) end\n    if lv.tier and lv.goal then return M.t(\"arena.\" .. lv.goal, { tier = M.t(\"tier.\" .. lv.tier) }) end\n    if lv.nameKey then return M.t(lv.nameKey) end\n    return lv.name or \"\"\nend\nreturn M\n"],["ringcore/input.lua","\nlocal M = {}\nlocal C = require \"ringcore.config\"\nlocal G = require \"ringcore.state\"\nlocal Audio = require \"ringcore.audio\"\nlocal Ring = require \"ringcore.ring\"\nlocal Link = require \"ringcore.interlock\"\nlocal Board = require \"ringcore.activeboard\"\nlocal Flow = require \"ringcore.flow\"\nlocal Progress = require \"ringcore.progress\"\nlocal Screens = require \"ringcore.ui.screens\"\nlocal Monetize = require \"ringcore.monetize\"\nlocal Funnel = require \"ringcore.funnel\"\nlocal tr = require(\"ringcore.i18n\").t\nlocal T = C.T\nlocal lastUpT = -1\n\n\n\n\nlocal function hitCannon(x, y)\n    return Ring.dist2(x, y, T.CANNON_X, T.CANNON_Y) <= (T.CANNON_R + 8) ^ 2\nend\n\n\n\n\n\nlocal function claimAd(placement, onOk)\n    local ctx = placement == \"practice\" and { runKind = \"daily\", practice = true, progress = Progress.unlocked() } or Flow.adCtx()\n    local o = Monetize.offer(placement, ctx)\n    if not o then return end\n    if o.blocked == \"dailyCap\" then Screens.hint(tr(\"ad.dailyCap\")); return end\n    if o.blocked == \"cooldown\" then\n        Screens.hint(tr(\"ad.cooldown\", { s = o.wait or Monetize.SPACING })); return\n    end\n    Funnel.count(\"ad:\" .. placement .. \":tap\")\n    Monetize.claim(placement, ctx, function(r)\n        if r.ok then\n            Funnel.count(\"ad:\" .. placement .. \":ok\")\n            onOk(r)\n            return\n        end\n        Funnel.count(\"ad:\" .. placement .. \":fail\")\n        if r.reason == \"closed\" then\n            Screens.hint(tr(\"ad.closed\"))\n        elseif r.reason == \"dailyCap\" then\n            Screens.hint(tr(\"ad.dailyCap\"))\n        else\n\n            Screens.hint(tr(\"ad.retryAvailable\"))\n        end\n    end)\nend\n\nlocal function startPractice()\n    if G.state == \"runover\" then Flow.restartRun(); Flow.beginPlay() else Flow.startDaily(true) end\nend\n\n\nlocal function act(id)\n    if Monetize.busy() then return end\n\n\n    if (id == \"tomap\" or id == \"restart\" or id == \"to_title\") and (G.state == \"paused\" or G.state == \"playing\") then\n        Flow.quitLevel()\n    end\n    if id == \"ad_continue\" then\n        claimAd(\"continue\", function(r) Flow.continueRun(r.free and \"free\" or \"ad\") end)\n        return\n\n    elseif id == \"ad_double\" then\n        claimAd(\"win_double\", function() Flow.claimWinDouble() end)\n        return\n    elseif id:sub(1, 8) == \"adboost_\" then\n        local bid = id:sub(9)\n        claimAd(\"booster\", function() Flow.grantBooster(bid) end)\n        return\n    elseif id == \"ad_practice\" then\n        claimAd(\"practice\", startPractice)\n        return\n    elseif id == \"funnel_open\" then G.ui.funnel, G.ui.funnelMsg = true, nil; return\n    elseif id == \"funnel_close\" then G.ui.funnel = false; return\n    elseif id == \"funnel_reset\" then Funnel.reset(); G.ui.funnelMsg = tr(\"funnel.cleared\"); return\n    elseif id == \"funnel_export\" then\n        local f = Funnel.export()\n        G.ui.funnelMsg = f and tr(\"funnel.exported\", { file = f }) or tr(\"funnel.exportFailed\")\n        return\n    elseif (id == \"daily_practice\" or (id == \"run_retry\" and G.runKind == \"daily\")) and Monetize.practiceLeft() == 0 then\n        return\n    end\n    if id == \"round_reverse\" or id == \"round_slow\" then\n        require(\"ringcore.roundboost\").activate(id:sub(7))\n    elseif id == \"quick_classic\" then\n        Flow.startLevel(math.min(#C.LEVELS, Progress.unlocked()))\n    elseif id == \"play\" then Flow.openModeSel()\n    elseif id:sub(1, 5) == \"mode_\" or id:sub(1, 6) == \"enter_\" then\n\n        local card = id:match(\"^%a+_(%a+)$\")\n        local enter = id:sub(1, 6) == \"enter_\" or Screens.selectedMode() == card\n        G.ui.modeSel = card\n        if Screens.modeLocked(card) then\n            Screens.hint(tr(\"hint.lockedHall\", { n = Progress.ARENA_AT }))\n        elseif enter then\n            if card == \"classic\" then\n                G.mode = \"classic\"; G.chapter = C.chapterOf(math.min(#C.LEVELS, Progress.unlocked())).id; Flow.openSelect()\n            elseif card == \"hex\" then\n                G.mode = \"hex\"; G.hexPage = C.hexPageOf(math.min(#C.HEX_LEVELS, Progress.hexUnlocked())).id; Flow.openSelect()\n            else Flow.openArena() end\n        end\n    elseif id == \"daily_entry\" then\n        if Progress.dailyUnlocked() then Flow.openDaily()\n        else Screens.hint(tr(\"hint.lockedDaily\", { n = Progress.DAILY_AT })) end\n    elseif id == \"daily_go\" then Flow.startDaily(false)\n    elseif id == \"daily_practice\" then Flow.startDaily(true)\n    elseif id == \"arena_endless\" or id == \"arena_blitz\" then\n        local m = id:sub(7)\n        Flow.startArena(m, (G.ui.arenaTier or {})[m] or 1)\n    elseif id:sub(1, 6) == \"atier_\" then\n        local m, t = id:match(\"^atier_(%a+)_(%d)$\")\n        local tn = tonumber(t)\n        if tn and not Progress.arenaTierOpen(tn) then\n\n            Screens.hint(tr(\"hall.tierLockedTip\", { n = C.ARENA_TIER_AT[tn] or 99 }))\n            return\n        end\n        G.ui.arenaTier = G.ui.arenaTier or {}\n        G.ui.arenaTier[m] = tn\n    elseif id == \"run_retry\" then Flow.restartRun(); Flow.beginPlay()\n    elseif (id == \"run_back\" or id == \"tomap\") and G.runKind then\n        if G.runKind == \"daily\" then Flow.openDaily() else Flow.openArena() end\n    elseif (id == \"restart\" or id == \"retry\") and G.runKind then Flow.restartRun(); Flow.beginPlay()\n    elseif id == \"tomap\" then Flow.openSelect()\n    elseif id == \"home\" then Flow.openModeSel()\n    elseif id == \"to_title\" then Flow.openTitle()\n    elseif id == \"resume_run\" then\n\n        if Flow.resumeSaved() then Screens.hint(tr(\"hint.resumed\")) end\n        return\n    elseif id == \"peek_close\" then G.ui.peek = nil\n    elseif id == \"hive_tip_ok\" then\n\n        G.ui.hiveTip = nil\n    elseif id == \"settings\" then G.ui.settings = true\n    elseif id == \"settings_close\" then G.ui.settings = false; Progress.saveSettings()\n    elseif id == \"shake_toggle\" then\n        G.shakeOn = not G.shakeOn\n        Progress.saveSettings()\n\n        if G.shakeOn then require(\"ringcore.haptics\").pulse(\"light\", G.now) end\n    elseif id:sub(1, 5) == \"diff_\" then\n\n\n        local d = id:sub(6)\n        if C.DIFFICULTY_SET[d] and d ~= G.difficulty then\n            G.difficulty = d\n            Progress.saveSettings()\n            if G.state == \"ready\" then\n                if G.mode == \"hex\" then Flow.startHexLevel(G.levelIdx) else Flow.startLevel(G.levelIdx) end\n            end\n        end\n    elseif id:sub(1, 5) == \"lang_\" then\n        local I18n = require \"ringcore.i18n\"\n        I18n.set(id:sub(6)); Progress.saveSettings()\n        pcall(function() graphics.windowTitle = I18n.t(\"app.window\") end)\n    elseif id == \"credits\" then G.ui.credits = true; G.ui.settings = false\n    elseif id == \"credits_close\" then G.ui.credits = false; if G.state == \"title\" then G.ui.settings = true end\n    elseif id == \"credits_home\" then G.ui.credits, G.ui.settings, G.ui.funnel = false, false, false\n    elseif id == \"music\" then Audio.toggleBgm(); Progress.saveSettings()\n    elseif id == \"sfx\" then Audio.toggleSfx(); Progress.saveSettings()\n    elseif id == \"vol_bgm_up\" then Audio.setBgmGain((C.BGM_GAIN or 0.35) + 0.1); Progress.saveSettings()\n    elseif id == \"vol_bgm_dn\" then Audio.setBgmGain((C.BGM_GAIN or 0.35) - 0.1); Progress.saveSettings()\n    elseif id == \"vol_sfx_up\" then Audio.setSfxGain((C.SFX_GAIN or 0.7) + 0.1); Progress.saveSettings()\n    elseif id == \"vol_sfx_dn\" then Audio.setSfxGain((C.SFX_GAIN or 0.7) - 0.1); Progress.saveSettings()\n        if G.sfxOn then Audio.playSfx(\"click\", 1.0, 0.6) end\n    elseif id == \"start\" then Flow.beginPlay()\n    elseif id == \"pause\" then Flow.pause()\n    elseif id == \"resume\" then Flow.resume()\n    elseif id == \"restart\" or id == \"retry\" then\n        Flow.retryLevel()\n    elseif id == \"next\" then\n        if G.mode == \"hex\" then Flow.startHexLevel(G.levelIdx + 1) else Flow.startLevel(G.levelIdx + 1) end\n    elseif id == \"ammo\" then Link.rotateAmmo()\n    elseif id == \"ch_prev\" then Flow.prevChapter()\n    elseif id == \"ch_next\" then Flow.nextChapter()\n    elseif id:sub(1, 5) == \"boost\" then\n        Flow.toggleBooster(id:sub(7))\n    elseif id:sub(1, 4) == \"node\" then\n        local i = tonumber(id:sub(5))\n        local open = i and i <= (G.mode == \"hex\" and Progress.hexUnlocked() or Progress.unlocked())\n        if open then\n            if G.mode == \"hex\" then Flow.startHexLevel(i) else Flow.startLevel(i) end\n        elseif i then\n            G.ui.peek = i\n        end\n    end\nend\n\n\n\n\nlocal guardUntil = -1\nM.GUARD = 0.12\n\n\nM.BACK_IDS = { \"hive_tip_ok\", \"peek_close\", \"settings_close\", \"funnel_close\", \"credits_close\", \"credits_home\", \"close\", \"back\", \"run_back\", \"tomap\", \"home\", \"to_title\" }\nfunction M.back()\n    if Monetize.busy() then return \"busy\" end\n    if G.state == \"playing\" then Flow.pause(); return \"paused\" end\n    if G.state == \"paused\" then Flow.resume(); return \"resumed\" end\n    local bs = Screens.buttons()\n    for _, want in ipairs(M.BACK_IDS) do\n        for _, b in ipairs(bs) do\n            if b.id == want then act(want); return want end\n        end\n    end\n    if G.state == \"title\" then return \"exit\" end\n    return \"none\"\nend\n\nlocal function pointerDown(x, y)\n    if G.animT < guardUntil then return end\n    local id = Screens.hit(x, y)\n    if id then\n        G.ui.press = id\n        local back = (id == \"hive_tip_ok\" or id == \"home\" or id == \"tomap\" or id == \"to_title\" or id == \"back\" or id == \"close\" or id == \"credits_close\" or id == \"credits_home\" or id == \"run_back\" or id == \"settings_close\")\n        Audio.playSfx(back and \"back\" or \"click\", 1.0, 0.45)\n        return\n    end\n    if G.state ~= \"playing\" then return end\n\n    local r, c = Board.cellAt(x, y)\n    if r then\n        G.press = { x = x, y = y, r = r, c = c, moved = false, onBoard = true }\n        return\n    end\n\n    if G.lvl.cannon then\n        if hitCannon(x, y) then\n            Link.rotateAmmo()\n            return\n        end\n        if require(\"ringcore.layout\").inFireArea(x,y) then\n            Link.fireCannon(x, y)\n            return\n        end\n    end\nend\n\nlocal function pointerMove(x, y)\n    if not G.press and not G.ui.press and G.state == \"playing\" and G.lvl.cannon\n        and #G.cannonAmmo > 0 and require(\"ringcore.layout\").inFireArea(x,y)\n        and not Screens.hit(x, y) and not hitCannon(x, y) then\n        G.aimX, G.aimY = x, y\n    end\n    local p=G.press\n    if not p or not p.onBoard then return end\n    local dx,dy=x-p.x,y-p.y\n    if math.max(math.abs(dx),math.abs(dy)) >= T.DRAG_THRESHOLD then p.moved=true;G.selected=nil end\n    p.r2,p.c2,p.preview=nil,nil,nil\n    if p.moved then\n        local r,c=Board.cellAt(x,y)\n        if r and Board.adjacent(p.r,p.c,r,c) then\n            p.r2,p.c2=r,c\n            p.preview=Board.previewSwap(p.r,p.c,r,c)\n        end\n    end\nend\n\nlocal function pointerUp(x, y)\n    if G.ui.press then\n        local id = G.ui.press\n        G.ui.press = nil\n        if Screens.hit(x, y) == id then\n            G.ui.popId, G.ui.popT = id, 0.18\n            Audio.playSfx(\"pulse\", 1.25, 0.45)\n            local st = G.state\n            act(id)\n            if G.state ~= st then guardUntil = G.animT + M.GUARD end\n        end\n        return\n    end\n    if not G.press then return end\n    if G.animT - lastUpT < 0.05 then G.press = nil return end\n    lastUpT = G.animT\n    if G.press.onBoard and G.press.moved then\n        local r,c=Board.cellAt(x,y)\n        if r and r==G.press.r2 and c==G.press.c2 and Board.adjacent(G.press.r,G.press.c,r,c) then\n            Board.trySwap(G.press.r,G.press.c,r,c)\n        end\n        G.selected=nil\n    elseif G.press.onBoard and not G.press.moved then\n        if G.selected and Board.adjacent(G.selected.r,G.selected.c,G.press.r,G.press.c) then\n            Board.trySwap(G.selected.r, G.selected.c, G.press.r, G.press.c)\n            G.selected = nil\n        elseif G.selected and G.selected.r == G.press.r and G.selected.c == G.press.c then\n            G.selected = nil\n        else\n            G.selected = { r = G.press.r, c = G.press.c }\n        end\n    end\n    G.press = nil\nend\n\nfunction M.preview()\n    local p=G.press\n    if not p or not p.r2 or G.state~=\"playing\" then return nil end\n    p.preview=Board.previewSwap(p.r,p.c,p.r2,p.c2)\n    return p.preview\nend\nM.pointerDown = pointerDown\nM.pointerMove = pointerMove\nM.pointerUp = pointerUp\n\nreturn M\n"],["ringcore/interlock.lua","\nlocal M = {}\nlocal C = require \"ringcore.config\"\nlocal G = require \"ringcore.state\"\nlocal Rng = require \"ringcore.rng\"\nlocal Audio = require \"ringcore.audio\"\nlocal Fx = require \"ringcore.fx\"\nlocal Ring = require \"ringcore.ring\"\nlocal T, BALL_D, N = C.T, C.BALL_D, C.N\n\n\n\n\nlocal function addAmmo(color, fromPulse)\n    if not G.lvl.cannon then\n        if fromPulse then Fx.cue(\"noTarget\", T.CANNON_X - 106, (C.ORIENT==\"landscape\" and C.DESIGN_H-18 or T.CANNON_Y-48), color) end\n        G.stats.fizzles = G.stats.fizzles + 1\n        return\n    end\n    if #G.cannonAmmo >= T.CANNON_MAX then\n        G.score = G.score + 50\n        Fx.popup(T.CANNON_X, T.CANNON_Y - 24, \"+50\", false)\n        Fx.cue(\"full\", T.CANNON_X - 106, (C.ORIENT==\"landscape\" and C.DESIGN_H-18 or T.CANNON_Y-48), color)\n        G.stats.fizzles = G.stats.fizzles + 1\n        return\n    end\n    G.cannonAmmo[#G.cannonAmmo + 1] = color\n    Fx.cue(fromPulse and \"ammo\" or \"loaded\", T.CANNON_X - 106, (C.ORIENT==\"landscape\" and C.DESIGN_H-18 or T.CANNON_Y-48), color)\n    G.ui.ammoFocusUntil = G.now + 0.45\n    G.stats.fizzles = G.stats.fizzles + 1\n    Audio.playSfx(\"ammo\", 1.0, 0.65)\nend\n\nlocal function rotateAmmo()\n    if #G.cannonAmmo < 2 then return end\n    local first = table.remove(G.cannonAmmo, 1)\n    G.cannonAmmo[#G.cannonAmmo + 1] = first\n    G.ui.ammoFocusUntil = G.now + 0.45\n    Audio.playSfx(\"swap\", 1.1, 0.4)\nend\n\nlocal function fireCannon(tx, ty)\n    if not G.lvl.cannon or #G.cannonAmmo == 0 or G.flying then\n        G.cannonShake = 0.25\n        return false\n    end\n    local color = table.remove(G.cannonAmmo, 1)\n    local dx, dy = tx - T.CANNON_X, ty - T.CANNON_Y\n    local len = math.sqrt(dx * dx + dy * dy)\n    if len < 8 then dx, dy, len = 0, -1, 1 end\n    local ux, uy = dx / len, dy / len\n    G.flying = {\n        x = T.CANNON_X + ux * 20, y = T.CANNON_Y + uy * 20,\n        vx = ux * T.FIRE_SPEED, vy = uy * T.FIRE_SPEED, color = color,\n    }\n    G.aimX, G.aimY = tx, ty\n    G.stats.cannonShots = G.stats.cannonShots + 1\n    Audio.playSfx(\"cannon\", 1.0, 0.85)\n    Fx.ringBurst(T.CANNON_X, T.CANNON_Y, color, 30, 0.2)\n    return true\nend\n\n\n\n\nlocal function findTarget(color)\n    local rs = Ring.runs()\n    for k = #rs, 1, -1 do\n        local r = rs[k]\n        if r.color == color and G.balls[r.i2].vdist >= 0 then\n            local free = true\n            for i = r.i1, r.i2 do\n                if G.reserved[G.balls[i].id] then free = false break end\n            end\n            if free then\n                local ids = {}\n                for i = r.i1, r.i2 do ids[#ids + 1] = G.balls[i].id end\n                return ids\n            end\n        end\n    end\n    return nil\nend\n\nlocal function reserve(ids, on)\n    for _, id in ipairs(ids) do G.reserved[id] = on or nil end\nend\n\nlocal function ballIndexById(id)\n    for i, b in ipairs(G.balls) do if b.id == id then return i end end\n    return nil\nend\n\nlocal function launchPulse(color, x, y, mult)\n    G.stats.pulses = G.stats.pulses + 1\n    local ids = findTarget(color)\n    local p = { color = color, x0 = x, y0 = y, x = x, y = y, t = 0, trail = {}, mult = mult }\n    if ids then\n        reserve(ids, true)\n        p.mode, p.ids, p.dur = \"hit\", ids, T.PULSE_TRAVEL\n    else\n        p.mode, p.dur, p.tx, p.ty = \"fizzle\", T.PULSE_TRAVEL, T.CANNON_X, T.CANNON_Y\n    end\n    G.pulses[#G.pulses + 1] = p\n    Fx.cue(\"pulse\", x, y - 28, color)\n    Audio.playSfx(\"pulse\", 1.0 + 0.04 * color, 0.5)\nend\n\nlocal function pulseTargetPos(p)\n    if p.mode == \"fizzle\" then return p.tx, p.ty end\n    for _, id in ipairs(p.ids) do\n        local i = ballIndexById(id)\n        if i then\n            local x, y = Ring.pointOnPath(G.balls[i].vdist)\n            return x, y, i\n        end\n    end\n    return nil\nend\n\nlocal function pulseImpact(p, idx)\n    reserve(p.ids, false)\n    local col = G.balls[idx].color\n    local i1, i2 = Ring.runAt(idx)\n    G.chain = 0\n    local set, cnt = {}, 0\n    local hx, hy = Ring.pointOnPath(G.balls[idx].vdist)\n    Fx.cue(\"hit\", hx, hy + 26, col)\n    for i = i1, i2 do\n        set[i] = true\n        cnt = cnt + 1\n        local x, y = Ring.pointOnPath(G.balls[i].vdist)\n        Fx.burst(x, y, col, 10, 200)\n    end\n    Ring.removeIndices(set)\n    Ring.bumpCombo()\n    G.stats.hits = G.stats.hits + 1\n    Ring.addRingScore(30 * cnt * (p.mult or 1), hx, hy - 12)\n    G.flashes[#G.flashes + 1] = { x = hx, y = hy, life = 0.2 }\n    G.reverseTimer = math.max(G.reverseTimer, T.REVERSE_TIME)\n    Fx.shake(3, 0.12)\n    Audio.playSfx(\"pulse_hit\", 1.0, 0.85)\n    Fx.ringBurst(hx, hy, col, 42, 0.28)\n    Fx.sparkle(hx, hy, 8)\nend\n\nlocal function updatePulses(dt)\n    for i = #G.pendingPulses, 1, -1 do\n        local q = G.pendingPulses[i]\n        q.delay = q.delay - dt\n        if q.delay <= 0 then\n            table.remove(G.pendingPulses, i)\n            launchPulse(q.color, q.x, q.y, q.mult)\n        end\n    end\n    for i = #G.pulses, 1, -1 do\n        local p = G.pulses[i]\n        p.t = p.t + dt\n        local tx, ty, idx = pulseTargetPos(p)\n        if not tx then\n            local ids = findTarget(p.color)\n            p.x0, p.y0, p.t = p.x, p.y, 0\n            if ids then\n                reserve(ids, true)\n                p.ids, p.dur, p.mode = ids, T.PULSE_RETARGET, \"hit\"\n            else\n                p.mode, p.dur, p.tx, p.ty = \"fizzle\", T.PULSE_RETARGET, T.CANNON_X, T.CANNON_Y\n            end\n            tx, ty, idx = pulseTargetPos(p)\n        end\n        local u = math.min(1, p.t / p.dur)\n        local e = 1 - (1 - u) * (1 - u)\n        local dx, dy = tx - p.x0, ty - p.y0\n        local len = math.sqrt(dx * dx + dy * dy) + 1e-6\n        local arc = math.sin(math.pi * u) * math.min(60, len * 0.25)\n        local nx, ny = -dy / len, dx / len\n        p.x = p.x0 + dx * e + nx * arc\n        p.y = p.y0 + dy * e + ny * arc\n        table.insert(p.trail, 1, { p.x, p.y })\n        if #p.trail > 12 then table.remove(p.trail) end\n        if u >= 1 then\n            table.remove(G.pulses, i)\n            if p.mode == \"hit\" and idx then\n                pulseImpact(p, idx)\n            else\n                addAmmo(p.color, true)\n                Fx.burst(T.CANNON_X, T.CANNON_Y, p.color, 8, 110)\n                Fx.ringBurst(T.CANNON_X, T.CANNON_Y, p.color, 28, 0.25)\n                Fx.sparkle(T.CANNON_X, T.CANNON_Y, 6)\n            end\n        end\n    end\nend\n\n\n\n\nlocal function lockCount()\n    local n = #G.lockDrops + #G.lockQueue\n    for r = 1, (G.boardRows or N) do for c = 1, (G.boardCols or N) do\n        if G.board[r] and G.board[r][c] and G.board[r][c].locked then n = n + 1 end\n    end end\n    return n\nend\n\nlocal function dropLock()\n    if lockCount() >= T.LOCK_MAX then return end\n    local taken = {}\n    for _, d in ipairs(G.lockDrops) do taken[d.r * 10 + d.c] = true end\n    for _, q in ipairs(G.lockQueue) do taken[q.r * 10 + q.c] = true end\n    local function candidates(maxRow)\n        local list = {}\n        for r = 1, maxRow do for c = 1, (G.boardCols or N) do\n            local cell = G.board[r] and G.board[r][c]\n            if cell and not cell.locked and not cell.special and not cell.clearing and not taken[r * 10 + c] then\n                list[#list + 1] = { r, c }\n            end\n        end end\n        return list\n    end\n    local list = candidates(3)\n    if #list == 0 then list = candidates(G.boardRows or N) end\n    if #list == 0 then return end\n    local pick = list[Rng.board(1, #list)]\n    local wx, wy = Ring.pointOnPath(G.pathLen * T.WARN_AT)\n    G.lockDrops[#G.lockDrops + 1] = { x0 = wx, y0 = wy, r = pick[1], c = pick[2], t = 0 }\n    G.stats.locks = G.stats.locks + 1\n    Audio.playSfx(\"lock\", 1.0, 0.7)\nend\n\nlocal function updateLocks(dt)\n    for i = #G.lockDrops, 1, -1 do\n        local d = G.lockDrops[i]\n        d.t = d.t + dt\n        if d.t >= T.LOCK_FALL_TIME then\n            table.remove(G.lockDrops, i)\n            G.lockQueue[#G.lockQueue + 1] = { r = d.r, c = d.c }\n        end\n    end\n    local head = Ring.headBall()\n    local warnDist = G.pathLen * T.WARN_AT\n    if head and head.dist >= warnDist then\n        G.stats.warnTime = G.stats.warnTime + dt\n        if G.lvl.locks then\n            if not G.warnActive then\n                G.warnActive = true\n                G.lockTimer = G.lvl.lockInterval\n                dropLock()\n            else\n                G.lockTimer = G.lockTimer - dt\n                if G.lockTimer <= 0 then\n                    G.lockTimer = G.lvl.lockInterval\n                    dropLock()\n                end\n            end\n        end\n    elseif G.warnActive and (not head or head.dist < warnDist - BALL_D) then\n        G.warnActive = false\n        G.lockTimer = 0\n    end\nend\n\n\n\nM.previewTarget = findTarget\nM.addAmmo = addAmmo\nM.rotateAmmo = rotateAmmo\nM.fireCannon = fireCannon\nM.updatePulses = updatePulses\nM.updateLocks = updateLocks\n\nreturn M\n"],["ringcore/layout.lua","\nlocal M = {}\nlocal C = require \"ringcore.config\"\nlocal G = require \"ringcore.state\"\nlocal DESIGN_W, DESIGN_H = C.DESIGN_W, C.DESIGN_H\nlocal physW = 540\nlocal physH = 960\n\n\n\n\n\nfunction M.orientation(w,h)\n    return not rawget(_G,\"HOST_PHONE\") and w>=600 and w/h>=1.35 and \"landscape\" or \"portrait\"\nend\nfunction M.fireRect()\n    if C.ORIENT==\"landscape\" then return 16,84,494,370 end\n    return 16,84+(G.safeShift or 0),508,C.T.TOP_FIRE_MAX_Y-84-(G.safeShift or 0)\nend\n\n\nfunction M.roundBoostRects()\n    local wide, out = C.ORIENT == \"landscape\", {}\n    for i, id in ipairs({ \"reverse\", \"slow\" }) do\n        local side = i == 1 and -1 or 1\n        out[#out+1] = { id = \"round_\"..id, x = wide and C.T.CANNON_X+side*144 or 270+side*222,\n            y = wide and 454 or 474, w = wide and 88 or 72, h = wide and 40 or 48,\n            hitW = wide and 88 or 72, hitH = 48 }\n    end\n    return out\nend\nfunction M.inFireArea(x,y)\n\n    if G.state == \"playing\" and not G.runKind then\n        for _, b in ipairs(M.roundBoostRects()) do\n            if math.abs(x-b.x)<=(b.hitW or b.w)/2 and math.abs(y-b.y)<=(b.hitH or b.h)/2+4 then return false end\n        end\n    end\n    local rx,ry,rw,rh=M.fireRect()\n    return x>=rx and x<=rx+rw and y>=ry and y<ry+rh\nend\n\nlocal function rebase(old)\n    local T=C.T;local L=T.LAYOUT\n    local function board(x,y)\n        if G.mode==\"hex\" then\n            local r=old.orient==\"landscape\" and 26 or 28\n            local center=old.orient==\"landscape\" and old.bx+C.N*old.cell/2 or 270\n            local ox=center-6.5*r*math.sqrt(3)/2\n            local nx,ny=require(\"ringcore.hexboard\").cellXY(1,1)\n            local scale=require(\"ringcore.hexboard\").radius()/r\n            return nx+(x-ox)*scale,ny+(y-(old.orient==\"landscape\" and 106 or 538))*scale\n        end\n        return T.BOARD_X+(x-old.bx)*T.CELL/old.cell,T.BOARD_Y+(y-old.by)*T.CELL/old.cell\n    end\n    local function ring(x,y) return L.ringX+(x-old.rx)*L.ringRX/old.rrx,L.ringY+(y-old.ry)*L.ringRY/old.rry end\n    local function point(x,y)\n        if old.orient==\"portrait\" and y>=old.by-24 or old.orient==\"landscape\" and x>=old.bx-24 then return board(x,y) end\n        return ring(x,y)\n    end\n    for _,q in ipairs(G.pendingPulses or {})do q.x,q.y=board(q.x,q.y) end\n    for _,p in ipairs(G.pulses or {})do\n        p.x0,p.y0=point(p.x0,p.y0);p.x,p.y=point(p.x,p.y);p.trail={}\n        if p.mode==\"fizzle\" then p.tx,p.ty=T.CANNON_X,T.CANNON_Y end\n    end\n    for _,d in ipairs(G.lockDrops or {})do d.x0,d.y0=ring(d.x0,d.y0) end\n    for _,v in ipairs(G.causalFX or {})do\n        if v.stage==\"hit\" then v.x,v.y=ring(v.x,v.y)\n        elseif v.stage==\"clear\" or v.stage==\"pulse\" then v.x,v.y=board(v.x,v.y)\n        else v.x,v.y=T.CANNON_X-106,C.ORIENT==\"landscape\" and C.DESIGN_H-18 or T.CANNON_Y-48 end\n    end\n\n    G.shards,G.motes,G.particles,G.popups,G.ringsFX,G.sparks,G.flashes,G.impacts={},{},{},{},{},{},{},{}\n    G.press,G.selected=nil,nil;G.ui.press=nil\n    G.aimX,G.aimY=L.ringX,L.ringY\nend\n\nlocal function recalcLayout()\n    physW, physH = graphics:GetWidth(), graphics:GetHeight()\n    G.dpr = graphics:GetDPR()\n    if not G.dpr or G.dpr <= 0 then G.dpr = 1 end\n    G.logicalW, G.logicalH = physW / G.dpr, physH / G.dpr\n\n    local want = rawget(_G, \"FORCE_ORIENT\") or M.orientation(G.logicalW,G.logicalH)\n    if want~=C.ORIENT and G.flying then G.pendingOrientation=want;want=C.ORIENT\n    else G.pendingOrientation=nil end\n    local old\n    if want ~= C.ORIENT then\n        local oldL=C.T.LAYOUT or C.LAYOUTS.portrait\n        old={orient=C.ORIENT,bx=C.T.BOARD_X,by=C.T.BOARD_Y,cell=C.T.CELL,\n            rx=oldL.ringX,ry=oldL.ringY,rrx=oldL.ringRX,rry=oldL.ringRY}\n        C.applyLayout(want)\n        require(\"ringcore.ring\").buildPath()\n    end\n    DESIGN_W, DESIGN_H = C.DESIGN_W, C.DESIGN_H\n\n    G.scale = math.min(G.logicalW / DESIGN_W, G.logicalH / DESIGN_H)\n    G.screenDW, G.screenDH = G.logicalW / G.scale, G.logicalH / G.scale\n    G.offX = (G.screenDW - DESIGN_W) / 2\n    G.offY = (G.screenDH - DESIGN_H) / 2\n\n\n\n    local extra = math.max(0, G.offY)\n\n\n    local insT, insB = 0, 0\n    pcall(function()\n        local r = GetSafeAreaInsets(false)\n        if r then insT, insB = r.min.y / G.dpr, r.max.y / G.dpr end\n    end)\n    local notchClass = G.logicalH / math.max(1, G.logicalW) >= 1.9\n\n\n    if not rawget(_G, \"HOST_SAFE_AREA\") then\n        insT = math.max(insT, notchClass and C.SAFE_TOP or 0)\n        insB = math.max(insB, C.SAFE_BOTTOM)\n    end\n\n    G.safeTop = math.max(0, insT / G.scale - G.offY)\n    G.safeBot = math.min(DESIGN_H + G.offY, DESIGN_H + G.offY - insB / G.scale)\n    G.safeShift = math.max(0, G.safeTop - 4)\n\n    G.barY = math.floor(math.min(G.safeBot - 10, DESIGN_H + math.min(extra, 48)))\n    C.layoutBoard(G.barY)\n    if old then rebase(old) end\n\n    G.press,G.selected=nil,nil\n    if G.ui then G.ui.press=nil end\nend\n\nlocal function toDesign(px, py)\n\n\n    return px / G.dpr / G.scale - G.offX, py / G.dpr / G.scale - G.offY\nend\n\nfunction M.updatePending() if G.pendingOrientation and not G.flying then recalcLayout() end end\nM.recalcLayout = recalcLayout\nM.toDesign = toDesign\n\nreturn M\n"],["ringcore/levels.lua","\n\n\n\n\n\nlocal Tune = require \"ringcore.leveltune\"\nlocal StepMoves = require \"ringcore.stepmoves\"\nlocal M = {}\n\nlocal function clamp(v, a, b) return math.max(a, math.min(b, v)) end\nlocal function r1(v) return math.floor(v * 100 + 0.5) / 100 end\n\n\n\n\n\nlocal function goalFor(i)\n    local ch, s = math.floor((i - 1) / 10) + 1, (i - 1) % 10 + 1\n    if s == 5 or s == 10 then return \"boss\" end\n    if s == 9 then return \"timed\" end\n    if (ch % 2 == 1 and (s == 4 or s == 7)) or (ch % 2 == 0 and (s == 3 or s == 7)) then return \"step\" end\n    return \"classic\"\nend\nM.goalFor = goalFor\n\n\nlocal STEP_PATTERNS = {\n    { \"color\", \"beads\" }, { \"unlock\", \"beads\" }, { \"color\", \"unlock\", \"beads\" }, { \"unlock\", \"color\", \"beads\" },\n}\n\n\n\n\n\n\n\nlocal TUT = { 0.97, 0.96, 0.96, 0.95, 0.95, 0.92, 0.90, 0.88, 0.86, 0.80 }\nfunction M.target(mode, i)\n    local p = (i - 1) % 5\n    if mode == \"hex\" then\n        if i <= 5 then return ({ 0.95, 0.94, 0.93, 0.92, 0.88 })[i] end\n\n\n        local b = math.floor((i - 6) / 5)\n        local start = 0.82 - 0.26 * b / 6\n        local q = (i - 6) % 5\n        if q == 4 or i == M.HEX_N then return r1(start - (0.16 + 0.02 * b)) end\n        return r1(start - 0.03 * q)\n    end\n    if i <= 10 then return TUT[i] end\n\n\n    local b = math.floor((i - 1) / 5) - 2\n    local start = 0.88 - 0.32 * b / 21\n    if p == 4 then return r1(start - (0.16 + 0.04 * b / 21)) end\n    return r1(start - 0.03 * p)\nend\nfunction M.floor(mode, i)\n    if mode == \"hex\" then return (i <= 5) and 0.70 or 0.20 end\n    if i <= 10 then return 0.70 elseif i <= 30 then return 0.40 end\n    return 0.20\nend\n\n\nlocal TUT_QUOTA = { [1] = 30, [2] = 36, [3] = 40, [5] = 46 }\nlocal HEX_QUOTA = { 48, 52, 56, 60, 66 }\n\n\n\n\n\n\n\n\n\n\n\n\nfunction M.classic(i, k)\n    k = k or Tune.classic[i] or 0.5\n    local ch = math.floor((i - 1) / 10) + 1\n    local g = goalFor(i)\n    local lv = {\n        name = string.format(\"L%d\", i), nameKey = \"lv.c\" .. i,\n        chapter = ch, colors = (i == 1) and 4 or 5,\n        locks = i >= 2, specials = (i == 1) and false or ((i == 2) and \"line\" or \"full\"),\n        cannon = i >= 3, powerChance = (i == 1) and 0 or clamp(0.012 + 0.0002 * i, 0, 0.024),\n        k = k,\n    }\n    if g == \"step\" then\n        local occ = 0\n        for j = 1, i do if goalFor(j) == \"step\" then occ = occ + 1 end end\n        local pat = STEP_PATTERNS[((occ - 1) % #STEP_PATTERNS) + 1]\n        lv.goal = \"step\"\n\n\n\n        lv.moves = StepMoves[i] or math.floor(clamp(30 - 14 * k, 8, 40) + 0.5)\n        lv.speed, lv.push, lv.pushRate = 6, 30 + 3 * ch, 240\n        lv.quota, lv.seed = 400, 24 + ch\n        lv.lockInterval = clamp(7.2 - 0.25 * ch, 5, 8)\n        lv.targets = {}\n        local color = ((i * 3) % 5) + 1\n        for _, kind in ipairs(pat) do\n            if kind == \"color\" then lv.targets[#lv.targets + 1] = { kind = \"color\", color = color, n = 16 + ch }\n            elseif kind == \"unlock\" then\n                lv.preLocks = math.min(12, 6 + ch)\n                lv.targets[#lv.targets + 1] = { kind = \"unlock\", n = math.max(3, lv.preLocks - 2) }\n            else lv.targets[#lv.targets + 1] = { kind = \"beads\", n = 22 + 3 * ch } end\n        end\n    elseif g == \"timed\" then\n        lv.goal = \"timed\"\n        lv.time = 90\n\n\n        lv.energy = Tune.energy and Tune.energy[\"c\" .. i]\n                or math.floor(clamp(2400 + 4000 * k, 1500, 16000) / 100 + 0.5) * 100\n        lv.speed, lv.push = 8 + ch, 36 + 4 * ch\n        lv.quota, lv.seed = 0, 20 + ch\n        lv.lockInterval = clamp(4.4 - 0.15 * ch, 3, 5)\n    else\n        lv.boss = (g == \"boss\") or nil\n        lv.speed = r1(clamp(6 + 6 * k, 4, 30))\n        lv.push = math.floor(clamp(30 + 90 * k, 10, 220) + 0.5)\n        lv.quota = math.floor(clamp(24 + 52 * k, 20, 140) + 0.5)\n        if TUT_QUOTA[i] then lv.quota = math.min(lv.quota, TUT_QUOTA[i]) end\n        lv.seed = math.floor(clamp(14 + 12 * k, 12, 32) + 0.5)\n        lv.lockInterval = r1(clamp(6 - 3 * k, 2.0, 7))\n    end\n    return lv\nend\n\n\nfunction M.hex(i, k)\n    k = k or Tune.hex[i] or 0.5\n    local boss = (i % 5 == 0) or i == M.HEX_N\n    return {\n\n\n        silt = true, siltStart = (i <= 3) and 0 or 1, siltColor = ((i * 3) % 4) + 1,\n        name = string.format(\"H%d\", i), nameKey = \"lv.h\" .. i,\n        colors = 5, locks = i >= 2, specials = (i == 1) and \"line\" or \"full\", cannon = i >= 2,\n        powerChance = (i == 1) and 0 or clamp(0.012 + 0.0004 * i, 0, 0.022),\n\n\n        speed = r1(clamp(4 + 4 * k, 3, 18)),\n        push = math.floor(clamp(18 + 50 * k, 8, 120) + 0.5),\n        quota = math.floor(math.min(clamp(34 + 45 * k, 26, 130), HEX_QUOTA[i] or 999) + 0.5),\n        seed = math.floor(clamp(14 + 8 * k, 12, 26) + 0.5),\n        lockInterval = r1(clamp(6.4 - 2.0 * k, 3.0, 8)),\n        boss = boss or nil, k = k,\n    }\nend\n\n\n\n\nM.DDA_AFTER = 3\nfunction M.eased(lv)\n    local e = {}\n    for key, v in pairs(lv) do e[key] = v end\n    e.eased = true\n    if e.goal == \"step\" then e.moves = e.moves + 3\n    elseif e.goal == \"timed\" then e.time = e.time + 15; e.energy = math.floor(e.energy * 0.92 / 100 + 0.5) * 100\n    else\n        e.speed = r1(e.speed * 0.9)\n        if e.push then e.push = math.floor(e.push * 0.85 + 0.5) end\n        e.quota = math.floor(e.quota * 0.9 + 0.5)\n    end\n    return e\nend\n\n\n\n\n\nM.DEFAULT_TIER = \"normal\"\nM.ORDER = { \"easy\", \"normal\", \"hard\" }\nM.TIER = {\n    easy   = { speed = 0.88, push = 0.90, quota = 0.94, locks = 1.10, moves = 1.08, energy = 0.92, seed = 0.96 },\n    normal = { speed = 1.00, push = 1.00, quota = 1.00, locks = 1.00, moves = 1.00, energy = 1.00, seed = 1.00 },\n    hard   = { speed = 1.18, push = 1.08, quota = 1.05, locks = 0.94, moves = 0.96, energy = 1.06, seed = 1.00 },\n}\n\n\nM.TIER_FIELDS = {\n    speed = { \"speed\", r1 },\n    push = { \"push\", function(v) return math.floor(v + 0.5) end },\n    quota = { \"quota\", function(v) return math.floor(v + 0.5) end },\n    locks = { \"lockInterval\", r1 },\n    moves = { \"moves\", function(v) return math.floor(v + 0.5) end },\n    energy = { \"energy\", function(v) return math.floor(v / 100 + 0.5) * 100 end },\n    seed = { \"seed\", function(v) return math.floor(v + 0.5) end },\n}\n\n\nfunction M.forTier(lv, tier)\n    if not lv or not tier or tier == M.DEFAULT_TIER or not M.TIER[tier] then return lv end\n    local f = M.TIER[tier]\n    local e = {}\n    for key, v in pairs(lv) do e[key] = v end\n    for knob, spec in pairs(M.TIER_FIELDS) do\n        local field, round = spec[1], spec[2]\n        if e[field] then e[field] = round(e[field] * f[knob]) end\n    end\n    e.tier = tier\n    return e\nend\n\nM.CLASSIC_N, M.HEX_N = 120, 36\nfunction M.allClassic() local t = {} for i = 1, M.CLASSIC_N do t[i] = M.classic(i) end return t end\nfunction M.allHex() local t = {} for i = 1, M.HEX_N do t[i] = M.hex(i) end return t end\n\nreturn M\n"],["ringcore/leveltune.lua","\n\n\n\n\n\nreturn {\n    classic = {\n        0.200, 0.350, 0.450, 0.528, 0.450, 0.620, 0.983, 0.793, 0.391, 0.896,\n        0.773, 0.800, 0.701, 0.894, 0.917, 0.835, 0.680, 0.978, 0.535, 0.943,\n        0.837, 1.024, 1.048, 0.751, 1.144, 0.851, 0.988, 0.946, 0.650, 1.063,\n        1.002, 1.047, 0.664, 0.948, 1.132, 0.905, 0.595, 0.906, 1.011, 1.228,\n        0.918, 1.096, 1.143, 0.843, 1.187, 1.102, 0.700, 1.039, 1.043, 1.237,\n        1.150, 1.216, 1.205, 1.195, 1.184, 1.174, 1.163, 1.152, 1.142, 1.131,\n        1.131, 1.131, 1.131, 1.131, 1.151, 1.151, 1.151, 1.151, 0.990, 1.170,\n        1.170, 1.170, 1.170, 1.170, 1.189, 1.189, 1.189, 1.189, 1.189, 1.199,\n        1.120, 1.199, 1.199, 1.199, 1.218, 1.218, 1.218, 1.218, 1.218, 1.237,\n        1.237, 1.237, 1.237, 1.237, 1.247, 1.170, 1.247, 1.247, 1.247, 1.267,\n        1.267, 1.267, 1.267, 1.267, 1.286, 1.286, 1.286, 1.286, 1.286, 1.306,\n        1.306, 1.306, 1.306, 1.306, 1.316, 1.316, 1.316, 1.316, 1.316, 1.337,\n    },\n\n    energy = {\n        c69 = 6300,\n    },\n    hex = {\n        0.400, 0.500, 0.600, 0.700, 0.800, 1.294, 1.241, 1.331, 1.371, 1.387,\n        1.338, 1.364, 1.389, 1.387, 1.448, 1.360, 1.427, 1.410, 1.490, 1.526,\n        1.450, 1.526, 1.526, 1.526, 1.570, 1.570, 1.570, 1.570, 1.570, 1.614,\n        1.614, 1.614, 1.530, 1.614, 1.667, 1.718,\n    },\n}\n"],["ringcore/locale/en.lua","\n\nreturn {\n    [\"ad.boosterBtn\"] = \"+1 {name}\",\n    [\"ad.boosterFree\"] = \"Claim +1 {name}\",\n    [\"ad.closed\"] = \"Watch the whole video to get the reward\",\n    [\"ad.continueBtn\"] = \"Watch ad · Continue\",\n    [\"ad.cooldown\"] = \"Ad cooldown, retry in {s}s\",\n    [\"ad.cooldownShort\"] = \"Wait {s}s\",\n    [\"ad.dailyCap\"] = \"Daily ad limit reached — come back tomorrow\",\n    [\"ad.dailyCapShort\"] = \"Ad limit reached\",\n    [\"ad.doubleBtn\"] = \"Watch ad · +1 to all three boosters\",\n    [\"ad.doubleDone\"] = \"Boosters +1 again: claimed\",\n    [\"ad.loading\"] = \"Loading…\",\n    [\"ad.multi\"] = \"Watch {n} ads\",\n    [\"ad.noReward\"] = \"No reward this time. Try again later\",\n    [\"ad.practiceBtn\"] = \"Watch ad · Practice +1\",\n    [\"ad.practiceNone\"] = \"No practice runs left today\",\n    [\"ad.retryAvailable\"] = \"Ad not ready — no reward, but nothing was used. Try again\",\n    [\"app.window\"] = \"Ring Core\",\n    [\"arena.blitz\"] = \"Score Attack · {tier}\",\n    [\"arena.endless\"] = \"Endless · {tier}\",\n    [\"booster.adLeft\"] = \"Ad refill {a}/{b} this level · {c}/{d} today\",\n    [\"booster.btn\"] = \"{name} ×{n}\",\n    [\"booster.hint\"] = \"Hint\",\n    [\"booster.hint.desc\"] = \"Highlights one recommended move at the start\",\n    [\"booster.pickLimit\"] = \"Picked {a}/{b} for this level\",\n    [\"booster.reverse\"] = \"Rewind\",\n    [\"booster.reverse.desc\"] = \"Tap during play to rewind the chain for 2 seconds\",\n    [\"booster.slow\"] = \"Slow\",\n    [\"booster.slow.desc\"] = \"Tap during play for 55% chain speed for 4 seconds\",\n    [\"btn.back\"] = \"Back\",\n    [\"btn.credits\"] = \"Credits\",\n    [\"btn.done\"] = \"Done\",\n    [\"btn.enter\"] = \"Enter\",\n    [\"btn.gotIt\"] = \"Got It\",\n    [\"btn.hall\"] = \"Hall\",\n    [\"btn.home\"] = \"Home\",\n    [\"btn.levelMap\"] = \"Level Map\",\n    [\"btn.map\"] = \"Map\",\n    [\"btn.modes\"] = \"Choose mode\",\n    [\"btn.next\"] = \"Next Level\",\n    [\"btn.nextCh\"] = \"Next\",\n    [\"btn.nextPage\"] = \"Next\",\n    [\"btn.off\"] = \"Off\",\n    [\"btn.on\"] = \"On\",\n    [\"btn.play\"] = \"Start Adventure\",\n    [\"btn.playAgain\"] = \"Play Again\",\n    [\"btn.practice\"] = \"Practice\",\n    [\"btn.prevCh\"] = \"Prev\",\n    [\"btn.prevPage\"] = \"Prev\",\n    [\"btn.quickClassic\"] = \"Classic · Level {n}\",\n    [\"btn.restart\"] = \"Restart\",\n    [\"btn.resume\"] = \"Resume\",\n    [\"btn.resumeRun\"] = \"Resume Run\",\n    [\"btn.retry\"] = \"Try Again\",\n    [\"btn.shakeOff\"] = \"Shake: Off\",\n    [\"btn.shakeOn\"] = \"Shake: On\",\n    [\"btn.start\"] = \"Start\",\n    [\"btn.startChallenge\"] = \"Start Sprint\",\n    [\"btn.toMap\"] = \"Back to Map\",\n    [\"bubble.arena\"] = \"Challenge Hall unlocked · in Start Adventure\",\n    [\"bubble.arena2\"] = \"Challenge Hall: Adept unlocked\",\n    [\"bubble.arena3\"] = \"Challenge Hall: Master unlocked\",\n    [\"bubble.daily\"] = \"New mode unlocked\",\n    [\"cal.m1\"] = \"JAN\",\n    [\"cal.m10\"] = \"OCT\",\n    [\"cal.m11\"] = \"NOV\",\n    [\"cal.m12\"] = \"DEC\",\n    [\"cal.m2\"] = \"FEB\",\n    [\"cal.m3\"] = \"MAR\",\n    [\"cal.m4\"] = \"APR\",\n    [\"cal.m5\"] = \"MAY\",\n    [\"cal.m6\"] = \"JUN\",\n    [\"cal.m7\"] = \"JUL\",\n    [\"cal.m8\"] = \"AUG\",\n    [\"cal.m9\"] = \"SEP\",\n    [\"chapter.1\"] = \"Firefly Wood\",\n    [\"chapter.10\"] = \"Snowfield\",\n    [\"chapter.11\"] = \"Amethyst Wood\",\n    [\"chapter.12\"] = \"Radiant Wilds\",\n    [\"chapter.2\"] = \"Mist Marsh\",\n    [\"chapter.3\"] = \"Dusk Wood\",\n    [\"chapter.4\"] = \"Frostmist Lake\",\n    [\"chapter.5\"] = \"Goldglow Vale\",\n    [\"chapter.6\"] = \"Star Nexus Sanctum\",\n    [\"chapter.7\"] = \"Starsand Wilds\",\n    [\"chapter.8\"] = \"Copper Gorge\",\n    [\"chapter.9\"] = \"Jade Tide Reach\",\n    [\"chip.best\"] = \"Best {n}\",\n    [\"chip.progress\"] = \"{a}/{b} cleared\",\n    [\"coach.chain\"] = \"3 beads of one color in a row go out\",\n    [\"coach.fire\"] = \"Cannon loaded: tap the track to shoot a matching bead\",\n    [\"coach.lock\"] = \"Locked vines block a gem - clear the colour next to it to free it\",\n    [\"coach.pulse\"] = \"A clear fires matching energy at the track; if a bead of that colour is there it breaks\",\n    [\"coach.swap\"] = \"Drag a stone onto a neighbor to line up 3 of a color\",\n    [\"color.1\"] = \"amber\",\n    [\"color.2\"] = \"sky\",\n    [\"color.3\"] = \"crimson\",\n    [\"color.4\"] = \"amethyst\",\n    [\"color.5\"] = \"lime\",\n    [\"credits.c.bg1\"] = \"Firefly Wood background\",\n    [\"credits.c.bg2\"] = \"Mist Marsh background\",\n    [\"credits.c.bgTitle\"] = \"Title / map backgrounds\",\n    [\"credits.c.music\"] = \"Music\",\n    [\"credits.c.orbs\"] = \"Beads / glowstones / specials\",\n    [\"credits.c.sfx\"] = \"Sound effects\",\n    [\"credits.c.sparkle\"] = \"Sparkle animations\",\n    [\"credits.c.ui\"] = \"UI / icons / particles / smoke\",\n    [\"credits.cc0\"] = \"All assets below are CC0 (public domain). Thanks to their authors.\",\n    [\"credits.code\"] = \"Game code is original to Ring Core\",\n    [\"credits.title\"] = \"Credits\",\n    [\"daily.board\"] = \"One board for today — the board and bead order stay the same all day\",\n    [\"daily.dateWeek\"] = \"{wk}, {date}\",\n    [\"daily.doneToday\"] = \"Today's sprint is done; the next one opens at 00:00\",\n    [\"daily.localBest\"] = \"Local Best\",\n    [\"daily.name\"] = \"Daily · {date}\",\n    [\"daily.nextReset\"] = \"Next reset (00:00 UTC+8)\",\n    [\"daily.notPlayed\"] = \"Not started\",\n    [\"daily.notStarted\"] = \"Today's sprint hasn't started\",\n    [\"daily.refresh\"] = \"Resets in {t}\",\n    [\"daily.rule1\"] = \"One official sprint a day: counts for local best and streak\",\n    [\"daily.rule2\"] = \"After the official sprint starts, replays are unscored practice\",\n    [\"daily.rule3\"] = \"Modes rotate by weekday: Classic → Moves → Timed, 3 variants each\",\n    [\"daily.rule4\"] = \"No boosters in the Daily Sprint\",\n    [\"daily.streak\"] = { one = \"{n} day\", other = \"{n} days\" },\n    [\"daily.streakLabel\"] = \"Streak\",\n    [\"daily.tag\"] = \"Today · {goal} · {variant}\",\n    [\"daily.title\"] = \"Daily Sprint\",\n    [\"daily.todayLost\"] = \"Today · Missed\",\n    [\"daily.todayScore\"] = \"Today\",\n    [\"daily.todayWon\"] = \"Today · Won\",\n    [\"feedback.ammo\"] = \"No match → ammo\",\n    [\"feedback.clear\"] = \"Match\",\n    [\"feedback.full\"] = \"Ammo full\",\n    [\"feedback.hit\"] = \"Same-color hit\",\n    [\"feedback.loaded\"] = \"Ammo loaded\",\n    [\"feedback.noTarget\"] = \"No same-color bead\",\n    [\"feedback.pulse\"] = \"Pulse\",\n    [\"fmt.date\"] = \"{mon} {d}\",\n    [\"funnel.cleared\"] = \"Counters cleared\",\n    [\"funnel.empty\"] = \"No other events yet\",\n    [\"funnel.export\"] = \"Export\",\n    [\"funnel.exportFailed\"] = \"Export failed\",\n    [\"funnel.exported\"] = \"Saved to {file}\",\n    [\"funnel.fail\"] = \"Fail\",\n    [\"funnel.level\"] = \"Level\",\n    [\"funnel.note\"] = \"Counted on this device only. Nothing is sent anywhere.\",\n    [\"funnel.open\"] = \"Local stats (debug)\",\n    [\"funnel.quit\"] = \"Quit\",\n    [\"funnel.reset\"] = \"Reset\",\n    [\"funnel.start\"] = \"Start\",\n    [\"funnel.title\"] = \"Local stats\",\n    [\"funnel.win\"] = \"Win\",\n    [\"fx.arc\"] = \"Arc Clear\",\n    [\"fx.cascade\"] = \"Cascade ×{x}\",\n    [\"fx.chain\"] = \"Chain ×{n}\",\n    [\"fx.chainBlast\"] = \"Chain Blast\",\n    [\"fx.colorClear\"] = \"Color Clear\",\n    [\"fx.crossMeteor\"] = \"Cross Meteor\",\n    [\"fx.danger\"] = \"Danger!\",\n    [\"fx.doubleNova\"] = \"Double Nova · Board Cleared\",\n    [\"fx.doubleQuake\"] = \"Double Quake 5×5\",\n    [\"fx.fullBlast\"] = \"Full-Board Blast\",\n    [\"fx.giantMeteor\"] = \"Giant Meteor\",\n    [\"fx.levelBonus\"] = \"Clear Bonus +{n}\",\n    [\"fx.meteor\"] = \"Meteor\",\n    [\"fx.meteorH\"] = \"Meteor →\",\n    [\"fx.meteorShower\"] = \"Meteor Shower\",\n    [\"fx.meteorV\"] = \"Meteor ↓\",\n    [\"fx.nova\"] = \"Nova\",\n    [\"fx.novaMeteor\"] = \"Nova + Meteor ×{n}\",\n    [\"fx.novaQuake\"] = \"Nova + Quake ×{n}\",\n    [\"fx.quake\"] = \"Quake\",\n    [\"fx.rescued\"] = \"Rescued!\",\n    [\"fx.reshuffle\"] = \"Rearrange\",\n    [\"fx.rewind\"] = \"Rewind!\",\n    [\"fx.rewindBead\"] = \"Rewind Bead\",\n    [\"fx.siltPush\"] = \"Honey pushed back\",\n    [\"fx.slow\"] = \"Slow!\",\n    [\"fx.slowBead\"] = \"Slow Bead\",\n    [\"fx.stun\"] = \"Stun!\",\n    [\"goal.bonusMoves\"] = \"Moves Bonus\",\n    [\"goal.bonusTime\"] = \"Time Bonus\",\n    [\"goal.classic\"] = \"Classic\",\n    [\"goal.endlessDesc\"] = \"Beads keep speeding up; last longer, score higher\",\n    [\"goal.quotaDesc\"] = \"Put out all {n} beads before they reach the Glow Nexus\",\n    [\"goal.rushDesc\"] = \"{t}s to score; the beads never run out\",\n    [\"goal.step\"] = \"Moves\",\n    [\"goal.stepDesc\"] = \"In {n} moves: {list}\",\n    [\"goal.timed\"] = \"Timed\",\n    [\"goal.timedDesc\"] = \"Collect {e} energy in {t}s\",\n    [\"hall.endless\"] = \"Endless\",\n    [\"hall.endlessDesc\"] = \"Beads speed up over time; last longer to score more\",\n    [\"hall.noLeaderboard\"] = \"No online leaderboard in this version\",\n    [\"hall.note\"] = \"Scores stay on this device · no boosters\",\n    [\"hall.rush\"] = \"Score Attack\",\n    [\"hall.rushDesc\"] = \"90 seconds of nonstop beads; longer chains score more\",\n    [\"hall.tierBest\"] = \"{tier} best {n}\",\n    [\"hall.tierLocked\"] = \"{tier} · classic Lv {n}\",\n    [\"hall.tierLockedTip\"] = \"Unlock: clear classic level {n}\",\n    [\"hexpage.1\"] = \"Hive Drop · Combs\",\n    [\"hexpage.2\"] = \"Hive Drop · Honey Pool\",\n    [\"hexpage.3\"] = \"Hive Drop · Queen's Throne\",\n    [\"hexpage.4\"] = \"Hive Drop · Nectar River\",\n    [\"hexpage.5\"] = \"Hive Drop · Deep Comb\",\n    [\"hexpage.6\"] = \"Hive Drop · Queen's Hall\",\n    [\"hint.lockedDaily\"] = \"Clear classic level {n} to unlock Daily Sprint\",\n    [\"hint.lockedHall\"] = \"Clear classic level {n} to unlock the Challenge Hall\",\n    [\"hint.resumed\"] = \"Run restored\",\n    [\"hive.tip.short\"] = \"Tip: the honey colour changes each time it rises — check the swatch above the cannon\",\n    [\"hive.tut.1\"] = \"Honey climbs up from the floor\",\n    [\"hive.tut.1s\"] = \"It rises faster in later levels\",\n    [\"hive.tut.2\"] = \"Clear the honey colour to push it back\",\n    [\"hive.tut.2s\"] = \"Only the deepest columns move down\",\n    [\"hive.tut.3\"] = \"Any flooded column ends the run\",\n    [\"hive.tut.3s\"] = \"Watch the honey swatch above the cannon\",\n    [\"hive.tut.title\"] = \"Hive Settle · how it works\",\n    [\"hud.badgeDaily\"] = \"D\",\n    [\"hud.badgeHall\"] = \"C\",\n    [\"hud.beads\"] = \"Beads {n}\",\n    [\"hud.beadsLeft\"] = \"Beads left\",\n    [\"hud.cannon\"] = \"Cannon\",\n    [\"hud.chain\"] = \"Chain ×{n}\",\n    [\"hud.classic\"] = \"Classic\",\n    [\"hud.clearProgress\"] = \"Clear progress\",\n    [\"hud.currentAmmo\"] = \"Current\",\n    [\"hud.daily\"] = \"Daily\",\n    [\"hud.dailyPractice\"] = \"Daily · Practice\",\n    [\"hud.energy\"] = \"Energy {a}/{b}\",\n    [\"hud.fireArea\"] = \"Tap this area to fire\",\n    [\"hud.hint\"] = \"Swap stones · tap the track to fire\",\n    [\"hud.hive\"] = \"Hive\",\n    [\"hud.hiveLevelN\"] = \"Hive {n}\",\n    [\"hud.left\"] = \"Left\",\n    [\"hud.levelN\"] = \"Level {n}\",\n    [\"hud.link\"] = \"Combo ×{x}\",\n    [\"hud.moves\"] = \"Moves\",\n    [\"hud.rush\"] = \"Attack\",\n    [\"hud.score\"] = \"Score\",\n    [\"hud.siltColor\"] = \"Honey colour · deepest {n} rows\",\n    [\"hud.siltShort\"] = \"Honey {n}\",\n    [\"hud.speed\"] = \"Speed +{p}%\",\n    [\"hud.survive\"] = \"Time\",\n    [\"hud.swapAmmoHint\"] = \"Cannon loaded. With 2+ shots, tap the tray or cannon to swap.\",\n    [\"lang.self\"] = \"English\",\n    [\"lose.beadsLeft\"] = { one = \"{n} bead to go\", other = \"{n} beads to go\" },\n    [\"lose.noMoves\"] = \"Out of moves\",\n    [\"lose.reached\"] = \"The beads reached the Glow Nexus\",\n    [\"lose.score\"] = \"Score {n}\",\n    [\"lose.short\"] = \"{label}: {n} left\",\n    [\"lose.siltWhy\"] = \"Hive column {n} filled up with honey\",\n    [\"lose.timeUp\"] = \"Time's up!\",\n    [\"lose.timedWhy\"] = \"{why} · Energy {a}/{b}\",\n    [\"lose.tip\"] = \"Tip: matching colors on both sides of a gap pull together and chain\",\n    [\"lose.tipBooster\"] = \"Tip: pick Rewind or Slow on the start panel\",\n    [\"lose.tipCannon\"] = \"Tip: a meteor clear, or energy that finds no matching bead on the track, loads the cannon\",\n    [\"lose.tipMoves\"] = \"Tip: clear the target colors first; 4+ matches make specials\",\n    [\"lose.tipPush\"] = \"Tip: every move nudges the chain forward — favour colours that reach near the chain head\",\n    [\"lose.tipSilt\"] = \"Tip: clearing the honey colour only pushes the deepest few columns back one row\",\n    [\"lose.tipTime\"] = \"Tip: keep matches coming; the Link multiplier speeds up energy\",\n    [\"lose.title\"] = \"The Glow Nexus Went Dark\",\n    [\"lose.titleMoves\"] = \"Out of Moves\",\n    [\"lose.titleSilt\"] = \"The honey flooded the hive\",\n    [\"lose.titleTime\"] = \"Time's Up\",\n    [\"lv.c1\"] = \"Firefly Wakes\",\n    [\"lv.c10\"] = \"Long Nexus Night\",\n    [\"lv.c100\"] = \"Deep Tide Finale\",\n    [\"lv.c101\"] = \"Snowfield Ferry\",\n    [\"lv.c102\"] = \"Whitebirch Way\",\n    [\"lv.c103\"] = \"Icicle Step\",\n    [\"lv.c104\"] = \"Frostwhisper Wood\",\n    [\"lv.c105\"] = \"Coldstar Deck\",\n    [\"lv.c106\"] = \"Rime Pine Bridge\",\n    [\"lv.c107\"] = \"Aurora Hall\",\n    [\"lv.c108\"] = \"Snowy Owl Nest\",\n    [\"lv.c109\"] = \"Glacier's Eye\",\n    [\"lv.c11\"] = \"Marsh Mist Lifts\",\n    [\"lv.c110\"] = \"Deepcold Finale\",\n    [\"lv.c111\"] = \"Amethyst Gallery\",\n    [\"lv.c112\"] = \"Nightfall Slope\",\n    [\"lv.c113\"] = \"Starwhisper Ravine\",\n    [\"lv.c114\"] = \"Phantom Light Hall\",\n    [\"lv.c115\"] = \"Violetmist Tower\",\n    [\"lv.c116\"] = \"Moonsong Deck\",\n    [\"lv.c117\"] = \"Moonshadow Bridge\",\n    [\"lv.c118\"] = \"Stardust Court\",\n    [\"lv.c119\"] = \"Violet Core\",\n    [\"lv.c12\"] = \"Reed Bay\",\n    [\"lv.c120\"] = \"Silvermoon Finale\",\n    [\"lv.c13\"] = \"Glow Creek\",\n    [\"lv.c14\"] = \"Mossy Stone Path\",\n    [\"lv.c15\"] = \"Marsh Heart\",\n    [\"lv.c16\"] = \"Wisp Pond\",\n    [\"lv.c17\"] = \"Mistvine Bridge\",\n    [\"lv.c18\"] = \"Sunken Lamp Bay\",\n    [\"lv.c19\"] = \"Night Heron Nest\",\n    [\"lv.c2\"] = \"Creeping Vines\",\n    [\"lv.c20\"] = \"Last Marsh Night\",\n    [\"lv.c21\"] = \"Dusk Trailhead\",\n    [\"lv.c22\"] = \"Wisteria Lane\",\n    [\"lv.c23\"] = \"Lantern Post\",\n    [\"lv.c24\"] = \"Nightdew Slope\",\n    [\"lv.c25\"] = \"Heart of Dusk\",\n    [\"lv.c26\"] = \"Blue Stone Steps\",\n    [\"lv.c27\"] = \"Starfall Ledge\",\n    [\"lv.c28\"] = \"Glowfeather Ridge\",\n    [\"lv.c29\"] = \"Violet Mist Spring\",\n    [\"lv.c3\"] = \"Dark Tide\",\n    [\"lv.c30\"] = \"Dusk Firefly Song\",\n    [\"lv.c31\"] = \"First Lake Mist\",\n    [\"lv.c32\"] = \"Ice Reed Shore\",\n    [\"lv.c33\"] = \"Frostlamp Bridge\",\n    [\"lv.c34\"] = \"Rime Forest\",\n    [\"lv.c35\"] = \"Eye of the Lake\",\n    [\"lv.c36\"] = \"Cold Moon Bay\",\n    [\"lv.c37\"] = \"Ice Floe Path\",\n    [\"lv.c38\"] = \"Egret Isle\",\n    [\"lv.c39\"] = \"Frostmist Harbor\",\n    [\"lv.c4\"] = \"Corelight\",\n    [\"lv.c40\"] = \"Frostmist Finale\",\n    [\"lv.c41\"] = \"Golden Dawn\",\n    [\"lv.c42\"] = \"Honeywheat Field\",\n    [\"lv.c43\"] = \"Amber Brook\",\n    [\"lv.c44\"] = \"Goldvine Slope\",\n    [\"lv.c45\"] = \"Gold Warden\",\n    [\"lv.c46\"] = \"Threshing Yard\",\n    [\"lv.c47\"] = \"Golden Falls\",\n    [\"lv.c48\"] = \"Wheatwave Hill\",\n    [\"lv.c49\"] = \"Sunset Cliff\",\n    [\"lv.c5\"] = \"Night of the Nexus\",\n    [\"lv.c50\"] = \"Long Golden Night\",\n    [\"lv.c51\"] = \"Star Gate\",\n    [\"lv.c52\"] = \"Stardust Road\",\n    [\"lv.c53\"] = \"Ringlight Hall\",\n    [\"lv.c54\"] = \"Starvine Cloister\",\n    [\"lv.c55\"] = \"Star Nexus Watch\",\n    [\"lv.c56\"] = \"Aurora Stair\",\n    [\"lv.c57\"] = \"Meteor Well\",\n    [\"lv.c58\"] = \"Startrack Tower\",\n    [\"lv.c59\"] = \"Core Forecourt\",\n    [\"lv.c6\"] = \"Firefly Cloister\",\n    [\"lv.c60\"] = \"Nexus End\",\n    [\"lv.c61\"] = \"Starsand Ferry\",\n    [\"lv.c62\"] = \"Halo Cloister\",\n    [\"lv.c63\"] = \"Shattered Star Slope\",\n    [\"lv.c64\"] = \"Firefly Riverbend\",\n    [\"lv.c65\"] = \"Star Nexus Gate\",\n    [\"lv.c66\"] = \"Polar Night Deck\",\n    [\"lv.c67\"] = \"Glimmer Stairs\",\n    [\"lv.c68\"] = \"Dome of Lights\",\n    [\"lv.c69\"] = \"Star Core Court\",\n    [\"lv.c7\"] = \"Misty Grove\",\n    [\"lv.c70\"] = \"Final Portico\",\n    [\"lv.c71\"] = \"Mistford\",\n    [\"lv.c72\"] = \"Greenvine Gorge\",\n    [\"lv.c73\"] = \"Silverleaf Hall\",\n    [\"lv.c74\"] = \"Glimmer Wetland\",\n    [\"lv.c75\"] = \"Spruce Ridge\",\n    [\"lv.c76\"] = \"Songbird Deck\",\n    [\"lv.c77\"] = \"Forest Beacon\",\n    [\"lv.c78\"] = \"Emerald Dome\",\n    [\"lv.c79\"] = \"Eye of the Firefly Tide\",\n    [\"lv.c8\"] = \"Sea of Vines\",\n    [\"lv.c80\"] = \"Verdant Coda\",\n    [\"lv.c81\"] = \"Copper Valley\",\n    [\"lv.c82\"] = \"Molten Step\",\n    [\"lv.c83\"] = \"Sandfall Crag\",\n    [\"lv.c84\"] = \"Flameheart Lake\",\n    [\"lv.c85\"] = \"Ironcalyx Gate\",\n    [\"lv.c86\"] = \"Ember Bridge\",\n    [\"lv.c87\"] = \"Moltenstar Deck\",\n    [\"lv.c88\"] = \"Amber Abyss\",\n    [\"lv.c89\"] = \"Crimson Cloister\",\n    [\"lv.c9\"] = \"Starfall\",\n    [\"lv.c90\"] = \"Redgold Finale\",\n    [\"lv.c91\"] = \"Jade Wave Shoal\",\n    [\"lv.c92\"] = \"Coral Step\",\n    [\"lv.c93\"] = \"Deep Current Ravine\",\n    [\"lv.c94\"] = \"Tide Hall\",\n    [\"lv.c95\"] = \"Tidemind Beacon\",\n    [\"lv.c96\"] = \"Pearshell Path\",\n    [\"lv.c97\"] = \"Seafog Tower\",\n    [\"lv.c98\"] = \"Azure Dome\",\n    [\"lv.c99\"] = \"Bluewave Core\",\n    [\"lv.h1\"] = \"Combs Awaken\",\n    [\"lv.h10\"] = \"Queen's Escort\",\n    [\"lv.h11\"] = \"Honeyfall\",\n    [\"lv.h12\"] = \"Hex Labyrinth\",\n    [\"lv.h13\"] = \"Amber Cell\",\n    [\"lv.h14\"] = \"Gold Wax Steps\",\n    [\"lv.h15\"] = \"Queen's Wrath\",\n    [\"lv.h16\"] = \"Night Hive\",\n    [\"lv.h17\"] = \"Honey Pool Spring\",\n    [\"lv.h18\"] = \"Queen's Dais Finale\",\n    [\"lv.h19\"] = \"Nectar Shallows\",\n    [\"lv.h2\"] = \"Honey Hall\",\n    [\"lv.h20\"] = \"Beeswax Cloister\",\n    [\"lv.h21\"] = \"Goldcomb Step\",\n    [\"lv.h22\"] = \"Queen's Forecourt\",\n    [\"lv.h23\"] = \"Honey Riverbend\",\n    [\"lv.h24\"] = \"Beechime Tower\",\n    [\"lv.h25\"] = \"Waxlight Hall\",\n    [\"lv.h26\"] = \"Combheart Abyss\",\n    [\"lv.h27\"] = \"Nectar Court\",\n    [\"lv.h28\"] = \"Hive Dome\",\n    [\"lv.h29\"] = \"Core of Golden Honey\",\n    [\"lv.h3\"] = \"The Drop\",\n    [\"lv.h30\"] = \"Deep Comb Finale\",\n    [\"lv.h31\"] = \"Queen Seat Step\",\n    [\"lv.h32\"] = \"Honeylight Gallery\",\n    [\"lv.h33\"] = \"Combshadow Hall\",\n    [\"lv.h34\"] = \"Amber Bee Way\",\n    [\"lv.h35\"] = \"Honeytongue Deep Comb\",\n    [\"lv.h36\"] = \"Queen's Final Court\",\n    [\"lv.h4\"] = \"Wax Maze\",\n    [\"lv.h5\"] = \"Throne Guard\",\n    [\"lv.h6\"] = \"Honey Pool Night\",\n    [\"lv.h7\"] = \"Hive Dawn\",\n    [\"lv.h8\"] = \"Pollen Walk\",\n    [\"lv.h9\"] = \"Wax-Sealed Gate\",\n    [\"map.chapterPage\"] = \"Chapter {a} / {b}\",\n    [\"map.chapterTitle\"] = \"Ch. {n} · {name}\",\n    [\"map.hexPage\"] = \"Page {a} / {b}\",\n    [\"map.locked\"] = \"Locked\",\n    [\"map.notCleared\"] = \"Not cleared\",\n    [\"mode.classic\"] = \"Classic Mode\",\n    [\"mode.classic.desc\"] = \"Match to charge · {n} levels\",\n    [\"mode.hall\"] = \"Challenge Hall\",\n    [\"mode.hall.desc\"] = \"Endless · Score Attack · 3 tiers\",\n    [\"mode.hex\"] = \"Hive Drop\",\n    [\"mode.hex.desc\"] = \"Hex match · rising honey · {n} levels\",\n    [\"modesel.note\"] = \"Tap a card to select, tap again to enter\",\n    [\"modesel.title\"] = \"Choose Mode\",\n    [\"pause.info\"] = \"Level {n} · Score {s}\",\n    [\"pause.infoDaily\"] = \"Daily Sprint · Score {s}\",\n    [\"pause.infoHex\"] = \"Hive Lv {n} · Score {s}\",\n    [\"pause.infoRun\"] = \"{mode} · Score {s}\",\n    [\"pause.music\"] = \"Music {p}%\",\n    [\"pause.sfx\"] = \"Sound {p}%\",\n    [\"pause.title\"] = \"Paused\",\n    [\"peek.howto\"] = \"Once unlocked, tap the level to play\",\n    [\"peek.title\"] = \"Level {n} · {name}\",\n    [\"peek.titleHex\"] = \"Hive {n} · {name}\",\n    [\"peek.unlock\"] = \"Clear classic level {n} to unlock\",\n    [\"peek.unlockHex\"] = \"Clear hive level {n} to unlock\",\n    [\"preview.label\"] = \"Preview: direct matches · possible target\",\n    [\"progress.chain\"] = \"Best chain this level: {a} → {b}\",\n    [\"progress.chapter\"] = \"Chapter {n} complete\",\n    [\"progress.page\"] = \"Hive page {n} complete\",\n    [\"progress.stars\"] = \"Stars improved: {a} → {b}\",\n    [\"pw.reverse\"] = \"Rewind\",\n    [\"pw.slow\"] = \"Slow\",\n    [\"pw.stun\"] = \"Stun\",\n    [\"ready.best\"] = \"Best {n}\",\n    [\"ready.boosterLine\"] = \"{name}: {desc}\",\n    [\"ready.boosters\"] = \"Carry-in boosters\",\n    [\"ready.goalEndless\"] = \"Endless: last as long as you can\",\n    [\"ready.goalQuota\"] = \"Goal: put out {n} beads\",\n    [\"ready.goalRush\"] = \"Score Attack: score all you can in {t}s\",\n    [\"ready.goalSteps\"] = \"Goal: finish these in {n} moves\",\n    [\"ready.goalTimed\"] = \"Goal: collect {e} energy in {t}s\",\n    [\"ready.practice\"] = \"Practice · {name}\",\n    [\"ready.slowBeads\"] = \" (slow beads)\",\n    [\"ready.starClassic\"] = \"The further the chain head stays from the core, the more stars\",\n    [\"ready.starLocal\"] = \"Counts toward your local best\",\n    [\"ready.starSteps\"] = \"More moves left, more stars\",\n    [\"ready.starTimed\"] = \"More time left, more stars\",\n    [\"ready.stock\"] = \"First clear of a level adds +1 to every booster (max 9)\",\n    [\"ready.subClassic\"] = \"Keep the bead chain away from the Glow Nexus\",\n    [\"ready.subHex\"] = \"Line up 3 of a color to clear and fire at the track\",\n    [\"ready.subTimed\"] = \"Score is energy · beads keep coming\",\n    [\"roundBoost.active\"] = \"{s}s\",\n    [\"roundBoost.entering\"] = \"Entering\",\n    [\"roundBoost.notCarried\"] = \"Not carried\",\n    [\"roundBoost.prepaid\"] = \"Prepaid\",\n    [\"roundBoost.rule\"] = \"Rewind/Slow: once each. Spend stock only when used.\",\n    [\"roundBoost.stock\"] = \"Stock {n}\",\n    [\"roundBoost.used\"] = \"Used\",\n    [\"run.dailyLost\"] = \"Daily Sprint Missed\",\n    [\"run.dailyMissed\"] = \"Today's goal not met\",\n    [\"run.dailyWon\"] = \"Daily Sprint Cleared!\",\n    [\"run.endlessLine\"] = \"Top speed +{p}% · Beads out {b}\",\n    [\"run.endlessOver\"] = \"Endless Run Over\",\n    [\"run.localBest\"] = \"Local best {n}\",\n    [\"run.localRecord\"] = \"New local record!\",\n    [\"run.practice\"] = \"Practice run · not scored\",\n    [\"run.recorded\"] = { one = \"Official score saved · {n}-day streak\", other = \"Official score saved · {n}-day streak\" },\n    [\"run.rushLine\"] = \"Beads out {b} · Best chain ×{c}\",\n    [\"run.rushTime\"] = \"Attack {t}\",\n    [\"run.stats\"] = \"Specials {s} · Combos {m} · Cannon {c}\",\n    [\"run.survived\"] = \"Lasted {t}\",\n    [\"run.timeUp\"] = \"Time's Up!\",\n    [\"sep.dot\"] = \" · \",\n    [\"set.diff.easy\"] = \"Easy\",\n    [\"set.diff.hard\"] = \"Hard\",\n    [\"set.diff.normal\"] = \"Normal\",\n    [\"set.diffHint\"] = \"Higher difficulty makes the bead chain faster\",\n    [\"set.difficulty\"] = \"Difficulty\",\n    [\"set.language\"] = \"Language\",\n    [\"set.music\"] = \"Music\",\n    [\"set.off\"] = \"Off\",\n    [\"set.on\"] = \"On\",\n    [\"set.sfx\"] = \"Sound\",\n    [\"set.shake\"] = \"Screen Shake\",\n    [\"set.shakeDesc\"] = \"Screen shake and device haptics\",\n    [\"set.title\"] = \"Settings\",\n    [\"tag.blitz\"] = \"Attack\",\n    [\"tag.cleared\"] = \"Cleared ✓\",\n    [\"tag.endless\"] = \"Endless\",\n    [\"tag.recommended\"] = \"Suggested\",\n    [\"tag.step\"] = \"Moves\",\n    [\"tag.timed\"] = \"Timed\",\n    [\"target.beads\"] = \"Put out beads\",\n    [\"target.color\"] = \"Clear {color}\",\n    [\"target.item\"] = \"{label} {n}\",\n    [\"target.unlock\"] = \"Break vine locks\",\n    [\"tier.1\"] = \"Novice\",\n    [\"tier.2\"] = \"Adept\",\n    [\"tier.3\"] = \"Master\",\n    [\"title.logo\"] = \"Ring Core\",\n    [\"title.sub\"] = \"The Glow Nexus Forest\",\n    [\"title.tagline\"] = \"Match to charge · Guard the Glow Nexus\",\n    [\"toast.arena\"] = \"New mode: Challenge Hall\",\n    [\"toast.arena.modesel\"] = \"Now open below\",\n    [\"toast.arena.other\"] = \"Start Adventure → Choose Mode → Challenge Hall\",\n    [\"toast.arena.title\"] = \"Find “Challenge Hall” in “Start Adventure”\",\n    [\"toast.arena2\"] = \"Challenge Hall: Adept tier unlocked\",\n    [\"toast.arena3\"] = \"Challenge Hall: Master tier unlocked\",\n    [\"toast.daily\"] = \"New mode: Daily Sprint\",\n    [\"toast.daily.other\"] = \"Open it from the title screen\",\n    [\"toast.daily.title\"] = \"Tap “Daily Sprint” below\",\n    [\"toast.tier.arena\"] = \"Switch a card to “{tier}”\",\n    [\"toast.tier.other\"] = \"Both Challenge Hall modes now offer “{tier}”\",\n    [\"unlock.atLevel\"] = \"Clear classic level {n} to unlock\",\n    [\"variant.classic.1\"] = \"Standard\",\n    [\"variant.classic.2\"] = \"Fast Chain\",\n    [\"variant.classic.3\"] = \"Long Chain\",\n    [\"variant.step.1\"] = \"Balanced\",\n    [\"variant.step.2\"] = \"Vine Locks\",\n    [\"variant.step.3\"] = \"Two-Tone\",\n    [\"variant.timed.1\"] = \"Standard\",\n    [\"variant.timed.2\"] = \"Blitz\",\n    [\"variant.timed.3\"] = \"Endurance\",\n    [\"week.1\"] = \"Sun\",\n    [\"week.2\"] = \"Mon\",\n    [\"week.3\"] = \"Tue\",\n    [\"week.4\"] = \"Wed\",\n    [\"week.5\"] = \"Thu\",\n    [\"week.6\"] = \"Fri\",\n    [\"week.7\"] = \"Sat\",\n    [\"win.allChapter\"] = \"Chapter complete!\",\n    [\"win.allHive\"] = \"Hive complete!\",\n    [\"win.clear\"] = \"Level Clear!\",\n    [\"win.newRecord\"] = \"New record!\",\n    [\"win.stats1\"] = \"Best chain ×{c} · Specials {s} · Combos {m}\",\n    [\"win.stats2\"] = \"Cannon {c} · Powers {p} · Best combo ×{l}\",\n}\n"],["ringcore/locale/zh_hans.lua","\n\nreturn {\n    [\"ad.boosterBtn\"] = \"+1 {name}\",\n    [\"ad.boosterFree\"] = \"领 +1 {name}\",\n    [\"ad.closed\"] = \"需要完整观看才能获得奖励\",\n    [\"ad.continueBtn\"] = \"看广告 · 原地续关\",\n    [\"ad.cooldown\"] = \"广告冷却中，{s} 秒后可再看\",\n    [\"ad.cooldownShort\"] = \"{s} 秒后可看\",\n    [\"ad.dailyCap\"] = \"今日广告次数已用完，明天再来\",\n    [\"ad.dailyCapShort\"] = \"今日广告已用完\",\n    [\"ad.doubleBtn\"] = \"看广告 · 三种道具各 +1\",\n    [\"ad.doubleDone\"] = \"道具再 +1：已领取\",\n    [\"ad.loading\"] = \"加载中…\",\n    [\"ad.multi\"] = \"看 {n} 个广告\",\n    [\"ad.noReward\"] = \"这次没有获得奖励，请稍后再试\",\n    [\"ad.practiceBtn\"] = \"看广告 · 再练一局\",\n    [\"ad.practiceNone\"] = \"今天的练习次数已用完\",\n    [\"ad.retryAvailable\"] = \"广告暂未就绪，未获得奖励，次数未扣可再试\",\n    [\"app.window\"] = \"萤核双消\",\n    [\"arena.blitz\"] = \"限时夺分 · {tier}\",\n    [\"arena.endless\"] = \"无尽挑战 · {tier}\",\n    [\"booster.adLeft\"] = \"广告补充 本关 {a}/{b} · 今日 {c}/{d}\",\n    [\"booster.btn\"] = \"{name} ×{n}\",\n    [\"booster.hint\"] = \"提示\",\n    [\"booster.hint.desc\"] = \"开局自动标出一步推荐走法\",\n    [\"booster.pickLimit\"] = \"本关已选 {a}/{b}\",\n    [\"booster.reverse\"] = \"倒退\",\n    [\"booster.reverse.desc\"] = \"局内点按使用，让珠链倒退 2 秒\",\n    [\"booster.slow\"] = \"减速\",\n    [\"booster.slow.desc\"] = \"局内点按使用，速度降到 55%，持续 4 秒\",\n    [\"btn.back\"] = \"返回\",\n    [\"btn.credits\"] = \"鸣谢\",\n    [\"btn.done\"] = \"完成\",\n    [\"btn.enter\"] = \"进入\",\n    [\"btn.gotIt\"] = \"知道了\",\n    [\"btn.hall\"] = \"大厅\",\n    [\"btn.home\"] = \"返回主界面\",\n    [\"btn.levelMap\"] = \"关卡地图\",\n    [\"btn.map\"] = \"地图\",\n    [\"btn.modes\"] = \"选择模式\",\n    [\"btn.next\"] = \"下一关\",\n    [\"btn.nextCh\"] = \"下一章\",\n    [\"btn.nextPage\"] = \"下一页\",\n    [\"btn.off\"] = \"关\",\n    [\"btn.on\"] = \"开\",\n    [\"btn.play\"] = \"开始冒险\",\n    [\"btn.playAgain\"] = \"再来一局\",\n    [\"btn.practice\"] = \"练习一局\",\n    [\"btn.prevCh\"] = \"上一章\",\n    [\"btn.prevPage\"] = \"上一页\",\n    [\"btn.quickClassic\"] = \"经典 · 第 {n} 关\",\n    [\"btn.restart\"] = \"重新开始\",\n    [\"btn.resume\"] = \"继续\",\n    [\"btn.resumeRun\"] = \"继续上局\",\n    [\"btn.retry\"] = \"再试一次\",\n    [\"btn.shakeOff\"] = \"震屏：关\",\n    [\"btn.shakeOn\"] = \"震屏：开\",\n    [\"btn.start\"] = \"开始\",\n    [\"btn.startChallenge\"] = \"开始冲刺\",\n    [\"btn.toMap\"] = \"回到地图\",\n    [\"bubble.arena\"] = \"挑战大厅已解锁，在「开始冒险」里选择\",\n    [\"bubble.arena2\"] = \"挑战大厅「进阶」已解锁\",\n    [\"bubble.arena3\"] = \"挑战大厅「大师」已解锁\",\n    [\"bubble.daily\"] = \"新玩法已解锁\",\n    [\"cal.m1\"] = \"1月\",\n    [\"cal.m10\"] = \"10月\",\n    [\"cal.m11\"] = \"11月\",\n    [\"cal.m12\"] = \"12月\",\n    [\"cal.m2\"] = \"2月\",\n    [\"cal.m3\"] = \"3月\",\n    [\"cal.m4\"] = \"4月\",\n    [\"cal.m5\"] = \"5月\",\n    [\"cal.m6\"] = \"6月\",\n    [\"cal.m7\"] = \"7月\",\n    [\"cal.m8\"] = \"8月\",\n    [\"cal.m9\"] = \"9月\",\n    [\"chapter.1\"] = \"萤林\",\n    [\"chapter.10\"] = \"雪原\",\n    [\"chapter.11\"] = \"紫晶林\",\n    [\"chapter.12\"] = \"辉核原野\",\n    [\"chapter.2\"] = \"雾沼\",\n    [\"chapter.3\"] = \"暮萤林\",\n    [\"chapter.4\"] = \"寒雾湖\",\n    [\"chapter.5\"] = \"金辉谷\",\n    [\"chapter.6\"] = \"星枢圣所\",\n    [\"chapter.7\"] = \"星砂原\",\n    [\"chapter.8\"] = \"赤铜峡谷\",\n    [\"chapter.9\"] = \"碧涛海隅\",\n    [\"chip.best\"] = \"最佳 {n}\",\n    [\"chip.progress\"] = \"进度 {a}/{b}\",\n    [\"coach.chain\"] = \"3 颗同色灯珠连在一起就会熄灭\",\n    [\"coach.fire\"] = \"核炮已装填：点击轨道，射向同色灯珠\",\n    [\"coach.lock\"] = \"藤锁会挡住萤石：消掉它旁边的同色萤石即可解开\",\n    [\"coach.pulse\"] = \"消除会向轨道发射同色能量；轨道上有同色灯珠就击碎一段\",\n    [\"coach.swap\"] = \"拖动萤石和相邻的交换，连成 3 颗同色\",\n    [\"color.1\"] = \"琥珀\",\n    [\"color.2\"] = \"天蓝\",\n    [\"color.3\"] = \"绯红\",\n    [\"color.4\"] = \"紫晶\",\n    [\"color.5\"] = \"青柠\",\n    [\"credits.c.bg1\"] = \"萤林背景\",\n    [\"credits.c.bg2\"] = \"雾沼背景\",\n    [\"credits.c.bgTitle\"] = \"标题 / 地图背景\",\n    [\"credits.c.music\"] = \"音乐\",\n    [\"credits.c.orbs\"] = \"灯珠 / 萤石 / 特殊块\",\n    [\"credits.c.sfx\"] = \"音效\",\n    [\"credits.c.sparkle\"] = \"闪光动画\",\n    [\"credits.c.ui\"] = \"界面 / 图标 / 粒子 / 烟雾\",\n    [\"credits.cc0\"] = \"以下素材均采用 CC0（公共领域）协议，感谢各位作者\",\n    [\"credits.code\"] = \"程序代码为本作原创\",\n    [\"credits.title\"] = \"鸣谢 · Credits\",\n    [\"daily.board\"] = \"今日盘面唯一 · 每天的棋盘和灯珠顺序不变\",\n    [\"daily.dateWeek\"] = \"{date} · {wk}\",\n    [\"daily.doneToday\"] = \"今日冲刺已完成，明天 0 点刷新\",\n    [\"daily.localBest\"] = \"本地最佳\",\n    [\"daily.name\"] = \"每日 {date}\",\n    [\"daily.nextReset\"] = \"距下次刷新（北京时间 0 点）\",\n    [\"daily.notPlayed\"] = \"未开始\",\n    [\"daily.notStarted\"] = \"今日冲刺未开始\",\n    [\"daily.refresh\"] = \"距刷新 {t}\",\n    [\"daily.rule1\"] = \"每天 1 次正式冲刺：计入本地最佳与连续天数\",\n    [\"daily.rule2\"] = \"正式冲刺开始后，再玩算练习，不计分\",\n    [\"daily.rule3\"] = \"玩法按星期轮换：经典 → 步数 → 限时，每类 3 种变体\",\n    [\"daily.rule4\"] = \"每日冲刺不可携带开局道具\",\n    [\"daily.streak\"] = \"连续 {n} 天\",\n    [\"daily.streakLabel\"] = \"连续天数\",\n    [\"daily.tag\"] = \"今日玩法 · {goal}·{variant}\",\n    [\"daily.title\"] = \"每日冲刺\",\n    [\"daily.todayLost\"] = \"今日 · 未达成\",\n    [\"daily.todayScore\"] = \"今日成绩\",\n    [\"daily.todayWon\"] = \"今日 · 成功\",\n    [\"feedback.ammo\"] = \"无同色，转为弹药\",\n    [\"feedback.clear\"] = \"消除\",\n    [\"feedback.full\"] = \"弹药已满\",\n    [\"feedback.hit\"] = \"命中同色灯珠\",\n    [\"feedback.loaded\"] = \"装入弹药\",\n    [\"feedback.noTarget\"] = \"暂无同色灯珠\",\n    [\"feedback.pulse\"] = \"脉冲\",\n    [\"fmt.date\"] = \"{m} 月 {d} 日\",\n    [\"funnel.cleared\"] = \"已清零\",\n    [\"funnel.empty\"] = \"还没有其他事件\",\n    [\"funnel.export\"] = \"导出\",\n    [\"funnel.exportFailed\"] = \"导出失败\",\n    [\"funnel.exported\"] = \"已保存到 {file}\",\n    [\"funnel.fail\"] = \"失败\",\n    [\"funnel.level\"] = \"关卡\",\n    [\"funnel.note\"] = \"只在本机计数，不会发送到任何地方\",\n    [\"funnel.open\"] = \"本地统计（调试）\",\n    [\"funnel.quit\"] = \"退出\",\n    [\"funnel.reset\"] = \"清零\",\n    [\"funnel.start\"] = \"开始\",\n    [\"funnel.title\"] = \"本地统计\",\n    [\"funnel.win\"] = \"通关\",\n    [\"fx.arc\"] = \"清弧\",\n    [\"fx.cascade\"] = \"连消倍率 ×{x}\",\n    [\"fx.chain\"] = \"连锁 ×{n}\",\n    [\"fx.chainBlast\"] = \"连爆\",\n    [\"fx.colorClear\"] = \"清色\",\n    [\"fx.crossMeteor\"] = \"十字流星\",\n    [\"fx.danger\"] = \"危险！\",\n    [\"fx.doubleNova\"] = \"双同色核 · 全盘清除\",\n    [\"fx.doubleQuake\"] = \"双震核 5×5\",\n    [\"fx.fullBlast\"] = \"全盘核爆\",\n    [\"fx.giantMeteor\"] = \"巨型流星\",\n    [\"fx.levelBonus\"] = \"通关奖励 +{n}\",\n    [\"fx.meteor\"] = \"流星\",\n    [\"fx.meteorH\"] = \"流星→\",\n    [\"fx.meteorShower\"] = \"流星雨\",\n    [\"fx.meteorV\"] = \"流星↓\",\n    [\"fx.nova\"] = \"同色核\",\n    [\"fx.novaMeteor\"] = \"同色核+流星 ×{n}\",\n    [\"fx.novaQuake\"] = \"同色核+震核 ×{n}\",\n    [\"fx.quake\"] = \"震核\",\n    [\"fx.rescued\"] = \"续关！\",\n    [\"fx.reshuffle\"] = \"重排\",\n    [\"fx.rewind\"] = \"倒退！\",\n    [\"fx.rewindBead\"] = \"倒退珠\",\n    [\"fx.siltPush\"] = \"蜜层退潮\",\n    [\"fx.slow\"] = \"减速！\",\n    [\"fx.slowBead\"] = \"减速珠\",\n    [\"fx.stun\"] = \"震环！\",\n    [\"goal.bonusMoves\"] = \"剩余步数奖励\",\n    [\"goal.bonusTime\"] = \"剩余时间奖励\",\n    [\"goal.classic\"] = \"经典\",\n    [\"goal.endlessDesc\"] = \"灯珠越来越快，坚持越久分越高\",\n    [\"goal.quotaDesc\"] = \"熄灭全部 {n} 颗灯珠，别让它们抵达萤核\",\n    [\"goal.rushDesc\"] = \"{t} 秒夺分，灯珠不会耗尽\",\n    [\"goal.step\"] = \"步数\",\n    [\"goal.stepDesc\"] = \"{n} 步内：{list}\",\n    [\"goal.timed\"] = \"限时\",\n    [\"goal.timedDesc\"] = \"{t} 秒内收集 {e} 能量\",\n    [\"hall.endless\"] = \"无尽挑战\",\n    [\"hall.endlessDesc\"] = \"灯珠逐渐加速，熄灭越多、坚持越久，得分越高\",\n    [\"hall.noLeaderboard\"] = \"本版本暂无在线排行榜\",\n    [\"hall.note\"] = \"成绩只存本机 · 不可携带开局道具\",\n    [\"hall.rush\"] = \"限时夺分\",\n    [\"hall.rushDesc\"] = \"90 秒夺分，灯珠持续出现，连锁越长得分越高\",\n    [\"hall.tierBest\"] = \"{tier}最佳 {n}\",\n    [\"hall.tierLocked\"] = \"{tier} · 需经典第 {n} 关\",\n    [\"hall.tierLockedTip\"] = \"解锁：通关经典模式第 {n} 关\",\n    [\"hexpage.1\"] = \"蜂巢沉降 · 蜂房\",\n    [\"hexpage.2\"] = \"蜂巢沉降 · 蜜池\",\n    [\"hexpage.3\"] = \"蜂巢沉降 · 蜂王台\",\n    [\"hexpage.4\"] = \"蜂巢沉降 · 蜜河\",\n    [\"hexpage.5\"] = \"蜂巢沉降 · 幽巢\",\n    [\"hexpage.6\"] = \"蜂巢沉降 · 蜂王殿\",\n    [\"hint.lockedDaily\"] = \"通关经典模式第 {n} 关解锁每日冲刺\",\n    [\"hint.lockedHall\"] = \"通关经典模式第 {n} 关解锁挑战大厅\",\n    [\"hint.resumed\"] = \"已恢复上局\",\n    [\"hive.tip.short\"] = \"小提示：蜜层色每涨一格会换色，随时看核炮上方的色块\",\n    [\"hive.tut.1\"] = \"蜜层会从底部往上爬\",\n    [\"hive.tut.1s\"] = \"越往后的关卡，涨得越快\",\n    [\"hive.tut.2\"] = \"消除「蜜层色」才能压回去\",\n    [\"hive.tut.2s\"] = \"只压最深的几列，其它列不动\",\n    [\"hive.tut.3\"] = \"任一列被填满就失败\",\n    [\"hive.tut.3s\"] = \"注意核炮上方的蜜色标记\",\n    [\"hive.tut.title\"] = \"蜂巢沉降 · 玩法说明\",\n    [\"hud.badgeDaily\"] = \"日\",\n    [\"hud.badgeHall\"] = \"厅\",\n    [\"hud.beads\"] = \"灯珠 {n}\",\n    [\"hud.beadsLeft\"] = \"剩余灯珠\",\n    [\"hud.cannon\"] = \"核炮\",\n    [\"hud.chain\"] = \"连锁 ×{n}\",\n    [\"hud.classic\"] = \"经典\",\n    [\"hud.clearProgress\"] = \"清关进度\",\n    [\"hud.currentAmmo\"] = \"当前\",\n    [\"hud.daily\"] = \"每日冲刺\",\n    [\"hud.dailyPractice\"] = \"每日 · 练习\",\n    [\"hud.energy\"] = \"能量 {a}/{b}\",\n    [\"hud.fireArea\"] = \"点此区域发射核炮\",\n    [\"hud.hint\"] = \"交换萤石 · 点轨道发射核炮\",\n    [\"hud.hive\"] = \"蜂巢\",\n    [\"hud.hiveLevelN\"] = \"蜂巢 第 {n} 关\",\n    [\"hud.left\"] = \"剩余\",\n    [\"hud.levelN\"] = \"第 {n} 关\",\n    [\"hud.link\"] = \"连击 ×{x}\",\n    [\"hud.moves\"] = \"步数\",\n    [\"hud.rush\"] = \"夺分\",\n    [\"hud.score\"] = \"分数\",\n    [\"hud.siltColor\"] = \"蜜层色 · 最深 {n} 格\",\n    [\"hud.siltShort\"] = \"蜜 {n}\",\n    [\"hud.speed\"] = \"速度 +{p}%\",\n    [\"hud.survive\"] = \"坚持\",\n    [\"hud.swapAmmoHint\"] = \"核炮已装填；多颗时点弹槽或核炮换弹\",\n    [\"lang.self\"] = \"简体中文\",\n    [\"lose.beadsLeft\"] = \"还差 {n} 颗灯珠\",\n    [\"lose.noMoves\"] = \"步数用完了\",\n    [\"lose.reached\"] = \"灯珠抵达了萤核\",\n    [\"lose.score\"] = \"分数 {n}\",\n    [\"lose.short\"] = \"{label} 差 {n}\",\n    [\"lose.siltWhy\"] = \"第 {n} 列蜂巢被蜜层填满了\",\n    [\"lose.timeUp\"] = \"时间到！\",\n    [\"lose.timedWhy\"] = \"{why} · 能量 {a}/{b}\",\n    [\"lose.tip\"] = \"提示：断口两侧颜色相同时会自动吸合，并触发连锁\",\n    [\"lose.tipBooster\"] = \"提示：开局前可以选「倒退」或「减速」道具\",\n    [\"lose.tipCannon\"] = \"提示：消出流星，或能量在轨道上没找到同色灯珠，都会给核炮装弹\",\n    [\"lose.tipMoves\"] = \"提示：先消目标颜色，4 连以上能做出特殊萤石\",\n    [\"lose.tipPush\"] = \"提示：每走一步，灯珠链都会往前挪一点，优先消能打到链头附近的颜色\",\n    [\"lose.tipSilt\"] = \"提示：消掉蜜层当前的颜色，只会让最深的几列各退一格\",\n    [\"lose.tipTime\"] = \"提示：连续消除倍率更高，能量涨得更快\",\n    [\"lose.title\"] = \"萤核被吞没了\",\n    [\"lose.titleMoves\"] = \"步数用尽\",\n    [\"lose.titleSilt\"] = \"蜜层淹没了蜂巢\",\n    [\"lose.titleTime\"] = \"时间耗尽\",\n    [\"lv.c1\"] = \"萤火初醒\",\n    [\"lv.c10\"] = \"萤核长夜\",\n    [\"lv.c100\"] = \"深潮终章\",\n    [\"lv.c101\"] = \"雪原渡口\",\n    [\"lv.c102\"] = \"白桦道\",\n    [\"lv.c103\"] = \"冰棱阶\",\n    [\"lv.c104\"] = \"霜语林\",\n    [\"lv.c105\"] = \"寒星台\",\n    [\"lv.c106\"] = \"雾松桥\",\n    [\"lv.c107\"] = \"极光厅\",\n    [\"lv.c108\"] = \"雪鸮巢\",\n    [\"lv.c109\"] = \"冰川之眼\",\n    [\"lv.c11\"] = \"沼雾初开\",\n    [\"lv.c110\"] = \"极寒终章\",\n    [\"lv.c111\"] = \"紫晶廊\",\n    [\"lv.c112\"] = \"夜幕坡\",\n    [\"lv.c113\"] = \"星语涧\",\n    [\"lv.c114\"] = \"幻光厅\",\n    [\"lv.c115\"] = \"紫雾塔\",\n    [\"lv.c116\"] = \"幽歌台\",\n    [\"lv.c117\"] = \"月影桥\",\n    [\"lv.c118\"] = \"星屑庭院\",\n    [\"lv.c119\"] = \"紫罗兰核\",\n    [\"lv.c12\"] = \"芦苇湾\",\n    [\"lv.c120\"] = \"银月终章\",\n    [\"lv.c13\"] = \"萤溪\",\n    [\"lv.c14\"] = \"苔石径\",\n    [\"lv.c15\"] = \"沼心\",\n    [\"lv.c16\"] = \"鬼火塘\",\n    [\"lv.c17\"] = \"雾藤桥\",\n    [\"lv.c18\"] = \"沉灯湾\",\n    [\"lv.c19\"] = \"夜鹭巢\",\n    [\"lv.c2\"] = \"藤蔓压境\",\n    [\"lv.c20\"] = \"雾沼终夜\",\n    [\"lv.c21\"] = \"暮色林口\",\n    [\"lv.c22\"] = \"紫藤小径\",\n    [\"lv.c23\"] = \"萤灯驿\",\n    [\"lv.c24\"] = \"夜露坡\",\n    [\"lv.c25\"] = \"暮林之心\",\n    [\"lv.c26\"] = \"幽蓝石阶\",\n    [\"lv.c27\"] = \"落星台\",\n    [\"lv.c28\"] = \"萤羽岭\",\n    [\"lv.c29\"] = \"紫雾泉\",\n    [\"lv.c3\"] = \"暗潮\",\n    [\"lv.c30\"] = \"暮萤长歌\",\n    [\"lv.c31\"] = \"寒湖初雾\",\n    [\"lv.c32\"] = \"冰苇滩\",\n    [\"lv.c33\"] = \"霜灯桥\",\n    [\"lv.c34\"] = \"雾凇林\",\n    [\"lv.c35\"] = \"寒湖之眼\",\n    [\"lv.c36\"] = \"冷月湾\",\n    [\"lv.c37\"] = \"浮冰径\",\n    [\"lv.c38\"] = \"白鹭洲\",\n    [\"lv.c39\"] = \"霜雾港\",\n    [\"lv.c4\"] = \"核光\",\n    [\"lv.c40\"] = \"寒雾终章\",\n    [\"lv.c41\"] = \"金谷晨光\",\n    [\"lv.c42\"] = \"蜜穗田\",\n    [\"lv.c43\"] = \"琥珀溪\",\n    [\"lv.c44\"] = \"金藤坡\",\n    [\"lv.c45\"] = \"金辉守卫\",\n    [\"lv.c46\"] = \"晒谷场\",\n    [\"lv.c47\"] = \"流金瀑\",\n    [\"lv.c48\"] = \"麦浪丘\",\n    [\"lv.c49\"] = \"夕照崖\",\n    [\"lv.c5\"] = \"萤核之夜\",\n    [\"lv.c50\"] = \"金辉长夜\",\n    [\"lv.c51\"] = \"星门\",\n    [\"lv.c52\"] = \"星砂路\",\n    [\"lv.c53\"] = \"环光殿\",\n    [\"lv.c54\"] = \"星藤回廊\",\n    [\"lv.c55\"] = \"星枢守望\",\n    [\"lv.c56\"] = \"极光阶\",\n    [\"lv.c57\"] = \"流星井\",\n    [\"lv.c58\"] = \"星轨塔\",\n    [\"lv.c59\"] = \"核心前庭\",\n    [\"lv.c6\"] = \"流萤回廊\",\n    [\"lv.c60\"] = \"星枢终焉\",\n    [\"lv.c61\"] = \"星砂渡\",\n    [\"lv.c62\"] = \"环光回廊\",\n    [\"lv.c63\"] = \"碎星坡\",\n    [\"lv.c64\"] = \"萤河湾\",\n    [\"lv.c65\"] = \"星枢门\",\n    [\"lv.c66\"] = \"极夜台\",\n    [\"lv.c67\"] = \"流萤阶\",\n    [\"lv.c68\"] = \"穹顶灯海\",\n    [\"lv.c69\"] = \"星核庭院\",\n    [\"lv.c7\"] = \"雾林\",\n    [\"lv.c70\"] = \"终章门廊\",\n    [\"lv.c71\"] = \"晨雾渡口\",\n    [\"lv.c72\"] = \"青藤峡\",\n    [\"lv.c73\"] = \"银叶厅\",\n    [\"lv.c74\"] = \"微光湿地\",\n    [\"lv.c75\"] = \"云杉岭\",\n    [\"lv.c76\"] = \"鸟鸣台\",\n    [\"lv.c77\"] = \"林间灯塔\",\n    [\"lv.c78\"] = \"翡翠穹顶\",\n    [\"lv.c79\"] = \"萤潮之眼\",\n    [\"lv.c8\"] = \"藤海\",\n    [\"lv.c80\"] = \"苍翠终章\",\n    [\"lv.c81\"] = \"赤铜谷\",\n    [\"lv.c82\"] = \"熔金阶\",\n    [\"lv.c83\"] = \"砂瀑岩\",\n    [\"lv.c84\"] = \"焰心湖\",\n    [\"lv.c85\"] = \"铁萼关\",\n    [\"lv.c86\"] = \"余烬桥\",\n    [\"lv.c87\"] = \"熔星台\",\n    [\"lv.c88\"] = \"琥珀深渊\",\n    [\"lv.c89\"] = \"红光回廊\",\n    [\"lv.c9\"] = \"星坠\",\n    [\"lv.c90\"] = \"赤金终焉\",\n    [\"lv.c91\"] = \"碧涛滩\",\n    [\"lv.c92\"] = \"珊瑚礁阶\",\n    [\"lv.c93\"] = \"深潮涧\",\n    [\"lv.c94\"] = \"潮汐厅\",\n    [\"lv.c95\"] = \"潮心灯塔\",\n    [\"lv.c96\"] = \"珠贝小径\",\n    [\"lv.c97\"] = \"海雾塔\",\n    [\"lv.c98\"] = \"苍蓝穹顶\",\n    [\"lv.c99\"] = \"碧波核\",\n    [\"lv.h1\"] = \"蜂房初启\",\n    [\"lv.h10\"] = \"蜂后卫队\",\n    [\"lv.h11\"] = \"蜜糖瀑\",\n    [\"lv.h12\"] = \"六角迷城\",\n    [\"lv.h13\"] = \"琥珀蜂室\",\n    [\"lv.h14\"] = \"金蜡阶\",\n    [\"lv.h15\"] = \"蜂王台之怒\",\n    [\"lv.h16\"] = \"夜巢\",\n    [\"lv.h17\"] = \"蜜池源\",\n    [\"lv.h18\"] = \"蜂王台终章\",\n    [\"lv.h19\"] = \"蜜池浅滩\",\n    [\"lv.h2\"] = \"蜜廊\",\n    [\"lv.h20\"] = \"蜂蜡回廊\",\n    [\"lv.h21\"] = \"金巢阶\",\n    [\"lv.h22\"] = \"蜂王前庭\",\n    [\"lv.h23\"] = \"蜜河湾\",\n    [\"lv.h24\"] = \"蜂语塔\",\n    [\"lv.h25\"] = \"蜡光厅\",\n    [\"lv.h26\"] = \"巢心深渊\",\n    [\"lv.h27\"] = \"蜜露庭院\",\n    [\"lv.h28\"] = \"蜂巢穹顶\",\n    [\"lv.h29\"] = \"金蜜之核\",\n    [\"lv.h3\"] = \"沉降\",\n    [\"lv.h30\"] = \"幽巢终章\",\n    [\"lv.h31\"] = \"蜂王座阶\",\n    [\"lv.h32\"] = \"蜜光回廊\",\n    [\"lv.h33\"] = \"巢影厅\",\n    [\"lv.h34\"] = \"琥珀蜂道\",\n    [\"lv.h35\"] = \"蜜语深巢\",\n    [\"lv.h36\"] = \"蜂王终殿\",\n    [\"lv.h4\"] = \"蜂蜡迷宫\",\n    [\"lv.h5\"] = \"蜂王台守卫\",\n    [\"lv.h6\"] = \"蜜池长夜\",\n    [\"lv.h7\"] = \"蜂巢晨曦\",\n    [\"lv.h8\"] = \"花粉廊\",\n    [\"lv.h9\"] = \"蜡封门\",\n    [\"map.chapterPage\"] = \"章节 {a} / {b}\",\n    [\"map.chapterTitle\"] = \"第 {n} 章 · {name}\",\n    [\"map.hexPage\"] = \"第 {a} / {b} 页\",\n    [\"map.locked\"] = \"未解锁\",\n    [\"map.notCleared\"] = \"未通关\",\n    [\"mode.classic\"] = \"经典模式\",\n    [\"mode.classic.desc\"] = \"三消充能 · 守护萤核 · {n} 关\",\n    [\"mode.hall\"] = \"挑战大厅\",\n    [\"mode.hall.desc\"] = \"无尽 · 夺分 · 各 3 档难度\",\n    [\"mode.hex\"] = \"蜂巢沉降\",\n    [\"mode.hex.desc\"] = \"六角三消 · 蜜层上涨 · {n} 关\",\n    [\"modesel.note\"] = \"点卡片选中，再点一次进入\",\n    [\"modesel.title\"] = \"选择玩法\",\n    [\"pause.info\"] = \"第 {n} 关 · 分数 {s}\",\n    [\"pause.infoDaily\"] = \"每日冲刺 · 分数 {s}\",\n    [\"pause.infoHex\"] = \"蜂巢 第 {n} 关 · 分数 {s}\",\n    [\"pause.infoRun\"] = \"{mode} · 分数 {s}\",\n    [\"pause.music\"] = \"音乐 {p}%\",\n    [\"pause.sfx\"] = \"音效 {p}%\",\n    [\"pause.title\"] = \"暂停\",\n    [\"peek.howto\"] = \"解锁后点关卡开始\",\n    [\"peek.title\"] = \"第 {n} 关 · {name}\",\n    [\"peek.titleHex\"] = \"蜂巢 · 第 {n} 关 · {name}\",\n    [\"peek.unlock\"] = \"通关经典模式第 {n} 关后解锁\",\n    [\"peek.unlockHex\"] = \"通关蜂巢第 {n} 关后解锁\",\n    [\"preview.label\"] = \"预览：直接三消 · 可能命中\",\n    [\"progress.chain\"] = \"本关最大连锁：{a} → {b}\",\n    [\"progress.chapter\"] = \"第 {n} 章已完成\",\n    [\"progress.page\"] = \"蜂巢第 {n} 页已完成\",\n    [\"progress.stars\"] = \"星级提高：{a} → {b}\",\n    [\"pw.reverse\"] = \"倒退\",\n    [\"pw.slow\"] = \"减速\",\n    [\"pw.stun\"] = \"震环\",\n    [\"ready.best\"] = \"最佳 {n}\",\n    [\"ready.boosterLine\"] = \"{name}：{desc}\",\n    [\"ready.boosters\"] = \"带入道具\",\n    [\"ready.goalEndless\"] = \"无尽挑战：坚持越久越好\",\n    [\"ready.goalQuota\"] = \"目标：熄灭 {n} 颗灯珠\",\n    [\"ready.goalRush\"] = \"限时夺分：{t} 秒内尽量多得分\",\n    [\"ready.goalSteps\"] = \"目标：{n} 步内完成下列任务\",\n    [\"ready.goalTimed\"] = \"目标：{t} 秒内收集 {e} 能量\",\n    [\"ready.practice\"] = \"练习 · {name}\",\n    [\"ready.slowBeads\"] = \"（灯珠缓行）\",\n    [\"ready.starClassic\"] = \"灯珠链链头离萤核越远，星越多\",\n    [\"ready.starLocal\"] = \"成绩记入本地最佳\",\n    [\"ready.starSteps\"] = \"剩余步数越多，星越多\",\n    [\"ready.starTimed\"] = \"剩余时间越多，星越多\",\n    [\"ready.stock\"] = \"首次通关各道具库存 +1（最多 9）\",\n    [\"ready.subClassic\"] = \"别让灯珠链抵达萤核\",\n    [\"ready.subHex\"] = \"同色 3 个连成一线即消除，并向轨道发射\",\n    [\"ready.subTimed\"] = \"得分即能量 · 灯珠持续出现\",\n    [\"roundBoost.active\"] = \"{s}秒\",\n    [\"roundBoost.entering\"] = \"入场中\",\n    [\"roundBoost.notCarried\"] = \"未带入\",\n    [\"roundBoost.prepaid\"] = \"已扣库存\",\n    [\"roundBoost.rule\"] = \"倒退、减速每局各 1 次，未用不扣\",\n    [\"roundBoost.stock\"] = \"库存 {n}\",\n    [\"roundBoost.used\"] = \"已用\",\n    [\"run.dailyLost\"] = \"每日冲刺未达成\",\n    [\"run.dailyMissed\"] = \"今日目标未完成\",\n    [\"run.dailyWon\"] = \"每日冲刺成功！\",\n    [\"run.endlessLine\"] = \"最高速度 +{p}% · 熄灭灯珠 {b}\",\n    [\"run.endlessOver\"] = \"无尽挑战结束\",\n    [\"run.localBest\"] = \"本地最佳 {n}\",\n    [\"run.localRecord\"] = \"本地新纪录！\",\n    [\"run.practice\"] = \"练习局 · 不计入成绩\",\n    [\"run.recorded\"] = \"正式成绩已记录 · 连续 {n} 天\",\n    [\"run.rushLine\"] = \"熄灭灯珠 {b} · 最大连锁 ×{c}\",\n    [\"run.rushTime\"] = \"夺分 {t}\",\n    [\"run.stats\"] = \"特殊萤石 {s} · 合体 {m} · 核炮 {c}\",\n    [\"run.survived\"] = \"坚持 {t}\",\n    [\"run.timeUp\"] = \"时间到！\",\n    [\"sep.dot\"] = \" · \",\n    [\"set.diff.easy\"] = \"容易\",\n    [\"set.diff.hard\"] = \"困难\",\n    [\"set.diff.normal\"] = \"普通\",\n    [\"set.diffHint\"] = \"难度越高，灯珠链移动越快\",\n    [\"set.difficulty\"] = \"难度\",\n    [\"set.language\"] = \"语言\",\n    [\"set.music\"] = \"音乐\",\n    [\"set.off\"] = \"已关闭\",\n    [\"set.on\"] = \"已开启\",\n    [\"set.sfx\"] = \"音效\",\n    [\"set.shake\"] = \"画面与手机震动\",\n    [\"set.shakeDesc\"] = \"画面震动与设备触感\",\n    [\"set.title\"] = \"设置\",\n    [\"tag.blitz\"] = \"夺分\",\n    [\"tag.cleared\"] = \"已通关 ✓\",\n    [\"tag.endless\"] = \"无尽\",\n    [\"tag.recommended\"] = \"推荐\",\n    [\"tag.step\"] = \"步数\",\n    [\"tag.timed\"] = \"限时\",\n    [\"target.beads\"] = \"熄灭灯珠\",\n    [\"target.color\"] = \"消除{color}\",\n    [\"target.item\"] = \"{label} {n}\",\n    [\"target.unlock\"] = \"解开藤锁\",\n    [\"tier.1\"] = \"入门\",\n    [\"tier.2\"] = \"进阶\",\n    [\"tier.3\"] = \"大师\",\n    [\"title.logo\"] = \"萤核双消\",\n    [\"title.sub\"] = \"萤核森林\",\n    [\"title.tagline\"] = \"三消充能 · 守护萤核\",\n    [\"toast.arena\"] = \"新玩法解锁：挑战大厅\",\n    [\"toast.arena.modesel\"] = \"入口已在下方开放\",\n    [\"toast.arena.other\"] = \"开始冒险 → 选择玩法 → 挑战大厅\",\n    [\"toast.arena.title\"] = \"在「开始冒险」里选择「挑战大厅」\",\n    [\"toast.arena2\"] = \"挑战大厅：进阶难度解锁\",\n    [\"toast.arena3\"] = \"挑战大厅：大师难度解锁\",\n    [\"toast.daily\"] = \"新玩法解锁：每日冲刺\",\n    [\"toast.daily.other\"] = \"回标题页进入\",\n    [\"toast.daily.title\"] = \"点下方「每日冲刺」进入\",\n    [\"toast.tier.arena\"] = \"在卡片上切换到「{tier}」\",\n    [\"toast.tier.other\"] = \"挑战大厅的两种玩法都可选择「{tier}」\",\n    [\"unlock.atLevel\"] = \"通关经典模式第 {n} 关解锁\",\n    [\"variant.classic.1\"] = \"标准\",\n    [\"variant.classic.2\"] = \"快链\",\n    [\"variant.classic.3\"] = \"长链\",\n    [\"variant.step.1\"] = \"均衡\",\n    [\"variant.step.2\"] = \"藤锁\",\n    [\"variant.step.3\"] = \"双色\",\n    [\"variant.timed.1\"] = \"标准\",\n    [\"variant.timed.2\"] = \"速攻\",\n    [\"variant.timed.3\"] = \"耐力\",\n    [\"week.1\"] = \"周日\",\n    [\"week.2\"] = \"周一\",\n    [\"week.3\"] = \"周二\",\n    [\"week.4\"] = \"周三\",\n    [\"week.5\"] = \"周四\",\n    [\"week.6\"] = \"周五\",\n    [\"week.7\"] = \"周六\",\n    [\"win.allChapter\"] = \"本章全部通关！\",\n    [\"win.allHive\"] = \"蜂巢全部通关！\",\n    [\"win.clear\"] = \"通关！\",\n    [\"win.newRecord\"] = \"新纪录！\",\n    [\"win.stats1\"] = \"最大连锁 ×{c} · 特殊萤石 {s} · 合体 {m}\",\n    [\"win.stats2\"] = \"核炮 {c} · 能力 {p} · 最高连击 ×{l}\",\n}\n"],["ringcore/locale/zh_hant.lua","\n\nreturn {\n    [\"ad.boosterBtn\"] = \"+1 {name}\",\n    [\"ad.boosterFree\"] = \"領 +1 {name}\",\n    [\"ad.closed\"] = \"需要完整觀看才能獲得獎勵\",\n    [\"ad.continueBtn\"] = \"看廣告 · 原地續關\",\n    [\"ad.cooldown\"] = \"廣告冷卻中，{s} 秒後可再看\",\n    [\"ad.cooldownShort\"] = \"{s} 秒後可看\",\n    [\"ad.dailyCap\"] = \"今日廣告次數已用完，明天再來\",\n    [\"ad.dailyCapShort\"] = \"今日廣告已用完\",\n    [\"ad.doubleBtn\"] = \"看廣告 · 三種道具各 +1\",\n    [\"ad.doubleDone\"] = \"道具再 +1：已領取\",\n    [\"ad.loading\"] = \"載入中…\",\n    [\"ad.multi\"] = \"看 {n} 個廣告\",\n    [\"ad.noReward\"] = \"這次沒有獲得獎勵，請稍後再試\",\n    [\"ad.practiceBtn\"] = \"看廣告 · 再練一局\",\n    [\"ad.practiceNone\"] = \"今天的練習次數已用完\",\n    [\"ad.retryAvailable\"] = \"廣告暫未就緒，未獲得獎勵，次數未扣可再試\",\n    [\"app.window\"] = \"螢核雙消\",\n    [\"arena.blitz\"] = \"限時奪分 · {tier}\",\n    [\"arena.endless\"] = \"無盡挑戰 · {tier}\",\n    [\"booster.adLeft\"] = \"廣告補充 本關 {a}/{b} · 今日 {c}/{d}\",\n    [\"booster.btn\"] = \"{name} ×{n}\",\n    [\"booster.hint\"] = \"提示\",\n    [\"booster.hint.desc\"] = \"開局自動標出一步推薦走法\",\n    [\"booster.pickLimit\"] = \"本關已選 {a}/{b}\",\n    [\"booster.reverse\"] = \"倒退\",\n    [\"booster.reverse.desc\"] = \"局內點按使用，讓珠鏈倒退 2 秒\",\n    [\"booster.slow\"] = \"減速\",\n    [\"booster.slow.desc\"] = \"局內點按使用，速度降到 55%，持續 4 秒\",\n    [\"btn.back\"] = \"返回\",\n    [\"btn.credits\"] = \"鳴謝\",\n    [\"btn.done\"] = \"完成\",\n    [\"btn.enter\"] = \"進入\",\n    [\"btn.gotIt\"] = \"知道了\",\n    [\"btn.hall\"] = \"大廳\",\n    [\"btn.home\"] = \"返回主介面\",\n    [\"btn.levelMap\"] = \"關卡地圖\",\n    [\"btn.map\"] = \"地圖\",\n    [\"btn.modes\"] = \"選擇模式\",\n    [\"btn.next\"] = \"下一關\",\n    [\"btn.nextCh\"] = \"下一章\",\n    [\"btn.nextPage\"] = \"下一頁\",\n    [\"btn.off\"] = \"關\",\n    [\"btn.on\"] = \"開\",\n    [\"btn.play\"] = \"開始冒險\",\n    [\"btn.playAgain\"] = \"再來一局\",\n    [\"btn.practice\"] = \"練習一局\",\n    [\"btn.prevCh\"] = \"上一章\",\n    [\"btn.prevPage\"] = \"上一頁\",\n    [\"btn.quickClassic\"] = \"經典 · 第 {n} 關\",\n    [\"btn.restart\"] = \"重新開始\",\n    [\"btn.resume\"] = \"繼續\",\n    [\"btn.resumeRun\"] = \"繼續上局\",\n    [\"btn.retry\"] = \"再試一次\",\n    [\"btn.shakeOff\"] = \"震動：關\",\n    [\"btn.shakeOn\"] = \"震動：開\",\n    [\"btn.start\"] = \"開始\",\n    [\"btn.startChallenge\"] = \"開始衝刺\",\n    [\"btn.toMap\"] = \"回到地圖\",\n    [\"bubble.arena\"] = \"挑戰大廳已解鎖，在「開始冒險」裡選擇\",\n    [\"bubble.arena2\"] = \"挑戰大廳「進階」已解鎖\",\n    [\"bubble.arena3\"] = \"挑戰大廳「大師」已解鎖\",\n    [\"bubble.daily\"] = \"新玩法已解鎖\",\n    [\"cal.m1\"] = \"1月\",\n    [\"cal.m10\"] = \"10月\",\n    [\"cal.m11\"] = \"11月\",\n    [\"cal.m12\"] = \"12月\",\n    [\"cal.m2\"] = \"2月\",\n    [\"cal.m3\"] = \"3月\",\n    [\"cal.m4\"] = \"4月\",\n    [\"cal.m5\"] = \"5月\",\n    [\"cal.m6\"] = \"6月\",\n    [\"cal.m7\"] = \"7月\",\n    [\"cal.m8\"] = \"8月\",\n    [\"cal.m9\"] = \"9月\",\n    [\"chapter.1\"] = \"螢林\",\n    [\"chapter.10\"] = \"雪原\",\n    [\"chapter.11\"] = \"紫晶林\",\n    [\"chapter.12\"] = \"輝核原野\",\n    [\"chapter.2\"] = \"霧沼\",\n    [\"chapter.3\"] = \"暮螢林\",\n    [\"chapter.4\"] = \"寒霧湖\",\n    [\"chapter.5\"] = \"金輝谷\",\n    [\"chapter.6\"] = \"星樞聖所\",\n    [\"chapter.7\"] = \"星砂原\",\n    [\"chapter.8\"] = \"赤銅峽谷\",\n    [\"chapter.9\"] = \"碧濤海隅\",\n    [\"chip.best\"] = \"最佳 {n}\",\n    [\"chip.progress\"] = \"進度 {a}/{b}\",\n    [\"coach.chain\"] = \"3 顆同色燈珠連在一起就會熄滅\",\n    [\"coach.fire\"] = \"核炮已裝填：點選軌道，射向同色燈珠\",\n    [\"coach.lock\"] = \"藤鎖會擋住螢石：消掉它旁邊的同色螢石即可解開\",\n    [\"coach.pulse\"] = \"消除會向軌道發射同色能量；軌道上有同色燈珠就擊碎一段\",\n    [\"coach.swap\"] = \"拖動螢石和相鄰的交換，連成 3 顆同色\",\n    [\"color.1\"] = \"琥珀\",\n    [\"color.2\"] = \"天藍\",\n    [\"color.3\"] = \"緋紅\",\n    [\"color.4\"] = \"紫晶\",\n    [\"color.5\"] = \"青檸\",\n    [\"credits.c.bg1\"] = \"螢林背景\",\n    [\"credits.c.bg2\"] = \"霧沼背景\",\n    [\"credits.c.bgTitle\"] = \"標題 / 地圖背景\",\n    [\"credits.c.music\"] = \"音樂\",\n    [\"credits.c.orbs\"] = \"燈珠 / 螢石 / 特殊塊\",\n    [\"credits.c.sfx\"] = \"音效\",\n    [\"credits.c.sparkle\"] = \"閃光動畫\",\n    [\"credits.c.ui\"] = \"介面 / 圖示 / 粒子 / 煙霧\",\n    [\"credits.cc0\"] = \"以下素材均採用 CC0（公共領域）協議，感謝各位作者\",\n    [\"credits.code\"] = \"程式碼為本作原創\",\n    [\"credits.title\"] = \"鳴謝 · Credits\",\n    [\"daily.board\"] = \"今日盤面唯一 · 每天的棋盤和燈珠順序不變\",\n    [\"daily.dateWeek\"] = \"{date} · {wk}\",\n    [\"daily.doneToday\"] = \"今日衝刺已完成，明天 0 點更新\",\n    [\"daily.localBest\"] = \"本地最佳\",\n    [\"daily.name\"] = \"每日 {date}\",\n    [\"daily.nextReset\"] = \"距下次更新（UTC+8 0 點）\",\n    [\"daily.notPlayed\"] = \"未開始\",\n    [\"daily.notStarted\"] = \"今日衝刺未開始\",\n    [\"daily.refresh\"] = \"距更新 {t}\",\n    [\"daily.rule1\"] = \"每天 1 次正式衝刺：計入本地最佳與連續天數\",\n    [\"daily.rule2\"] = \"正式衝刺開始後，再玩算練習，不計分\",\n    [\"daily.rule3\"] = \"玩法按星期輪換：經典 → 步數 → 限時，每類 3 種變體\",\n    [\"daily.rule4\"] = \"每日衝刺不可攜帶開局道具\",\n    [\"daily.streak\"] = \"連續 {n} 天\",\n    [\"daily.streakLabel\"] = \"連續天數\",\n    [\"daily.tag\"] = \"今日玩法 · {goal}·{variant}\",\n    [\"daily.title\"] = \"每日衝刺\",\n    [\"daily.todayLost\"] = \"今日 · 未達成\",\n    [\"daily.todayScore\"] = \"今日成績\",\n    [\"daily.todayWon\"] = \"今日 · 成功\",\n    [\"feedback.ammo\"] = \"無同色，轉為彈藥\",\n    [\"feedback.clear\"] = \"消除\",\n    [\"feedback.full\"] = \"彈藥已滿\",\n    [\"feedback.hit\"] = \"命中同色燈珠\",\n    [\"feedback.loaded\"] = \"裝入彈藥\",\n    [\"feedback.noTarget\"] = \"暫無同色燈珠\",\n    [\"feedback.pulse\"] = \"脈衝\",\n    [\"fmt.date\"] = \"{m} 月 {d} 日\",\n    [\"funnel.cleared\"] = \"已清零\",\n    [\"funnel.empty\"] = \"還沒有其他事件\",\n    [\"funnel.export\"] = \"匯出\",\n    [\"funnel.exportFailed\"] = \"匯出失敗\",\n    [\"funnel.exported\"] = \"已儲存到 {file}\",\n    [\"funnel.fail\"] = \"失敗\",\n    [\"funnel.level\"] = \"關卡\",\n    [\"funnel.note\"] = \"只在本機計數，不會發送到任何地方\",\n    [\"funnel.open\"] = \"本地統計（除錯）\",\n    [\"funnel.quit\"] = \"退出\",\n    [\"funnel.reset\"] = \"清零\",\n    [\"funnel.start\"] = \"開始\",\n    [\"funnel.title\"] = \"本地統計\",\n    [\"funnel.win\"] = \"通關\",\n    [\"fx.arc\"] = \"清弧\",\n    [\"fx.cascade\"] = \"連消倍率 ×{x}\",\n    [\"fx.chain\"] = \"連鎖 ×{n}\",\n    [\"fx.chainBlast\"] = \"連爆\",\n    [\"fx.colorClear\"] = \"清色\",\n    [\"fx.crossMeteor\"] = \"十字流星\",\n    [\"fx.danger\"] = \"危險！\",\n    [\"fx.doubleNova\"] = \"雙同色核 · 全盤清除\",\n    [\"fx.doubleQuake\"] = \"雙震核 5×5\",\n    [\"fx.fullBlast\"] = \"全盤核爆\",\n    [\"fx.giantMeteor\"] = \"巨型流星\",\n    [\"fx.levelBonus\"] = \"通關獎勵 +{n}\",\n    [\"fx.meteor\"] = \"流星\",\n    [\"fx.meteorH\"] = \"流星→\",\n    [\"fx.meteorShower\"] = \"流星雨\",\n    [\"fx.meteorV\"] = \"流星↓\",\n    [\"fx.nova\"] = \"同色核\",\n    [\"fx.novaMeteor\"] = \"同色核+流星 ×{n}\",\n    [\"fx.novaQuake\"] = \"同色核+震核 ×{n}\",\n    [\"fx.quake\"] = \"震核\",\n    [\"fx.rescued\"] = \"續關！\",\n    [\"fx.reshuffle\"] = \"重排\",\n    [\"fx.rewind\"] = \"倒退！\",\n    [\"fx.rewindBead\"] = \"倒退珠\",\n    [\"fx.siltPush\"] = \"蜜層退潮\",\n    [\"fx.slow\"] = \"減速！\",\n    [\"fx.slowBead\"] = \"減速珠\",\n    [\"fx.stun\"] = \"震環！\",\n    [\"goal.bonusMoves\"] = \"剩餘步數獎勵\",\n    [\"goal.bonusTime\"] = \"剩餘時間獎勵\",\n    [\"goal.classic\"] = \"經典\",\n    [\"goal.endlessDesc\"] = \"燈珠越來越快，堅持越久分越高\",\n    [\"goal.quotaDesc\"] = \"熄滅全部 {n} 顆燈珠，別讓它們抵達螢核\",\n    [\"goal.rushDesc\"] = \"{t} 秒奪分，燈珠不會耗盡\",\n    [\"goal.step\"] = \"步數\",\n    [\"goal.stepDesc\"] = \"{n} 步內：{list}\",\n    [\"goal.timed\"] = \"限時\",\n    [\"goal.timedDesc\"] = \"{t} 秒內收集 {e} 能量\",\n    [\"hall.endless\"] = \"無盡挑戰\",\n    [\"hall.endlessDesc\"] = \"燈珠逐漸加速，熄滅越多、堅持越久，得分越高\",\n    [\"hall.noLeaderboard\"] = \"本版本暫無線上排行榜\",\n    [\"hall.note\"] = \"成績只存本機 · 不可攜帶開局道具\",\n    [\"hall.rush\"] = \"限時奪分\",\n    [\"hall.rushDesc\"] = \"90 秒奪分，燈珠持續出現，連鎖越長得分越高\",\n    [\"hall.tierBest\"] = \"{tier}最佳 {n}\",\n    [\"hall.tierLocked\"] = \"{tier} · 需經典第 {n} 關\",\n    [\"hall.tierLockedTip\"] = \"解鎖：通關經典模式第 {n} 關\",\n    [\"hexpage.1\"] = \"蜂巢沉降 · 蜂房\",\n    [\"hexpage.2\"] = \"蜂巢沉降 · 蜜池\",\n    [\"hexpage.3\"] = \"蜂巢沉降 · 蜂王台\",\n    [\"hexpage.4\"] = \"蜂巢沉降 · 蜜河\",\n    [\"hexpage.5\"] = \"蜂巢沉降 · 幽巢\",\n    [\"hexpage.6\"] = \"蜂巢沉降 · 蜂王殿\",\n    [\"hint.lockedDaily\"] = \"通關經典模式第 {n} 關解鎖每日衝刺\",\n    [\"hint.lockedHall\"] = \"通關經典模式第 {n} 關解鎖挑戰大廳\",\n    [\"hint.resumed\"] = \"已恢復上局\",\n    [\"hive.tip.short\"] = \"小提示：蜜層色每漲一格會換色，隨時看核砲上方的色塊\",\n    [\"hive.tut.1\"] = \"蜜層會從底部往上爬\",\n    [\"hive.tut.1s\"] = \"越往後的關卡，漲得越快\",\n    [\"hive.tut.2\"] = \"消除「蜜層色」才能壓回去\",\n    [\"hive.tut.2s\"] = \"只壓最深的幾列，其它列不動\",\n    [\"hive.tut.3\"] = \"任一列被填滿就失敗\",\n    [\"hive.tut.3s\"] = \"注意核炮上方的蜜色標記\",\n    [\"hive.tut.title\"] = \"蜂巢沉降 · 玩法說明\",\n    [\"hud.badgeDaily\"] = \"日\",\n    [\"hud.badgeHall\"] = \"廳\",\n    [\"hud.beads\"] = \"燈珠 {n}\",\n    [\"hud.beadsLeft\"] = \"剩餘燈珠\",\n    [\"hud.cannon\"] = \"核炮\",\n    [\"hud.chain\"] = \"連鎖 ×{n}\",\n    [\"hud.classic\"] = \"經典\",\n    [\"hud.clearProgress\"] = \"通關進度\",\n    [\"hud.currentAmmo\"] = \"目前\",\n    [\"hud.daily\"] = \"每日衝刺\",\n    [\"hud.dailyPractice\"] = \"每日 · 練習\",\n    [\"hud.energy\"] = \"能量 {a}/{b}\",\n    [\"hud.fireArea\"] = \"點此區域發射核炮\",\n    [\"hud.hint\"] = \"交換螢石 · 點軌道發射核炮\",\n    [\"hud.hive\"] = \"蜂巢\",\n    [\"hud.hiveLevelN\"] = \"蜂巢 第 {n} 關\",\n    [\"hud.left\"] = \"剩餘\",\n    [\"hud.levelN\"] = \"第 {n} 關\",\n    [\"hud.link\"] = \"連擊 ×{x}\",\n    [\"hud.moves\"] = \"步數\",\n    [\"hud.rush\"] = \"奪分\",\n    [\"hud.score\"] = \"分數\",\n    [\"hud.siltColor\"] = \"蜜層色 · 最深 {n} 格\",\n    [\"hud.siltShort\"] = \"蜜 {n}\",\n    [\"hud.speed\"] = \"速度 +{p}%\",\n    [\"hud.survive\"] = \"堅持\",\n    [\"hud.swapAmmoHint\"] = \"核炮已裝填；多顆時點彈槽或核炮換彈\",\n    [\"lang.self\"] = \"繁體中文\",\n    [\"lose.beadsLeft\"] = \"還差 {n} 顆燈珠\",\n    [\"lose.noMoves\"] = \"步數用完了\",\n    [\"lose.reached\"] = \"燈珠抵達了螢核\",\n    [\"lose.score\"] = \"分數 {n}\",\n    [\"lose.short\"] = \"{label} 差 {n}\",\n    [\"lose.siltWhy\"] = \"第 {n} 列蜂巢被蜜層填滿了\",\n    [\"lose.timeUp\"] = \"時間到！\",\n    [\"lose.timedWhy\"] = \"{why} · 能量 {a}/{b}\",\n    [\"lose.tip\"] = \"提示：斷口兩側顏色相同時會自動吸合，並觸發連鎖\",\n    [\"lose.tipBooster\"] = \"提示：開局前可以選「倒退」或「減速」道具\",\n    [\"lose.tipCannon\"] = \"提示：消出流星，或能量在軌道上沒找到同色燈珠，都會給核炮裝彈\",\n    [\"lose.tipMoves\"] = \"提示：先消目標顏色，4 連以上能做出特殊螢石\",\n    [\"lose.tipPush\"] = \"提示：每走一步，燈珠鏈都會往前挪一點，優先消能打到鏈頭附近的顏色\",\n    [\"lose.tipSilt\"] = \"提示：消掉蜜層當前的顏色，只會讓最深的幾列各退一格\",\n    [\"lose.tipTime\"] = \"提示：連續消除倍率更高，能量漲得更快\",\n    [\"lose.title\"] = \"螢核被吞沒了\",\n    [\"lose.titleMoves\"] = \"步數用盡\",\n    [\"lose.titleSilt\"] = \"蜜層淹沒了蜂巢\",\n    [\"lose.titleTime\"] = \"時間耗盡\",\n    [\"lv.c1\"] = \"螢火初醒\",\n    [\"lv.c10\"] = \"螢核長夜\",\n    [\"lv.c100\"] = \"深潮終章\",\n    [\"lv.c101\"] = \"雪原渡口\",\n    [\"lv.c102\"] = \"白樺道\",\n    [\"lv.c103\"] = \"冰稜階\",\n    [\"lv.c104\"] = \"霜語林\",\n    [\"lv.c105\"] = \"寒星台\",\n    [\"lv.c106\"] = \"霧松橋\",\n    [\"lv.c107\"] = \"極光廳\",\n    [\"lv.c108\"] = \"雪鴞巢\",\n    [\"lv.c109\"] = \"冰川之眼\",\n    [\"lv.c11\"] = \"沼霧初開\",\n    [\"lv.c110\"] = \"極寒終章\",\n    [\"lv.c111\"] = \"紫晶廊\",\n    [\"lv.c112\"] = \"夜幕坡\",\n    [\"lv.c113\"] = \"星語澗\",\n    [\"lv.c114\"] = \"幻光廳\",\n    [\"lv.c115\"] = \"紫霧塔\",\n    [\"lv.c116\"] = \"幽歌台\",\n    [\"lv.c117\"] = \"月影橋\",\n    [\"lv.c118\"] = \"星屑庭院\",\n    [\"lv.c119\"] = \"紫羅蘭核\",\n    [\"lv.c12\"] = \"蘆葦灣\",\n    [\"lv.c120\"] = \"銀月終章\",\n    [\"lv.c13\"] = \"螢溪\",\n    [\"lv.c14\"] = \"苔石徑\",\n    [\"lv.c15\"] = \"沼心\",\n    [\"lv.c16\"] = \"鬼火塘\",\n    [\"lv.c17\"] = \"霧藤橋\",\n    [\"lv.c18\"] = \"沉燈灣\",\n    [\"lv.c19\"] = \"夜鷺巢\",\n    [\"lv.c2\"] = \"藤蔓壓境\",\n    [\"lv.c20\"] = \"霧沼終夜\",\n    [\"lv.c21\"] = \"暮色林口\",\n    [\"lv.c22\"] = \"紫藤小徑\",\n    [\"lv.c23\"] = \"螢燈驛\",\n    [\"lv.c24\"] = \"夜露坡\",\n    [\"lv.c25\"] = \"暮林之心\",\n    [\"lv.c26\"] = \"幽藍石階\",\n    [\"lv.c27\"] = \"落星台\",\n    [\"lv.c28\"] = \"螢羽嶺\",\n    [\"lv.c29\"] = \"紫霧泉\",\n    [\"lv.c3\"] = \"暗潮\",\n    [\"lv.c30\"] = \"暮螢長歌\",\n    [\"lv.c31\"] = \"寒湖初霧\",\n    [\"lv.c32\"] = \"冰葦灘\",\n    [\"lv.c33\"] = \"霜燈橋\",\n    [\"lv.c34\"] = \"霧凇林\",\n    [\"lv.c35\"] = \"寒湖之眼\",\n    [\"lv.c36\"] = \"冷月灣\",\n    [\"lv.c37\"] = \"浮冰徑\",\n    [\"lv.c38\"] = \"白鷺洲\",\n    [\"lv.c39\"] = \"霜霧港\",\n    [\"lv.c4\"] = \"核光\",\n    [\"lv.c40\"] = \"寒霧終章\",\n    [\"lv.c41\"] = \"金谷晨光\",\n    [\"lv.c42\"] = \"蜜穗田\",\n    [\"lv.c43\"] = \"琥珀溪\",\n    [\"lv.c44\"] = \"金藤坡\",\n    [\"lv.c45\"] = \"金輝守衛\",\n    [\"lv.c46\"] = \"曬穀場\",\n    [\"lv.c47\"] = \"流金瀑\",\n    [\"lv.c48\"] = \"麥浪丘\",\n    [\"lv.c49\"] = \"夕照崖\",\n    [\"lv.c5\"] = \"螢核之夜\",\n    [\"lv.c50\"] = \"金輝長夜\",\n    [\"lv.c51\"] = \"星門\",\n    [\"lv.c52\"] = \"星砂路\",\n    [\"lv.c53\"] = \"環光殿\",\n    [\"lv.c54\"] = \"星藤迴廊\",\n    [\"lv.c55\"] = \"星樞守望\",\n    [\"lv.c56\"] = \"極光階\",\n    [\"lv.c57\"] = \"流星井\",\n    [\"lv.c58\"] = \"星軌塔\",\n    [\"lv.c59\"] = \"核心前庭\",\n    [\"lv.c6\"] = \"流螢迴廊\",\n    [\"lv.c60\"] = \"星樞終焉\",\n    [\"lv.c61\"] = \"星砂渡\",\n    [\"lv.c62\"] = \"環光迴廊\",\n    [\"lv.c63\"] = \"碎星坡\",\n    [\"lv.c64\"] = \"螢河灣\",\n    [\"lv.c65\"] = \"星樞門\",\n    [\"lv.c66\"] = \"極夜台\",\n    [\"lv.c67\"] = \"流螢階\",\n    [\"lv.c68\"] = \"穹頂燈海\",\n    [\"lv.c69\"] = \"星核庭院\",\n    [\"lv.c7\"] = \"霧林\",\n    [\"lv.c70\"] = \"終章門廊\",\n    [\"lv.c71\"] = \"晨霧渡口\",\n    [\"lv.c72\"] = \"青藤峽\",\n    [\"lv.c73\"] = \"銀葉廳\",\n    [\"lv.c74\"] = \"微光濕地\",\n    [\"lv.c75\"] = \"雲杉嶺\",\n    [\"lv.c76\"] = \"鳥鳴台\",\n    [\"lv.c77\"] = \"林間燈塔\",\n    [\"lv.c78\"] = \"翡翠穹頂\",\n    [\"lv.c79\"] = \"螢潮之眼\",\n    [\"lv.c8\"] = \"藤海\",\n    [\"lv.c80\"] = \"蒼翠終章\",\n    [\"lv.c81\"] = \"赤銅谷\",\n    [\"lv.c82\"] = \"熔金階\",\n    [\"lv.c83\"] = \"砂瀑岩\",\n    [\"lv.c84\"] = \"焰心湖\",\n    [\"lv.c85\"] = \"鐵萼關\",\n    [\"lv.c86\"] = \"餘燼橋\",\n    [\"lv.c87\"] = \"熔星台\",\n    [\"lv.c88\"] = \"琥珀深淵\",\n    [\"lv.c89\"] = \"紅光迴廊\",\n    [\"lv.c9\"] = \"星墜\",\n    [\"lv.c90\"] = \"赤金終焉\",\n    [\"lv.c91\"] = \"碧濤灘\",\n    [\"lv.c92\"] = \"珊瑚礁階\",\n    [\"lv.c93\"] = \"深潮澗\",\n    [\"lv.c94\"] = \"潮汐廳\",\n    [\"lv.c95\"] = \"潮心燈塔\",\n    [\"lv.c96\"] = \"珠貝小徑\",\n    [\"lv.c97\"] = \"海霧塔\",\n    [\"lv.c98\"] = \"蒼藍穹頂\",\n    [\"lv.c99\"] = \"碧波核\",\n    [\"lv.h1\"] = \"蜂房初啟\",\n    [\"lv.h10\"] = \"蜂后衛隊\",\n    [\"lv.h11\"] = \"蜜糖瀑\",\n    [\"lv.h12\"] = \"六角迷城\",\n    [\"lv.h13\"] = \"琥珀蜂室\",\n    [\"lv.h14\"] = \"金蠟階\",\n    [\"lv.h15\"] = \"蜂王台之怒\",\n    [\"lv.h16\"] = \"夜巢\",\n    [\"lv.h17\"] = \"蜜池源\",\n    [\"lv.h18\"] = \"蜂王台終章\",\n    [\"lv.h19\"] = \"蜜池淺灘\",\n    [\"lv.h2\"] = \"蜜廊\",\n    [\"lv.h20\"] = \"蜂蠟迴廊\",\n    [\"lv.h21\"] = \"金巢階\",\n    [\"lv.h22\"] = \"蜂王前庭\",\n    [\"lv.h23\"] = \"蜜河灣\",\n    [\"lv.h24\"] = \"蜂語塔\",\n    [\"lv.h25\"] = \"蠟光廳\",\n    [\"lv.h26\"] = \"巢心深淵\",\n    [\"lv.h27\"] = \"蜜露庭院\",\n    [\"lv.h28\"] = \"蜂巢穹頂\",\n    [\"lv.h29\"] = \"金蜜之核\",\n    [\"lv.h3\"] = \"沉降\",\n    [\"lv.h30\"] = \"幽巢終章\",\n    [\"lv.h31\"] = \"蜂王座階\",\n    [\"lv.h32\"] = \"蜜光迴廊\",\n    [\"lv.h33\"] = \"巢影廳\",\n    [\"lv.h34\"] = \"琥珀蜂道\",\n    [\"lv.h35\"] = \"蜜語深巢\",\n    [\"lv.h36\"] = \"蜂王終殿\",\n    [\"lv.h4\"] = \"蜂蠟迷宮\",\n    [\"lv.h5\"] = \"蜂王台守衛\",\n    [\"lv.h6\"] = \"蜜池長夜\",\n    [\"lv.h7\"] = \"蜂巢晨曦\",\n    [\"lv.h8\"] = \"花粉廊\",\n    [\"lv.h9\"] = \"蠟封門\",\n    [\"map.chapterPage\"] = \"章節 {a} / {b}\",\n    [\"map.chapterTitle\"] = \"第 {n} 章 · {name}\",\n    [\"map.hexPage\"] = \"第 {a} / {b} 頁\",\n    [\"map.locked\"] = \"未解鎖\",\n    [\"map.notCleared\"] = \"未通關\",\n    [\"mode.classic\"] = \"經典模式\",\n    [\"mode.classic.desc\"] = \"三消充能 · 守護螢核 · {n} 關\",\n    [\"mode.hall\"] = \"挑戰大廳\",\n    [\"mode.hall.desc\"] = \"無盡 · 奪分 · 各 3 檔難度\",\n    [\"mode.hex\"] = \"蜂巢沉降\",\n    [\"mode.hex.desc\"] = \"六角三消 · 蜜層上漲 · {n} 關\",\n    [\"modesel.note\"] = \"點卡片選中，再點一次進入\",\n    [\"modesel.title\"] = \"選擇玩法\",\n    [\"pause.info\"] = \"第 {n} 關 · 分數 {s}\",\n    [\"pause.infoDaily\"] = \"每日衝刺 · 分數 {s}\",\n    [\"pause.infoHex\"] = \"蜂巢 第 {n} 關 · 分數 {s}\",\n    [\"pause.infoRun\"] = \"{mode} · 分數 {s}\",\n    [\"pause.music\"] = \"音樂 {p}%\",\n    [\"pause.sfx\"] = \"音效 {p}%\",\n    [\"pause.title\"] = \"暫停\",\n    [\"peek.howto\"] = \"解鎖後點關卡開始\",\n    [\"peek.title\"] = \"第 {n} 關 · {name}\",\n    [\"peek.titleHex\"] = \"蜂巢 · 第 {n} 關 · {name}\",\n    [\"peek.unlock\"] = \"通關經典模式第 {n} 關後解鎖\",\n    [\"peek.unlockHex\"] = \"通關蜂巢第 {n} 關後解鎖\",\n    [\"preview.label\"] = \"預覽：直接三消 · 可能命中\",\n    [\"progress.chain\"] = \"本關最大連鎖：{a} → {b}\",\n    [\"progress.chapter\"] = \"第 {n} 章已完成\",\n    [\"progress.page\"] = \"蜂巢第 {n} 頁已完成\",\n    [\"progress.stars\"] = \"星級提高：{a} → {b}\",\n    [\"pw.reverse\"] = \"倒退\",\n    [\"pw.slow\"] = \"減速\",\n    [\"pw.stun\"] = \"震環\",\n    [\"ready.best\"] = \"最佳 {n}\",\n    [\"ready.boosterLine\"] = \"{name}：{desc}\",\n    [\"ready.boosters\"] = \"帶入道具\",\n    [\"ready.goalEndless\"] = \"無盡挑戰：堅持越久越好\",\n    [\"ready.goalQuota\"] = \"目標：熄滅 {n} 顆燈珠\",\n    [\"ready.goalRush\"] = \"限時奪分：{t} 秒內儘量多得分\",\n    [\"ready.goalSteps\"] = \"目標：{n} 步內完成下列任務\",\n    [\"ready.goalTimed\"] = \"目標：{t} 秒內收集 {e} 能量\",\n    [\"ready.practice\"] = \"練習 · {name}\",\n    [\"ready.slowBeads\"] = \"（燈珠緩行）\",\n    [\"ready.starClassic\"] = \"燈珠鏈鏈頭離螢核越遠，星越多\",\n    [\"ready.starLocal\"] = \"成績記入本地最佳\",\n    [\"ready.starSteps\"] = \"剩餘步數越多，星越多\",\n    [\"ready.starTimed\"] = \"剩餘時間越多，星越多\",\n    [\"ready.stock\"] = \"首次通關各道具庫存 +1（最多 9）\",\n    [\"ready.subClassic\"] = \"別讓燈珠鏈抵達螢核\",\n    [\"ready.subHex\"] = \"同色 3 個連成一線即消除，並向軌道發射\",\n    [\"ready.subTimed\"] = \"得分即能量 · 燈珠持續出現\",\n    [\"roundBoost.active\"] = \"{s}秒\",\n    [\"roundBoost.entering\"] = \"入場中\",\n    [\"roundBoost.notCarried\"] = \"未帶入\",\n    [\"roundBoost.prepaid\"] = \"已扣庫存\",\n    [\"roundBoost.rule\"] = \"倒退、減速每局各 1 次，未用不扣\",\n    [\"roundBoost.stock\"] = \"庫存 {n}\",\n    [\"roundBoost.used\"] = \"已用\",\n    [\"run.dailyLost\"] = \"每日衝刺未達成\",\n    [\"run.dailyMissed\"] = \"今日目標未完成\",\n    [\"run.dailyWon\"] = \"每日衝刺成功！\",\n    [\"run.endlessLine\"] = \"最高速度 +{p}% · 熄滅燈珠 {b}\",\n    [\"run.endlessOver\"] = \"無盡挑戰結束\",\n    [\"run.localBest\"] = \"本地最佳 {n}\",\n    [\"run.localRecord\"] = \"本地新紀錄！\",\n    [\"run.practice\"] = \"練習局 · 不計入成績\",\n    [\"run.recorded\"] = \"正式成績已記錄 · 連續 {n} 天\",\n    [\"run.rushLine\"] = \"熄滅燈珠 {b} · 最大連鎖 ×{c}\",\n    [\"run.rushTime\"] = \"奪分 {t}\",\n    [\"run.stats\"] = \"特殊螢石 {s} · 合體 {m} · 核炮 {c}\",\n    [\"run.survived\"] = \"堅持 {t}\",\n    [\"run.timeUp\"] = \"時間到！\",\n    [\"sep.dot\"] = \" · \",\n    [\"set.diff.easy\"] = \"容易\",\n    [\"set.diff.hard\"] = \"困難\",\n    [\"set.diff.normal\"] = \"普通\",\n    [\"set.diffHint\"] = \"難度越高，燈珠鏈移動越快\",\n    [\"set.difficulty\"] = \"難度\",\n    [\"set.language\"] = \"語言\",\n    [\"set.music\"] = \"音樂\",\n    [\"set.off\"] = \"已關閉\",\n    [\"set.on\"] = \"已開啟\",\n    [\"set.sfx\"] = \"音效\",\n    [\"set.shake\"] = \"畫面與手機震動\",\n    [\"set.shakeDesc\"] = \"畫面震動與裝置觸感\",\n    [\"set.title\"] = \"設定\",\n    [\"tag.blitz\"] = \"奪分\",\n    [\"tag.cleared\"] = \"已通關 ✓\",\n    [\"tag.endless\"] = \"無盡\",\n    [\"tag.recommended\"] = \"推薦\",\n    [\"tag.step\"] = \"步數\",\n    [\"tag.timed\"] = \"限時\",\n    [\"target.beads\"] = \"熄滅燈珠\",\n    [\"target.color\"] = \"消除{color}\",\n    [\"target.item\"] = \"{label} {n}\",\n    [\"target.unlock\"] = \"解開藤鎖\",\n    [\"tier.1\"] = \"入門\",\n    [\"tier.2\"] = \"進階\",\n    [\"tier.3\"] = \"大師\",\n    [\"title.logo\"] = \"螢核雙消\",\n    [\"title.sub\"] = \"螢核森林\",\n    [\"title.tagline\"] = \"三消充能 · 守護螢核\",\n    [\"toast.arena\"] = \"新玩法解鎖：挑戰大廳\",\n    [\"toast.arena.modesel\"] = \"入口已在下方開放\",\n    [\"toast.arena.other\"] = \"開始冒險 → 選擇玩法 → 挑戰大廳\",\n    [\"toast.arena.title\"] = \"在「開始冒險」裡選擇「挑戰大廳」\",\n    [\"toast.arena2\"] = \"挑戰大廳：進階難度解鎖\",\n    [\"toast.arena3\"] = \"挑戰大廳：大師難度解鎖\",\n    [\"toast.daily\"] = \"新玩法解鎖：每日衝刺\",\n    [\"toast.daily.other\"] = \"回標題頁進入\",\n    [\"toast.daily.title\"] = \"點下方「每日衝刺」進入\",\n    [\"toast.tier.arena\"] = \"在卡片上切換到「{tier}」\",\n    [\"toast.tier.other\"] = \"挑戰大廳的兩種玩法都可選擇「{tier}」\",\n    [\"unlock.atLevel\"] = \"通關經典模式第 {n} 關解鎖\",\n    [\"variant.classic.1\"] = \"標準\",\n    [\"variant.classic.2\"] = \"快鏈\",\n    [\"variant.classic.3\"] = \"長鏈\",\n    [\"variant.step.1\"] = \"均衡\",\n    [\"variant.step.2\"] = \"藤鎖\",\n    [\"variant.step.3\"] = \"雙色\",\n    [\"variant.timed.1\"] = \"標準\",\n    [\"variant.timed.2\"] = \"速攻\",\n    [\"variant.timed.3\"] = \"耐力\",\n    [\"week.1\"] = \"週日\",\n    [\"week.2\"] = \"週一\",\n    [\"week.3\"] = \"週二\",\n    [\"week.4\"] = \"週三\",\n    [\"week.5\"] = \"週四\",\n    [\"week.6\"] = \"週五\",\n    [\"week.7\"] = \"週六\",\n    [\"win.allChapter\"] = \"本章全部通關！\",\n    [\"win.allHive\"] = \"蜂巢全部通關！\",\n    [\"win.clear\"] = \"通關！\",\n    [\"win.newRecord\"] = \"新紀錄！\",\n    [\"win.stats1\"] = \"最大連鎖 ×{c} · 特殊螢石 {s} · 合體 {m}\",\n    [\"win.stats2\"] = \"核炮 {c} · 能力 {p} · 最高連擊 ×{l}\",\n}\n"],["ringcore/monetize.lua","\n\n\nlocal G = require \"ringcore.state\"\n\nif type(rawget(_G, \"__ringcore_monetize\")) == \"table\" then\n    return rawget(_G, \"__ringcore_monetize\")\nend\nlocal M = {}\n\nM.FIRST_LEVEL = 11\n\n\nM.SPACING = 45\nM.DAY_TOTAL = 15\nM.WATCHDOG = 30\nM.FREE_PRACTICE = 1\n\n\n\n\n\n\nM.PLACEMENTS = {\n    continue = { day = 8, run = 2, perLevel = true },\n    win_double = { day = 10, run = 2, ads = function(n) return n end },\n    booster = { day = 5, run = 1 },\n    practice = { day = 3 },\n}\n\n\n\n\nlocal SAVE = \"ringcore_monetize.json\"\nlocal st = { day = 0, n = {}, lvl = {}, total = 0, practice = 0, extra = 0 }\nlocal run = { n = {} }\nlocal host = nil\nlocal pending = nil\nlocal lastAd = -1e9\n\nlocal function dayKey()\n    return tonumber(os.date(\"!%Y%m%d\", os.time() + 8 * 3600))\nend\n\n\n\nlocal function roll()\n    local k = dayKey()\n    if st.day ~= k then\n        if k < st.day and st.day > 0 then return end\n        st.day, st.n, st.lvl, st.total, st.practice, st.extra = k, {}, {}, 0, 0, 0\n    end\nend\nlocal function save()\n    if G.noSave then return end\n    pcall(function()\n        local f = File(SAVE, FILE_WRITE)\n        if f:IsOpen() then f:WriteString(cjson.encode(st)); f:Close() end\n    end)\nend\nfunction M.load()\n    if G.noSave then return end\n    pcall(function()\n        if not fileSystem:FileExists(SAVE) then return end\n        local f = File(SAVE, FILE_READ)\n        if not f:IsOpen() then return end\n        local ok, d = pcall(cjson.decode, f:ReadString())\n        f:Close()\n        if not ok or type(d) ~= \"table\" then return end\n        st.day = tonumber(d.day) or 0\n        st.total, st.practice, st.extra = tonumber(d.total) or 0, tonumber(d.practice) or 0, tonumber(d.extra) or 0\n        for k, v in pairs(type(d.n) == \"table\" and d.n or {}) do st.n[k] = tonumber(v) or 0 end\n        for k, v in pairs(type(d.lvl) == \"table\" and d.lvl or {}) do st.lvl[k] = tonumber(v) or 0 end\n\n    end)\nend\n\n\nlocal function noHost() return { name = \"none\" } end\nlocal function detect()\n    local forced = rawget(_G, \"MONETIZE_HOST\")\n    if type(forced) == \"table\" then return forced end\n    return noHost()\nend\nfunction M.host() host = host or detect(); return host end\nfunction M.setHost(h) host = h or noHost(); pending = nil end\n\n\nfunction M.fakeHost(o)\n    o = o or {}\n    return {\n        name = \"fake\",\n        rewarded = function() return o.ads ~= false end,\n        showRewarded = function(_, cb) cb({ ok = o.adOk ~= false, reason = o.adOk == false and \"failed\" or nil }) end,\n        iap = false,\n    }\nend\n\nfunction M.caps()\n    local h = M.host()\n    local ads = h.rewarded and h.rewarded() and true or false\n    return { rewarded = ads, interstitial = false, iap = false }\nend\n\n\nfunction M.owns(_) return false end\nfunction M.monetized() return M.caps().rewarded end\n\n\n\nlocal function eligible(placement, ctx)\n    ctx = ctx or {}\n    if placement == \"practice\" then\n        if ctx.runKind ~= \"daily\" or not ctx.practice then return false end\n        return (ctx.progress or 0) >= M.FIRST_LEVEL\n    end\n    if ctx.runKind then return false end\n    return (ctx.level or 0) >= M.FIRST_LEVEL\nend\nlocal function lvlKey(ctx) return ((ctx and ctx.mode == \"hex\") and \"h\" or \"c\") .. tostring(ctx and ctx.level or 0) end\n\n\nfunction M.canContinue()\n    local p = M.PLACEMENTS.continue\n    return (G.runContinues or 0) < (p and p.run or 3)\nend\n\n\nfunction M.offer(placement, ctx)\n    local p = M.PLACEMENTS[placement]\n    if not p or not eligible(placement, ctx) then return nil end\n    if placement == \"continue\" and not M.canContinue() then return nil end\n\n    if not M.caps().rewarded then return nil end\n    roll()\n\n\n    local used = run.n[placement] or 0\n    if p.run and used >= p.run then return nil end\n    local need = 1\n    if p.ads then need = math.max(1, math.floor(p.ads(used + 1) or 1)) end\n\n\n    local left = p.day - (st.n[placement] or 0)\n    if p.perLevel then left = math.min(left, p.day - (st.lvl[lvlKey(ctx)] or 0)) end\n    if left < need then return nil end\n    if st.total + need > M.DAY_TOTAL then\n\n        return { kind = \"ad\", blocked = \"dailyCap\", ads = need }\n    end\n    local wait = M.SPACING - (os.time() - lastAd)\n    if wait > 0 then\n        return { kind = \"ad\", blocked = \"cooldown\", wait = math.floor(wait + 0.5), ads = need }\n    end\n\n    return { kind = \"ad\", left = (p.run or 99) - used, ads = need }\nend\n\n\nlocal function record(placement, ctx, adsWatched)\n    roll()\n    st.n[placement] = (st.n[placement] or 0) + 1\n    if M.PLACEMENTS[placement].perLevel then st.lvl[lvlKey(ctx)] = (st.lvl[lvlKey(ctx)] or 0) + 1 end\n    run.n[placement] = (run.n[placement] or 0) + 1\n    st.total = st.total + (adsWatched or 1)\n    if placement == \"practice\" then st.extra = st.extra + 1 end\n    save()\nend\n\nfunction M.busy() return pending ~= nil end\n\n\nfunction M.claim(placement, ctx, cb)\n    local o = M.offer(placement, ctx)\n    if not o or pending then cb({ ok = false, reason = pending and \"busy\" or \"capped\" }) return end\n\n    if o.blocked then cb({ ok = false, reason = o.blocked, wait = o.wait }) return end\n    local h = M.host()\n    local need = o.ads or 1\n    local watched = 0\n    local pendingRef\n    local done = false\n\n\n\n\n\n\n\n    local function finish(r)\n        if done then return end\n        done = true\n        if pending == pendingRef then pending = nil end\n        if watched > 0 then lastAd = os.time() end\n        if r and r.ok then\n            record(placement, ctx, watched)\n            cb({ ok = true, watched = watched })\n        else\n            cb(r or { ok = false, reason = \"failed\" })\n        end\n    end\n\n    local function playNext()\n        if watched >= need then finish({ ok = true }) return end\n        pendingRef = { t = 0, placement = placement }\n        pending = pendingRef\n        local me = pendingRef\n        local fired = false\n        me.finish = function(r)\n            if fired then return end\n            fired = true\n            if pending == me then pending = nil end\n            if r and r.ok then\n                watched = watched + 1\n                if watched < need then\n                    playNext()\n                else\n                    finish({ ok = true })\n                end\n            else\n                finish(r)\n            end\n        end\n        local ok, err = pcall(h.showRewarded, placement, me.finish)\n        if not ok then finish({ ok = false, reason = \"error:\" .. tostring(err) }) end\n    end\n\n    playNext()\nend\n\nfunction M.update(dt)\n    if pending then\n        pending.t = pending.t + dt\n        if pending.t > M.WATCHDOG and pending.finish then pending.finish({ ok = false, reason = \"timeout\" }) end\n    end\nend\n\nfunction M.newRun() run = { n = {} } end\n\n\nfunction M.practiceLeft()\n    if not M.monetized() then return nil end\n    roll()\n    return math.max(0, M.FREE_PRACTICE + st.extra - st.practice)\nend\nfunction M.notePractice()\n    if not M.monetized() then return end\n    roll(); st.practice = st.practice + 1; save()\nend\n\n\n\nfunction M.stock(_) return 0 end\nfunction M.useStock(_) return false end\n\n\nfunction M.debugState()\n    return { day = st.day, n = st.n, lvl = st.lvl, total = st.total, practice = st.practice, extra = st.extra, run = run }\nend\n\n\nfunction M.usedToday(placement)\n    if not M.PLACEMENTS[placement] then return nil end\n    roll()\n    return st.n[placement] or 0\nend\n\n\nfunction M.usedThisRun(placement)\n    if not M.PLACEMENTS[placement] then return nil end\n    return run.n[placement] or 0\nend\n\n\nfunction M._reset()\n    st = { day = 0, n = {}, lvl = {}, total = 0, practice = 0, extra = 0 }\n    run, pending, lastAd = { n = {} }, nil, -1e9\nend\n\nrawset(_G, \"__ringcore_monetize\", M)\nreturn M\n"],["ringcore/progress.lua","\n\n\n\n\n\nif type(rawget(_G, \"__ringcore_progress\")) == \"table\" then\n    return rawget(_G, \"__ringcore_progress\")\nend\nlocal M = {}\nlocal C = require \"ringcore.config\"\nlocal G = require \"ringcore.state\"\nlocal I18n = require \"ringcore.i18n\"\n\nlocal SAVE_FILE = \"ringcore_save.json\"\nlocal lastSavedAt = 0\nlocal data = {\n    unlocked = 1,\n    best = {},\n    boosters = { reverse = 2, slow = 2, hint = 2 },\n    hexUnlocked = 1,\n    hexBest = {},\n\n    toasts = {},\n    fails = {},\n    daily = { last = 0, streak = 0, best = 0, todayScore = 0, todayWon = false, played = 0 },\n    arena = {},\n}\n\n\nM.DAILY_AT = 8\nM.ARENA_AT = 15\n\n\nlocal save\n\nlocal function applyRecord(d)\n    if type(d) ~= \"table\" then return false end\n\n\n    if not (type(d.settings) == \"table\" and type(d.settings.lang) == \"string\") then I18n.set(I18n.DEFAULT) end\n\n\n    data.unlocked = math.max(1, math.min(#C.LEVELS + 1, tonumber(d.unlocked) or 1))\n    data.best = {}\n    for k, v in pairs(d.best or {}) do\n        data.best[tonumber(k)] = { stars = tonumber(v.stars) or 0, score = tonumber(v.score) or 0,\n            maxChain = tonumber(v.maxChain) and math.max(0, math.floor(tonumber(v.maxChain))) or nil }\n    end\n\n\n\n    if type(d.boosters) == \"table\" then\n        for _, id in ipairs({ \"reverse\", \"slow\", \"hint\" }) do\n            data.boosters[id] = math.max(0, tonumber(d.boosters[id]) or 0)\n        end\n    end\n    if type(d.fails) == \"table\" then\n        for k, v in pairs(d.fails) do data.fails[k] = tonumber(v) or 0 end\n    end\n    if type(d.toasts) == \"table\" then\n        for k, v in pairs(d.toasts) do data.toasts[k] = v and true or nil end\n    end\n    if type(d.daily) == \"table\" then\n        for k, v in pairs(d.daily) do\n            if k == \"todayWon\" then data.daily[k] = v == true else data.daily[k] = tonumber(v) or 0 end\n        end\n    end\n    if type(d.arena) == \"table\" then\n        for id, a in pairs(d.arena) do\n            if type(a) == \"table\" then\n\n                if id == \"endless\" or id == \"blitz\" then id = id .. \"2\" end\n                data.arena[id] = { score = tonumber(a.score) or 0, time = tonumber(a.time) or 0 }\n            end\n        end\n    end\n    if type(d.settings) == \"table\" then\n        local st = d.settings\n        if st.bgm ~= nil then G.bgmOn = st.bgm == true end\n        if st.sfx ~= nil then G.sfxOn = st.sfx == true end\n        if st.shake ~= nil then G.shakeOn = st.shake == true end\n        if type(st.lang) == \"string\" then I18n.set(st.lang) end\n        if tonumber(st.bgmGain) then C.BGM_GAIN = math.max(0, math.min(1, tonumber(st.bgmGain))) end\n        if tonumber(st.sfxGain) then C.SFX_GAIN = math.max(0, math.min(1, tonumber(st.sfxGain))) end\n        if C.DIFFICULTY_SET[st.difficulty] then G.difficulty = st.difficulty end\n    end\n    data.hexUnlocked = math.max(1, math.min(#C.HEX_LEVELS + 1, tonumber(d.hexUnlocked) or 1))\n    data.hexBest = {}\n    for k, v in pairs(d.hexBest or {}) do\n        data.hexBest[tonumber(k)] = { stars = tonumber(v.stars) or 0, score = tonumber(v.score) or 0,\n            maxChain = tonumber(v.maxChain) and math.max(0, math.floor(tonumber(v.maxChain))) or nil }\n    end\n\n    lastSavedAt = tonumber(d.savedAt) or 0\n    return true\nend\nM.applyRemote = function(rec)\n    if not applyRecord(rec) then return false end\n    save()\n    return true\nend\nM.lastSavedAt = function() return lastSavedAt end\n\nlocal function load()\n    if G.noSave then return data end\n    local ok = pcall(function()\n        if fileSystem:FileExists(SAVE_FILE) then\n            local f = File(SAVE_FILE, FILE_READ)\n            if f:IsOpen() then\n                local okj, d = pcall(cjson.decode, f:ReadString())\n                f:Close()\n                if okj then applyRecord(d) end\n            end\n        end\n    end)\n    if not ok then print(\"[Ringcore] save load failed\") end\n    return data\nend\n\n\nlocal function snapshot()\n    local best, hexBest = {}, {}\n    for k, v in pairs(data.best) do best[tostring(k)] = v end\n    for k, v in pairs(data.hexBest) do hexBest[tostring(k)] = v end\n    lastSavedAt = os.time()\n    return {\n        v = 1,\n        savedAt = lastSavedAt,\n        unlocked = data.unlocked, best = best,\n        boosters = data.boosters,\n        hexUnlocked = data.hexUnlocked, hexBest = hexBest,\n        toasts = data.toasts, daily = data.daily, arena = data.arena, fails = data.fails,\n        settings = { bgm = G.bgmOn, sfx = G.sfxOn, shake = G.shakeOn, bgmGain = C.BGM_GAIN, sfxGain = C.SFX_GAIN,\n                     lang = I18n.lang, difficulty = G.difficulty },\n    }\nend\nM.snapshot = snapshot\n\nsave = function()\n    if G.noSave then return end\n    local snap = snapshot()\n    pcall(function()\n        local f = File(SAVE_FILE, FILE_WRITE)\n        if f:IsOpen() then\n            f:WriteString(cjson.encode(snap))\n            f:Close()\n        end\n    end)\nend\n\nM.saveSettings = function() save() end\n\n\nlocal function recordBest(store, levelIdx, stars, score, maxChain, pages, hex)\n    local b = store[levelIdx]\n    local delta = {}\n    if b and stars > b.stars then delta.stars = { before = b.stars, after = stars } end\n    local chain = tonumber(maxChain) and math.max(0, math.floor(tonumber(maxChain))) or nil\n    if b and b.maxChain ~= nil and chain and chain > b.maxChain then\n        delta.chain = { before = b.maxChain, after = chain }\n    end\n    local page\n    for _, p in ipairs(pages) do if levelIdx >= p.first and levelIdx <= p.last then page = p; break end end\n    local function complete()\n        if not page then return false end\n        for i = page.first, page.last do if not store[i] then return false end end\n        return true\n    end\n    local wasComplete = complete()\n    store[levelIdx] = { stars = math.max(b and b.stars or 0, stars), score = math.max(b and b.score or 0, score),\n        maxChain = chain and math.max(b and b.maxChain or 0, chain) or (b and b.maxChain) }\n    if not wasComplete and complete() then delta.chapter = { id = page.id, nameKey = page.nameKey, hex = hex } end\n    return delta\nend\n\n\nlocal function record(levelIdx, stars, score, levelCount, maxChain)\n    local first = data.best[levelIdx] == nil\n    local b = data.best[levelIdx] or { stars = 0, score = 0 }\n    local improved = stars > b.stars or score > b.score\n    local delta = recordBest(data.best, levelIdx, stars, score, maxChain, C.CHAPTERS, false)\n    data.unlocked = math.min(levelCount, math.max(data.unlocked, levelIdx + 1))\n\n\n    if first then\n        for _, id in ipairs({ \"reverse\", \"slow\", \"hint\" }) do\n            data.boosters[id] = math.min(9, (data.boosters[id] or 0) + 1)\n        end\n    end\n    save()\n    return improved, delta\nend\n\nlocal function recordHex(levelIdx, stars, score, levelCount, maxChain)\n    local b = data.hexBest[levelIdx] or { stars = 0, score = 0 }\n    local improved = stars > b.stars or score > b.score\n    local delta = recordBest(data.hexBest, levelIdx, stars, score, maxChain, C.HEX_PAGES, true)\n    data.hexUnlocked = math.min(levelCount, math.max(data.hexUnlocked, levelIdx + 1))\n    save()\n    return improved, delta\nend\n\nlocal function unlocked() return data.unlocked end\nlocal function best(i) return data.best[i] end\nlocal function hexUnlocked() return data.hexUnlocked end\nlocal function hexBest(i) return data.hexBest[i] end\n\nlocal function stock(id) return data.boosters[id] or 0 end\nlocal function consume(id)\n    if (data.boosters[id] or 0) <= 0 then return false end\n    data.boosters[id] = data.boosters[id] - 1\n    save()\n    return true\nend\nlocal function grant(id, n)\n    data.boosters[id] = math.min(9, (data.boosters[id] or 0) + (n or 1))\n    save()\nend\n\n\nlocal function cleared(n) return data.unlocked > n or (data.best[n] ~= nil) end\nlocal function dailyUnlocked() return cleared(M.DAILY_AT) end\nlocal function arenaUnlocked() return cleared(M.ARENA_AT) end\n\n\nlocal function pendingToast()\n    if dailyUnlocked() and not data.toasts.daily then return \"daily\" end\n    if arenaUnlocked() and not data.toasts.arena then return \"arena\" end\n    for t = 2, 3 do\n        if cleared(C.ARENA_TIER_AT[t]) and not data.toasts[\"arena\" .. t] then return \"arena\" .. t end\n    end\n    return nil\nend\nlocal function ackToast(id)\n    data.toasts[id] = true\n    save()\nend\n\n\n\n\nfunction M.hiveTipSeen() return data.toasts.hiveTip == true end\nfunction M.ackHiveTip() data.toasts.hiveTip = true; save() end\n\n\n\nlocal function dayNum(key)\n    local y, m, d = math.floor(key / 10000), math.floor(key / 100) % 100, key % 100\n    return math.floor(os.time({ year = y, month = m, day = d, hour = 12 }) / 86400 + 0.5)\nend\nlocal function dailyPlayed(key) return data.daily.last == key end\n\nlocal function dailyBegin(key)\n    local dd = data.daily\n    if dd.last == key then return false end\n    if dd.last > 0 and dayNum(key) - dayNum(dd.last) == 1 then dd.streak = dd.streak + 1 else dd.streak = 1 end\n    dd.last, dd.todayScore, dd.todayWon = key, 0, false\n    dd.played = (dd.played or 0) + 1\n    save()\n    return true\nend\nlocal function dailyFinish(key, score, won)\n    local dd = data.daily\n    if dd.last ~= key then return false end\n    dd.todayScore, dd.todayWon = score, won and true or false\n    local nb = score > (dd.best or 0)\n    if nb then dd.best = score end\n    save()\n    return nb\nend\nlocal function daily() return data.daily end\n\nlocal function dailyStreak(todayKey)\n    local dd = data.daily\n    if dd.last == 0 then return 0 end\n    local gap = dayNum(todayKey) - dayNum(dd.last)\n    return (gap <= 1) and dd.streak or 0\nend\n\n\nlocal function arenaBest(id) return data.arena[id] end\n\nlocal function arenaTop(id)\n    local top = 0\n    for k, a in pairs(data.arena) do\n        if not id or k:sub(1, #id) == id then top = math.max(top, a.score or 0) end\n    end\n    return top\nend\nM.arenaTop = arenaTop\nlocal function arenaTierOpen(t) return cleared(C.ARENA_TIER_AT[t] or 99) end\nM.arenaTierOpen = arenaTierOpen\nlocal function recordArena(id, score, time)\n    local a = data.arena[id] or { score = 0, time = 0 }\n    local nb = score > (a.score or 0)\n    a.score = math.max(a.score or 0, score)\n    a.time = math.max(a.time or 0, time or 0)\n    data.arena[id] = a\n    save()\n    return nb\nend\n\n\nlocal function failKey(mode, i) return (mode == \"hex\" and \"h\" or \"c\") .. i end\nlocal function fails(mode, i) return data.fails[failKey(mode, i)] or 0 end\nlocal function recordLoss(mode, i)\n    local k = failKey(mode, i)\n    data.fails[k] = (data.fails[k] or 0) + 1\n    save()\nend\nlocal function clearLoss(mode, i)\n    local k = failKey(mode, i)\n    if data.fails[k] then data.fails[k] = nil; save() end\nend\nlocal function undoLoss(mode, i)\n    local k = failKey(mode, i)\n    if (data.fails[k] or 0) > 0 then data.fails[k] = data.fails[k] - 1; if data.fails[k] == 0 then data.fails[k] = nil end; save() end\nend\nM.undoLoss = undoLoss\nM.fails = fails\nM.recordLoss = recordLoss\nM.clearLoss = clearLoss\n\nM.dailyUnlocked = dailyUnlocked\nM.arenaUnlocked = arenaUnlocked\nM.pendingToast = pendingToast\nM.ackToast = ackToast\nM.dailyPlayed = dailyPlayed\nM.dailyBegin = dailyBegin\nM.dailyFinish = dailyFinish\nM.daily = daily\nM.dailyStreak = dailyStreak\nM.arenaBest = arenaBest\nM.recordArena = recordArena\nM.load = load\nM.save = save\nM.record = record\nM.recordHex = recordHex\nM.unlocked = unlocked\nM.best = best\nM.hexUnlocked = hexUnlocked\nM.hexBest = hexBest\nM.stock = stock\nM.consume = consume\nM.grant = grant\n\nrawset(_G, \"__ringcore_progress\", M)\nreturn M\n"],["ringcore/ring.lua","\nlocal M = {}\nlocal C = require \"ringcore.config\"\nlocal G = require \"ringcore.state\"\nlocal Rng = require \"ringcore.rng\"\nlocal Goals = require \"ringcore.goals\"\nlocal Audio = require \"ringcore.audio\"\nlocal Fx = require \"ringcore.fx\"\nlocal Haptics = require \"ringcore.haptics\"\nlocal tr = require(\"ringcore.i18n\").t\nlocal T, BALL_D = C.T, C.BALL_D\nlocal nextId = 1\n\n\n\n\nlocal function buildPath()\n    G.path = {}\n\n\n    local L = C.T.LAYOUT or {}\n    local logical=C.LAYOUTS.portrait\n    local cx, cy = L.ringX or 270, L.ringY or 290\n    local samples, turns = 600, 1.6\n    local rx, ry = L.ringRX or 232, L.ringRY or 188\n    local cum, px, py = 0, nil, nil\n    for i = 0, samples do\n        local t = i / samples\n        local a = -math.pi * 0.75 + t * turns * 2 * math.pi\n        local k = 1 - t * 0.84\n        local x = cx + math.cos(a) * rx * k\n        local y = cy + math.sin(a) * ry * k\n\n        local lx,ly=logical.ringX+math.cos(a)*logical.ringRX*k,logical.ringY+math.sin(a)*logical.ringRY*k\n        if px then cum = cum + math.sqrt((lx - px) ^ 2 + (ly - py) ^ 2) end\n        G.path[#G.path + 1] = { x = x, y = y, d = cum }\n        px, py = lx, ly\n    end\n    G.pathLen = cum\nend\n\nlocal function pointOnPath(d)\n    if d <= 0 then\n        local p, p2 = G.path[1], G.path[2]\n        return p.x, p.y, math.atan(p2.y - p.y, p2.x - p.x)\n    end\n    if d >= G.pathLen then\n        local p, p2 = G.path[#G.path], G.path[#G.path - 1]\n        return p.x, p.y, math.atan(p.y - p2.y, p.x - p2.x)\n    end\n    local lo, hi = 1, #G.path\n    while lo < hi do\n        local mid = math.floor((lo + hi) / 2)\n        if G.path[mid].d < d then lo = mid + 1 else hi = mid end\n    end\n    local i = math.max(2, lo)\n    local a, b = G.path[i - 1], G.path[i]\n    local span = b.d - a.d\n    local u = span > 1e-6 and (d - a.d) / span or 0\n    return a.x + (b.x - a.x) * u, a.y + (b.y - a.y) * u, math.atan(b.y - a.y, b.x - a.x)\nend\n\nlocal function dist2(ax, ay, bx, by)\n    local dx, dy = ax - bx, ay - by\n    return dx * dx + dy * dy\nend\n\n\n\n\n\n\n\n\nlocal function sortBalls()\n    table.sort(G.balls, function(a, b) return a.dist < b.dist end)\nend\n\nlocal function touching(a, b)\n    return b.dist - a.dist <= BALL_D + 0.5\nend\n\n\nlocal function rebuildSpacing()\n    if #G.balls == 0 then return end\n    sortBalls()\n    for i = 2, #G.balls do\n        local need = G.balls[i - 1].dist + BALL_D\n        if G.balls[i].dist < need then G.balls[i].dist = need end\n    end\nend\n\nlocal function randColor()\n    return Rng.chain(1, G.lvl.colors)\nend\n\nlocal function tailSafeColor()\n    local c = randColor()\n    if #G.balls >= 2 and G.balls[1].color == G.balls[2].color and G.balls[1].color == c then\n        c = c % G.lvl.colors + 1\n    end\n    return c\nend\n\nlocal POWER_KINDS = { \"reverse\", \"slow\" }\n\nlocal function spawnTail(dist)\n    local b = { id = nextId, dist = dist, vdist = dist, color = tailSafeColor() }\n    nextId = nextId + 1\n    if (G.lvl.powerChance or 0) > 0 and G.state == \"playing\" and Rng.chain() < G.lvl.powerChance then\n        b.power = POWER_KINDS[Rng.chain(1, #POWER_KINDS)]\n    end\n    table.insert(G.balls, 1, b)\n    G.queueLeft = G.queueLeft - 1\n    if G.state == \"playing\" then G.rollInTail = false end\nend\n\n\nlocal function runs()\n    local out = {}\n    local i, n = 1, #G.balls\n    while i <= n do\n        local j = i\n        while j + 1 <= n and G.balls[j + 1].color == G.balls[i].color and touching(G.balls[j], G.balls[j + 1]) do j = j + 1 end\n        out[#out + 1] = { color = G.balls[i].color, i1 = i, i2 = j }\n        i = j + 1\n    end\n    return out\nend\n\n\nlocal function runAt(idx)\n    local col = G.balls[idx].color\n    local i1, i2 = idx, idx\n    while i1 > 1 and G.balls[i1 - 1].color == col and touching(G.balls[i1 - 1], G.balls[i1]) do i1 = i1 - 1 end\n    while i2 < #G.balls and G.balls[i2 + 1].color == col and touching(G.balls[i2], G.balls[i2 + 1]) do i2 = i2 + 1 end\n    return i1, i2\nend\n\n\nlocal function groups()\n    local out = {}\n    local i, n = 1, #G.balls\n    while i <= n do\n        local j = i\n        while j < n and touching(G.balls[j], G.balls[j + 1]) do j = j + 1 end\n        out[#out + 1] = { i1 = i, i2 = j }\n        i = j + 1\n    end\n    return out\nend\n\nlocal function bumpCombo()\n    if G.now - G.lastHitTime <= T.COMBO_WINDOW then\n        G.combo = math.min(T.COMBO_MAX, G.combo + 0.5)\n    else\n        G.combo = 1\n    end\n    G.lastHitTime = G.now\n    G.stats.maxCombo = math.max(G.stats.maxCombo, G.combo)\nend\n\nlocal function addRingScore(base, x, y)\n    local pts = math.floor(base * G.combo + 0.5)\n    G.score = G.score + pts\n    Fx.popup(x, y, \"+\" .. pts, G.combo > 1)\nend\n\n\nlocal function activatePower(kind, x, y)\n    G.stats.powers = (G.stats.powers or 0) + 1\n    if kind == \"reverse\" then\n        G.powerReverse = math.max(G.powerReverse, T.PW_REVERSE_TIME)\n        Fx.popup(x, y - 30, tr(\"fx.rewind\"), true)\n        Audio.playSfx(\"stun\", 1.35, 0.8)\n        Fx.ringBurst(x, y, 2, 64, 0.45)\n    else\n        G.slowTimer = math.max(G.slowTimer, T.PW_SLOW_TIME)\n        Fx.popup(x, y - 30, tr(\"fx.slow\"), true)\n        Audio.playSfx(\"unlock\", 0.7, 0.85)\n        Fx.ringBurst(x, y, 4, 64, 0.45)\n    end\n    Fx.sparkle(x, y, 12)\nend\n\nlocal function removeIndices(set)\n    local nb, powers = {}, {}\n    for i, b in ipairs(G.balls) do\n        if not set[i] then\n            nb[#nb + 1] = b\n        elseif b.power then\n            local x, y = pointOnPath(b.vdist)\n            powers[#powers + 1] = { b.power, x, y }\n        end\n    end\n    Goals.onBeads(#G.balls - #nb)\n    G.balls = nb\n    for _, p in ipairs(powers) do activatePower(p[1], p[2], p[3]) end\nend\n\n\nlocal function grantPower(fromX, fromY)\n    local list = {}\n    for _, b in ipairs(G.balls) do\n        if not b.power and b.vdist >= 0 and not G.reserved[b.id] then list[#list + 1] = b end\n    end\n    if #list == 0 then return false end\n    local b = list[Rng.board(1, #list)]\n    b.power = POWER_KINDS[Rng.board(1, #POWER_KINDS)]\n    local x, y = pointOnPath(b.vdist)\n    G.beams[#G.beams + 1] = { x0 = fromX, y0 = fromY, x1 = x, y1 = y, life = 0.45, color = b.color }\n    Fx.sparkle(x, y, 10)\n    Fx.popup(x, y - 26, tr(b.power == \"reverse\" and \"fx.rewindBead\" or \"fx.slowBead\"), true)\n    Audio.playSfx(\"ammo\", 1.3, 0.6)\n    return true\nend\n\n\nlocal function ringResolveChains(depth)\n    local remove, any, cx, cy, cnt = {}, false, 0, 0, 0\n    for _, r in ipairs(runs()) do\n        if r.i2 - r.i1 + 1 >= 3 then\n            any = true\n            for k = r.i1, r.i2 do\n                remove[k] = true\n                local x, y = pointOnPath(G.balls[k].vdist)\n                cx, cy, cnt = cx + x, cy + y, cnt + 1\n                Fx.burst(x, y, r.color, 6, 160)\n            end\n        end\n    end\n    if not any then return false end\n    removeIndices(remove)\n    depth = (depth or 0) + 1\n    bumpCombo()\n    addRingScore(50 * depth * cnt, cx / cnt, cy / cnt - 10)\n    G.reverseTimer = T.REVERSE_TIME * (1 + 0.5 * depth)\n    G.hitstop = T.HITSTOP\n    Fx.shake(4, 0.15)\n    Haptics.pulse(depth >= 2 and \"heavy\" or \"medium\", G.now)\n    Audio.playSfx(\"clear\", 1 + 0.08 * depth, 0.8)\n    return true\nend\n\n\nlocal function chainClear(idx)\n    if idx <= 1 or idx > #G.balls then return false end\n    if G.balls[idx - 1].color ~= G.balls[idx].color then return false end\n    local i1, i2 = runAt(idx)\n    if i2 - i1 + 1 < 3 then return false end\n    G.chain = G.chain + 1\n    G.stats.maxChain = math.max(G.stats.maxChain or 0, G.chain)\n    local set, cx, cy, cnt, col = {}, 0, 0, 0, G.balls[idx].color\n    for k = i1, i2 do\n        set[k] = true\n        local x, y = pointOnPath(G.balls[k].vdist)\n        cx, cy, cnt = cx + x, cy + y, cnt + 1\n        Fx.burst(x, y, col, 8 + 2 * G.chain, 170 + 20 * G.chain)\n    end\n    removeIndices(set)\n    bumpCombo()\n    cx, cy = cx / cnt, cy / cnt\n    addRingScore(50 * G.chain * cnt, cx, cy - 10)\n    if G.chain >= 2 then\n        Fx.popup(cx, cy - 34, tr(\"fx.chain\", { n = G.chain }), true)\n        G.chainFlash = { n = G.chain, life = 0.9 }\n    end\n    Fx.ringBurst(cx, cy, col, 40 + 14 * G.chain, 0.35)\n    Fx.sparkle(cx, cy, 6 + 3 * G.chain)\n    G.reverseTimer = math.max(G.reverseTimer, T.REVERSE_TIME * (1 + 0.5 * G.chain))\n    G.hitstop = T.HITSTOP\n    Fx.shake(3 + G.chain, 0.15)\n    Audio.playSfx(\"clear\", math.min(2.0, 1 + 0.12 * G.chain), 0.85)\n    return true\nend\n\nlocal function headBall() return G.balls[#G.balls] end\n\nlocal function removeHeadBeads(count, scorePer, label)\n    if #G.balls == 0 or count <= 0 then return 0 end\n    sortBalls()\n    G.chain = 0\n    local n = math.min(count, #G.balls)\n    local set, cx, cy = {}, 0, 0\n    for i = #G.balls - n + 1, #G.balls do\n        set[i] = true\n        local x, y = pointOnPath(G.balls[i].vdist)\n        cx, cy = cx + x, cy + y\n        Fx.burst(x, y, G.balls[i].color, 8, 180)\n    end\n    removeIndices(set)\n    bumpCombo()\n    addRingScore(scorePer * n, cx / n, cy / n - 12)\n    if label then Fx.popup(cx / n, cy / n - 28, label, true) end\n    G.reverseTimer = math.max(G.reverseTimer, T.REVERSE_TIME * 2)\n    return n\nend\n\nlocal function removeColorFromRing(color, maxN)\n    sortBalls()\n    G.chain = 0\n    local ids = {}\n    for i = #G.balls, 1, -1 do\n        if G.balls[i].color == color then\n            ids[#ids + 1] = i\n            if #ids >= maxN then break end\n        end\n    end\n    if #ids == 0 then return 0 end\n    local set, cx, cy = {}, 0, 0\n    for _, i in ipairs(ids) do\n        set[i] = true\n        local x, y = pointOnPath(G.balls[i].vdist)\n        cx, cy = cx + x, cy + y\n        Fx.burst(x, y, color, 6, 160)\n    end\n    local n = #ids\n    removeIndices(set)\n    bumpCombo()\n    addRingScore(40 * n, cx / n, cy / n - 12)\n    Fx.popup(cx / n, cy / n - 28, tr(\"fx.colorClear\"), true)\n    G.reverseTimer = math.max(G.reverseTimer, T.REVERSE_TIME * 2)\n    return n\nend\n\nlocal function stunRing(mult)\n    mult = mult or 1\n    G.stunTimer = math.max(G.stunTimer, T.STUN_TIME * mult)\n    sortBalls()\n    local push = G.balls[1] and math.min(T.STUN_PUSHBACK * mult, math.max(0, G.balls[1].dist)) or 0\n    for _, b in ipairs(G.balls) do\n        b.dist = b.dist - push\n        b.vdist = b.vdist - push * 0.5\n    end\n    rebuildSpacing()\n    Fx.shake(8, 0.25)\n    G.hitstop = T.HITSTOP_SPECIAL\n    local ccx, ccy = G.path[#G.path].x, G.path[#G.path].y\n    Fx.popup(ccx, ccy - 8, tr(\"fx.stun\"), true)\n    Audio.playSfx(\"stun\", 1.0, 0.9)\n    Fx.ringBurst(ccx, ccy, 1, 90, 0.45)\n    Fx.sparkle(ccx, ccy, 14)\nend\n\nlocal function closestPathDist(x, y)\n    local bestD, bestI = 1e18, 1\n    for i, p in ipairs(G.path) do\n        local d = dist2(x, y, p.x, p.y)\n        if d < bestD then bestD, bestI = d, i end\n    end\n    return G.path[bestI].d, math.sqrt(bestD)\nend\n\nlocal function insertBallAt(dist, color)\n    local newbie = { id = nextId, dist = dist, vdist = dist, color = color }\n    nextId = nextId + 1\n    G.balls[#G.balls + 1] = newbie\n    sortBalls()\n    local idx = 1\n    for i, b in ipairs(G.balls) do if b == newbie then idx = i break end end\n    if idx > 1 and newbie.dist - G.balls[idx - 1].dist < BALL_D then newbie.dist = G.balls[idx - 1].dist + BALL_D end\n    for i = idx + 1, #G.balls do\n        local need = G.balls[i - 1].dist + BALL_D\n        if G.balls[i].dist < need then G.balls[i].dist = need else break end\n    end\n    newbie.vdist = newbie.dist\n    G.chain = 0\n    bumpCombo()\n    Audio.playSfx(\"insert\", 1.0, 0.85)\n    ringResolveChains(0)\nend\n\nlocal function tryInsertFlying()\n    if not G.flying then return end\n    local hitIdx, hitDist = nil, nil\n    for i, b in ipairs(G.balls) do\n        local bx, by = pointOnPath(b.vdist)\n        if dist2(G.flying.x, G.flying.y, bx, by) <= (T.BALL_R * 1.85) ^ 2 then\n            hitIdx, hitDist = i, b.dist\n            break\n        end\n    end\n    if hitIdx then\n        local insertDist = hitDist\n        local preferBehind = true\n        if hitIdx < #G.balls then\n            local ax, ay = pointOnPath(G.balls[hitIdx + 1].vdist)\n            local bx, by = pointOnPath(hitDist)\n            if dist2(G.flying.x, G.flying.y, ax, ay) < dist2(G.flying.x, G.flying.y, bx, by) then\n                preferBehind = false\n                insertDist = G.balls[hitIdx + 1].dist\n            end\n        end\n        if preferBehind and hitIdx > 1 then\n            insertDist = (G.balls[hitIdx - 1].dist + hitDist) * 0.5\n        end\n        insertBallAt(insertDist, G.flying.color)\n        G.flying = nil\n        return\n    end\n    local pd, near = closestPathDist(G.flying.x, G.flying.y)\n    if near < T.BALL_R * 1.2 and #G.balls > 0 then\n        sortBalls()\n        local minD, maxD = G.balls[1].dist, G.balls[#G.balls].dist\n        if pd >= minD - BALL_D and pd <= maxD + BALL_D then\n            insertBallAt(pd, G.flying.color)\n            G.flying = nil\n        end\n    end\nend\n\n\nlocal function updateRing(dt)\n    if G.stunTimer > 0 then\n        G.stunTimer = G.stunTimer - dt\n    end\n    if G.powerReverse > 0 then G.powerReverse = G.powerReverse - dt end\n    if G.slowTimer > 0 then G.slowTimer = G.slowTimer - dt end\n    local speed = G.lvl.speed + (G.speedBonus or 0)\n    local tail = G.balls[1]\n    local h0 = G.balls[#G.balls]\n    if G.rollInTail and h0 and h0.dist >= G.pathLen * T.ROLL_IN_HEAD then\n        G.rollInTail = false\n    end\n    if G.rollInTimer > 0 or G.rollInTail then\n\n        G.rollInTimer = G.rollInTimer - dt\n        speed = T.ROLL_IN_SPEED\n    end\n    local head = headBall()\n    if head and head.dist > G.pathLen * T.END_SLOW_AT then speed = speed * T.END_SLOW_MUL end\n    if G.slowTimer > 0 and G.rollInTimer <= 0 then speed = speed * T.PW_SLOW_MUL end\n\n    if (G.pushQ or 0) > 0 and G.rollInTimer <= 0 and not G.rollInTail and dt > 0 then\n        local step = math.min(G.pushQ, (G.lvl.pushRate or T.PUSH_RATE) * dt)\n        G.pushQ = G.pushQ - step\n        local mul = (head and head.dist > G.pathLen * T.END_SLOW_AT) and T.END_SLOW_MUL or 1\n        if G.slowTimer > 0 then mul = mul * T.PW_SLOW_MUL end\n        speed = speed + step * mul / dt\n    end\n    if G.reverseTimer > 0 then\n\n        G.reverseTimer = G.reverseTimer - dt\n        speed = (tail and tail.dist > 0) and T.REVERSE_SPEED or 0\n    elseif G.powerReverse > 0 then\n        speed = T.PW_REVERSE_SPEED\n    elseif G.stunTimer > 0 then\n        speed = 0\n    end\n\n    local gs = groups()\n    local k = math.min(1, dt * 14)\n    for g = 1, #gs do\n        local v = speed\n        if g > 1 then\n            local back, front = G.balls[gs[g - 1].i2], G.balls[gs[g].i1]\n            if back.color == front.color then\n                v = v - (T.MAGNET_SPEED + T.MAGNET_PER_CHAIN * G.chain)\n            else\n                v = v - T.GAP_CLOSE_SPEED\n            end\n        end\n        for i = gs[g].i1, gs[g].i2 do\n            local b = G.balls[i]\n            b.dist = b.dist + v * dt\n            b.vdist = b.vdist + v * dt\n            b.vdist = b.vdist + (b.dist - b.vdist) * k\n        end\n    end\n\n    local contacts = {}\n    for g = 2, #gs do\n        local back, front = G.balls[gs[g - 1].i2], G.balls[gs[g].i1]\n        if front.dist - back.dist <= BALL_D + 0.5 then\n            local delta = back.dist + BALL_D - front.dist\n            for i = gs[g].i1, gs[g].i2 do G.balls[i].dist = G.balls[i].dist + delta end\n            contacts[#contacts + 1] = front\n        end\n    end\n\n    for _, bead in ipairs(contacts) do\n        for i, b in ipairs(G.balls) do\n            if b == bead then chainClear(i) break end\n        end\n    end\n\n    if G.chain > 0 then\n        local live = false\n        local gs2 = groups()\n        for g = 2, #gs2 do\n            if G.balls[gs2[g - 1].i2].color == G.balls[gs2[g].i1].color then live = true break end\n        end\n        if not live then G.chain = 0 end\n    end\n    while G.queueLeft > 0 and (#G.balls == 0 or G.balls[1].dist > -BALL_D * 0.5) do\n        spawnTail(#G.balls == 0 and 0 or G.balls[1].dist - BALL_D)\n    end\n    head = headBall()\n    if head then G.maxHead = math.max(G.maxHead, head.dist) end\nend\n\nM.buildPath = buildPath\nM.pointOnPath = pointOnPath\nM.dist2 = dist2\nM.sortBalls = sortBalls\nM.rebuildSpacing = rebuildSpacing\nM.randColor = randColor\nM.spawnTail = spawnTail\nM.runs = runs\nM.runAt = runAt\nM.touching = touching\nM.grantPower = grantPower\nM.bumpCombo = bumpCombo\nM.addRingScore = addRingScore\nM.removeIndices = removeIndices\nM.ringResolveChains = ringResolveChains\nM.headBall = headBall\nM.removeHeadBeads = removeHeadBeads\nM.removeColorFromRing = removeColorFromRing\nM.stunRing = stunRing\nM.tryInsertFlying = tryInsertFlying\nM.updateRing = updateRing\n\nreturn M\n"],["ringcore/rng.lua","\n\n\n\n\nif type(rawget(_G, \"__ringcore_rng\")) == \"table\" then\n    return rawget(_G, \"__ringcore_rng\")\nend\nlocal M = {}\nlocal state = { chain = 1, board = 2, vis = 0x2545F491 }\n\nM.state = state\n\nlocal function mix(x)\n    x = x + 0x9E3779B97F4A7C15\n    x = (x ~ (x >> 30)) * 0xBF58476D1CE4E5B9\n    x = (x ~ (x >> 27)) * 0x94D049BB133111EB\n    x = x ~ (x >> 31)\n    if x == 0 then x = 0x2545F491 end\n    return x\nend\n\nlocal function nextf(name)\n    local x = state[name]\n    x = x ~ (x << 13); x = x ~ (x >> 7); x = x ~ (x << 17)\n    state[name] = x\n    return (x >> 11) * (1.0 / 9007199254740992.0)\nend\n\n\nlocal function draw(name, a, b)\n    local f = nextf(name)\n    if not a then return f end\n    if not b then a, b = 1, a end\n    return a + math.floor(f * (b - a + 1))\nend\n\nfunction M.seed(s)\n    s = math.floor(tonumber(s) or 0)\n    M.lastSeed = s\n    state.chain = mix(s * 2 + 1)\n    state.board = mix(s * 2 + 2)\nend\nfunction M.chain(a, b) return draw(\"chain\", a, b) end\nfunction M.board(a, b) return draw(\"board\", a, b) end\nfunction M.vis(a, b) return draw(\"vis\", a, b) end\n\nrawset(_G, \"__ringcore_rng\", M)\nreturn M\n"],["ringcore/roundboost.lua","\nlocal M = {}\nlocal G = require 'ringcore.state'\nlocal C = require 'ringcore.config'\nlocal P = require 'ringcore.progress'\nlocal T = C.T\nlocal ids = { 'reverse', 'slow' }\nfunction M.begin(picks)\n    G.armedBoost, G.roundBoost = nil, {}\n    for _, id in ipairs(ids) do\n        G.roundBoost[id] = { allowed = not G.runKind and picks and picks[id] == true or false, used = false, prepaid = false }\n    end\nend\nfunction M.restore(snap)\n    M.begin({})\n    if type(snap.roundBoost) == 'table' then\n        for _, id in ipairs(ids) do\n            local old = snap.roundBoost[id]\n            if type(old) == 'table' then\n                G.roundBoost[id] = { allowed = old.allowed == true, used = old.used == true,\n                    prepaid = old.prepaid == true and old.allowed == true and old.used ~= true }\n            end\n        end\n    else\n\n\n        for _, id in ipairs(type(snap.armedBoost) == 'table' and snap.armedBoost or {}) do\n            if G.roundBoost[id] then G.roundBoost[id] = { allowed = true, used = false, prepaid = true } end\n        end\n    end\nend\nfunction M.status(id)\n    local valid = id == 'reverse' or id == 'slow'\n    local b = valid and G.roundBoost and G.roundBoost[id] or {}\n    local timer = valid and (id == 'reverse' and math.max(G.powerReverse or 0, G.reverseTimer or 0) or G.slowTimer or 0) or 0\n    local stock = valid and P.stock(id) + (b.prepaid and 1 or 0) or 0\n    local reason\n    if not valid or G.state ~= 'playing' or G.runKind then reason = 'outside'\n    elseif timer > 0 then reason = 'active'\n    elseif not b.allowed then reason = 'notCarried'\n    elseif b.used then reason = 'used'\n    elseif stock <= 0 then reason = 'empty'\n    elseif (G.rollInTimer or 0) > 0 or G.rollInTail then reason = 'entering' end\n    return { enabled = reason == nil, reason = reason, stock = stock, timer = timer,\n        allowed = b.allowed == true, prepaid = b.prepaid == true, remaining = b.allowed and not b.used and 1 or 0 }\nend\nfunction M.activate(id)\n    if not M.status(id).enabled then return false end\n    local b = G.roundBoost[id]\n    if not b.prepaid and not P.consume(id) then return false end\n    b.used, b.prepaid = true, false\n    if id == 'reverse' then G.powerReverse = T.PW_REVERSE_TIME else G.slowTimer = T.PW_SLOW_TIME end\n    G.stats.powers = (G.stats.powers or 0) + 1\n    require('ringcore.funnel').count('booster:' .. id)\n    require('ringcore.audio').playSfx('pulse', 0.8, 0.6)\n    return true\nend\nreturn M\n"],["ringcore/savegame.lua","\n\n\n\n\n\n\n\n\n\n\n\nlocal M = {}\nlocal C = require \"ringcore.config\"\nlocal G = require \"ringcore.state\"\nlocal Rng = require \"ringcore.rng\"\n\nlocal SAVE_FILE = \"ringcore_run.json\"\nlocal VERSION = 1\nM.FILE = SAVE_FILE\n\n\nlocal SCALAR = {\n    \"levelIdx\", \"mode\", \"chapter\", \"hexPage\", \"score\", \"combo\", \"maxHead\", \"queueLeft\",\n    \"cannonAmmo\", \"reverseTimer\", \"stunTimer\", \"slowTimer\", \"powerReverse\", \"chain\",\n    \"hitstop\", \"shakeAmp\", \"shakeT\", \"stateTimer\", \"pushQ\", \"now\", \"lastHitTime\",\n    \"runContinues\", \"winDoubled\", \"clearDur\", \"rollInTimer\", \"rollInTail\", \"roundBoost\", \"boosterPick\", \"stats\", \"lvl\",\n\n\n    \"silt\", \"siltT\", \"siltColor\", \"siltLost\", \"siltColBottom\",\n}\n\nlocal function copyBalls()\n    local out = {}\n    for i, b in ipairs(G.balls or {}) do\n        out[i] = { color = b.color, dist = b.dist, vdist = b.vdist, id = b.id, power = b.power }\n    end\n    return out\nend\n\nlocal function copyBoard()\n    local out = {}\n    for r = 1, #(G.board or {}) do\n        local row = G.board[r]\n        local cells = {}\n        for c = 1, #row do\n            local cell = row[c]\n            if cell then\n                cells[c] = { color = cell.color, locked = cell.locked or false,\n                             special = cell.special, oy = cell.oy or 0,\n                             axis = cell.axis, popT = cell.popT }\n            end\n        end\n        out[r] = cells\n    end\n    return out\nend\n\nlocal function copyGoal()\n    local g = G.goal\n    if not g then return nil end\n    return {\n        kind = g.kind, moves = g.moves, used = g.used,\n        time = g.time, left = g.left,\n        cleared = g.cleared, beads = g.beads, survived = g.survived,\n    }\nend\n\nfunction M.hasRun()\n    if G.noSave then return false end\n\n    return M.peek() ~= nil\nend\n\n\nfunction M.saveRun()\n    if G.noSave then return false end\n    if G.state ~= \"playing\" and G.state ~= \"paused\" then return false end\n    if G.runKind then return false end\n    if not G.board or not G.lvl then return false end\n\n    local snap = { v = VERSION, savedAt = os.time(), boardState = G.boardState }\n    for _, k in ipairs(SCALAR) do snap[k] = G[k] end\n    snap.balls = copyBalls()\n    snap.board = copyBoard()\n    snap.goal = copyGoal()\n    snap.rng = { chain = Rng.state and Rng.state.chain, board = Rng.state and Rng.state.board }\n    snap.state = \"paused\"\n\n    local ok = pcall(function()\n        local f = File(SAVE_FILE, FILE_WRITE)\n        if not f:IsOpen() then return end\n        f:WriteString(cjson.encode(snap))\n        f:Close()\n    end)\n    if ok then pcall(function() require(\"ringcore.ui.screens\").invalidateSaveCache() end) end\n    return ok\nend\n\n\n\nfunction M.onProgressReplaced()\n    M.clearRun()\nend\n\nfunction M.clearRun()\n    if G.noSave then return false end\n\n    local ok = pcall(function()\n        local f = File(SAVE_FILE, FILE_WRITE)\n        if f:IsOpen() then f:WriteString(\"{}\"); f:Close() end\n    end)\n    if ok then pcall(function() require(\"ringcore.ui.screens\").invalidateSaveCache() end) end\n    return ok\nend\n\nlocal function readSnap()\n    if G.noSave then return nil end\n    local snap\n    local ok = pcall(function()\n        if not fileSystem:FileExists(SAVE_FILE) then return end\n        local f = File(SAVE_FILE, FILE_READ)\n        if not f:IsOpen() then return end\n        local okj, d = pcall(cjson.decode, f:ReadString())\n        f:Close()\n        if okj and type(d) == \"table\" and d.v == VERSION and type(d.board) == \"table\" then snap = d end\n    end)\n    if not ok then return nil end\n    return snap\nend\n\n\nfunction M.peek()\n    local snap = readSnap()\n    if not snap then return nil end\n    if not snap.board[1] or not snap.levelIdx then return nil end\n    return {\n        levelIdx = snap.levelIdx, mode = snap.mode or \"classic\",\n        score = snap.score or 0, kind = snap.goal and snap.goal.kind or \"classic\",\n        savedAt = snap.savedAt or 0,\n    }\nend\n\n\nfunction M.loadRun()\n    local snap = readSnap()\n    if not snap then return false end\n\n    G.mode = snap.mode or \"classic\"\n    G.levelIdx = snap.levelIdx\n    G.lvl = snap.lvl or G.lvl\n    G.chapter = snap.chapter or G.chapter\n    G.hexPage = snap.hexPage or G.hexPage\n\n\n    local board = {}\n    for r = 1, #snap.board do\n        local row = {}\n        for c = 1, #(snap.board[r] or {}) do\n            local cs = snap.board[r][c]\n            if cs then\n                row[c] = { color = cs.color, locked = cs.locked or false,\n                           special = cs.special, oy = cs.oy or 0,\n                           axis = cs.axis, popT = cs.popT }\n            end\n        end\n        board[r] = row\n    end\n    G.board = board\n\n\n    G.balls = {}\n    for i, b in ipairs(snap.balls or {}) do\n        G.balls[i] = { color = b.color, dist = b.dist, vdist = b.vdist, id = b.id, power = b.power }\n    end\n\n    for _, k in ipairs(SCALAR) do\n        if snap[k] ~= nil then G[k] = snap[k] end\n    end\n    G.rollInTimer = math.max(0, tonumber(snap.rollInTimer) or 0)\n    G.rollInTail = snap.rollInTail == true\n    require(\"ringcore.roundboost\").restore(snap)\n    G.goal = snap.goal\n    G.boardState = \"idle\"\n    G.state = \"paused\"\n    G.hint, G.selected, G.swapAnim, G.press = nil, nil, nil, nil\n    G.flying, G.pendingPulses, G.pulses, G.particles, G.popups, G.impacts = nil, {}, {}, {}, {}, {}\n    G.flashes, G.ringsFX, G.sparks, G.shards, G.motes = {}, {}, {}, {}, {}\n    G.causalFX = {}\n    G.progressFeedback, G.ui.tutorial = {}, nil\n    G.ui.ammoFocusUntil, G.ui.ammoHint = nil, nil\n    if G.mode ~= \"hex\" then G.silt, G.siltT, G.siltLost, G.siltColor, G.siltColBottom = nil, 0, nil, nil, nil end\n\n\n    if snap.rng then\n        Rng.state = { chain = snap.rng.chain or 1, board = snap.rng.board or 2 }\n    end\n    return true\nend\n\nreturn M\n"],["ringcore/state.lua","\n\n\n\nif type(rawget(_G, \"__ringcore_state\")) == \"table\" then\n    return rawget(_G, \"__ringcore_state\")\nend\nlocal C = require \"ringcore.config\"\nlocal G = {}\n\nG.fontId = -1\nG.img = {}\nG.dpr = 1\nG.logicalW = 540\nG.logicalH = 960\nG.scale = 1\nG.offX = 0\nG.offY = 0\nG.screenDW = 540\nG.screenDH = 960\nG.levelIdx = 1\nG.lvl = C.LEVELS[1]\nG.state = \"ready\"\nG.now = 0\nG.animT = 0\nG.score = 0\nG.combo = 1\nG.lastHitTime = -99\nG.path = {}\nG.pathLen = 0\nG.balls = {}\nG.queueLeft = 0\nG.reverseTimer = 0\nG.rollInTimer = 0\nG.stunTimer = 0\nG.maxHead = 0\nG.pulses = {}\nG.pendingPulses = {}\nG.reserved = {}\nG.board = {}\nG.boardState = \"idle\"\nG.stateTimer = 0\nG.swapAnim = nil\nG.cascade = 0\nG.idleTime = 0\nG.hint = nil\nG.lockDrops = {}\nG.lockQueue = {}\nG.warnActive = false\nG.lockTimer = 0\nG.cannonAmmo = {}\nG.flying = nil\nG.cannonShake = 0\nG.aimX = C.T.CANNON_X\nG.aimY = C.T.CANNON_Y - 80\nG.press = nil\nG.selected = nil\nG.particles = {}\nG.popups = {}\nG.flashes = {}\nG.ringsFX = {}\nG.sparks = {}\nG.anims = {}\nG.shakeT = 0\nG.shakeAmp = 0\nG.causalFX = {}\nG.impacts = {}\nG.shakeTrauma = 0\nG.hitstop = 0\nG.stats = {}\n\nG.chain = 0\nG.chainFlash = nil\nG.powerReverse = 0\nG.slowTimer = 0\nG.beams = {}\nG.powerGranted = false\nG.danger = 0\nG.heartT, G.heartDub, G.heartBeat = 0, 0, 0\nG.rollInTail = false\nG.lastStars, G.newBest = 0, false\nG.noSave = false\nG.bgmSrc = nil\nG.bgmOn = true\nG.mode = \"classic\"\nG.chapter = 1\nG.boosterPick = { reverse = false, slow = false, hint = false }\nG.sfxOn = true\nG.shakeOn = true\nG.difficulty = \"normal\"\nG.safeTop, G.safeBot, G.safeShift = 0, 926, 0\n\nG.ui = { press = nil, popId = nil, popT = 0, lastState = nil, t0 = 0, starsShown = 0, fx = {}, hudScore = 0 }\n\nrawset(_G, \"__ringcore_state\", G)\nreturn G\n"],["ringcore/stepmoves.lua","\n\n\nreturn {\n    [4] = 26,\n    [7] = 27,\n    [13] = 19,\n    [17] = 18,\n    [24] = 19,\n    [27] = 23,\n    [33] = 23,\n    [37] = 29,\n    [44] = 17,\n    [47] = 19,\n    [53] = 26,\n    [57] = 25,\n    [64] = 18,\n    [67] = 23,\n    [73] = 26,\n    [77] = 28,\n    [84] = 24,\n    [87] = 19,\n    [93] = 23,\n    [97] = 23,\n    [104] = 23,\n    [107] = 21,\n    [113] = 25,\n    [117] = 24,\n}\n"],["ringcore/ui/landscape.lua","\nlocal M={}\nlocal C=require 'ringcore.config'\nlocal G=require 'ringcore.state'\nlocal P=require 'ringcore.progress'\nlocal I=require 'ringcore.i18n'\nlocal D=require 'ringcore.daily'\nlocal Goals=require 'ringcore.goals'\nlocal W=require 'ringcore.ui.widgets'\nlocal Th=require 'ringcore.ui.theme'\nlocal Draw=require 'ringcore.draw'\nlocal ProgressFeedback=require 'ringcore.ui.progress_feedback'\nlocal tr=I.t\nlocal function text(ctx,x,y,t,size,width,color)\n    W.fitLabel(ctx,x,y,t,size or 18,width or 360,{col=color or Th.col.cream,outline=false,shadow=false})\nend\nlocal function at(b,x,y,w,h) b.x,b.y=x,y;b.w=w or b.w;b.h=h or b.h end\nfunction M.nodePos(i)\n    local row=math.floor((i-1)/5);local c=(i-1)%5\n    if row%2==1 then c=4-c end\n    return 140+c*170,190+row*180\nend\nfunction M.boosters(list)\n    for i,b in ipairs(list)do at(b,560+(i-1)*140,208,124,50) end\n    return list\nend\nfunction M.buttons(list)\n    local s,u=G.state,G.ui\n    for _,b in ipairs(list)do\n        local id=b.id\n        if s=='title' and u.settings then\n            local fixed={music={595,190},vol_bgm_dn={776,190},vol_bgm_up={875,190},sfx={595,290},vol_sfx_dn={776,290},vol_sfx_up={875,290},shake_toggle={825,376},credits={280,460},settings_close={720,460}}\n            if fixed[id] then at(b,table.unpack(fixed[id])) end\n            for i,lang in ipairs(I.LANGS)do if id=='lang_'..lang then at(b,160+(i-1)*145,190)end end\n            for i,d in ipairs(C.DIFFICULTY_TIERS)do if id=='diff_'..d then at(b,160+(i-1)*145,365)end end\n        elseif s=='title' and (u.credits or u.funnel) then\n            if id=='credits_home' then at(b,290,480)elseif id=='credits_close' or id=='funnel_close' then at(b,710,480)\n            elseif id=='funnel_open' then at(b,480,465,160,40)elseif id=='funnel_export' then at(b,180,480)elseif id=='funnel_reset' then at(b,440,480)end\n        elseif s=='playing' then if id=='pause' then at(b,925,39)end\n        elseif s=='select' then\n            if id=='peek_close' then at(b,720,456)elseif id=='ch_prev' then at(b,130,498,180,44)elseif id=='ch_next' then at(b,830,498,180,44)end\n        elseif s=='daily' then\n            if id~='to_title' then at(b,720,440)end\n        elseif s=='ready' then\n            if id=='start' then at(b,790,480,220,54)elseif id=='tomap' then at(b,560,480,180,50)elseif id=='hive_tip_ok' then at(b,720,450,220,50)end\n        elseif s=='paused' then\n            local fixed={resume={720,174},restart={720,254},tomap={720,334},to_title={720,414},music={130,340},vol_bgm_dn={190,340},vol_bgm_up={246,340},sfx={324,340},vol_sfx_dn={384,340},vol_sfx_up={440,340},shake_toggle={280,458}}\n            if fixed[id] then at(b,table.unpack(fixed[id]))end\n        elseif s=='lost' then\n            if id=='retry' then at(b,740,444,230,62)elseif id=='tomap' then at(b,520,444,150,54)elseif id=='to_title' then at(b,400,444,56,54)elseif id=='ad_continue' then at(b,720,368)end\n        elseif s=='won' or s=='allclear' then\n            if id=='next' or id=='to_title' and b.skin=='primary' then at(b,740,480,250,54)\n            elseif id=='retry' then at(b,560,480,48,50)elseif id=='tomap' then at(b,420,480,190,50)\n            elseif id=='to_title' then at(b,480,412,220,40)elseif id=='ad_double' then at(b,720,308)end\n        elseif s=='runover' then\n            if id=='run_retry' or id=='ad_practice' or id=='practice_none' then at(b,740,444,240,62)elseif id=='run_back' then at(b,510,444,160,54)elseif id=='to_title' then at(b,380,444,56,54)end\n        end\n    end\n    return list\nend\nlocal function buttons(ctx,S)\n    for _,b in ipairs(S.buttons())do if not b.card and not b.hitOnly then W.button(ctx,b)end end\nend\nlocal function backdrop(ctx,title)\n    W.cover(ctx,G.img.titleBg or G.img.gameBg,1);W.dim(ctx,130)\n    if title~=\"\" then W.ribbon(ctx,480,52,400,title,Th.btn.secondary,27) end\nend\nlocal function panel(ctx,title,skin,textOpts,height)\n    W.dim(ctx,165);W.panel(ctx,48,102,864,height or 404,{r=26})\n    W.ribbon(ctx,480,110,420,title,skin or Th.btn.secondary,27,nil,textOpts)\nend\nlocal function settings(ctx,S)\n    panel(ctx,tr('set.title'))\n    text(ctx,300,148,tr('set.language'),20)\n    text(ctx,300,285,tr('set.difficulty'),20)\n    W.paragraph(ctx,300,312,tr('set.diffHint'),15,400,{col=Th.col.mist},20)\n    local L=NVG_ALIGN_LEFT+NVG_ALIGN_MIDDLE\n    local R=NVG_ALIGN_RIGHT+NVG_ALIGN_MIDDLE\n    for i=1,2 do\n        local y=i==1 and 148 or 248\n        local gain=i==1 and (C.BGM_GAIN or .35) or (C.SFX_GAIN or .7)\n        W.label(ctx,570,y,tr(i==1 and 'set.music' or 'set.sfx'),20,{align=L})\n        W.label(ctx,890,y,string.format('%.0f%%',gain*100),19,{align=R,col=Th.col.amber})\n    end\n    W.label(ctx,570,376,tr('set.shake'),20,{align=L})\n    W.paragraph(ctx,570,413,tr('set.shakeDesc'),15,320,{align=L,col=Th.col.mist},20)\n    buttons(ctx,S)\nend\nlocal function credits(ctx,S)\n    panel(ctx,tr('credits.title'))\n    text(ctx,480,148,tr('credits.cc0'),15,760)\n    for i,row in ipairs(S.creditRows)do\n        local x=i%2==1 and 280 or 700;local y=190+math.floor((i-1)/2)*55\n        text(ctx,x,y,tr(row[1]),16,380,Th.col.amber);text(ctx,x,y+24,row[2],13,380)\n    end\n    text(ctx,480,415,tr('credits.code'),14,780,Th.col.mist);buttons(ctx,S)\nend\nfunction M.full(ctx,S)\n    local s=G.state\n    if s=='title' then\n        backdrop(ctx,'')\n        if G.ui.funnel then\n            panel(ctx,tr('funnel.title'));text(ctx,480,160,tr('funnel.note'),16,760)\n            local funnel=require 'ringcore.funnel'\n            for i,key in ipairs({'funnel.level','funnel.start','funnel.win','funnel.fail','funnel.quit'})do text(ctx,95+(i-1)*100,195,tr(key),15,92,Th.col.mist)end\n            for i,row in ipairs(funnel.levelRows())do\n                for k,value in ipairs(row)do text(ctx,95+(k-1)*100,230+(i-1)*32,tostring(value),17,92)end\n            end\n            local other=funnel.otherRows()\n            if #other==0 then text(ctx,740,240,tr('funnel.empty'),16,290)end\n            for i=1,math.min(#other,20)do\n                local col=i<=10 and 640 or 825;local y=210+((i-1)%10)*22\n                text(ctx,col,y,other[i][1]..' '..other[i][2],12,175,Th.col.mist)\n            end\n            if G.ui.funnelMsg then text(ctx,480,442,G.ui.funnelMsg,14,780,Th.col.mossLt)end\n            buttons(ctx,S);return\n        elseif G.ui.credits then credits(ctx,S);return\n        elseif G.ui.settings then settings(ctx,S);return end\n        text(ctx,250,220,tr('title.logo'),50,430,Th.col.amber)\n        text(ctx,250,290,tr('title.tagline'),19,400,Th.col.mist)\n        for _,b in ipairs(S.buttons())do if b.card=='daily' then S.drawDailyEntry(ctx,b)else W.button(ctx,b)end end\n    elseif s=='modesel' then\n        backdrop(ctx,tr('btn.modes'));for _,b in ipairs(S.buttons())do if b.card then S.drawModeCard(ctx,b)end end;buttons(ctx,S)\n    elseif s=='arena' then\n        backdrop(ctx,tr('mode.hall'));for _,b in ipairs(S.buttons())do if b.card then S.drawArenaCard(ctx,b)end end\n        text(ctx,480,492,tr('hall.note'),16,840,Th.col.mist);text(ctx,480,517,tr('hall.noLeaderboard'),14,840,Th.col.mist);buttons(ctx,S)\n    elseif s=='select' then\n        local hx=G.mode=='hex';local ch=hx and C.HEX_PAGES[G.hexPage or 1] or C.CHAPTERS[G.chapter or 1]\n        backdrop(ctx,hx and tr(ch.nameKey) or tr('map.chapterTitle',{n=ch.id,name=tr(ch.nameKey)}))\n        local list=S.mapLevels()\n        nvgBeginPath(ctx)\n        for i,lv in ipairs(list)do local x,y=M.nodePos(lv.slot);if i==1 then nvgMoveTo(ctx,x,y)else nvgLineTo(ctx,x,y)end end\n        nvgStrokeColor(ctx,nvgRGBA(128,200,150,180));nvgStrokeWidth(ctx,6);nvgStroke(ctx)\n        for _,lv in ipairs(list)do\n            local x,y=M.nodePos(lv.slot);local open=lv.idx<=(hx and P.hexUnlocked() or P.unlocked())\n            W.button(ctx,{id='node'..lv.idx,x=x,y=y,w=64,h=64,round=true,skin=open and 'secondary' or 'neutral',label=tostring(lv.idx),size=24})\n            text(ctx,x,y+53,open and I.levelName(lv.lv) or tr('map.locked'),14,152,open and Th.col.cream or Th.col.mist)\n            local best=hx and P.hexBest(lv.idx) or P.best(lv.idx)\n            if best then for k=1,3 do W.star(ctx,x-20+(k-1)*20,y-46,8,k<=(best.stars or 0))end end\n        end\n        buttons(ctx,S)\n        if G.ui.peek then\n            local lv=hx and C.HEX_LEVELS[G.ui.peek] or C.LEVELS[G.ui.peek]\n            panel(ctx,tr('map.locked'));text(ctx,480,185,I.levelName(lv),24,720,Th.col.amber)\n            local a,b,c=S.goalLines(lv);text(ctx,480,245,a,22,760);text(ctx,480,288,b,17,760);text(ctx,480,345,c,16,760)\n            buttons(ctx,S)\n        end\n    elseif s=='daily' then\n        backdrop(ctx,tr('daily.title'));local day=D.now();local lv=D.level(day);local dd=P.daily()\n        W.panel(ctx,44,110,440,370,{r=24});W.panel(ctx,510,110,406,370,{r=24})\n        text(ctx,264,150,tr('daily.dateWeek',{date=I.date(day.month,day.day),wk=D.weekName(day.wday)}),22,390,Th.col.amber)\n        text(ctx,264,220,Goals.describe(lv),23,390);text(ctx,264,260,tr('daily.board'),15,390,Th.col.mist)\n        text(ctx,264,325,tr('daily.streak',{n=P.dailyStreak(day.key)}),19,390)\n        text(ctx,264,365,tr('run.localBest',{n=dd.best or 0}),20,390)\n        text(ctx,264,415,P.dailyPlayed(day.key) and tr('daily.todayScore')..' '..tostring(dd.todayScore or 0) or tr('daily.notStarted'),18,390)\n        text(ctx,714,150,tr('daily.nextReset'),17,370,Th.col.mist);text(ctx,714,196,D.fmtCountdown(day.secLeft),31,370,Th.col.amber)\n        for i,key in ipairs({'daily.rule1','daily.rule2','daily.rule3','daily.rule4'})do text(ctx,714,250+(i-1)*35,tr(key),15,370,Th.col.mist)end\n        buttons(ctx,S)\n    end\nend\nfunction M.overlay(ctx,S)\n    local s=G.state\n    if s=='ready' then\n        if G.ui.hiveTip then\n            panel(ctx,tr('hive.tut.title'))\n            for i=1,3 do text(ctx,480,180+(i-1)*75,tr('hive.tut.'..i),23,760,Th.col.amber);text(ctx,480,210+(i-1)*75,tr('hive.tut.'..i..'s'),17,760)end\n            buttons(ctx,S);return\n        end\n        panel(ctx,I.levelName(G.lvl),nil,nil,418)\n        local a,b,c=S.goalLines(G.lvl)\n        W.paragraph(ctx,270,184,a,23,380,{col=Th.col.amber},27)\n        W.paragraph(ctx,270,245,b,17,380,{col=Th.col.cream},22)\n        W.paragraph(ctx,270,320,c,16,380,{col=Th.col.mist},21)\n        local best=not G.runKind and (G.mode=='hex' and P.hexBest(G.levelIdx) or P.best(G.levelIdx))\n        if best then text(ctx,270,390,tr('ready.best',{n=best.score}),17,380)end\n        if not G.runKind and G.mode~='hex' then W.paragraph(ctx,270,430,tr('ready.stock'),15,380,{col=Th.col.mossLt},20)end\n        if not G.runKind then\n            text(ctx,720,156,tr('ready.boosters'),19,370,Th.col.mossLt)\n            for _,b in ipairs(S.boosterButtons())do W.button(ctx,b)end\n            for i,bd in ipairs(C.BOOSTERS)do W.paragraph(ctx,530,326+(i-1)*42,tr('ready.boosterLine',{name=tr(bd.nameKey),desc=tr(bd.descKey)}),15,360,{align=NVG_ALIGN_LEFT+NVG_ALIGN_MIDDLE,col=G.boosterPick and G.boosterPick[bd.id] and Th.col.amber or Th.col.mist},20)end\n            local n=0;for _,v in pairs(G.boosterPick or {})do if v then n=n+1 end end\n            W.paragraph(ctx,720,282,tr('roundBoost.rule'),14,380,{col=Th.col.cream},19)\n            text(ctx,720,254,tr('booster.pickLimit',{a=n,b=2}),15,380,Th.col.mossLt)\n        else text(ctx,720,270,tr('hall.note'),18,380,Th.col.mist)end\n        buttons(ctx,S)\n    elseif s=='paused' then\n        local opts={col=Th.col.cream,crisp=true,shadow=false,ow=1,min=20}\n        panel(ctx,tr('pause.title'),nil,{col=Th.col.cream,crisp=true,shadow=false,ow=1,min=26})\n        W.fitLabel(ctx,280,190,I.levelName(G.lvl),26,390,opts)\n        W.fitLabel(ctx,280,240,S.pauseInfo(),20,390,opts)\n        for i,key in ipairs({'pause.music','pause.sfx'})do\n            local gain=i==1 and (C.BGM_GAIN or .35) or (C.SFX_GAIN or .7)\n            W.fitLabel(ctx,i==1 and 190 or 385,395,tr(key,{p=string.format('%.0f',gain*100)}),18,170,\n                {col=Th.col.cream,crisp=true,shadow=false,ow=1,min=18})\n        end\n        buttons(ctx,S)\n    elseif s=='lost' then\n        local title,why=S.lossText();panel(ctx,title,Th.btn.danger)\n        local nextY=W.paragraph(ctx,480,200,why,24,780,{col=Th.col.amber},30)\n        text(ctx,480,nextY+30,tr('lose.score',{n=G.score}),19,780,Th.col.mist)\n        W.paragraph(ctx,480,nextY+82,tr(S.loseTipKey()),18,760,{col=Th.col.cream},25)\n        buttons(ctx,S)\n    elseif s=='won' or s=='allclear' then\n        panel(ctx,tr(s=='allclear' and (G.mode=='hex' and 'win.allHive' or 'win.allChapter') or 'win.clear'),nil,nil,418)\n        for k=1,3 do W.star(ctx,190+(k-1)*80,230,30,k<=(G.lastStars or 0))end\n        local progress=#ProgressFeedback.lines()>0\n        text(ctx,270,progress and 285 or 320,tostring(G.score),28,360)\n        if progress then ProgressFeedback.draw(ctx,270,327,350,25)\n        elseif G.newBest then text(ctx,270,367,tr('win.newRecord'),18,360,Th.col.mossLt)end\n        W.paragraph(ctx,710,205,tr('win.stats1',{c=G.stats.maxChain or 0,s=G.stats.specialsFired or 0,m=G.stats.combos or 0}),17,365,{col=Th.col.mist},23)\n        W.paragraph(ctx,710,265,tr('win.stats2',{c=G.stats.cannonShots or 0,p=G.stats.powers or 0,l=string.format('%.1f',G.stats.maxCombo or 1)}),17,365,{col=Th.col.mist},23)\n        buttons(ctx,S)\n    elseif s=='runover' then\n        local key=G.runKind=='daily' and (G.runResult=='won' and 'run.dailyWon' or 'run.dailyLost') or (G.runKind=='blitz' and 'run.timeUp' or 'run.endlessOver')\n        panel(ctx,tr(key));text(ctx,270,225,tostring(G.score),38,360,Th.col.amber)\n        local timeLabel=G.runKind=='daily' and tr('run.dailyMissed') or tr(G.runKind=='endless' and 'run.survived' or 'run.rushTime',{t=Goals.fmtTime(G.goal and G.goal.survived or 0)})\n        if G.runKind=='daily' and G.runResult=='won' then\n            for k=1,3 do W.star(ctx,200+(k-1)*70,310,22,k<=(G.lastStars or 0))end\n        else text(ctx,270,295,timeLabel,20,360)end\n        local best=G.runKind=='daily' and (P.daily().best or 0) or ((P.arenaBest(G.runKind..(G.runTier or 1)) or {}).score or 0)\n        text(ctx,270,365,G.newBest and not G.runPractice and tr('run.localRecord') or tr('run.localBest',{n=best}),18,360,Th.col.mossLt)\n        text(ctx,710,220,tr('run.stats',{s=G.stats.specialsFired or 0,m=G.stats.combos or 0,c=G.stats.cannonShots or 0}),17,370,Th.col.mist)\n        local line\n        if G.runKind=='daily' then line=G.runPractice and tr('run.practice') or tr('run.recorded',{n=P.dailyStreak(G.lvl.dayKey)})\n        elseif G.runKind=='endless' then line=tr('run.endlessLine',{p=math.floor((G.speedBonus or 0)/G.lvl.speed*100+.5),b=G.goal and G.goal.beads or 0})\n        else line=tr('run.rushLine',{b=G.goal and G.goal.beads or 0,c=G.stats.maxChain or 0})end\n        text(ctx,710,290,line,18,370)\n        buttons(ctx,S)\n    end\nend\nreturn M\n"],["ringcore/ui/progress_feedback.lua","\nlocal M = {}\nlocal G = require 'ringcore.state'\nlocal W = require 'ringcore.ui.widgets'\nlocal tr = require('ringcore.i18n').t\nfunction M.lines()\n    if G.state ~= 'won' and G.state ~= 'allclear' then return {} end\n    local d, out = G.progressFeedback or {}, {}\n    if d.stars and d.stars.after > d.stars.before then\n        out[#out + 1] = tr('progress.stars', { a = d.stars.before, b = d.stars.after })\n    end\n    if d.chain and d.chain.after > d.chain.before then\n        out[#out + 1] = tr('progress.chain', { a = d.chain.before, b = d.chain.after })\n    end\n    if d.chapter then out[#out + 1] = tr(d.chapter.hex and 'progress.page' or 'progress.chapter', { n = d.chapter.id }) end\n    return out\nend\nfunction M.draw(ctx, x, y, width, gap)\n    local lines = M.lines()\n    for i, line in ipairs(lines) do\n        W.fitLabel(ctx, x, y + (i - 1) * gap, line, 17, width,\n            { col = { 190, 240, 170 }, outline = false, shadow = false })\n    end\n    return #lines\nend\nreturn M\n"],["ringcore/ui/screens.lua","\n\n\nlocal Rng = require \"ringcore.rng\"\nlocal M = {}\nlocal C = require \"ringcore.config\"\nlocal G = require \"ringcore.state\"\nlocal Progress = require \"ringcore.progress\"\nlocal Ring = require \"ringcore.ring\"\nlocal Audio = require \"ringcore.audio\"\nlocal Draw = require \"ringcore.draw\"\nlocal Goals = require \"ringcore.goals\"\nlocal Daily = require \"ringcore.daily\"\nlocal Monetize = require \"ringcore.monetize\"\nlocal Funnel = require \"ringcore.funnel\"\nlocal Th = require \"ringcore.ui.theme\"\nlocal W = require \"ringcore.ui.widgets\"\nlocal I18n = require \"ringcore.i18n\"\nlocal SaveGame = require \"ringcore.savegame\"\nlocal tr = I18n.t\nlocal col, rgba, TY = Th.col, Th.rgba, Th.type\nlocal T, LEVELS, COLORS = C.T, C.LEVELS, C.COLORS\nlocal DW, DH = C.DESIGN_W, C.DESIGN_H\nlocal Wide=require \"ringcore.ui.landscape\"\nlocal ProgressFeedback=require \"ringcore.ui.progress_feedback\"\n\nM.FULLSCREEN = { title = true, modesel = true, select = true, daily = true, arena = true }\n\n\n\n\n\nlocal LAYOUTS = {\n    ch1 = { { 330, 868 }, { 432, 790 }, { 330, 712 }, { 182, 650 }, { 108, 566 },\n            { 212, 488 }, { 392, 446 }, { 440, 352 }, { 312, 280 }, { 170, 202 } },\n    ch2 = { { 270, 862 }, { 120, 790 }, { 236, 700 }, { 410, 668 }, { 440, 560 },\n            { 300, 500 }, { 140, 452 }, { 96, 344 }, { 236, 276 }, { 400, 214 } },\n    hex = { { 270, 858 }, { 410, 748 }, { 260, 636 }, { 120, 520 }, { 250, 404 },\n            { 400, 300 }, { 270, 196 } },\n    ch5 = { { 290, 860 }, { 420, 800 }, { 300, 724 }, { 140, 672 }, { 110, 566 },\n            { 250, 520 }, { 420, 500 }, { 430, 384 }, { 270, 318 }, { 130, 220 } },\n    ch6 = { { 150, 866 }, { 312, 820 }, { 440, 736 }, { 322, 650 }, { 160, 598 },\n            { 118, 486 }, { 270, 432 }, { 430, 378 }, { 352, 268 }, { 188, 206 } },\n}\nlocal function mirror(L)\n    local out = {}\n    for i, p in ipairs(L) do out[i] = { 540 - p[1], p[2] } end\n    return out\nend\nLAYOUTS.ch3 = mirror(LAYOUTS.ch1)\nLAYOUTS.ch4 = mirror(LAYOUTS.ch2)\nLAYOUTS.hex2 = mirror(LAYOUTS.hex)\nlocal function hexPage()\n    return C.HEX_PAGES[G.hexPage or 1] or C.HEX_PAGES[1]\nend\nlocal function layoutKey()\n    if G.mode == \"hex\" then return hexPage().layout end\n    for _, ch in ipairs(C.CHAPTERS) do if ch.id == (G.chapter or 1) then return ch.layout end end\n    return \"ch1\"\nend\nlocal function nodePos(i)\n    if C.ORIENT==\"landscape\" then return Wide.nodePos(i) end\n    local L = LAYOUTS[layoutKey()] or LAYOUTS.ch1\n    local n = L[i] or L[#L]\n    return n[1], n[2]\nend\nM.nodePos = nodePos\nM.LAYOUTS = LAYOUTS\nlocal NODE_R = 32\n\nlocal function chapterInfo()\n    for _, ch in ipairs(C.CHAPTERS) do if ch.id == (G.chapter or 1) then return ch end end\n    return C.CHAPTERS[1]\nend\n\n\nlocal function mapLevels()\n    local out = {}\n    if G.mode == \"hex\" then\n        local pg = hexPage()\n        for i = pg.first, pg.last do out[#out + 1] = { idx = i, slot = i - pg.first + 1, lv = C.HEX_LEVELS[i] } end\n    else\n        local ch = chapterInfo()\n        local slot = 1\n        for i = ch.first, ch.last do\n            out[#out + 1] = { idx = i, slot = slot, lv = LEVELS[i] }\n            slot = slot + 1\n        end\n    end\n    return out\nend\nM.mapLevels=mapLevels\n\n\nlocal function adCtx()\n    return { mode = G.mode, level = G.levelIdx, runKind = G.runKind, practice = G.runPractice, progress = Progress.unlocked() }\nend\nM.adCtx = adCtx\n\nlocal function adLabel(offer, adKey, vars)\n    if Monetize.busy() then return tr(\"ad.loading\") end\n\n    if offer.blocked == \"dailyCap\" then return tr(\"ad.dailyCapShort\") end\n    if offer.blocked == \"cooldown\" then return tr(\"ad.cooldownShort\", { s = offer.wait or 0 }) end\n    if (offer.ads or 1) > 1 then return tr(\"ad.multi\", { n = offer.ads }) end\n    return tr(adKey, vars)\nend\n\nlocal function boosterButtons()\n    if G.runKind then return {} end\n    local ids = { \"reverse\", \"slow\", \"hint\" }\n    local names = { reverse = \"booster.reverse\", slow = \"booster.slow\", hint = \"booster.hint\" }\n    local btns = {}\n\n    local offer = Monetize.offer(\"booster\", adCtx())\n    for i, id in ipairs(ids) do\n        local picked = G.boosterPick and G.boosterPick[id]\n        local stock = Progress.stock(id)\n        if stock <= 0 and not picked and offer then\n            btns[#btns + 1] = {\n                id = \"adboost_\" .. id, x = 120 + (i - 1) * 150, y = 452, w = 134, h = 54,\n                label = adLabel(offer, \"ad.boosterBtn\", { name = tr(names[id]) }), icon = offer.kind == \"ad\" and \"play\" or nil,\n                skin = \"secondary\", enabled = not Monetize.busy(), size = 14,\n            }\n        else\n            btns[#btns + 1] = {\n                id = \"boost_\" .. id, x = 120 + (i - 1) * 150, y = 452, w = 134, h = 54,\n                label = tr(\"booster.btn\", { name = tr(names[id]), n = stock }),\n                skin = picked and \"primary\" or \"secondary\",\n                enabled = stock > 0 or picked, size = 15,\n            }\n        end\n    end\n    return C.ORIENT==\"landscape\" and Wide.boosters(btns) or btns\nend\nM.boosterButtons=boosterButtons\n\n\n\nlocal function rescueButtons()\n    if G.runKind or not Monetize.canContinue() then return {} end\n    local ctx = adCtx()\n    local list = {}\n    local o = Monetize.offer(\"continue\", ctx)\n    if o then\n        list[#list + 1] = { id = \"ad_continue\", label = adLabel(o, \"ad.continueBtn\"), icon = o.kind == \"ad\" and \"play\" or nil,\n            skin = \"primary\", size = 19, enabled = not Monetize.busy() }\n    end\n    for _, b in ipairs(list) do b.x, b.w, b.y, b.h = 270, 320, 584, 54 end\n    return list\nend\nM.rescueButtons = rescueButtons\n\n\nlocal HUD = { y = 6, h = 66 }\nlocal BAR = { y = 902, h = 52 }\nM.BAR = BAR\n\n\nlocal AMMO = { x0 = 334, dx = 48, y = 464, hit = 44 }\n\nlocal function syncOriented()\n    if C.ORIENT==\"landscape\" then AMMO.x0,AMMO.y=T.CANNON_X+64,T.CANNON_Y+26\n    else AMMO.x0,AMMO.y=334,464 end\nend\nlocal function syncBar()\n    BAR.y = G.barY or 902\n    HUD.y = 6 + (G.safeShift or 0)\n    syncOriented()\nend\nlocal function ammoSlotAt(x, y)\n    syncOriented()\n    for i = 1, T.CANNON_MAX do\n        local ax = AMMO.x0 + (i - 1) * AMMO.dx\n        if math.abs(x - ax) <= AMMO.hit / 2 and math.abs(y - AMMO.y) <= AMMO.hit / 2 then return i end\n    end\nend\n\nlocal function shortName(lv) return I18n.levelName(lv) end\n\n\nlocal TAG_COL = {\n    step = { { 120, 200, 255 }, { 40, 110, 200 } },\n    timed = { { 255, 176, 80 }, { 214, 92, 40 } },\n    endless = { { 190, 150, 255 }, { 110, 60, 200 } },\n    blitz = { { 255, 140, 200 }, { 200, 50, 130 } },\n}\nlocal function goalBadge(lv)\n    if lv.boss then return \"BOSS\" end\n    local k = Goals.kind(lv)\n    local tc = TAG_COL[k]\n    if not tc then return nil end\n    return { Goals.tag(k), top = tc[1], bot = tc[2] }\nend\nM.goalBadge = goalBadge\n\n\nfunction M.practiceAdButton(b)\n    local o = Monetize.offer(\"practice\", { runKind = \"daily\", practice = true, progress = Progress.unlocked() })\n    if o then\n        return { id = \"ad_practice\", x = b.x, y = b.y, w = b.w + 40, h = b.h, label = adLabel(o, \"ad.practiceBtn\"),\n            icon = o.kind == \"ad\" and \"play\" or nil, skin = \"secondary\", size = 18, enabled = not Monetize.busy() }\n    end\n    return { id = \"practice_none\", x = b.x, y = b.y, w = b.w + 40, h = b.h, label = tr(\"ad.practiceNone\"), skin = \"neutral\", size = 16, enabled = false }\nend\n\n\nlocal function buildButtons(hasSave)\n    syncBar()\n    local s = G.state\n    if s == \"title\" and G.ui.funnel then\n\n        return {\n            { id = \"funnel_export\", x = 150, y = 800, w = 190, h = 52, label = tr(\"funnel.export\"), skin = \"secondary\", size = 18 },\n            { id = \"funnel_reset\", x = 390, y = 800, w = 190, h = 52, label = tr(\"funnel.reset\"), skin = \"neutral\", size = 18 },\n            { id = \"funnel_close\", x = 270, y = 868, w = 200, h = 56, label = tr(\"btn.back\"), icon = \"back\", skin = \"primary\", size = 22 },\n        }\n    elseif s == \"title\" and G.ui.credits then\n        return {\n            { id = \"funnel_open\", x = 270, y = 780, w = 230, h = 44, label = tr(\"funnel.open\"), skin = \"neutral\", size = 16 },\n            { id = \"credits_home\", x = 150, y = 860, w = 150, h = 56, label = tr(\"btn.home\"), icon = \"home\", skin = \"secondary\", size = 17 },\n            { id = \"credits_close\", x = 350, y = 860, w = 190, h = 56, label = tr(\"btn.back\"), icon = \"back\", skin = \"primary\", size = 21 },\n        }\n    elseif s == \"title\" and G.ui.settings then\n\n        local btns = {\n            { id = \"music\", x = 112, y = 364, w = 52, h = 52, icon = \"music\", skin = \"neutral\", round = true, iconOff = not G.bgmOn },\n            { id = \"vol_bgm_dn\", x = 318, y = 364, w = 48, h = 48, label = \"−\", skin = \"neutral\", size = 24 },\n            { id = \"vol_bgm_up\", x = 440, y = 364, w = 48, h = 48, label = \"+\", skin = \"neutral\", size = 24 },\n            { id = \"sfx\", x = 112, y = 462, w = 52, h = 52, icon = \"sfx\", skin = \"neutral\", round = true, iconOff = not G.sfxOn },\n            { id = \"vol_sfx_dn\", x = 318, y = 462, w = 48, h = 48, label = \"−\", skin = \"neutral\", size = 24 },\n            { id = \"vol_sfx_up\", x = 440, y = 462, w = 48, h = 48, label = \"+\", skin = \"neutral\", size = 24 },\n            { id = \"shake_toggle\", x = 418, y = 532, w = 112, h = 44, label = G.shakeOn and tr(\"btn.on\") or tr(\"btn.off\"), skin = G.shakeOn and \"primary\" or \"neutral\", size = 20 },\n            { id = \"credits\", x = 270, y = 758, w = 220, h = 42, label = tr(\"btn.credits\"), icon = \"info\", skin = \"secondary\", size = 17 },\n            { id = \"settings_close\", x = 270, y = 828, w = 220, h = 48, label = tr(\"btn.done\"), skin = \"primary\", size = 22 },\n        }\n\n        for i, d in ipairs(C.DIFFICULTY_TIERS) do\n            btns[#btns + 1] = { id = \"diff_\" .. d, x = 122 + (i - 1) * 148, y = 695, w = 140, h = 46,\n                label = tr(\"set.diff.\" .. d), skin = (G.difficulty == d) and \"primary\" or \"neutral\", size = 19 }\n        end\n\n        for i, lang in ipairs(I18n.LANGS) do\n            btns[#btns + 1] = { id = \"lang_\" .. lang, x = 122 + (i - 1) * 148, y = 252, w = 140, h = 48, label = I18n.nativeName(lang),\n                skin = (I18n.lang == lang) and \"primary\" or \"neutral\", size = 18 }\n        end\n        return btns\n    elseif s == \"title\" then\n        local wide = C.ORIENT == \"landscape\"\n        local x, y = wide and 720 or 270, wide and 330 or 712\n        local current = math.min(#LEVELS, Progress.unlocked())\n        return {\n            { id = hasSave and \"resume_run\" or \"quick_classic\", x = x, y = y, w = 320, h = 76,\n                label = hasSave and tr(\"btn.resumeRun\") or tr(\"btn.quickClassic\", { n = current }),\n                icon = \"play\", skin = \"primary\", size = 27, pulse = true },\n            { id = \"play\", x = x, y = wide and 236 or 620, w = 280, h = 54,\n                label = tr(\"btn.modes\"), skin = \"secondary\", size = 21 },\n            { id = \"daily_entry\", x = x, y = wide and 150 or 520, w = 360, h = 88, card = \"daily\" },\n            { id = \"settings\", x = wide and 912 or 490, y = 52, w = 52, h = 52, icon = \"gear\", skin = \"neutral\", round = true },\n        }\n    elseif s == \"modesel\" then\n\n        local cards = (C.ORIENT == \"landscape\") and {\n            { id = \"mode_classic\", x = 190, y = 330, w = 286, h = 330, card = \"classic\" },\n            { id = \"mode_hex\", x = 480, y = 330, w = 286, h = 330, card = \"hex\" },\n            { id = \"mode_arena\", x = 770, y = 330, w = 286, h = 330, card = \"arena\" },\n        } or {\n            { id = \"mode_classic\", x = 270, y = 262, w = 440, h = 212, card = \"classic\" },\n            { id = \"mode_hex\", x = 270, y = 492, w = 440, h = 212, card = \"hex\" },\n            { id = \"mode_arena\", x = 270, y = 722, w = 440, h = 212, card = \"arena\" },\n        }\n        local btns = { { id = \"to_title\", x = 46, y = 52, w = 56, h = 56, icon = \"back\", skin = \"neutral\", round = true } }\n        local sel = M.selectedMode()\n        for _, b in ipairs(cards) do\n            if b.card == sel and not M.modeLocked(b.card) then btns[#btns + 1] = M.modeEnterButton(b) end\n        end\n        for _, b in ipairs(cards) do btns[#btns + 1] = b end\n        return btns\n    elseif s == \"daily\" then\n        local played = Progress.dailyPlayed(Daily.now().key)\n        local pb = { id = \"daily_practice\", x = 270, y = 790, w = 300, h = 72, label = tr(\"btn.practice\"), icon = \"restart\", skin = \"secondary\", size = 24 }\n        if played and Monetize.practiceLeft() == 0 then pb = M.practiceAdButton(pb) end\n        return {\n            { id = \"to_title\", x = 46, y = 52, w = 56, h = 56, icon = \"back\", skin = \"neutral\", round = true },\n            played and pb\n                or { id = \"daily_go\", x = (C.ORIENT == \"landscape\") and 720 or 270, y = (C.ORIENT == \"landscape\") and 300 or 790,\n                     w = 300, h = 78, label = tr(\"btn.startChallenge\"), skin = \"primary\", size = 28, pulse = true },\n        }\n    elseif s == \"arena\" then\n        local btns = {\n            { id = \"home\", x = 46, y = 52, w = 56, h = 56, icon = \"back\", skin = \"neutral\", round = true },\n        }\n\n        local cards = { { \"endless\", 316 }, { \"blitz\", 656 } }\n        for _, cdef in ipairs(cards) do\n            local m, cy = cdef[1], cdef[2]\n            local sel = (G.ui.arenaTier or {})[m] or 1\n            for t = 1, 3 do\n                local open = Progress.arenaTierOpen(t)\n                local cx = (C.ORIENT == \"landscape\") and (m == \"endless\" and 268 or 700) or 270\n                local ty = (C.ORIENT == \"landscape\") and 371 or (cy + 58)\n\n\n\n\n\n                btns[#btns + 1] = { id = \"atier_\" .. m .. \"_\" .. t, x = cx + (t - 2) * 128, y = ty, w = 120, h = 42,\n                    label = open and tr(C.ARENA_TIER_KEYS[t]) or tr(\"hall.tierLocked\", { tier = tr(C.ARENA_TIER_KEYS[t]), n = C.ARENA_TIER_AT[t] }),\n                    skin = (t == sel) and \"primary\" or \"neutral\", size = open and 17 or 13,\n                    enabled = true, locked = not open }\n            end\n        end\n        if C.ORIENT == \"landscape\" then\n            btns[#btns + 1] = { id = \"arena_endless\", x = 268, y = 300, w = 420, h = 330, card = \"endless\" }\n            btns[#btns + 1] = { id = \"arena_blitz\", x = 700, y = 300, w = 420, h = 330, card = \"blitz\" }\n        else\n            btns[#btns + 1] = { id = \"arena_endless\", x = 270, y = 316, w = 440, h = 320, card = \"endless\" }\n            btns[#btns + 1] = { id = \"arena_blitz\", x = 270, y = 656, w = 440, h = 320, card = \"blitz\" }\n        end\n        return btns\n    elseif s == \"runover\" then\n        local daily = G.runKind == \"daily\"\n        local rb = { id = \"run_retry\", x = 352, y = 650, w = 200, h = 62, label = daily and tr(\"btn.practice\") or tr(\"btn.playAgain\"), icon = \"restart\", skin = \"primary\", size = 19, pulse = true }\n\n\n\n        if daily and Monetize.practiceLeft() == 0 then rb = M.practiceAdButton(rb) end\n        return {\n            { id = \"run_back\", x = 108, y = 650, w = 116, h = 56, label = daily and tr(\"btn.back\") or tr(\"btn.hall\"), icon = \"back\", skin = \"secondary\", size = 15 },\n            { id = \"to_title\", x = 196, y = 650, w = 52, h = 56, icon = \"home\", skin = \"neutral\" },\n            rb,\n        }\n    elseif s == \"select\" and G.ui.peek then\n        return { { id = \"peek_close\", x = 270, y = 610, w = 200, h = 56, label = tr(\"btn.gotIt\"), skin = \"primary\", size = 22 } }\n    elseif s == \"select\" then\n        local btns = { { id = \"home\", x = 46, y = 52, w = 56, h = 56, icon = \"back\", skin = \"neutral\", round = true } }\n        local hx = G.mode == \"hex\"\n        local cur = hx and (G.hexPage or 1) or (G.chapter or 1)\n        local n = hx and #C.HEX_PAGES or #C.CHAPTERS\n        btns[#btns + 1] = { id = \"ch_prev\", x = 100, y = 920, w = 100, h = 48, label = hx and tr(\"btn.prevPage\") or tr(\"btn.prevCh\"), skin = \"neutral\", size = 15, enabled = cur > 1 }\n        btns[#btns + 1] = { id = \"ch_next\", x = 440, y = 920, w = 100, h = 48, label = hx and tr(\"btn.nextPage\") or tr(\"btn.nextCh\"), skin = \"neutral\", size = 15, enabled = cur < n }\n\n\n        return btns\n    elseif s == \"ready\" then\n        if G.ui.hiveTip then\n\n            return { { id = \"hive_tip_ok\", x = 270, y = 632, w = 180, h = 42, label = tr(\"btn.gotIt\"), skin = \"primary\", size = 18 } }\n        end\n        return {\n            { id = \"tomap\", x = 150, y = 814, w = 140, h = 54, label = G.runKind and tr(\"btn.back\") or tr(\"btn.map\"), icon = G.runKind and \"back\" or \"map\", skin = \"secondary\" },\n            { id = \"start\", x = 360, y = 814, w = 190, h = 62, label = tr(\"btn.start\"), skin = \"primary\", size = 26, pulse = true },\n        }\n    elseif s == \"playing\" then\n        local btns = { { id = \"pause\", x = C.DESIGN_W-39, y = HUD.y + HUD.h / 2, w = 44, h = 44, icon = \"pause\", skin = \"neutral\", round = true, noSafeLift = true } }\n        for _, b in ipairs(M.roundBoostButtons()) do btns[#btns+1] = b end\n        return btns\n    elseif s == \"paused\" then\n        return {\n            { id = \"resume\", x = 270, y = 368, w = 270, h = 60, label = tr(\"btn.resume\"), icon = \"play\", skin = \"primary\", size = 26 },\n            { id = \"restart\", x = 270, y = 446, w = 270, h = 56, label = tr(\"btn.restart\"), icon = \"restart\", skin = \"secondary\", size = 24 },\n            { id = \"tomap\", x = 270, y = 522, w = 270, h = 56, label = G.runKind and (G.runKind == \"daily\" and tr(\"daily.title\") or tr(\"mode.hall\")) or tr(\"btn.levelMap\"), icon = G.runKind and \"back\" or \"map\", skin = \"neutral\", size = 22 },\n            { id = \"to_title\", x = 270, y = 598, w = 270, h = 56, label = tr(\"btn.home\"), icon = \"home\", skin = \"secondary\", size = 22 },\n            { id = \"music\", x = 131, y = 684, w = 52, h = 52, icon = \"music\", skin = \"neutral\", round = true, iconOff = not G.bgmOn },\n            { id = \"vol_bgm_dn\", x = 185, y = 684, w = 44, h = 44, label = \"−\", skin = \"neutral\", size = 22 },\n            { id = \"vol_bgm_up\", x = 235, y = 684, w = 44, h = 44, label = \"+\", skin = \"neutral\", size = 22 },\n            { id = \"sfx\", x = 309, y = 684, w = 52, h = 52, icon = \"sfx\", skin = \"neutral\", round = true, iconOff = not G.sfxOn },\n            { id = \"vol_sfx_dn\", x = 363, y = 684, w = 44, h = 44, label = \"−\", skin = \"neutral\", size = 22 },\n            { id = \"vol_sfx_up\", x = 413, y = 684, w = 44, h = 44, label = \"+\", skin = \"neutral\", size = 22 },\n            { id = \"shake_toggle\", x = 270, y = 782, w = 240, h = 48, label = G.shakeOn and tr(\"btn.shakeOn\") or tr(\"btn.shakeOff\"), skin = \"neutral\", size = 22 },\n        }\n    elseif s == \"won\" or s == \"allclear\" then\n\n\n\n\n\n\n        local nextB = (s == \"won\")\n            and { id = \"next\", x = 370, y = 728, w = 216, h = 60, label = tr(\"btn.next\"), skin = \"primary\", size = 24, pulse = true }\n            or { id = \"to_title\", x = 370, y = 728, w = 216, h = 60, label = tr(\"btn.home\"), icon = \"home\", skin = \"primary\", size = 22, pulse = true }\n        local btns = {\n            { id = \"tomap\", x = 126, y = 728, w = 136, h = 60, label = tr(\"btn.map\"), icon = \"map\", skin = \"secondary\", size = 16 },\n            { id = \"retry\", x = 228, y = 728, w = 48, h = 60, icon = \"restart\", skin = \"neutral\", round = true },\n            nextB,\n        }\n        local o = Monetize.offer(\"win_double\", adCtx())\n        if s == \"won\" then\n            btns[#btns + 1] = { id = \"to_title\", x = o and 152 or 270, y = 662, w = 188, h = 44, label = tr(\"btn.home\"), icon = \"home\", skin = \"neutral\", size = 16 }\n        end\n\n        if o then\n            btns[#btns + 1] = { id = \"ad_double\", x = 366, y = 662, w = 224, h = 44, label = adLabel(o, \"ad.doubleBtn\"),\n                icon = o.kind == \"ad\" and \"play\" or nil, skin = \"secondary\", size = 15, enabled = not Monetize.busy() }\n        end\n        return btns\n    elseif s == \"lost\" then\n        local rescue = rescueButtons()\n        local y = #rescue > 0 and 742 or 730\n        local btns = {\n            { id = \"tomap\", x = 128, y = y, w = 118, h = 56, label = tr(\"btn.map\"), icon = \"map\", skin = \"secondary\", size = 16 },\n            { id = \"to_title\", x = 224, y = y, w = 56, h = 56, icon = \"home\", skin = \"neutral\" },\n            { id = \"retry\", x = 366, y = y, w = 184, h = 62, label = tr(\"btn.retry\"), icon = \"restart\", skin = #rescue > 0 and \"secondary\" or \"primary\", pulse = #rescue == 0, size = 21 },\n        }\n        for _, b in ipairs(rescue) do btns[#btns + 1] = b end\n        return btns\n    end\n    return {}\nend\n\n\nfunction M.roundBoostButtons()\n    if G.state ~= \"playing\" or G.runKind then return {} end\n    local out = require(\"ringcore.layout\").roundBoostRects()\n    local names = { reverse = \"booster.reverse\", slow = \"booster.slow\" }\n    for i, id in ipairs({ \"reverse\", \"slow\" }) do\n        local v = require(\"ringcore.roundboost\").status(id)\n        local name, label = tr(names[id])\n        if v.reason == \"active\" then label = tr(\"roundBoost.active\", { s = string.format(\"%.1f\", v.timer) })\n        elseif v.reason == \"used\" then label = tr(\"roundBoost.used\")\n        elseif v.reason == \"notCarried\" then label = tr(\"roundBoost.notCarried\")\n        elseif v.reason == \"entering\" then label = tr(\"roundBoost.entering\")\n        elseif v.prepaid then label = tr(\"roundBoost.prepaid\")\n        else label = tr(\"roundBoost.stock\", { n = v.stock }) end\n        local b = out[i]\n        b.label, b.sublabel, b.size, b.skin, b.enabled, b.noSafeLift = name, label, 15, i == 1 and \"boost_gold\" or \"boost_cyan\", v.enabled, true\n        b.disabledSkin, b.labelColor, b.crisp, b.minSize = b.skin..\"_off\", col.ink, true, 13\n    end\n    return out\nend\n\n\n\n\n\n\nlocal saveCacheState, saveCacheVal = nil, false\nfunction M.invalidateSaveCache() saveCacheState = nil end\n\nfunction M.buttons()\n\n    if saveCacheState ~= G.state then\n        saveCacheState, saveCacheVal = G.state, SaveGame.hasRun()\n    end\n    local hasSave = saveCacheVal\n    local list = buildButtons(hasSave)\n    if C.ORIENT==\"landscape\" then list=Wide.buttons(list) end\n    if G.state == \"paused\" then\n        for _, b in ipairs(list) do b.crisp, b.minSize = true, 20 end\n    end\n    local out = {}\n    for _, b in ipairs(list) do\n        if not b.onlyIfSave or hasSave then out[#out + 1] = b end\n    end\n    local top, bot = G.safeShift or 0, (G.safeBot or 926) - 2\n    for _, b in ipairs(out) do\n\n        if top > 0 and b.y < 130 and not b.noSafeLift then b.y = b.y + top end\n        local over = b.y + (b.h or 0) / 2 - bot\n        if over > 0 then b.y = b.y - over end\n    end\n    return out\nend\n\n\nfunction M.hit(x, y)\n\n\n\n    if G.ui.hiveTip then\n        for _, b in ipairs(M.buttons()) do\n            if W.hit(b, x, y) then return b.id end\n        end\n        return nil\n    end\n    for _, b in ipairs(M.buttons()) do\n        if W.hit(b, x, y) then return b.id end\n    end\n    if G.state == \"select\" and not G.ui.peek then\n        local list = mapLevels()\n        for _, lv in ipairs(list) do\n            local nx, ny = nodePos(lv.slot)\n            if (x - nx) ^ 2 + (y - ny) ^ 2 <= (NODE_R + 8) ^ 2 then return \"node\" .. lv.idx end\n        end\n    elseif G.state == \"ready\" then\n        for _, b in ipairs(boosterButtons()) do\n            if W.hit(b, x, y) then return b.id end\n        end\n\n        if C.ORIENT==\"landscape\" then\n            if x<48 or x>912 or y<102 or y>506 then return \"tomap\" end\n        elseif x < 30 or x > 510 or y < 150 or y > 740 then return \"tomap\" end\n    elseif G.state == \"playing\" and G.lvl.cannon and ammoSlotAt(x, y) then\n        return \"ammo\"\n    end\n    return nil\nend\n\n\nlocal function ui() return G.ui end\n\nlocal function spawnBurst(x, y, n, c)\n    for k = 1, n do\n        local a = Rng.vis() * math.pi * 2\n        local sp = 120 + Rng.vis() * 220\n        table.insert(ui().fx, { x = x, y = y, vx = math.cos(a) * sp, vy = math.sin(a) * sp - 60,\n            life = 0.7 + Rng.vis() * 0.4, max = 1.1, c = c or col.amber, r = 2 + Rng.vis() * 3 })\n    end\nend\n\nfunction M.update(dt)\n    local u = ui()\n    if u.lastState ~= G.state then\n        u.lastState, u.t0, u.starsShown, u.fx = G.state, G.animT, 0, {}\n    end\n    if u.popT > 0 then u.popT = u.popT - dt end\n\n    local calm = G.state == \"title\" or G.state == \"modesel\" or G.state == \"select\" or G.state == \"won\"\n        or G.state == \"allclear\" or G.state == \"daily\" or G.state == \"arena\"\n\n    if not u.hiveTip and G.state == \"ready\" and G.mode == \"hex\" and not BOT_SINGLE\n        and not Progress.hiveTipSeen() then\n        u.hiveTip = { t = 0 }\n        Progress.ackHiveTip()\n        Audio.playSfx(\"unlock\", 1.05, 0.7)\n    end\n    if u.hiveTip then\n        u.hiveTip.t = u.hiveTip.t + dt\n\n        if u.hiveTip.t > M.HIVE_TIP_T and G.state ~= \"ready\" then u.hiveTip = nil end\n    end\n    if not u.toast and calm and not BOT_SINGLE then\n        local id = Progress.pendingToast()\n        if id then\n            u.toast = { id = id, t = 0 }\n            u.highlight = { feat = id, t = 0 }\n            Progress.ackToast(id)\n            Audio.playSfx(\"unlock\", 1.0, 0.7)\n        end\n    end\n    if u.highlight then\n        u.highlight.t = u.highlight.t + dt\n        if u.highlight.t > M.HIGHLIGHT_T then u.highlight = nil end\n    end\n    if u.toast then\n        u.toast.t = u.toast.t + dt\n        if u.toast.t > 3.4 then u.toast = nil end\n    end\n    if u.hint then\n        u.hint.t = u.hint.t + dt\n        if u.hint.t > 1.8 then u.hint = nil end\n    end\n    if G.state == \"playing\" and G.lvl.cannon and #G.cannonAmmo > 0 and not u.ammoHintSeen then\n        u.ammoHintSeen, u.ammoHint = true, G.now + 5\n    end\n    if u.ammoHint and G.now >= u.ammoHint then u.ammoHint = nil end\n    local e = G.animT - u.t0\n    if (G.state == \"won\" or G.state == \"allclear\") then\n        for k = 1, G.lastStars do\n            if u.starsShown < k and e > 0.45 + (k - 1) * Th.anim.starStep then\n                u.starsShown = k\n                local sx = 270 + (k - 2) * 92\n                spawnBurst(sx, k == 2 and 318 or 330, 22)\n                Audio.playSfx(\"star\", 0.9 + 0.12 * k, 0.6)\n            end\n        end\n    end\n    for i = #u.fx, 1, -1 do\n        local p = u.fx[i]\n        p.life = p.life - dt\n        p.x, p.y = p.x + p.vx * dt, p.y + p.vy * dt\n        p.vy = p.vy + 380 * dt\n        p.vx = p.vx * (1 - 2 * dt)\n        if p.life <= 0 then table.remove(u.fx, i) end\n    end\nend\n\nlocal function drawFx(ctx)\n    for _, p in ipairs(ui().fx) do\n        local a = math.floor(255 * math.max(0, p.life / p.max))\n        nvgBeginPath(ctx)\n        nvgCircle(ctx, p.x, p.y, p.r)\n        nvgFillColor(ctx, rgba(p.c, a))\n        nvgFill(ctx)\n    end\nend\n\nlocal function fireflies(ctx, n, y0, y1, seed)\n    for i = 1, n do\n        local k = i * 7.13 + (seed or 0)\n        local fx = (i * 97 + math.sin(G.animT * 0.35 + k) * 40) % DW\n        local fy = y0 + (i * 53 + k * 11) % (y1 - y0) + math.cos(G.animT * 0.5 + k) * 16\n        local a = 0.5 + 0.5 * math.sin(G.animT * 2.2 + k)\n        nvgBeginPath(ctx)\n        nvgCircle(ctx, fx, fy, 7)\n        nvgFillPaint(ctx, nvgRadialGradient(ctx, fx, fy, 0.5, 7, nvgRGBA(255, 236, 150, math.floor(160 * a)), nvgRGBA(255, 200, 80, 0)))\n        nvgFill(ctx)\n        nvgBeginPath(ctx)\n        nvgCircle(ctx, fx, fy, 1.6)\n        nvgFillColor(ctx, nvgRGBA(255, 250, 210, math.floor(230 * a)))\n        nvgFill(ctx)\n    end\nend\n\nlocal function popScale()\n    local e = G.animT - ui().t0\n    return W.backOut(e / Th.anim.popIn), math.min(1, e / 0.2)\nend\n\nlocal function drawButtons(ctx)\n    for _, b in ipairs(M.buttons()) do if not b.card and not b.hitOnly then W.button(ctx, b) end end\nend\n\n\nlocal CREDITS = {\n    { \"credits.c.orbs\", \"Cethiel · Orbs Collection, Gems & Jewels (OpenGameArt)\" },\n    { \"credits.c.bg1\", \"Karmabub · Night Landscape (OpenGameArt)\" },\n    { \"credits.c.bg2\", \"JAP · Oil painting landscapes; bevouliin · The Mist\" },\n    { \"credits.c.bgTitle\", \"heartpunch! · Hand Painted Nature Backgrounds (itch.io)\" },\n    { \"credits.c.ui\", \"Kenney · UI Pack 2.0, Fantasy UI Borders, Game Icons, Particle Pack, Smoke\" },\n    { \"credits.c.sparkle\", \"VWolfdog · Multiple Colors of Sparkle Animations\" },\n    { \"credits.c.sfx\", \"Kenney · Interface Sounds, Casino Audio, Music Jingles\" },\n    { \"credits.c.music\", \"MintoDog · Cozy Puzzle In-Game 1 & 2; Yoiyami · First Light Particles\" },\n}\n\nM.creditRows=CREDITS\nfunction M.drawSettings(ctx)\n    W.dim(ctx, 170)\n    W.panel(ctx, 40, 144, 460, 732, { r = 24 })\n    W.ribbon(ctx, 270, 144, 240, tr(\"set.title\"), Th.btn.secondary, 26)\n    local L = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE\n    local R = NVG_ALIGN_RIGHT + NVG_ALIGN_MIDDLE\n    W.label(ctx, 80, 208, tr(\"set.language\"), 20, { align = L })\n    for i, id in ipairs({ \"bgm\", \"sfx\" }) do\n        local y = i == 1 and 320 or 418\n        local on = i == 1 and G.bgmOn or i == 2 and G.sfxOn\n        local gain = id == \"bgm\" and (C.BGM_GAIN or .35) or (C.SFX_GAIN or .7)\n        W.label(ctx, 80, y, tr(i == 1 and \"set.music\" or \"set.sfx\"), 20, { align = L })\n        W.label(ctx, 220, y, on and tr(\"set.on\") or tr(\"set.off\"), 15,\n            { align = L, col = on and col.mossLt or col.mist })\n        W.label(ctx, 464, y, string.format(\"%.0f%%\", gain*100), 19, { align = R, col = col.amber })\n        local cy = i == 1 and 364 or 462\n        W.bar(ctx, 346, cy-6, 66, 12, gain, col.amber, col.amberDk)\n    end\n    W.label(ctx, 80, 532, tr(\"set.shake\"), 20, { align = L })\n    W.paragraph(ctx, 80, 576, tr(\"set.shakeDesc\"), 15, 380, { align = L, col = col.mist }, 20)\n    W.label(ctx, 80, 624, tr(\"set.difficulty\"), 20, { align = L })\n    W.paragraph(ctx, 80, 651, tr(\"set.diffHint\"), 15, 380, { align = L, col = col.mist }, 20)\n    drawButtons(ctx)\nend\n\nlocal function drawCredits(ctx)\n    W.dim(ctx, 170)\n    W.panel(ctx, 30, 150, 480, 650, { r = 24 })\n    W.ribbon(ctx, 270, 150, 300, tr(\"credits.title\"), Th.btn.secondary, 26)\n    W.fitLabel(ctx, 270, 205, tr(\"credits.cc0\"), TY.caption + 1, 420, { col = col.mossLt, outline = false })\n    for i, c in ipairs(CREDITS) do\n        local y = 248 + (i - 1) * 62\n        W.fitLabel(ctx, 62, y, tr(c[1]), TY.body, 410, { col = col.amber, align = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, ow = 1.4 })\n        W.fitLabel(ctx, 62, y + 25, c[2], TY.caption, 416, { col = col.cream, align = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, outline = false })\n    end\n    W.fitLabel(ctx, 270, 738, tr(\"credits.code\"), TY.caption, 420, { col = col.mist, outline = false })\n    drawButtons(ctx)\nend\n\n\nlocal function drawFunnel(ctx)\n    W.dim(ctx, 180)\n    W.panel(ctx, 30, 120, 480, 720, { r = 24 })\n    W.ribbon(ctx, 270, 120, 300, tr(\"funnel.title\"), Th.btn.secondary, 24)\n    W.fitLabel(ctx, 270, 172, tr(\"funnel.note\"), TY.caption, 430, { col = col.mossLt, outline = false })\n    local L, R = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, NVG_ALIGN_RIGHT + NVG_ALIGN_MIDDLE\n    local cx = { 200, 290, 370, 450 }\n    W.fitLabel(ctx, 60, 210, tr(\"funnel.level\"), TY.caption + 1, 120, { col = col.mist, align = L, outline = false })\n    local heads = { tr(\"funnel.start\"), tr(\"funnel.win\"), tr(\"funnel.fail\"), tr(\"funnel.quit\") }\n    for k = 1, 4 do\n        W.fitLabel(ctx, cx[k], 210, heads[k], TY.caption + 1, 80, { col = col.mist, align = R, outline = false })\n    end\n    for r, row in ipairs(Funnel.levelRows()) do\n        local y = 240 + (r - 1) * 30\n        W.fitLabel(ctx, 60, y, tr(\"hud.levelN\", { n = row[1] }), TY.body, 130, { col = col.amber, align = L, ow = 1.2, shadow = false })\n        for k = 1, 4 do W.label(ctx, cx[k], y, tostring(row[k + 1]), TY.body, { col = col.cream, align = R, outline = false }) end\n    end\n    local other = Funnel.otherRows()\n    if #other == 0 then\n        W.fitLabel(ctx, 270, 360, tr(\"funnel.empty\"), TY.caption + 1, 420, { col = col.mist, outline = false })\n    end\n    for i = 1, math.min(#other, 20) do\n        local colx = (i <= 10) and 60 or 290\n        local y = 344 + ((i - 1) % 10) * 34\n        W.fitLabel(ctx, colx, y, other[i][1], TY.caption, 170, { col = col.mist, align = L, outline = false })\n        W.label(ctx, colx + 200, y, tostring(other[i][2]), TY.caption + 1, { col = col.cream, align = R, outline = false })\n    end\n    if G.ui.funnelMsg then W.fitLabel(ctx, 270, 752, G.ui.funnelMsg, TY.caption, 430, { col = col.mossLt, outline = false }) end\n    drawButtons(ctx)\nend\n\n\nlocal function lockedVeil(ctx, x0, y0, w, h, r, hint)\n    nvgBeginPath(ctx); nvgRoundedRect(ctx, x0 + 4, y0 + 4, w - 8, h - 8, r)\n    nvgFillColor(ctx, nvgRGBA(20, 24, 34, 150)); nvgFill(ctx)\n    if h < 120 then\n\n        W.chip(ctx, x0 + w / 2 - 126, y0 + h / 2 - 20, 252, 40, 230)\n        W.icon(ctx, \"lock\", x0 + w / 2 - 100, y0 + h / 2, 28, { 230, 236, 245 })\n        W.fitLabel(ctx, x0 + w / 2 + 14, y0 + h / 2, hint, TY.body, 200, { col = col.cream, ow = 1.4, shadow = false })\n        return\n    end\n    W.icon(ctx, \"lock\", x0 + w / 2, y0 + h / 2 - 14, 40, { 230, 236, 245 })\n    W.chip(ctx, x0 + w / 2 - 120, y0 + h / 2 + 14, 240, 34, 220)\n    W.fitLabel(ctx, x0 + w / 2, y0 + h / 2 + 31, hint, TY.body, 224, { col = col.cream, ow = 1.4, shadow = false })\nend\nM.lockedVeil = lockedVeil\n\n\nlocal GOAL_KEY = { classic = \"goal.classic\", step = \"goal.step\", timed = \"goal.timed\" }\nlocal function goalName(k) return tr(GOAL_KEY[k] or \"goal.classic\") end\nlocal function drawDailyEntry(ctx, b)\n    local pressed = ui().press == b.id\n    local sc = pressed and Th.anim.press or 1\n    local open = Progress.dailyUnlocked()\n    local day = Daily.now()\n    local played = Progress.dailyPlayed(day.key)\n    nvgSave(ctx)\n    nvgTranslate(ctx, b.x, b.y); nvgScale(ctx, sc, sc); nvgTranslate(ctx, -b.x, -b.y)\n    local x0, y0, w, h = b.x - b.w / 2, b.y - b.h / 2, b.w, b.h\n    W.panel(ctx, x0, y0, w, h, { r = 22 })\n\n    local bx, by = x0 + 52, y0 + h / 2\n    nvgBeginPath(ctx); nvgRoundedRect(ctx, bx - 30, by - 30, 60, 60, 12); nvgFillColor(ctx, rgba(col.ink)); nvgFill(ctx)\n    nvgBeginPath(ctx); nvgRoundedRect(ctx, bx - 27, by - 27, 54, 54, 10)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, 0, by - 27, 0, by + 27, nvgRGBA(255, 248, 230, 255), nvgRGBA(226, 214, 190, 255))); nvgFill(ctx)\n    nvgBeginPath(ctx); nvgRoundedRect(ctx, bx - 27, by - 27, 54, 17, 10); nvgFillColor(ctx, nvgRGBA(214, 52, 48, 255)); nvgFill(ctx)\n    W.fitLabel(ctx, bx, by - 18, tr(\"cal.m\" .. day.month), 11, 50, { col = { 255, 246, 230 }, outline = false, shadow = false })\n    W.label(ctx, bx, by + 9, tostring(day.day), 26, { col = { 60, 40, 30 }, outline = false, shadow = false })\n    local tx = x0 + 96\n\n    local gk = Daily.goalFor(day.wday)\n    local tc = TAG_COL[gk] or { Th.btn.secondary.top, Th.btn.secondary.bot }\n    local tagS = Daily.weekName(day.wday) .. \"·\" .. goalName(gk)\n    local tagW = W.measure(ctx, tagS, 12) + 14\n    W.fitLabel(ctx, tx, y0 + 30, tr(\"daily.title\"), 26, (x0 + w - 100) - tx, { col = col.amber, align = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, ow = 2.4 })\n    W.tag(ctx, tx + tagW / 2, y0 + 1, tagS, tc[1], tc[2], 12)\n    if open then\n        W.fitLabel(ctx, tx, y0 + 62, tr(\"daily.refresh\", { t = Daily.fmtCountdown(day.secLeft) }), TY.body, 170,\n            { col = col.cream, align = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, ow = 1.2, shadow = false })\n        local st = Progress.dailyStreak(day.key)\n        W.chip(ctx, x0 + w - 92, y0 + 46, 78, 32)\n        W.fitLabel(ctx, x0 + w - 53, y0 + 62, played and tr(\"daily.streak\", { n = st }) or tr(\"daily.notPlayed\"), TY.caption, 70,\n            { col = played and col.mossLt or col.amber, outline = false })\n        if not played then W.dot(ctx, x0 + w - 8, y0 + 8, 10) end\n    else\n\n\n        nvgBeginPath(ctx); nvgRoundedRect(ctx, x0 + 3, y0 + 3, w - 6, h - 6, 20)\n        nvgFillColor(ctx, nvgRGBA(10, 14, 22, 96)); nvgFill(ctx)\n        W.icon(ctx, \"lock\", tx + 9, y0 + 62, 18, { 200, 206, 216 })\n        W.fitLabel(ctx, tx + 24, y0 + 62, tr(\"unlock.atLevel\", { n = Progress.DAILY_AT }), TY.caption + 1, (x0 + w - 24) - (tx + 24),\n            { col = col.mist, align = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, outline = false, shadow = false })\n    end\n    nvgRestore(ctx)\nend\n\n\nM.drawDailyEntry=drawDailyEntry\nlocal function drawTitle(ctx)\n    if not W.cover(ctx, G.img.titleBg, 1) then\n        nvgBeginPath(ctx); nvgRect(ctx, -G.offX, -G.offY, G.screenDW, G.screenDH)\n        nvgFillColor(ctx, rgba(col.night)); nvgFill(ctx)\n    end\n\n    nvgBeginPath(ctx)\n    nvgRect(ctx, -G.offX, -G.offY, G.screenDW, 420 + G.offY)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, 0, 0, 0, 420, nvgRGBA(4, 8, 20, 120), nvgRGBA(4, 8, 20, 0)))\n    nvgFill(ctx)\n    nvgBeginPath(ctx)\n    nvgRect(ctx, -G.offX, 760, G.screenDW, DH - 760 + G.offY + 2)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, 0, 760, 0, DH, nvgRGBA(4, 8, 20, 0), nvgRGBA(4, 8, 20, 150)))\n    nvgFill(ctx)\n\n    local breathe = 0.85 + 0.15 * math.sin(G.animT * 1.3)\n    if nvgGlobalCompositeOperation and NVG_LIGHTER then nvgGlobalCompositeOperation(ctx, NVG_LIGHTER) end\n    nvgBeginPath(ctx)\n    nvgRect(ctx, -G.offX, -G.offY, G.screenDW, G.screenDH)\n    nvgFillPaint(ctx, nvgRadialGradient(ctx, 270, 640, 20, 330, nvgRGBA(255, 160, 70, math.floor(70 * breathe)), nvgRGBA(255, 160, 70, 0)))\n    nvgFill(ctx)\n    nvgBeginPath(ctx)\n    nvgRect(ctx, -G.offX, -G.offY, G.screenDW, G.screenDH)\n    nvgFillPaint(ctx, nvgRadialGradient(ctx, 270, 200, 30, 260, nvgRGBA(255, 190, 110, math.floor(38 * breathe)), nvgRGBA(255, 190, 110, 0)))\n    nvgFill(ctx)\n    if nvgGlobalCompositeOperation and NVG_SOURCE_OVER then nvgGlobalCompositeOperation(ctx, NVG_SOURCE_OVER) end\n    nvgBeginPath(ctx)\n    nvgRect(ctx, -G.offX, -G.offY, G.screenDW, G.screenDH)\n    nvgFillPaint(ctx, nvgRadialGradient(ctx, 270, 520, 260, 640, nvgRGBA(2, 6, 16, 0), nvgRGBA(2, 6, 16, 150)))\n    nvgFill(ctx)\n    fireflies(ctx, 18, 120, 860, 3)\n    if G.ui.funnel then drawFunnel(ctx); return end\n    if G.ui.credits then drawCredits(ctx); return end\n    if G.ui.settings then M.drawSettings(ctx); return end\n    local bob = math.sin(G.animT * 1.6) * 4\n\n    local function softEllipse(cy, rx, ry, inner, c0, c1)\n        nvgSave(ctx)\n        nvgTranslate(ctx, 270, cy); nvgScale(ctx, 1, ry / rx)\n        nvgBeginPath(ctx); nvgRect(ctx, -rx, -rx, rx * 2, rx * 2)\n        nvgFillPaint(ctx, nvgRadialGradient(ctx, 0, 0, inner, rx, c0, c1)); nvgFill(ctx)\n        nvgRestore(ctx)\n    end\n\n    local tcx = (C.ORIENT == \"landscape\") and 250 or 270\n    softEllipse(214 + bob, 270, 120, 80, nvgRGBA(2, 6, 16, 140), nvgRGBA(2, 6, 16, 0))\n    softEllipse(200 + bob, 240, 95, 30, nvgRGBA(255, 190, 80, 62), nvgRGBA(255, 190, 80, 0))\n    W.fitLabel(ctx, tcx, 196 + bob, tr(\"title.logo\"), 66, 480, { col = col.amber, ow = 5 })\n    W.fitLabel(ctx, tcx, 196 + bob - 2, tr(\"title.logo\"), 66, 480, { col = { 255, 236, 170 }, outline = false, shadow = false, alpha = 120 })\n    W.chip(ctx, tcx - 110, 252 + bob, 220, 34, 170)\n    W.fitLabel(ctx, tcx, 269 + bob, tr(\"title.sub\"), TY.body, 206, { col = col.mossLt, outline = false })\n    for _, b in ipairs(M.buttons()) do if b.card == \"daily\" then drawDailyEntry(ctx, b) end end\n    drawButtons(ctx)\n    W.fitLabel(ctx, (C.ORIENT == \"landscape\") and C.DESIGN_W / 2 or 270, (C.ORIENT == \"landscape\") and C.DESIGN_H - 16 or 948, tr(\"title.tagline\"), TY.caption, 480, { col = col.mist, outline = false })\nend\n\n\n\n\n\n\n\nlocal function modeDone(card)\n    if card == \"classic\" then return Progress.best(#LEVELS) ~= nil end\n    if card == \"hex\" then return Progress.hexBest(#C.HEX_LEVELS) ~= nil end\n    return false\nend\nfunction M.recommendedMode()\n    if not modeDone(\"classic\") then return \"classic\" end\n    if not modeDone(\"hex\") then return \"hex\" end\n    if Progress.arenaUnlocked() then return \"arena\" end\n    return \"classic\"\nend\n\n\nfunction M.selectedMode()\n    return G.ui.modeSel or G.lastMode or M.recommendedMode()\nend\nM.SEL_SCALE = 1.05\nfunction M.modeLocked(card) return card == \"arena\" and not Progress.arenaUnlocked() end\n\nfunction M.modeEnterButton(b)\n    local x0, y0 = b.x - b.w / 2, b.y - b.h / 2\n    local ex, ey = x0 + b.w - 62, y0 + b.h - 36\n    local k = C.ORIENT==\"landscape\" and 1 or M.SEL_SCALE\n    return { id = \"enter_\" .. b.card, x = b.x + (ex - b.x) * k, y = b.y + (ey - b.y) * k, w = 96 * k, h = 42 * k, hitOnly = true }\nend\nlocal function drawModeCard(ctx, b)\n    local wide=C.ORIENT==\"landscape\"\n    local pressed = ui().press == b.id or ui().press == \"enter_\" .. b.card\n    local hex, arena = b.card == \"hex\", b.card == \"arena\"\n    local locked = M.modeLocked(b.card)\n    local rec = M.recommendedMode() == b.card\n    local sel = M.selectedMode() == b.card\n    local done = modeDone(b.card)\n\n    local sc = (pressed and Th.anim.press or 1) * (sel and (wide and 1 or M.SEL_SCALE) or 1)\n    nvgSave(ctx)\n    nvgTranslate(ctx, b.x, b.y); nvgScale(ctx, sc, sc); nvgTranslate(ctx, -b.x, -b.y)\n    local x0, y0, w, h = b.x - b.w / 2, b.y - b.h / 2, b.w, b.h\n    if sel then\n\n        local g = 0.6 + 0.4 * math.sin(G.animT * 2.4)\n        nvgBeginPath(ctx); nvgRect(ctx, x0 - 30, y0 - 30, w + 60, h + 60)\n        nvgFillPaint(ctx, nvgBoxGradient(ctx, x0 - 2, y0 - 2, w + 4, h + 4, 28, 26, nvgRGBA(255, 196, 80, math.floor(150 * g)), nvgRGBA(255, 196, 80, 0)))\n        nvgFill(ctx)\n    end\n    local wellH = wide and 140 or h-132\n    W.panel(ctx, x0, y0, w, h, { r = 26 })\n\n    nvgBeginPath(ctx); nvgRoundedRect(ctx, x0 + 18, y0 + 18, w - 36, wellH, 18)\n    local wt, wb\n    if hex then wt, wb = nvgRGBA(40, 30, 10, 230), nvgRGBA(18, 14, 6, 230)\n    elseif arena then wt, wb = nvgRGBA(34, 16, 44, 230), nvgRGBA(12, 6, 22, 230)\n    else wt, wb = nvgRGBA(8, 26, 30, 230), nvgRGBA(4, 12, 18, 230) end\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, 0, y0 + 18, 0, y0 + 18 + wellH, wt, wb))\n    nvgFill(ctx)\n    local cx, cy = b.x, y0 + 18 + wellH / 2\n    local t = G.animT\n    local sq = wellH / 118\n    nvgSave(ctx)\n    if wide then\n        nvgScissor(ctx,x0+18,y0+18,w-36,wellH)\n        nvgTranslate(ctx,cx,cy);nvgScale(ctx,(w-36)/400,0.8);nvgTranslate(ctx,-cx,-cy)\n    end\n    if hex then\n        local k = 0\n        for row = -1, 1 do\n            for c = -3, 3 do\n                local gx = cx + c * 40 + ((row % 2 ~= 0) and 20 or 0)\n                if math.abs(gx - cx) < 170 then\n                    k = k + 1\n                    Draw.drawGem(ctx, gx, cy + row * 33 * sq + math.sin(t * 2 + k) * 1.5, (k % 5) + 1, 36 * math.min(1, sq + 0.1), \"hex\")\n                end\n            end\n        end\n    elseif arena then\n\n        for k = 0, 6 do\n            local yy = cy - 26 * sq + k * 9 * sq\n            local xx = cx - 180 + ((t * 260 + k * 53) % 300)\n            nvgBeginPath(ctx); nvgMoveTo(ctx, xx, yy); nvgLineTo(ctx, xx + 46, yy)\n            nvgLineCap(ctx, NVG_ROUND); nvgStrokeColor(ctx, nvgRGBA(220, 190, 255, 70)); nvgStrokeWidth(ctx, 3); nvgStroke(ctx)\n        end\n        for k = 0, 6 do\n            Draw.drawBead(ctx, cx - 150 + k * 34 + (t * 30 % 34), cy + 6, ((k + math.floor(t * 3)) % 5) + 1, 13)\n        end\n        local rx = cx + 130\n        nvgBeginPath(ctx); nvgCircle(ctx, rx, cy, 28 * sq + 4); nvgFillColor(ctx, nvgRGBA(6, 4, 14, 220)); nvgFill(ctx)\n        nvgBeginPath(ctx); nvgArc(ctx, rx, cy, 24 * sq + 2, -math.pi / 2, -math.pi / 2 + 2 * math.pi * ((t * 0.2) % 1), NVG_CW)\n        nvgStrokeColor(ctx, nvgRGBA(255, 160, 220, 255)); nvgStrokeWidth(ctx, 5); nvgStroke(ctx)\n        W.label(ctx, rx, cy + 1, \"90\", 18, { col = col.cream, ow = 1.4, shadow = false })\n    else\n\n        for k = 0, 10 do\n            local a = math.pi * (1.06 + 0.88 * k / 10)\n            Draw.drawBead(ctx, cx + math.cos(a) * 178, cy + 40 * sq - 18 * (1 - sq) + math.sin(a) * 72 * sq, ((k + math.floor(t * 2)) % 5) + 1, 12)\n        end\n        for k = -2, 2 do Draw.drawTile(ctx, cx + k * 42, cy + 32 * sq - 6 * (1 - sq), ((k + 7) % 5) + 1, 36 * math.min(1, sq + 0.1)) end\n        if G.img.core then Draw.drawImage(ctx, G.img.core, cx, cy - 2 * sq - 8 * (1 - sq), 28, 28, 0.95) end\n    end\n    nvgRestore(ctx)\n\n    if locked then\n        nvgBeginPath(ctx); nvgRoundedRect(ctx, x0+3, y0+3, w-6, h-6, 24)\n        nvgFillColor(ctx, nvgRGBA(10, 14, 22, 110)); nvgFill(ctx)\n    elseif not sel then\n        nvgBeginPath(ctx); nvgRoundedRect(ctx, x0, y0, w, h, 26)\n        nvgFillColor(ctx, nvgRGBA(4, 8, 16, 96)); nvgFill(ctx)\n    end\n    local title = tr(hex and \"mode.hex\" or (arena and \"mode.hall\" or \"mode.classic\"))\n    local desc = hex and tr(\"mode.hex.desc\", { n = #C.HEX_LEVELS }) or (arena and tr(\"mode.hall.desc\") or tr(\"mode.classic.desc\", { n = #LEVELS }))\n    local by = y0 + wellH + 18\n    W.fitLabel(ctx, x0 + 28, by + 32, title, wide and 26 or 30, wide and w-56 or 220, { col = hex and col.amber or (arena and { 220, 190, 255 } or col.cream), align = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, ow = 3 })\n    if locked then\n\n        W.icon(ctx, \"lock\", x0 + 38, by + 70, 18, { 200, 206, 216 })\n        W.fitLabel(ctx, x0 + 54, by + 70, tr(\"unlock.atLevel\", { n = Progress.ARENA_AT }), TY.body, w - 90,\n            { col = col.mist, align = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, outline = false })\n    else\n        W.fitLabel(ctx, x0 + 28, by + 70, desc, wide and 16 or TY.body, wide and w-56 or w-190, { col = col.mist, align = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, outline = false })\n    end\n\n    local chipT\n    if arena then\n        chipT = tr(\"chip.best\", { n = Progress.arenaTop() })\n    else\n        local done, total\n        if hex then done, total = Progress.hexUnlocked() - 1, #C.HEX_LEVELS\n        else done, total = Progress.unlocked() - 1, #LEVELS end\n        done = math.max(0, math.min(total, done))\n        chipT = tr(\"chip.progress\", { a = done, b = total })\n    end\n\n\n\n    if not locked then\n        local px,py=wide and x0+24 or x0+w-150,wide and y0+h-58 or by\n        W.chip(ctx, px, py, 124, 30)\n        W.fitLabel(ctx, px+62, py+15, chipT, TY.caption + 1, 112, { col = col.cream, outline = false })\n    end\n    if sel and not locked then\n        W.button(ctx, { id = \"enter_\" .. b.card, x = x0 + w - 62, y = y0 + h - 36, w = 96, h = 42, label = tr(\"btn.enter\"),\n            skin = \"primary\", size = 17 })\n    end\n    if sel then\n        nvgBeginPath(ctx); nvgRoundedRect(ctx, x0 + 1, y0 + 1, w - 2, h - 2, 25)\n        nvgStrokeColor(ctx, nvgRGBA(255, 214, 110, 235)); nvgStrokeWidth(ctx, 3); nvgStroke(ctx)\n    end\n    if rec then\n        W.tag(ctx, x0 + 44, y0 + 4, tr(\"tag.recommended\"), { 255, 236, 170 }, { 214, 140, 40 }, 14)\n    end\n    if done then\n        W.tag(ctx, x0 + w - 56, y0 + 4, tr(\"tag.cleared\"), { 190, 240, 170 }, { 60, 130, 70 }, 12)\n    end\n    nvgRestore(ctx)\nend\n\nM.drawModeCard=drawModeCard\nlocal function drawModeSel(ctx)\n    if not W.cover(ctx, G.img.titleBg, 1) then\n        nvgBeginPath(ctx); nvgRect(ctx, -G.offX, -G.offY, G.screenDW, G.screenDH)\n        nvgFillColor(ctx, rgba(col.night)); nvgFill(ctx)\n    end\n    W.dim(ctx, 150)\n    fireflies(ctx, 14, 120, 900, 5)\n    W.ribbon(ctx, 270, 60 + (G.safeShift or 0), 240, tr(\"modesel.title\"), Th.btn.secondary, 26)\n    for _, b in ipairs(M.buttons()) do if b.card then drawModeCard(ctx, b) end end\n\n\n    W.fitLabel(ctx, 270, 124, tr(\"modesel.note\"), TY.caption, 460, { col = col.mist, outline = false })\n    drawButtons(ctx)\nend\n\n\nlocal function curvePoints()\n    local pts = {}\n    for _, e in ipairs(mapLevels()) do local x, y = nodePos(e.slot); pts[#pts + 1] = { x, y } end\n    local out = {}\n    for i = 1, #pts - 1 do\n        local p0, p1, p2, p3 = pts[math.max(1, i - 1)], pts[i], pts[i + 1], pts[math.min(#pts, i + 2)]\n        for s = 0, 11 do\n            local t = s / 12\n            local t2, t3 = t * t, t * t * t\n            local function cr(a, b, c, d) return 0.5 * (2 * b + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (-a + 3 * b - 3 * c + d) * t3) end\n            out[#out + 1] = { cr(p0[1], p1[1], p2[1], p3[1]), cr(p0[2], p1[2], p2[2], p3[2]), seg = i }\n        end\n    end\n    out[#out + 1] = { pts[#pts][1], pts[#pts][2], seg = #pts }\n    return out\nend\nlocal CURVE = nil\n\n\nlocal function rectHit(a, b)\n    return a[1] < b[1] + b[3] and b[1] < a[1] + a[3] and a[2] < b[2] + b[4] and b[2] < a[2] + a[4]\nend\nlocal function overlapArea(a, b)\n    local w = math.min(a[1] + a[3], b[1] + b[3]) - math.max(a[1], b[1])\n    local h = math.min(a[2] + a[4], b[2] + b[4]) - math.max(a[2], b[2])\n    if w <= 0 or h <= 0 then return 0 end\n    return w * h\nend\nlocal LABEL_MAXW = 128\nlocal LABEL_WIDTHS = { LABEL_MAXW, 112, 100 }\nlocal function nodeCaption(i, open)\n    local best = G.mode == \"hex\" and Progress.hexBest(i) or Progress.best(i)\n    return best and tr(\"chip.best\", { n = best.score }) or (open and tr(\"map.notCleared\") or tr(\"map.locked\")), best\nend\n\n\n\n\nlocal function placeLabels(ctx, levels)\n    local r = NODE_R\n    local obstacles = {}\n    for _, e in ipairs(levels) do\n        local x, y = nodePos(e.slot)\n        obstacles[#obstacles + 1] = { x - r - 14, y - r - 44, 2 * r + 28, 2 * r + 54 }\n    end\n    obstacles[#obstacles + 1] = { -100, -100, 740, 196 }\n\n    obstacles[#obstacles + 1] = { 0, 890, 160, 80 }\n    obstacles[#obstacles + 1] = { 380, 890, 160, 80 }\n    obstacles[#obstacles + 1] = { 205, 906, 130, 30 }\n    local unlockedN = G.mode == \"hex\" and Progress.hexUnlocked() or Progress.unlocked()\n    local placed, out, bad = {}, {}, 0\n    for _, e in ipairs(levels) do\n      local x, y = nodePos(e.slot)\n      local open = e.idx <= unlockedN\n      if open then\n        local cap = nodeCaption(e.idx, open)\n        local best, bestCost\n\n\n        for wi, mw in ipairs(LABEL_WIDTHS) do\n            local nameS, nameT = (wi == 1 and W.fit or W.fitQuiet)(ctx, shortName(e.lv), TY.body, mw, 12)\n            if wi > 1 and nameT ~= shortName(e.lv) then break end\n            local bw = math.max(W.measure(ctx, nameT, nameS), W.measure(ctx, cap, TY.caption)) + 8\n            local bh = 44\n            local inward = (x < 270) and 1 or -1\n            local cands = {\n                { kind = \"side\", side = inward }, { kind = \"side\", side = -inward },\n                { kind = \"below\" }, { kind = \"above\" },\n            }\n            for _, cd in ipairs(cands) do\n                local tx, ty, al, rect\n                if cd.kind == \"side\" then\n                    tx, ty = x + cd.side * (r + 14), y + 2\n                    al = cd.side > 0 and NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE or NVG_ALIGN_RIGHT + NVG_ALIGN_MIDDLE\n                    rect = { cd.side > 0 and tx or tx - bw, ty - 20, bw, bh }\n                else\n                    tx = x\n                    ty = cd.kind == \"below\" and (y + r + 32) or (y - r - 70)\n                    al = NVG_ALIGN_CENTER + NVG_ALIGN_MIDDLE\n                    rect = { tx - bw / 2, ty - 20, bw, bh }\n                end\n                local cost = 0\n                if rect[1] < 6 or rect[1] + rect[3] > DW - 6 or rect[2] < 0 or rect[2] + rect[4] > DH then cost = cost + 1e6 end\n                for oi, ob in ipairs(obstacles) do\n                    if oi ~= e.slot then cost = cost + overlapArea(rect, ob) * 10 end\n                end\n                for _, pr in ipairs(placed) do cost = cost + overlapArea(rect, pr) * 10 end\n                if not bestCost or cost < bestCost then best, bestCost = { tx = tx, ty = ty, align = al, rect = rect, nameS = nameS, nameT = nameT, cap = cap }, cost end\n                if cost == 0 then break end\n            end\n            if bestCost == 0 then break end\n        end\n        if bestCost > 0 then bad = bad + 1; ui().labelBadSlot = e.slot end\n        placed[#placed + 1] = best.rect\n        out[e.slot] = best\n      end\n    end\n    ui().labelBad = bad\n    return out\nend\nM.placeLabels = placeLabels\n\nlocal function nodePath(ctx, x, y, r)\n    nvgBeginPath(ctx)\n    if G.mode == \"hex\" then\n        r = r * 1.08\n        for i = 0, 5 do\n            local a = -math.pi / 2 + i * math.pi / 3\n            if i == 0 then nvgMoveTo(ctx, x + math.cos(a) * r, y + math.sin(a) * r)\n            else nvgLineTo(ctx, x + math.cos(a) * r, y + math.sin(a) * r) end\n        end\n        nvgClosePath(ctx)\n    else\n        nvgCircle(ctx, x, y, r)\n    end\nend\n\n\nlocal function honeyOverlay(ctx)\n    nvgBeginPath(ctx); nvgRect(ctx, -G.offX, -G.offY, G.screenDW, G.screenDH)\n    nvgFillColor(ctx, nvgRGBA(120, 70, 10, 70)); nvgFill(ctx)\n    local R = 34\n    local w, h = R * math.sqrt(3), R * 1.5\n    for row = 0, math.ceil(DH / h) + 1 do\n        for col = -1, math.ceil(DW / w) + 1 do\n            local x = col * w + ((row % 2 == 1) and w / 2 or 0)\n            local y = row * h\n            nvgBeginPath(ctx)\n            for i = 0, 5 do\n                local a = -math.pi / 2 + i * math.pi / 3\n                if i == 0 then nvgMoveTo(ctx, x + math.cos(a) * R, y + math.sin(a) * R)\n                else nvgLineTo(ctx, x + math.cos(a) * R, y + math.sin(a) * R) end\n            end\n            nvgClosePath(ctx)\n            nvgStrokeColor(ctx, nvgRGBA(255, 196, 90, 22)); nvgStrokeWidth(ctx, 2); nvgStroke(ctx)\n        end\n    end\nend\n\nlocal function drawMap(ctx)\n    local page = (G.mode == \"hex\") and hexPage() or chapterInfo()\n    local mapBg = (G.mode == \"hex\" and G.img.mapBgHex) or G.img[page.map or \"mapBg\"]\n    if not W.cover(ctx, mapBg or G.img.mapBg, 1) then\n        nvgBeginPath(ctx); nvgRect(ctx, -G.offX, -G.offY, G.screenDW, G.screenDH)\n        nvgFillColor(ctx, rgba(col.forest)); nvgFill(ctx)\n    end\n    if page.tint then W.tint(ctx, page.tint) end\n    W.dim(ctx, 70)\n    if G.mode ~= \"hex\" and page.mist then W.mist(ctx, 0.8) end\n    if G.mode == \"hex\" then honeyOverlay(ctx) end\n    CURVE = curvePoints()\n    local unlocked = G.mode == \"hex\" and Progress.hexUnlocked() or Progress.unlocked()\n\n    nvgBeginPath(ctx)\n    for i, p in ipairs(CURVE) do if i == 1 then nvgMoveTo(ctx, p[1], p[2]) else nvgLineTo(ctx, p[1], p[2]) end end\n    nvgLineCap(ctx, NVG_ROUND); nvgLineJoin(ctx, NVG_ROUND)\n    nvgStrokeColor(ctx, nvgRGBA(10, 20, 18, 150)); nvgStrokeWidth(ctx, 30); nvgStroke(ctx)\n    nvgStrokeColor(ctx, nvgRGBA(70, 60, 40, 150)); nvgStrokeWidth(ctx, 20); nvgStroke(ctx)\n\n\n\n    local nodeXY = {}\n    do\n        local lvls = mapLevels()\n        for _, entry in ipairs(lvls) do\n            local nx, ny = nodePos(entry.slot)\n            nodeXY[#nodeXY + 1] = { nx, ny }\n        end\n    end\n    local acc, last = 0, nil\n    for _, p in ipairs(CURVE) do\n        if last then acc = acc + math.sqrt((p[1] - last[1]) ^ 2 + (p[2] - last[2]) ^ 2) end\n        last = p\n        if acc >= 17 then\n            acc = 0\n            local near = false\n            for _, n in ipairs(nodeXY) do\n                if (p[1] - n[1]) ^ 2 + (p[2] - n[2]) ^ 2 <= (NODE_R + 10) ^ 2 then near = true break end\n            end\n            if not near then\n                local lit = p.seg < unlocked\n                nvgBeginPath(ctx)\n                nvgEllipse(ctx, p[1], p[2], 4.5, 3.2)\n                nvgFillColor(ctx, lit and nvgRGBA(255, 214, 120, 230) or nvgRGBA(150, 150, 140, 150))\n                nvgFill(ctx)\n            end\n        end\n    end\n    fireflies(ctx, 14, 110, 900, 9)\n\n    local levels = mapLevels()\n    local labels = placeLabels(ctx, levels)\n    local unlockedN = G.mode == \"hex\" and Progress.hexUnlocked() or Progress.unlocked()\n    for li = #levels, 1, -1 do\n        local entry = levels[li]\n        local i, slot, lv = entry.idx, entry.slot, entry.lv\n        local x, y = nodePos(slot)\n        local best = G.mode == \"hex\" and Progress.hexBest(i) or Progress.best(i)\n        local open = i <= unlockedN\n        local current = open and not best\n        local r = NODE_R\n        local pressed = ui().press == (\"node\" .. i)\n        if pressed then r = r * 0.92 end\n        if current then\n            local g = 0.5 + 0.5 * math.sin(G.animT * 4)\n            nvgBeginPath(ctx)\n            nvgCircle(ctx, x, y, r + 22)\n            nvgFillPaint(ctx, nvgRadialGradient(ctx, x, y, r, r + 22, nvgRGBA(255, 220, 120, math.floor(90 + 80 * g)), nvgRGBA(255, 220, 120, 0)))\n            nvgFill(ctx)\n        end\n        W.shadow(ctx, x - r, y - r, r * 2, r * 2, r, 8, 5, 140)\n        nodePath(ctx, x, y + 5, r + 3); nvgFillColor(ctx, rgba(col.ink)); nvgFill(ctx)\n\n\n        local skin = not open and Th.btn.disabled or (current and Th.btn.primary or Th.btn.secondary)\n        nodePath(ctx, x, y + 4, r); nvgFillColor(ctx, rgba(skin.lip)); nvgFill(ctx)\n        nodePath(ctx, x, y, r + 3); nvgFillColor(ctx, rgba(col.ink)); nvgFill(ctx)\n        nodePath(ctx, x, y, r)\n        nvgFillPaint(ctx, nvgLinearGradient(ctx, x, y - r, x, y + r, rgba(skin.top), rgba(skin.bot))); nvgFill(ctx)\n\n        local orb = open and G.img[current and \"orb5\" or \"orb1\"]\n        if orb then\n            Draw.drawImage(ctx, orb, x, y, r * 2.05, r * 2.05, 1)\n        else\n            nvgBeginPath(ctx); nvgEllipse(ctx, x, y - r * 0.45, r * 0.62, r * 0.32)\n            nvgFillColor(ctx, nvgRGBA(255, 255, 255, 80)); nvgFill(ctx)\n        end\n        if open then\n            W.label(ctx, x, y + 1, tostring(i), 26, { col = col.cream })\n            if lv.boss then\n                W.label(ctx, x, y + 22, \"BOSS\", 10, { col = col.ember, ow = 1.2, shadow = false })\n            end\n        else\n\n            W.icon(ctx, \"lock\", x, y - 7, 24, { 230, 236, 245 })\n            W.label(ctx, x, y + 15, tostring(i), 15, { col = { 230, 236, 245 }, ow = 1.6, shadow = false })\n        end\n        local tc = open and TAG_COL[Goals.kind(lv)]\n        if tc then W.tag(ctx, x, y + r + 2, Goals.tag(Goals.kind(lv)), tc[1], tc[2], 11) end\n\n\n        if open then\n            for k = 1, 3 do\n                local ang = -math.pi / 2 + (k - 2) * 0.62\n                W.star(ctx, x + math.cos(ang) * (r + 14), y + math.sin(ang) * (r + 14) + (k == 2 and -2 or 2), 10, best and best.stars >= k)\n            end\n\n\n\n            if best then\n\n\n                local L0 = labels[slot]\n                local bx = (L0 and L0.rect and (L0.rect[1] + L0.rect[3] / 2 > x)) and (x + r * 0.92) or (x - r * 0.92)\n                local by = y + r * 0.42\n                nvgBeginPath(ctx); nvgCircle(ctx, bx, by, 7)\n                nvgFillColor(ctx, nvgRGBA(22, 40, 34, 240)); nvgFill(ctx)\n                nvgBeginPath(ctx); nvgMoveTo(ctx, bx - 3.2, by)\n                nvgLineTo(ctx, bx - 0.8, by + 2.6); nvgLineTo(ctx, bx + 3.4, by - 2.6)\n                nvgStrokeColor(ctx, nvgRGBA(140, 226, 150, 255)); nvgStrokeWidth(ctx, 2.2)\n                nvgLineCap(ctx, NVG_ROUND); nvgStroke(ctx)\n            end\n        end\n\n        local L = labels[slot]\n        if L then\n\n            local rc = L.rect\n            nvgBeginPath(ctx); nvgRoundedRect(ctx, rc[1] - 4, rc[2] - 1, rc[3] + 8, rc[4] + 2, 9)\n            nvgFillColor(ctx, nvgRGBA(6, 10, 16, 178)); nvgFill(ctx)\n            W.label(ctx, L.tx, L.ty - 9, L.nameT, L.nameS, { col = col.cream, align = L.align, ow = 2.0 })\n            W.label(ctx, L.tx, L.ty + 12, L.cap, TY.caption,\n                { col = best and col.amber or col.mist, align = L.align, ow = 1.3, shadow = false })\n        end\n        if current then\n            local by = y - r - 40 + math.sin(G.animT * 5) * 5\n            nvgBeginPath(ctx)\n            nvgMoveTo(ctx, x - 9, by); nvgLineTo(ctx, x + 9, by); nvgLineTo(ctx, x, by + 12); nvgClosePath(ctx)\n            nvgFillColor(ctx, rgba(col.amber)); nvgFill(ctx)\n            nvgStrokeColor(ctx, rgba(col.ink)); nvgStrokeWidth(ctx, 2); nvgStroke(ctx)\n        end\n    end\n\n    nvgBeginPath(ctx)\n    nvgRect(ctx, -G.offX, -G.offY, G.screenDW, 100 + G.offY)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, 0, 0, 0, 100, nvgRGBA(4, 8, 20, 220), nvgRGBA(4, 8, 20, 0)))\n    nvgFill(ctx)\n    local title = G.mode == \"hex\" and tr(hexPage().nameKey) or tr(\"map.chapterTitle\", { n = G.chapter or 1, name = tr(chapterInfo().nameKey) })\n\n    W.ribbon(ctx, 249, 50 + (G.safeShift or 0), 236, title, Th.btn.secondary, 22)\n    local total, maxS = 0, 0\n    for _, e in ipairs(levels) do\n        local b = G.mode == \"hex\" and Progress.hexBest(e.idx) or Progress.best(e.idx)\n        total = total + (b and b.stars or 0); maxS = maxS + 3\n    end\n    local sy = G.safeShift or 0\n    W.chip(ctx, 424, 32 + sy, 106, 38)\n    W.star(ctx, 444, 51 + sy, 12, true)\n    W.fitLabel(ctx, 490, 51 + sy, total .. \"/\" .. maxS, TY.body, 66, { col = col.cream, ow = 1.5 })\n    if G.mode ~= \"hex\" then\n        W.label(ctx, 270, 920, tr(\"map.chapterPage\", { a = G.chapter or 1, b = #C.CHAPTERS }), TY.caption, { col = col.mist, outline = false })\n    else\n        W.label(ctx, 270, 920, tr(\"map.hexPage\", { a = G.hexPage or 1, b = #C.HEX_PAGES }), TY.caption, { col = col.mist, outline = false })\n    end\n    if G.ui.peek then M.drawPeek(ctx, G.ui.peek) end\n    drawButtons(ctx)\nend\n\n\n\n\n\nfunction M.drawGoalHUD(ctx, gk, px, pw)\n    local y1, y2 = HUD.y + 22, HUD.y + 49\n    local left = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE\n    local main, caption, low\n    if gk == \"step\" then\n        main, caption, low = tostring(Goals.movesLeft()), tr(\"hud.moves\"), Goals.movesLeft() <= 3\n    elseif gk == \"classic\" then\n        main, caption = tostring(G.queueLeft + #G.balls), tr(\"hud.beadsLeft\")\n    else\n        local t = gk == \"endless\" and G.goal.survived or Goals.timeLeft()\n        main, caption, low = Goals.fmtTime(t), tr(gk == \"endless\" and \"hud.survive\" or \"hud.left\"), gk ~= \"endless\" and t <= 15\n    end\n    W.fitLabel(ctx, px, y1, main, gk == \"blitz\" and 20 or 28, 92,\n        { col = low and col.ember or col.cream, align = left, ow = 1.5, min = gk == \"blitz\" and 18 or 26 })\n    W.fitLabel(ctx, px, y2, caption, TY.caption, 92, { col = col.mist, align = left, outline = false })\n    local x, w = px + 102, pw - 102\n    if gk == \"step\" then\n        local targets = G.lvl.targets or {}\n        local cw = w / math.max(1, #targets)\n        for _, t in ipairs(targets) do\n            local cur, need = Goals.targetProgress(t)\n            if t.kind == \"color\" then Draw.drawBead(ctx, x + cw / 2, y1 - 3, t.color, 10)\n            elseif t.kind == \"unlock\" then W.icon(ctx, \"lock\", x + cw / 2, y1 - 3, 20, col.mist)\n            else W.icon(ctx, \"trophy\", x + cw / 2, y1 - 3, 20, col.mist) end\n            W.fitLabel(ctx, x + cw / 2, y2, cur >= need and \"✓\" or (cur .. \"/\" .. need), 17, cw - 4,\n                { col = cur >= need and col.mossLt or col.cream, outline = false })\n            x = x + cw\n        end\n    elseif gk == \"classic\" or gk == \"timed\" then\n        local frac = gk == \"classic\" and (1 - (G.queueLeft + #G.balls) / math.max(1, G.lvl.quota))\n            or G.score / math.max(1, G.lvl.energy)\n        local text = gk == \"classic\" and tr(\"hud.clearProgress\")\n            or tr(\"hud.energy\", { a = math.min(G.score, G.lvl.energy), b = G.lvl.energy })\n        W.fitLabel(ctx, x, y1, text, TY.body, w, { col = col.cream, align = left, outline = false })\n        W.bar(ctx, x, y2 - 5, w, 10, frac, col.mossLt, col.moss)\n        if G.mode == \"hex\" and G.silt then\n            local deepest = 0\n            for _, n in ipairs(G.silt) do deepest = math.max(deepest, n) end\n            Draw.drawBead(ctx, x + w - 68, y1, G.siltColor or 5, 8)\n            W.fitLabel(ctx, x + w, y1, tr(\"hud.siltShort\", { n = deepest }), TY.caption, 56,\n                { col = deepest >= 4 and col.ember or col.mist, align = NVG_ALIGN_RIGHT + NVG_ALIGN_MIDDLE, outline = false })\n        end\n    elseif gk == \"blitz\" then\n\n        W.fitLabel(ctx, x, y1, tostring(math.floor((G.ui.hudScore or G.score) + 0.5)), 28, w,\n            { col = col.cream, align = left, min = 26, ow = 1.5 })\n        W.fitLabel(ctx, x, y2, tr(\"hud.rush\"), TY.caption, w, { col = col.mist, align = left, outline = false })\n    else\n        W.fitLabel(ctx, x, y1, tr(\"hud.speed\", { p = math.floor((G.speedBonus or 0) / math.max(1, G.lvl.speed) * 100 + 0.5) }),\n            TY.body, w, { col = col.mist, align = left, outline = false })\n    end\nend\n\n\n\nlocal function instructionChip(ctx, x, bottom, text, outlined)\n    local tw = math.min(430, W.measure(ctx, text, TY.body)+48)\n    local lines = W.wrap(ctx, text, TY.body, tw-28)\n    local h = math.max(38, #lines*21+14)\n    local top = bottom-h\n    if outlined then\n        nvgBeginPath(ctx); nvgRoundedRect(ctx, x-tw/2, top, tw, h, 19)\n        nvgFillColor(ctx, nvgRGBA(8, 22, 26, 225)); nvgFill(ctx)\n        nvgStrokeColor(ctx, rgba(col.amber, math.floor(200+55*math.sin(G.animT*5))))\n        nvgStrokeWidth(ctx, 2); nvgStroke(ctx)\n    else W.chip(ctx, x-tw/2, top, tw, h, 230) end\n    W.paragraph(ctx, x, top+(h-(#lines-1)*21)/2, text, TY.body, tw-28, { col = col.cream }, 21)\n    return top+h/2, h\nend\n\nfunction M.drawHUD(ctx)\n    syncBar()\n    local u = ui()\n    u.hudScore = u.hudScore or 0\n    if G.score < u.hudScore then u.hudScore = G.score end\n    u.hudScore = u.hudScore + (G.score - u.hudScore) * 0.2\n    if math.abs(G.score - u.hudScore) < 1 then u.hudScore = G.score end\n    W.panel(ctx, 8, HUD.y, C.DESIGN_W-16, HUD.h, { r = 20, inset = 4 })\n    local bx, by = 44, HUD.y + 26\n    local rk = G.runKind\n    W.label(ctx, bx, by, rk and tr(rk == \"daily\" and \"hud.badgeDaily\" or \"hud.badgeHall\") or tostring(G.levelIdx), 21,\n        { col = col.mist, ow = 1.2, shadow = false })\n    W.fitLabel(ctx, bx, HUD.y + 50, tr(rk == \"daily\" and \"hud.daily\" or (rk and \"mode.hall\" or (G.mode == \"hex\" and \"hud.hive\" or \"hud.classic\"))), TY.caption, 62,\n        { col = col.mist, outline = false })\n    M.drawGoalHUD(ctx, Goals.kind(), 108, C.ORIENT==\"landscape\" and 650 or 288)\n    if Goals.kind() ~= \"blitz\" then\n        W.fitLabel(ctx, C.DESIGN_W-70, HUD.y + 25, tostring(math.floor(u.hudScore + 0.5)), 17, 62,\n            { col = col.mist, align = NVG_ALIGN_RIGHT + NVG_ALIGN_MIDDLE, outline = false, shadow = false })\n        W.label(ctx, C.DESIGN_W-70, HUD.y + 49, tr(\"hud.score\"), TY.caption, { col = col.mist,\n            align = NVG_ALIGN_RIGHT + NVG_ALIGN_MIDDLE, outline = false })\n    end\n    if G.state == \"playing\" then\n        for _, b in ipairs(M.buttons()) do W.button(ctx, b) end\n    else\n        W.button(ctx, { id = \"_pause\", x = C.DESIGN_W-39, y = HUD.y + HUD.h / 2, w = 44, h = 44, icon = \"pause\", skin = \"neutral\", round = true })\n    end\n\n\n    if G.chainFlash and G.chainFlash.n >= 2 then\n        local f = G.chainFlash\n        local a = math.floor(255 * math.min(1, f.life / 0.4))\n        local sz = 34 + 4 * math.min(5, f.n) + (0.9 - f.life) * 10\n        W.label(ctx, 270, 150, tr(\"hud.chain\", { n = f.n }), sz, { col = { 255, 230 - 20 * math.min(5, f.n), 120 }, alpha = a, ow = 3 })\n    end\n\n\n\n    local cy = AMMO.y\n    if G.lvl.cannon then\n        local focus = math.max(0, math.min(1, ((u.ammoFocusUntil or 0) - G.now) / 0.45))\n        for i = 1, T.CANNON_MAX do\n            local ax = AMMO.x0 + (i - 1) * AMMO.dx\n            local active = i == 1\n            nvgBeginPath(ctx); nvgCircle(ctx, ax, cy, active and (19 + focus * 3) or 16)\n            nvgFillColor(ctx, nvgRGBA(6, 14, 22, 220)); nvgFill(ctx)\n            nvgStrokeColor(ctx, rgba(active and col.amber or col.moss, active and 255 or 115))\n            nvgStrokeWidth(ctx, active and 2.5 or 1.2); nvgStroke(ctx)\n            if G.cannonAmmo[i] then Draw.drawBead(ctx, ax, cy, G.cannonAmmo[i], active and 13 or 10, active and 255 or 150) end\n        end\n        W.fitLabel(ctx, AMMO.x0, cy - 31, tr(\"hud.currentAmmo\"), TY.caption, 58, { col = col.cream, outline = false })\n    end\n    local manual = G.state == \"playing\" and not G.runKind\n    local items = {}\n    if not manual and G.powerReverse > 0 then items[#items + 1] = { tr(\"pw.reverse\"), G.powerReverse, T.PW_REVERSE_TIME, col.sky } end\n    if not manual and G.slowTimer > 0 then items[#items + 1] = { tr(\"pw.slow\"), G.slowTimer, T.PW_SLOW_TIME, { 190, 160, 255 } } end\n    if G.stunTimer > 0 then items[#items + 1] = { tr(\"pw.stun\"), G.stunTimer, T.STUN_TIME, { 180, 220, 255 } } end\n    if #items > 0 then\n        for k, it in ipairs(items) do\n            local x = not manual and T.CANNON_X-74 - (k - 1) * 80 or T.CANNON_X\n            local statusY = not manual and cy or (C.ORIENT==\"landscape\" and 460 or 438)\n            W.chip(ctx, x - 38, statusY - 15, 76, 30)\n            nvgBeginPath(ctx)\n            nvgArc(ctx, x - 22, statusY, 9, -math.pi / 2, -math.pi / 2 + 2 * math.pi * math.min(1, it[2] / it[3]), NVG_CW)\n            nvgStrokeColor(ctx, rgba(it[4])); nvgStrokeWidth(ctx, 4); nvgStroke(ctx)\n            W.label(ctx, x + 8, statusY, it[1], TY.caption + 1, { col = it[4], ow = 1.2, shadow = false })\n        end\n    elseif G.combo > 1 then\n        W.label(ctx, not manual and T.CANNON_X-106 or T.CANNON_X, not manual and cy or (C.ORIENT==\"landscape\" and 460 or 438), tr(\"hud.link\", { x = string.format(\"%.1f\", G.combo) }), not manual and TY.h2 or TY.caption+1, { col = col.amber })\n    elseif G.now < 6 and not G.runKind and not u.ammoHint and G.lvl.cannon and (G.levelIdx or 1) <= 4 and not M.coachKey() then\n        W.fitLabel(ctx, C.ORIENT==\"landscape\" and 268 or 270, C.ORIENT==\"landscape\" and 492 or 401, tr(\"hud.hint\"), TY.caption, 400, { col = col.mist, ow = 1.2, shadow = false })\n    end\n    if u.ammoHint and G.state == \"playing\" then\n        local hx=C.ORIENT==\"landscape\" and 268 or 270\n        instructionChip(ctx, hx, C.ORIENT==\"landscape\" and 522 or cy-42, tr(\"hud.swapAmmoHint\"))\n    else\n        M.drawCoach(ctx)\n    end\n    require(\"ringcore.ui.tutorial\").draw(ctx,M.coachKey())\nend\n\n\n\nfunction M.coachKey()\n    local i = G.levelIdx or 99\n    if G.state ~= \"playing\" or G.runKind then return nil end\n    local hex = G.mode == \"hex\"\n    if not ((hex and i == 1) or (not hex and (i == 1 or i == 2 or i == 3))) then return nil end\n    local st = G.stats or {}\n    if (st.time or 0) > 60 then return nil end\n    if hex or i == 1 then\n        if (st.swaps or 0) == 0 then return (st.time or 0) > 0.8 and \"coach.swap\" or nil end\n        if (st.hits or 0) < 2 and (st.swaps or 0) <= 4 then return \"coach.pulse\" end\n        return nil\n    end\n\n    if i == 2 then\n        if (st.unlocks or 0) == 0 and (st.swaps or 0) >= 2 then return \"coach.lock\" end\n        return nil\n    end\n\n    if not G.lvl.cannon then return nil end\n    if (st.cannonShots or 0) == 0 then return #G.cannonAmmo > 0 and \"coach.fire\" or nil end\n    if (st.cannonShots or 0) < 3 then return \"coach.chain\" end\n    return nil\nend\nfunction M.drawCoach(ctx)\n    local key = M.coachKey()\n    local u = ui()\n    if key ~= u.coachKey then u.coachKey, u.coachT = key, G.animT end\n    if not key then return end\n    local a = math.min(1, (G.animT - (u.coachT or 0)) / 0.25)\n    local x=C.ORIENT==\"landscape\" and 268 or 270\n    nvgSave(ctx)\n    nvgGlobalAlpha(ctx, a)\n    local y, h = instructionChip(ctx, x, C.ORIENT==\"landscape\" and 522 or AMMO.y-42, tr(key), true)\n\n    local up = key ~= \"coach.swap\"\n    local bob = 4 * math.sin(G.animT * 6)\n    local ay = up and (y-h/2-7-bob) or (y+h/2+7+bob)\n    nvgBeginPath(ctx)\n    if up then nvgMoveTo(ctx, x-8, ay + 6); nvgLineTo(ctx, x, ay - 4); nvgLineTo(ctx, x+8, ay + 6)\n    else nvgMoveTo(ctx, x-8, ay - 6); nvgLineTo(ctx, x, ay + 4); nvgLineTo(ctx, x+8, ay - 6) end\n    nvgClosePath(ctx); nvgFillColor(ctx, rgba(col.amber)); nvgFill(ctx)\n    nvgRestore(ctx)\nend\n\n\nlocal function popupFrame(ctx, x, y, w, h, title, skin, badge, textOpts)\n    local s, fa = popScale()\n    W.dim(ctx, 170 * fa)\n    nvgSave(ctx)\n    nvgTranslate(ctx, 270, y + h / 2)\n    nvgScale(ctx, s, s)\n    nvgTranslate(ctx, -270, -(y + h / 2))\n    W.panel(ctx, x, y, w, h)\n    W.ribbon(ctx, 270, y, math.min(w - 70, 340), title, skin, nil, badge, textOpts)\n    return function() nvgRestore(ctx) end\nend\n\nlocal function wrapLines(ctx, x, y, lines, size, gap)\n    for i, l in ipairs(lines) do\n        W.label(ctx, x, y + (i - 1) * gap, l, size, { col = col.cream, ow = 1.2, shadow = false })\n    end\nend\n\nlocal function goalLines(lv)\n    local gk = Goals.kind(lv)\n    local goalT, subT, starT\n    if gk == \"step\" then\n        goalT = tr(\"ready.goalSteps\", { n = lv.moves })\n        local parts = {}\n        for _, t in ipairs(lv.targets or {}) do parts[#parts + 1] = tr(\"target.item\", { label = Goals.targetLabel(t), n = t.n }) end\n        subT = table.concat(parts, tr(\"sep.dot\")) .. tr(\"ready.slowBeads\")\n        starT = tr(\"ready.starSteps\")\n    elseif gk == \"timed\" then\n        goalT = tr(\"ready.goalTimed\", { t = lv.time, e = lv.energy })\n        subT = tr(\"ready.subTimed\")\n        starT = tr(\"ready.starTimed\")\n    elseif gk == \"endless\" or gk == \"blitz\" then\n        goalT = gk == \"endless\" and tr(\"ready.goalEndless\") or tr(\"ready.goalRush\", { t = lv.time })\n        subT = Goals.describe(lv)\n        starT = tr(\"ready.starLocal\")\n    else\n        goalT = tr(\"ready.goalQuota\", { n = lv.quota })\n        subT = G.mode == \"hex\" and tr(\"ready.subHex\") or tr(\"ready.subClassic\")\n        starT = tr(\"ready.starClassic\")\n    end\n    return goalT, subT, starT\nend\nM.goalLines = goalLines\n\n\nfunction M.drawPeek(ctx, i)\n    local hx = G.mode == \"hex\"\n    local lv = hx and C.HEX_LEVELS[i] or C.LEVELS[i]\n    if not lv then return end\n    W.dim(ctx, 150)\n    local done = popupFrame(ctx, 60, 330, 420, 330, { tr(hx and \"peek.titleHex\" or \"peek.title\", { n = i, name = shortName(lv) }), shortName(lv) }, Th.btn.disabled, goalBadge(lv))\n    local goalT, subT = goalLines(lv)\n    nvgBeginPath(ctx); nvgRoundedRect(ctx, 86, 378, 368, 84, 16); nvgFillColor(ctx, nvgRGBA(4, 12, 18, 150)); nvgFill(ctx)\n    W.icon(ctx, \"lock\", 122, 420, 40, { 230, 236, 245 })\n    W.fitLabel(ctx, 160, 404, goalT, TY.body + 1, 280, { col = col.amber, align = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, ow = 1.5 })\n    W.fitLabel(ctx, 160, 434, subT, TY.caption + 1, 280, { col = col.mist, align = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, outline = false })\n\n\n    local unlockT = hx and tr(\"peek.unlockHex\", { n = i - 1 }) or tr(\"peek.unlock\", { n = i - 1 })\n    W.fitLabel(ctx, 270, 506, unlockT, TY.h2, 380, { col = col.cream, ow = 1.6 })\n    W.fitLabel(ctx, 270, 540, tr(\"peek.howto\"), TY.caption + 1, 380, { col = col.mist, outline = false })\n    done()\nend\n\n\n\n\nlocal function drawHiveTutorial(ctx, tip)\n    local a = math.min(1, tip.t / 0.3)\n    nvgSave(ctx)\n    if nvgGlobalAlpha then nvgGlobalAlpha(ctx, a) end\n\n    local x, y, w, h = 52, 318, 436, 342\n    nvgBeginPath(ctx); nvgRect(ctx, -G.offX, -G.offY, G.screenDW, G.screenDH)\n    nvgFillColor(ctx, nvgRGBA(4, 10, 8, 190)); nvgFill(ctx)\n    nvgBeginPath(ctx); nvgRoundedRect(ctx, x, y, w, h, 20)\n    nvgFillColor(ctx, nvgRGBA(8, 18, 14, 246)); nvgFill(ctx)\n    nvgStrokeColor(ctx, nvgRGBA(255, 206, 120, 150)); nvgStrokeWidth(ctx, 2); nvgStroke(ctx)\n    W.fitLabel(ctx, 270, y + 26, tr(\"hive.tut.title\"), TY.body + 1, w - 40,\n        { col = { 255, 214, 140 }, ow = 1.4, shadow = false })\n\n    local rows = {\n        { key = \"hive.tut.1\", draw = function(cx, cy)\n\n              local sc = C.COLORS[G.siltColor or 5]\n              for i = -1, 1 do\n                  nvgBeginPath(ctx); nvgRoundedRect(ctx, cx + i * 22 - 9, cy - 14, 18, 28, 6)\n                  nvgFillColor(ctx, nvgRGBA(40, 32, 16, 220)); nvgFill(ctx)\n                  nvgBeginPath(ctx); nvgRoundedRect(ctx, cx + i * 22 - 9, cy + 2, 18, 12, 5)\n                  nvgFillColor(ctx, nvgRGBA(sc[1], sc[2], sc[3], 210)); nvgFill(ctx)\n              end\n          end },\n        { key = \"hive.tut.2\", draw = function(cx, cy)\n\n              local sc = C.COLORS[G.siltColor or 5]\n              Draw.drawBead(ctx, cx - 16, cy, G.siltColor or 5, 11)\n              nvgBeginPath(ctx); nvgMoveTo(ctx, cx + 2, cy - 12); nvgLineTo(ctx, cx + 2, cy + 10)\n              nvgStrokeColor(ctx, nvgRGBA(200, 240, 200, 230)); nvgStrokeWidth(ctx, 3)\n              nvgLineCap(ctx, NVG_ROUND); nvgStroke(ctx)\n              nvgBeginPath(ctx); nvgMoveTo(ctx, cx - 3, cy + 3); nvgLineTo(ctx, cx + 2, cy + 11); nvgLineTo(ctx, cx + 7, cy + 3)\n              nvgFillColor(ctx, nvgRGBA(200, 240, 200, 230)); nvgFill(ctx)\n              nvgBeginPath(ctx); nvgRoundedRect(ctx, cx + 14, cy + 2, 16, 12, 5)\n              nvgFillColor(ctx, nvgRGBA(sc[1], sc[2], sc[3], 210)); nvgFill(ctx)\n          end },\n        { key = \"hive.tut.3\", draw = function(cx, cy)\n\n              local sc = C.COLORS[G.siltColor or 5]\n              nvgBeginPath(ctx); nvgRoundedRect(ctx, cx - 9, cy - 15, 18, 30, 6)\n              nvgFillColor(ctx, nvgRGBA(sc[1], sc[2], sc[3], 225)); nvgFill(ctx)\n              nvgBeginPath(ctx); nvgCircle(ctx, cx, cy - 1, 8)\n              nvgFillColor(ctx, nvgRGBA(20, 10, 10, 200)); nvgFill(ctx)\n              W.label(ctx, cx, cy - 1, \"!\", 13, { col = { 255, 140, 110 }, ow = 1.2, shadow = false })\n          end },\n    }\n    for i, r in ipairs(rows) do\n        local ry = y + 70 + (i - 1) * 62\n        nvgBeginPath(ctx); nvgCircle(ctx, x + 30, ry, 13)\n        nvgFillColor(ctx, nvgRGBA(255, 206, 120, 40)); nvgFill(ctx)\n        nvgStrokeColor(ctx, nvgRGBA(255, 206, 120, 130)); nvgStrokeWidth(ctx, 1.4); nvgStroke(ctx)\n        W.label(ctx, x + 30, ry + 1, tostring(i), 15, { col = { 255, 220, 150 }, shadow = false })\n        r.draw(x + 78, ry)\n        W.fitLabel(ctx, x + 104, ry - 8, tr(r.key), TY.caption + 1, w - 128,\n            { col = col.cream, align = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, outline = false })\n        local sub = tr(r.key .. \"s\")\n        if sub ~= (r.key .. \"s\") then\n            W.fitLabel(ctx, x + 104, ry + 12, sub, TY.caption - 1, w - 128,\n                { col = col.mist, align = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, outline = false })\n        end\n    end\n\n    W.fitLabel(ctx, 270, 600, tr(\"hive.tip.short\"), TY.caption - 1, w - 36,\n        { col = col.mist, outline = false })\n    W.button(ctx, { id = \"hive_tip_ok\", x = 270, y = 632, w = 180, h = 42,\n        label = tr(\"btn.gotIt\"), skin = \"primary\", size = 18 })\n    nvgRestore(ctx)\nend\n\nlocal function drawReady(ctx)\n    local done = popupFrame(ctx, 40, 148, 460, 724, G.runKind and ((G.runKind == \"daily\" and G.runPractice) and tr(\"ready.practice\", { name = shortName(G.lvl) }) or shortName(G.lvl)) or { tr(G.mode == \"hex\" and \"peek.titleHex\" or \"peek.title\", { n = G.levelIdx, name = shortName(G.lvl) }), shortName(G.lvl) }, nil, goalBadge(G.lvl))\n    local L = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE\n    nvgBeginPath(ctx); nvgRoundedRect(ctx, 64, 198, 412, 116, 16)\n    nvgFillColor(ctx, nvgRGBA(4, 12, 18, 190)); nvgFill(ctx)\n    if G.img.core then\n        nvgBeginPath(ctx); nvgRect(ctx, 82, 224, 64, 64)\n        nvgFillPaint(ctx, nvgImagePattern(ctx, 82, 224, 64, 64, 0, G.img.core, 1)); nvgFill(ctx)\n    end\n    local goalT, subT, starT = goalLines(G.lvl)\n    W.paragraph(ctx, 160, 222, goalT, 20, 300, { col = col.amber, align = L }, 23)\n    W.paragraph(ctx, 160, 270, subT, 15, 300, { col = col.mist, align = L }, 19)\n    for k = 1, 3 do W.star(ctx, 120+(k-1)*28, 344, 11, true) end\n    W.paragraph(ctx, 200, 334, starT, 15, 264, { align = L }, 20)\n    local best = not G.runKind and (G.mode == \"hex\" and Progress.hexBest(G.levelIdx) or Progress.best(G.levelIdx))\n    if best then W.label(ctx, 270, 380, tr(\"ready.best\", { n = best.score }), 16, { col = col.amber }) end\n    local hiveTip = G.mode == \"hex\" and ui().hiveTip\n    if not hiveTip and not G.runKind then\n        W.label(ctx, 270, 410, tr(\"ready.boosters\"), 18, { col = col.mossLt })\n        for _, b in ipairs(boosterButtons()) do W.button(ctx, b) end\n        local pickedN = 0\n        for _, v in pairs(G.boosterPick or {}) do if v then pickedN = pickedN+1 end end\n        W.label(ctx, 270, 496, tr(\"booster.pickLimit\", { a = pickedN, b = 2 }), 16,\n            { col = pickedN >= 2 and col.amber or col.mist })\n        W.paragraph(ctx, 270, 526, tr(\"roundBoost.rule\"), 16, 408, { col = col.cream }, 21)\n        local pl = Monetize.PLACEMENTS and Monetize.PLACEMENTS.booster\n        if pl and pl.run and Monetize.monetized() then\n            W.fitLabel(ctx, 270, 558, tr(\"booster.adLeft\", {\n                a = math.max(0, pl.run-(Monetize.usedThisRun(\"booster\") or 0)), b = pl.run,\n                c = math.max(0, (pl.day or 0)-(Monetize.usedToday(\"booster\") or 0)), d = pl.day or 0 }),\n                TY.caption, 430, { col = col.mist, outline = false })\n        end\n        for k, bd in ipairs(C.BOOSTERS) do\n            local on = G.boosterPick and G.boosterPick[bd.id]\n            W.paragraph(ctx, 76, 586+(k-1)*48, tr(\"ready.boosterLine\", { name = tr(bd.nameKey), desc = tr(bd.descKey) }),\n                16, 388, { align = L, col = on and col.amber or col.mist }, 21)\n        end\n        if G.mode ~= \"hex\" then\n            W.paragraph(ctx, 270, 734, tr(\"ready.stock\"), 15, 408, { col = col.mossLt }, 20)\n        end\n    end\n    if hiveTip then drawHiveTutorial(ctx, hiveTip) else drawButtons(ctx) end\n    done()\nend\n\nfunction M.pauseInfo()\n    local infoT\n    if G.runKind == \"daily\" then infoT = tr(\"pause.infoDaily\", { s = G.score })\n    elseif G.runKind then infoT = tr(\"pause.infoRun\", { s = G.score, mode = tr(\"mode.hall\") })\n    elseif G.mode == \"hex\" then infoT = tr(\"pause.infoHex\", { n = G.levelIdx, s = G.score })\n    else infoT = tr(\"pause.info\", { n = G.levelIdx, s = G.score }) end\n    return infoT\nend\n\nlocal function drawPause(ctx)\n    local done = popupFrame(ctx, 54, 208, 432, 624, tr(\"pause.title\"), Th.btn.secondary, nil,\n        { col = col.cream, crisp = true, shadow = false, ow = 1, min = 28 })\n\n    local opts = { col = col.cream, crisp = true, shadow = false, ow = 1, min = 22 }\n    W.fitLabel(ctx, 270, 266, shortName(G.lvl), 24, 376, opts)\n    W.fitLabel(ctx, 270, 300, M.pauseInfo(), 20, 376,\n        { col = col.cream, crisp = true, shadow = false, ow = 1, min = 20 })\n    drawButtons(ctx)\n    for i, key in ipairs({ \"pause.music\", \"pause.sfx\" }) do\n        local gain = i == 1 and (C.BGM_GAIN or 0.35) or (C.SFX_GAIN or 0.7)\n        W.fitLabel(ctx, i == 1 and 181 or 359, 734, tr(key, { p = string.format(\"%.0f\", gain * 100) }), 18, 164,\n            { col = col.cream, crisp = true, shadow = false, ow = 1, min = 18 })\n    end\n    done()\nend\n\nlocal function drawWin(ctx)\n    local u = ui()\n    local all = G.state == \"allclear\"\n    local done = popupFrame(ctx, 40, 220, 460, 560, all and tr(G.mode == \"hex\" and \"win.allHive\" or \"win.allChapter\") or tr(\"win.clear\"))\n\n    local e = G.animT - u.t0\n    nvgSave(ctx)\n    nvgTranslate(ctx, 270, 330)\n    nvgRotate(ctx, G.animT * 0.4)\n    for k = 0, 11 do\n        nvgBeginPath(ctx)\n        nvgMoveTo(ctx, 0, 0)\n        nvgArc(ctx, 0, 0, 150, k * math.pi / 6, k * math.pi / 6 + 0.16, NVG_CW)\n        nvgClosePath(ctx)\n        nvgFillColor(ctx, nvgRGBA(255, 220, 120, 26))\n        nvgFill(ctx)\n    end\n    nvgRestore(ctx)\n    for k = 1, 3 do\n        local sx, sy, r = 270 + (k - 2) * 92, k == 2 and 318 or 330, k == 2 and 40 or 32\n        W.star(ctx, sx, sy, r, false)\n        if u.starsShown >= k then\n            local t = (e - 0.45 - (k - 1) * Th.anim.starStep) / 0.25\n            local sc = 1 + 0.6 * (1 - W.backOut(t))\n            W.star(ctx, sx, sy, r * math.max(0.2, sc), true)\n        end\n    end\n    local shown = math.floor(G.score * math.min(1, e / Th.anim.countUp) + 0.5)\n\n    nvgBeginPath(ctx); nvgRect(ctx, 140, 386, 260, 1.5)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, 140, 0, 400, 0,\n        nvgRGBA(255, 214, 130, 0), nvgRGBA(255, 214, 130, 120), nvgRGBA(255, 214, 130, 0)))\n    nvgFill(ctx)\n    W.label(ctx, 270, 420, tostring(shown), 30, { col = col.cream, ow = 1.5 })\n    local progress = #ProgressFeedback.lines()>0\n    if progress then\n        ProgressFeedback.draw(ctx,270,458,420,24)\n    elseif G.newBest and e > Th.anim.countUp then\n        W.chip(ctx, 200, 450, 140, 30, 200)\n        W.label(ctx, 270, 465, tr(\"win.newRecord\"), TY.body, { col = col.mossLt, ow = 1.2, shadow = false })\n    else\n        local b = (G.mode == \"hex\" and Progress.hexBest(G.levelIdx) or Progress.best(G.levelIdx))\n        W.label(ctx, 270, 465, b and tr(\"chip.best\", { n = b.score }) or \"\", TY.caption + 1, { col = col.mist, outline = false })\n    end\n    W.paragraph(ctx, 270, 548, tr(\"win.stats1\", { c = G.stats.maxChain or 0, s = G.stats.specialsFired or 0, m = G.stats.combos or 0 }),\n        16, 416, { col = col.mist }, 22)\n    W.paragraph(ctx, 270, 586, tr(\"win.stats2\", { c = G.stats.cannonShots or 0, p = G.stats.powers or 0, l = string.format(\"%.1f\", G.stats.maxCombo or 1) }),\n        16, 416, { col = col.mist }, 22)\n\n    if (G.winDoubled or 0) >= (Monetize.PLACEMENTS.win_double.run or 2) then\n        W.fitLabel(ctx, 270, 586, tr(\"ad.doubleDone\"), TY.body, 400, { col = col.mossLt, ow = 1.2, shadow = false })\n    end\n    drawButtons(ctx)\n    done()\n    drawFx(ctx)\nend\n\n\nfunction M.loseTipKey()\n    local gk = Goals.kind()\n\n    if G.mode == \"hex\" and G.siltLost then return \"lose.tipSilt\" end\n    if gk == \"step\" and Goals.movesLeft() <= 0 then return \"lose.tipMoves\" end\n    if gk == \"timed\" and Goals.timeLeft() <= 0 then return \"lose.tipTime\" end\n    if G.lvl.cannon and (G.stats.cannonShots or 0) < 2 then return \"lose.tipCannon\" end\n\n    if (G.lvl.push or 0) > 0 and gk ~= \"step\" and ((G.stats.swaps or 0) % 2 == 0) then return \"lose.tipPush\" end\n    if not G.runKind and G.levelIdx and G.levelIdx > 0\n        and Progress.fails(G.mode == \"hex\" and \"hex\" or \"classic\", G.levelIdx) >= 2\n        and ((Progress.stock(\"reverse\") or 0) > 0 or (Progress.stock(\"slow\") or 0) > 0) then\n        return \"lose.tipBooster\"\n    end\n    return \"lose.tip\"\nend\n\nfunction M.lossText()\n    local remain=G.queueLeft+#G.balls\n    local gkLose = Goals.kind()\n    local loseTitle\n    if G.mode == \"hex\" and G.siltLost then loseTitle = tr(\"lose.titleSilt\")\n    elseif gkLose == \"step\" then loseTitle = tr(\"lose.titleMoves\")\n    elseif gkLose == \"timed\" then loseTitle = tr(\"lose.titleTime\")\n    else loseTitle = tr(\"lose.title\") end\n    local gk = Goals.kind()\n    local why\n    if G.mode == \"hex\" and G.siltLost then\n\n        why = tr(\"lose.siltWhy\", { n = G.siltLost })\n    elseif gk == \"step\" then\n        why = Goals.movesLeft() <= 0 and tr(\"lose.noMoves\") or tr(\"lose.reached\")\n        local parts = {}\n        for _, t in ipairs(G.lvl.targets or {}) do\n            local cur, need = Goals.targetProgress(t)\n            if cur < need then parts[#parts + 1] = tr(\"lose.short\", { label = Goals.targetLabel(t), n = need - cur }) end\n        end\n        if #parts > 0 then why = why .. tr(\"sep.dot\") .. table.concat(parts, tr(\"sep.dot\")) end\n    elseif gk == \"timed\" then\n        why = tr(\"lose.timedWhy\", { why = Goals.timeLeft() <= 0 and tr(\"lose.timeUp\") or tr(\"lose.reached\"), a = G.score, b = G.lvl.energy })\n    else\n        why = tr(\"lose.beadsLeft\", { n = remain })\n    end\n    return loseTitle,why\nend\n\nlocal function drawLose(ctx)\n    local rescue = #rescueButtons() > 0\n    local dy = rescue and -18 or 0\n\n\n\n\n    local loseTitle,why=M.lossText()\n    local done = popupFrame(ctx, 50, 260, 440, rescue and 562 or 538, loseTitle, Th.btn.danger)\n    local e = G.animT - ui().t0\n    local sink = math.min(1, e / 0.6) * 6\n    if G.img.core then\n        nvgBeginPath(ctx); nvgRect(ctx, 225, 340 + sink, 90, 90)\n        nvgFillPaint(ctx, nvgImagePattern(ctx, 225, 340 + sink, 90, 90, 0, G.img.core, 0.45)); nvgFill(ctx)\n    end\n    local nextY = W.paragraph(ctx, 270, 462+dy, why, 20, 394, { col = col.amber }, 26)\n    W.label(ctx, 270, nextY+18, tr(\"lose.score\", { n = G.score }), 18, { col = col.mist })\n    W.paragraph(ctx, 270, nextY+58, tr(M.loseTipKey()), 16, 394, { col = col.cream }, 22)\n    drawButtons(ctx)\n    done()\nend\n\n\n\nlocal function hubBackdrop(ctx, title, tint)\n    if not W.cover(ctx, G.img.titleBg, 1) then\n        nvgBeginPath(ctx); nvgRect(ctx, -G.offX, -G.offY, G.screenDW, G.screenDH)\n        nvgFillColor(ctx, rgba(col.night)); nvgFill(ctx)\n    end\n    W.dim(ctx, 150)\n    if tint then\n        nvgBeginPath(ctx); nvgRect(ctx, -G.offX, -G.offY, G.screenDW, G.screenDH)\n        nvgFillPaint(ctx, nvgRadialGradient(ctx, 270, 420, 40, 420, rgba(tint, 46), rgba(tint, 0))); nvgFill(ctx)\n    end\n    fireflies(ctx, 14, 120, 900, 7)\n    W.ribbon(ctx, 270, 60 + (G.safeShift or 0), 240, title, Th.btn.secondary, 26)\nend\n\nlocal function statChip(ctx, x, y, w, label, value, vcol)\n    nvgBeginPath(ctx); nvgRoundedRect(ctx, x, y, w, 68, 16); nvgFillColor(ctx, nvgRGBA(4, 12, 18, 160)); nvgFill(ctx)\n    nvgStrokeColor(ctx, nvgRGBA(140, 214, 150, 50)); nvgStrokeWidth(ctx, 1.5); nvgStroke(ctx)\n    W.fitLabel(ctx, x + w / 2, y + 24, value, TY.h2, w - 12, { col = vcol or col.amber, ow = 1.6 })\n    W.fitLabel(ctx, x + w / 2, y + 52, label, TY.caption, w - 12, { col = col.mist, outline = false })\nend\n\nlocal function drawDaily(ctx)\n    hubBackdrop(ctx, tr(\"daily.title\"), { 255, 160, 70 })\n    local day = Daily.now()\n    local lv = Daily.level(day)\n    local dd = Progress.daily()\n    local played = Progress.dailyPlayed(day.key)\n    W.panel(ctx, 34, 116, 472, 616, { r = 26 })\n    W.fitLabel(ctx, 270, 166, tr(\"daily.dateWeek\", { date = I18n.date(day.month, day.day), wk = Daily.weekName(day.wday) }), TY.h1, 400, { col = col.cream, ow = 2.4 })\n    local gk = lv.kind\n    local tc = TAG_COL[gk] or { Th.btn.secondary.top, Th.btn.secondary.bot }\n    W.tag(ctx, 270, 206, tr(\"daily.tag\", { goal = goalName(gk), variant = lv.variant and tr(lv.variant) or \"\" }), tc[1], tc[2], 15)\n\n    nvgBeginPath(ctx); nvgRoundedRect(ctx, 62, 232, 416, 84, 16); nvgFillColor(ctx, nvgRGBA(4, 12, 18, 150)); nvgFill(ctx)\n    if G.img.core then Draw.drawImage(ctx, G.img.core, 104, 274, 56, 56, 1) end\n    W.fitLabel(ctx, 144, 260, Goals.describe(lv), TY.body, 320, { col = col.amber, align = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, ow = 1.4 })\n    W.fitLabel(ctx, 144, 290, tr(\"daily.board\"), TY.caption, 320,\n        { col = col.mist, align = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, outline = false })\n\n    W.fitLabel(ctx, 270, 352, tr(\"daily.nextReset\"), TY.caption + 1, 420, { col = col.mist, outline = false })\n    W.label(ctx, 270, 394, Daily.fmtCountdown(day.secLeft), 40, { col = col.cream, ow = 3 })\n\n    statChip(ctx, 62, 440, 128, tr(\"daily.streakLabel\"), tostring(Progress.dailyStreak(day.key)), col.mossLt)\n    statChip(ctx, 206, 440, 128, tr(\"daily.localBest\"), tostring(dd.best or 0))\n    local todayV = played and tostring(dd.todayScore or 0) or tr(\"daily.notPlayed\")\n    statChip(ctx, 350, 440, 128, played and (dd.todayWon and tr(\"daily.todayWon\") or tr(\"daily.todayLost\")) or tr(\"daily.todayScore\"), todayV,\n        played and col.cream or col.ember)\n\n    local rules = {\n        tr(\"daily.rule1\"),\n        tr(\"daily.rule2\"),\n        tr(\"daily.rule3\"),\n        tr(\"daily.rule4\"),\n    }\n    for i, l in ipairs(rules) do\n        W.fitLabel(ctx, 270, 548 + (i - 1) * 30, l, TY.caption + 1, 420, { col = i == 1 and col.cream or col.mist, outline = false })\n    end\n    if played then\n        W.fitLabel(ctx, 270, 690, tr(\"daily.doneToday\"), TY.body, 420, { col = col.mossLt, ow = 1.2, shadow = false })\n    else\n        W.fitLabel(ctx, 270, 690, tr(\"daily.notStarted\"), TY.body, 420, { col = col.amber, ow = 1.2, shadow = false })\n    end\n    drawButtons(ctx)\n    if not played then\n        local b = M.buttons()[2]\n        W.dot(ctx, b.x + b.w / 2 - 6, b.y - b.h / 2 + 6, 11)\n    end\nend\n\n\nlocal function drawArenaCard(ctx, b)\n    local pressed = ui().press == b.id\n    local sc = pressed and Th.anim.press or 1\n    local endless = b.card == \"endless\"\n    nvgSave(ctx)\n    nvgTranslate(ctx, b.x, b.y); nvgScale(ctx, sc, sc); nvgTranslate(ctx, -b.x, -b.y)\n    local x0, y0, w, h = b.x - b.w / 2, b.y - b.h / 2, b.w, b.h\n    W.panel(ctx, x0, y0, w, h, { r = 26 })\n    local tier = (G.ui.arenaTier or {})[b.card] or 1\n    local lv = C.ARENA[b.card .. tier]\n    local tc = TAG_COL[b.card]\n\n    nvgBeginPath(ctx); nvgRoundedRect(ctx, x0 + 18, y0 + 18, w - 36, 100, 18)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, 0, y0 + 18, 0, y0 + 118, rgba(Th.mix(tc[2], { 0, 0, 0 }, 0.7), 235), nvgRGBA(6, 6, 16, 235)))\n    nvgFill(ctx)\n    local cx, cy, t = b.x, y0 + 68, G.animT\n    if endless then\n\n        local sp = 0.6 + (t % 6) * 0.5\n        for k = 0, 15 do\n            local a = t * sp + k * 0.55\n            local rr = 16 + k * 2.6\n            Draw.drawBead(ctx, cx + math.cos(a) * rr * 1.9, cy + math.sin(a) * rr * 0.9, (k % 5) + 1, 9)\n        end\n        if G.img.core then Draw.drawImage(ctx, G.img.core, cx, cy, 34, 34, 1) end\n        W.label(ctx, x0 + w - 60, cy, \"∞\", 40, { col = tc[1], ow = 2.4 })\n    else\n        local f = (t / 9) % 1\n        nvgBeginPath(ctx); nvgCircle(ctx, cx, cy, 44); nvgFillColor(ctx, nvgRGBA(6, 4, 14, 230)); nvgFill(ctx)\n        nvgBeginPath(ctx); nvgArc(ctx, cx, cy, 38, -math.pi / 2, -math.pi / 2 + 2 * math.pi * (1 - f), NVG_CW)\n        nvgStrokeColor(ctx, rgba(tc[1])); nvgStrokeWidth(ctx, 7); nvgLineCap(ctx, NVG_ROUND); nvgStroke(ctx)\n        W.label(ctx, cx, cy + 1, tostring(math.ceil(90 * (1 - f))), 28, { col = col.cream, ow = 2 })\n        for k = 0, 9 do\n            local a = k * 0.63 + t\n            Draw.drawGem(ctx, cx + math.cos(a) * 140, cy + math.sin(a * 1.3) * 34, (k % 5) + 1, 22 + 6 * math.sin(t * 3 + k))\n        end\n    end\n    W.fitLabel(ctx, x0 + 28, y0 + 144, endless and tr(\"hall.endless\") or tr(\"hall.rush\"), 30, 220, { col = tc[1], align = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, ow = 3 })\n    W.tag(ctx, x0 + w - 70, y0 + 144, Goals.tag(b.card), tc[1], tc[2], 14)\n\n    W.fitLabel(ctx, x0 + 28, y0 + 176, endless and tr(\"hall.endlessDesc\") or tr(\"hall.rushDesc\"),\n        TY.body, w - 56, { col = col.mist, align = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, outline = false })\n\n\n    local best = Progress.arenaBest(b.card .. tier) or { score = 0, time = 0 }\n    W.chip(ctx, x0 + 22, y0 + 288, 250, 44)\n    W.icon(ctx, \"trophy\", x0 + 46, y0 + 288, 26, col.amber)\n    local bt = tr(\"hall.tierBest\", { tier = tr(C.ARENA_TIER_KEYS[tier]), n = best.score or 0 })\n    if endless then bt = bt .. \" · \" .. Goals.fmtTime(best.time or 0) end\n    W.fitLabel(ctx, x0 + 66, y0 + 288, bt, TY.body, 196, { col = col.cream, align = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, ow = 1.2, shadow = false })\n    W.button(ctx, { id = b.id, x = x0 + w - 84, y = y0 + 288, w = 112, h = 50, label = tr(\"btn.start\"), skin = \"primary\", size = 22 })\n    nvgRestore(ctx)\nend\n\nM.drawArenaCard=drawArenaCard\nlocal function drawArena(ctx)\n    hubBackdrop(ctx, tr(\"mode.hall\"), { 190, 120, 255 })\n    for _, b in ipairs(M.buttons()) do if b.card then drawArenaCard(ctx, b) end end\n    W.fitLabel(ctx, 270, 838, tr(\"hall.note\"), TY.caption + 1, 440, { col = col.mist, outline = false })\n    W.fitLabel(ctx, 270, 864, tr(\"hall.noLeaderboard\"), TY.caption, 440, { col = col.locked, outline = false })\n    drawButtons(ctx)\nend\n\n\nlocal function drawRunover(ctx)\n    local kind, res = G.runKind, G.runResult\n    local title, skin\n    if kind == \"daily\" then\n        title = res == \"won\" and tr(\"run.dailyWon\") or tr(\"run.dailyLost\")\n        skin = res == \"won\" and nil or Th.btn.danger\n    elseif kind == \"endless\" then title = tr(\"run.endlessOver\")\n    else title = tr(\"run.timeUp\") end\n    local done = popupFrame(ctx, 40, 250, 460, 460, title, skin)\n    local u = ui()\n    local e = G.animT - u.t0\n    if kind == \"daily\" and res == \"won\" then\n        for k = 1, 3 do\n            local sx, sy, r = 270 + (k - 2) * 80, k == 2 and 322 or 332, k == 2 and 32 or 26\n            W.star(ctx, sx, sy, r, G.lastStars >= k)\n        end\n    else\n        local tc = TAG_COL[kind] or TAG_COL.timed\n        W.tag(ctx, 270, 326, kind == \"daily\" and tr(\"run.dailyMissed\") or tr(kind == \"endless\" and \"run.survived\" or \"run.rushTime\", { t = Goals.fmtTime(G.goal and G.goal.survived or 0) }), tc[1], tc[2], 16)\n    end\n    local shown = math.floor(G.score * math.min(1, e / Th.anim.countUp) + 0.5)\n    W.label(ctx, 270, 410, tostring(shown), 46, { col = col.amber, ow = 3 })\n    local practice = kind == \"daily\" and G.runPractice\n    if G.newBest and not practice and e > Th.anim.countUp then\n        W.chip(ctx, 190, 440, 160, 30, 200)\n        W.label(ctx, 270, 455, tr(\"run.localRecord\"), TY.body, { col = col.mossLt, ow = 1.2, shadow = false })\n    else\n        local best = kind == \"daily\" and (Progress.daily().best or 0) or ((Progress.arenaBest(kind .. (G.runTier or 1)) or {}).score or 0)\n        W.label(ctx, 270, 455, tr(\"run.localBest\", { n = best }), TY.caption + 1, { col = col.mist, outline = false })\n    end\n    local line\n    if kind == \"daily\" then\n        line = practice and tr(\"run.practice\") or tr(\"run.recorded\", { n = Progress.dailyStreak(G.lvl.dayKey) })\n    elseif kind == \"endless\" then\n        line = tr(\"run.endlessLine\", { p = math.floor((G.speedBonus or 0) / G.lvl.speed * 100 + 0.5), b = G.goal and G.goal.beads or 0 })\n    else\n        line = tr(\"run.rushLine\", { b = G.goal and G.goal.beads or 0, c = G.stats.maxChain or 0 })\n    end\n    W.fitLabel(ctx, 270, 510, line, TY.body, 420, { col = practice and col.ember or col.cream, ow = 1.2, shadow = false })\n    W.fitLabel(ctx, 270, 546, tr(\"run.stats\", { s = G.stats.specialsFired or 0, m = G.stats.combos or 0, c = G.stats.cannonShots or 0 }),\n        TY.caption + 1, 420, { col = col.mist, outline = false })\n    drawButtons(ctx)\n    done()\n    drawFx(ctx)\nend\n\n\n\nlocal TOAST_TITLE = {\n    daily = \"toast.daily\", arena = \"toast.arena\",\n    arena2 = \"toast.arena2\", arena3 = \"toast.arena3\",\n}\nlocal function toastSub(id, state)\n    if id == \"daily\" then\n        return state == \"title\" and tr(\"toast.daily.title\") or tr(\"toast.daily.other\")\n    elseif id == \"arena\" then\n        if state == \"title\" then return tr(\"toast.arena.title\") end\n        if state == \"modesel\" then return tr(\"toast.arena.modesel\") end\n        return tr(\"toast.arena.other\")\n    end\n    local nm = tr(id == \"arena2\" and \"tier.2\" or \"tier.3\")\n    if state == \"arena\" then return tr(\"toast.tier.arena\", { tier = nm }) end\n    return tr(\"toast.tier.other\", { tier = nm })\nend\n\nlocal function highlightIds(feat, state)\n    if feat == \"daily\" then return state == \"title\" and { \"daily_entry\" } or {} end\n    if state == \"title\" then return { \"play\" } end\n    if state == \"modesel\" then return { \"mode_arena\" } end\n    if state == \"arena\" and (feat == \"arena2\" or feat == \"arena3\") then\n        local t = feat == \"arena2\" and 2 or 3\n        return { \"atier_endless_\" .. t, \"atier_blitz_\" .. t }\n    end\n    return {}\nend\nM.HIGHLIGHT_T = 8\nM.HIVE_TIP_T = 9.5\n\nlocal function toastY(state)\n    if C.ORIENT==\"landscape\" then return 78 end\n    if state == \"title\" then return 318 end\n    if state == \"modesel\" or state == \"arena\" then return 846 end\n    return 120\nend\nfunction M.hint(text)\n    ui().hint = { text = text, t = 0 }\nend\nlocal function drawHighlight(ctx, u)\n    local hl = u.highlight\n    if not hl then return end\n    local ids = highlightIds(hl.feat, G.state)\n    if #ids == 0 then return end\n    local fade = math.min(1, (M.HIGHLIGHT_T - hl.t) / 1.0)\n    local pulse = 0.5 + 0.5 * math.sin(hl.t * 6)\n    for _, b in ipairs(M.buttons()) do\n        for _, id in ipairs(ids) do\n            if b.id == id then\n                local g = 6 + 6 * pulse\n                nvgBeginPath(ctx)\n                nvgRoundedRect(ctx, b.x - b.w / 2 - g, b.y - b.h / 2 - g, b.w + 2 * g, b.h + 2 * g, 20 + g)\n                nvgStrokeColor(ctx, nvgRGBA(255, 214, 110, math.floor((120 + 135 * pulse) * fade)))\n                nvgStrokeWidth(ctx, 3 + 2 * pulse); nvgStroke(ctx)\n\n                W.tag(ctx, b.x - b.w / 2 + 28, b.y - b.h / 2 - 2, \"NEW\", { 255, 236, 170 }, { 200, 70, 40 }, 12)\n            end\n        end\n    end\nend\n\n\n\n\nlocal BUBBLE_TEXT = { daily = \"bubble.daily\", arena = \"bubble.arena\",\n    arena2 = \"bubble.arena2\", arena3 = \"bubble.arena3\" }\nlocal function drawUnlockBubble(ctx, u)\n    local hl = u.highlight\n    if not hl or G.state ~= \"title\" or G.ui.settings or G.ui.credits then return end\n    local target = hl.feat == \"daily\" and \"daily_entry\" or \"play\"\n    local b\n    for _, bb in ipairs(M.buttons()) do if bb.id == target then b = bb end end\n    if not b then return end\n    local txt = BUBBLE_TEXT[hl.feat] and tr(BUBBLE_TEXT[hl.feat]) or \"\"\n    local fs = TY.caption + 2\n    local tw = W.measure(ctx, txt, fs) + 28\n    local h = 32\n    local k = math.max(0, math.min(1, hl.t / 0.25, (M.HIGHLIGHT_T - hl.t) / 0.5))\n    local pop = W.backOut(math.min(1, hl.t / 0.3))\n    local above = target == \"daily_entry\"\n    local ax = above and (b.x + b.w / 2 - 34) or (b.x + 60)\n    local ay = above and (b.y - b.h / 2 - 2) or (b.y + b.h / 2 + 2)\n    local bx = math.max(14, math.min(C.DESIGN_W-14 - tw, ax - tw + 40))\n    local by = above and (ay - 10 - h) or (ay + 10)\n    nvgSave(ctx)\n    if nvgGlobalAlpha then nvgGlobalAlpha(ctx, k) end\n    nvgTranslate(ctx, ax, ay); nvgScale(ctx, 0.6 + 0.4 * pop, 0.6 + 0.4 * pop); nvgTranslate(ctx, -ax, -ay)\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, bx, by, tw, h, h / 2)\n    if above then\n        nvgMoveTo(ctx, ax - 8, by + h - 1); nvgLineTo(ctx, ax, ay); nvgLineTo(ctx, ax + 8, by + h - 1)\n    else\n        nvgMoveTo(ctx, ax - 8, by + 1); nvgLineTo(ctx, ax, ay); nvgLineTo(ctx, ax + 8, by + 1)\n    end\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, 0, by, 0, by + h, nvgRGBA(255, 214, 120, 245), nvgRGBA(214, 140, 50, 245)))\n    nvgFill(ctx)\n    nvgStrokeColor(ctx, nvgRGBA(60, 30, 8, 230)); nvgStrokeWidth(ctx, 1.5); nvgStroke(ctx)\n    W.label(ctx, bx + tw / 2, by + h / 2 + 1, txt, fs, { col = { 48, 22, 6 }, outline = false, shadow = false })\n    nvgRestore(ctx)\nend\n\nlocal function drawToast(ctx)\n    local u = ui()\n    drawHighlight(ctx, u)\n    drawUnlockBubble(ctx, u)\n    if u.toast and G.state ~= \"title\" then\n\n        local tt = { TOAST_TITLE[u.toast.id] and tr(TOAST_TITLE[u.toast.id]) or \"\", toastSub(u.toast.id, G.state) }\n        local k = math.max(0, math.min(1, u.toast.t / 0.3, (3.4 - u.toast.t) / 0.4))\n        local baseY = toastY(G.state) + ((G.state == \"modesel\" or G.state == \"arena\") and 10 or 0)\n        local y = baseY - (1 - W.backOut(math.min(1, u.toast.t / 0.35))) * 30\n        nvgSave(ctx)\n        if nvgGlobalAlpha then\n            nvgGlobalAlpha(ctx, k)\n        elseif u.toast.t > 3.0 then\n            nvgTranslate(ctx, -(1 - k) * 560, 0)\n        end\n        W.chip(ctx, 60, y, 420, 64, 225)\n        nvgBeginPath(ctx); nvgRect(ctx, 60, y + 8, 4, 48); nvgFillColor(ctx, rgba(col.amber)); nvgFill(ctx)\n        W.fitLabel(ctx, 82, y + 22, tt[1], TY.body + 1, 384, { col = col.amber, align = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, ow = 1.4, shadow = false })\n        W.fitLabel(ctx, 82, y + 45, tt[2], TY.caption + 1, 384, { col = col.cream, align = NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE, outline = false })\n        nvgRestore(ctx)\n    end\n    if u.hint then\n        local k = math.max(0, math.min(1, u.hint.t / 0.15, (1.8 - u.hint.t) / 0.3))\n        nvgSave(ctx)\n        if nvgGlobalAlpha then nvgGlobalAlpha(ctx, k)\n        else nvgTranslate(ctx, 0, (1 - k) * 120) end\n\n\n        local liveWide = C.ORIENT==\"landscape\" and G.state==\"playing\" and not G.runKind\n        local hx = liveWide and 268 or C.DESIGN_W/2\n        local hy = liveWide and 452 or (C.ORIENT==\"landscape\" and 515 or 865)\n        local hh = liveWide and 28 or 42\n        W.chip(ctx, hx-160, hy-hh/2, 320, hh, 230)\n        W.fitLabel(ctx, hx, hy, u.hint.text, liveWide and TY.caption+1 or TY.body, 300, { col = col.cream, ow = 1.2, shadow = false })\n        nvgRestore(ctx)\n    end\nend\nM.drawToast = drawToast\n\n\nfunction M.drawFull(ctx)\n    if C.ORIENT==\"landscape\" then Wide.full(ctx,M);drawToast(ctx);return end\n\n\n    if G.state == \"title\" then drawTitle(ctx)\n    elseif G.state == \"modesel\" then drawModeSel(ctx)\n    elseif G.state == \"daily\" then drawDaily(ctx)\n    elseif G.state == \"arena\" then drawArena(ctx)\n    else drawMap(ctx) end\n    drawToast(ctx)\nend\n\nfunction M.drawOverlay(ctx)\n    if C.ORIENT==\"landscape\" then Wide.overlay(ctx,M);drawToast(ctx);return end\n    local s = G.state\n    if s == \"ready\" then drawReady(ctx)\n    elseif s == \"paused\" then drawPause(ctx)\n    elseif s == \"won\" or s == \"allclear\" then drawWin(ctx)\n    elseif s == \"lost\" then drawLose(ctx)\n    elseif s == \"runover\" then drawRunover(ctx) end\n    drawToast(ctx)\nend\n\nreturn M\n"],["ringcore/ui/theme.lua","\nlocal M = {}\n\nlocal function c(r, g, b, a) return { r, g, b, a or 255 } end\n\nM.col = {\n    night     = c(9, 16, 32),\n    forest    = c(18, 48, 50),\n    forestDk  = c(10, 28, 34),\n    moss      = c(64, 132, 96),\n    mossLt    = c(140, 214, 150),\n    ink       = c(14, 16, 30),\n    cream     = c(255, 246, 222),\n    mist      = c(190, 210, 220),\n    amber     = c(255, 200, 87),\n    amberDk   = c(206, 128, 32),\n    ember     = c(255, 110, 90),\n    sky       = c(110, 190, 255),\n    locked    = c(168, 184, 194),\n}\n\n\nM.btn = {\n    primary   = { top = c(140, 230, 110), bot = c(58, 160, 74),  lip = c(34, 96, 46) },\n    secondary = { top = c(255, 214, 120), bot = c(226, 150, 46), lip = c(150, 90, 24) },\n    neutral   = { top = c(120, 196, 255), bot = c(58, 124, 214), lip = c(34, 74, 140) },\n    danger    = { top = c(255, 150, 120), bot = c(214, 76, 70),  lip = c(132, 40, 40) },\n    disabled  = { top = c(130, 138, 150), bot = c(90, 96, 110),  lip = c(56, 60, 72) },\n}\n\n\nM.type = { display = 46, h1 = 30, h2 = 22, body = 17, caption = 13 }\n\nM.radius = { panel = 22, button = 0.42, chip = 14 }\nM.minHit = 48\n\nM.anim = {\n    press = 0.92,\n    popIn = 0.28,\n    starStep = 0.32,\n    countUp = 1.1,\n}\n\nfunction M.rgba(t, a) return nvgRGBA(t[1], t[2], t[3], a or t[4] or 255) end\nfunction M.mix(a, b, u)\n    return { math.floor(a[1] + (b[1] - a[1]) * u), math.floor(a[2] + (b[2] - a[2]) * u),\n             math.floor(a[3] + (b[3] - a[3]) * u), 255 }\nend\n\nreturn M\n"],["ringcore/ui/tutorial.lua","\nlocal M = {}\nlocal C = require 'ringcore.config'\nlocal G = require 'ringcore.state'\nlocal Board = require 'ringcore.activeboard'\nlocal Ring = require 'ringcore.ring'\nlocal Link = require 'ringcore.interlock'\nlocal Draw = require 'ringcore.draw'\nlocal T = C.T\nlocal function ammoXY()\n    return C.ORIENT == 'landscape' and T.CANNON_X + 64 or 334,\n           C.ORIENT == 'landscape' and T.CANNON_Y+26 or 464\nend\nlocal function cell(out, r, c, move, kind)\n    local x, y = Board.cellXY(r, c)\n    out[#out + 1] = { kind = kind or 'cell', x = x, y = y, r = r, c = c, move = move }\nend\nlocal function exchange(out, move)\n    if not move then return end\n    cell(out, move[1], move[2], move)\n    cell(out, move[3], move[4], move)\nend\nlocal function lockedMove()\n\n    for r, row in ipairs(G.board) do for c in pairs(row) do\n        for rr = math.max(1, r - 1), math.min(#G.board, r + 1) do\n            for cc = math.max(1, c - 1), math.min(#row, c + 1) do\n                if Board.adjacent(r, c, rr, cc) then\n                    for _, group in ipairs(Board.previewSwap(r, c, rr, cc) or {}) do\n                        for _, rc in ipairs(group.cells) do\n                            if G.board[rc[1]][rc[2]].locked then return { r, c, rr, cc }, rc end\n                        end\n                    end\n                end\n            end\n        end\n    end end\nend\nlocal function recommendation(u, key)\n\n\n    local parts = {}\n    for r, row in ipairs(G.board) do for c, v in pairs(row) do\n        parts[#parts + 1] = r .. ',' .. c .. ':' .. v.color .. ':' .. tostring(v.locked)\n            .. ':' .. tostring(v.special) .. ':' .. tostring(v.clearing)\n    end end\n    local signature = table.concat(parts, ';')\n    if u.board ~= G.board or u.signature ~= signature then\n        u.board, u.signature = G.board, signature\n        if key == 'coach.lock' then u.move, u.lock = lockedMove()\n        else u.move, u.lock = Board.findMove(), nil end\n    end\n    return u.move, u.lock\nend\nlocal function beads(out, ids, color)\n    local set = {}; for _, id in ipairs(ids or {}) do set[id] = true end\n    for _, ball in ipairs(G.balls) do if set[ball.id] then\n        local x, y = Ring.pointOnPath(ball.vdist)\n        out[#out + 1] = { kind = 'bead', id = ball.id, x = x, y = y, color = color }\n    end end\nend\nfunction M.targets(key)\n    if G.state ~= 'playing' or G.ui.hiveTip then return {} end\n    if not key then G.ui.tutorial = nil; return {} end\n    local now = G.stats.time or 0\n    local u = G.ui.tutorial\n    if not u or u.key ~= key then u = { key = key, start = now }; G.ui.tutorial = u end\n\n    if G.press and G.press.onBoard and G.press.moved then return {} end\n    local out = {}\n    if now - u.start < 3.2 then\n        if key == 'coach.swap' and G.boardState == 'idle' then\n            exchange(out, recommendation(u, key))\n            if G.mode == 'hex' and G.silt then\n                out[#out + 1] = { kind = 'honey', x = C.ORIENT == 'landscape' and 690 or 328,\n                    y = 28 + (G.safeShift or 0), color = G.siltColor }\n            end\n        elseif key == 'coach.lock' and G.boardState == 'idle' then\n            local move, lock = recommendation(u, key)\n            exchange(out, move)\n            if lock then cell(out, lock[1], lock[2], nil, 'lock')\n            else\n\n                for r, row in ipairs(G.board) do for c, v in pairs(row) do\n                    if v.locked and not v.clearing then cell(out, r, c, nil, 'lock'); return out end\n                end end\n            end\n        elseif key == 'coach.fire' or key == 'coach.chain' then\n            local color = G.cannonAmmo[1]\n            if color then\n                local x, y = ammoXY(); out[#out + 1] = { kind = 'ammo', x = x, y = y, color = color }\n                if key == 'coach.fire' then out[#out + 1] = { kind = 'cannon', x = T.CANNON_X, y = T.CANNON_Y, color = color } end\n                beads(out, Link.previewTarget(color), color)\n            end\n        end\n    end\n\n    if key == 'coach.pulse' then\n        for _, p in ipairs(G.pulses or {}) do\n            out[#out + 1] = { kind = 'pulse', x = p.x, y = p.y, color = p.color }\n            if p.mode == 'hit' then beads(out, p.ids, p.color) end\n        end\n    end\n    for _, v in ipairs(G.causalFX or {}) do if v.life > 0 then\n        if v.stage == 'hit' then\n            out[#out + 1] = { kind = 'hit', x = v.x, y = v.y - 26, color = v.ci }\n        elseif v.stage == 'ammo' then\n            local x, y = ammoXY(); out[#out + 1] = { kind = 'ammoResult', x = x, y = y, color = v.ci }\n        end\n    end end\n    return out\nend\nfunction M.draw(ctx, key)\n    local marks = M.targets(key)\n    local alpha = math.floor(190 + 55 * math.sin(G.animT * 5))\n    nvgSave(ctx)\n    for _, v in ipairs(marks) do\n        local color = v.color and C.COLORS[v.color] or { 255, 224, 136 }\n        nvgBeginPath(ctx)\n        if v.kind == 'cell' or v.kind == 'lock' then\n            local size = Board.isHex() and 51 or T.CELL + 2\n            nvgRoundedRect(ctx, v.x - size / 2, v.y - size / 2, size, size, 12)\n        elseif v.kind == 'ammo' or v.kind == 'ammoResult' then\n            nvgRoundedRect(ctx, v.x - 25, v.y - 25, 50, 50, 12)\n        else\n            local radius = v.kind == 'cannon' and T.CANNON_R + 7 or (v.kind == 'pulse' and 15 or T.BALL_R + 8)\n            nvgCircle(ctx, v.x, v.y, radius)\n        end\n        nvgStrokeColor(ctx, nvgRGBA(12, 20, 24, alpha)); nvgStrokeWidth(ctx, 6); nvgStroke(ctx)\n        nvgStrokeColor(ctx, Draw.rgba(color, alpha)); nvgStrokeWidth(ctx, 3); nvgStroke(ctx)\n    end\n    local a, b = marks[1], marks[2]\n    if a and b and a.kind == 'cell' and b.kind == 'cell' then\n        local x, y, xx, yy = a.x, a.y, b.x, b.y\n        local dx, dy = xx - x, yy - y; local len = math.sqrt(dx * dx + dy * dy)\n        if len > 0 then\n            local ux, uy = dx / len, dy / len\n            nvgBeginPath(ctx); nvgMoveTo(ctx, x, y); nvgLineTo(ctx, xx, yy)\n            nvgMoveTo(ctx, xx - ux * 10 - uy * 6, yy - uy * 10 + ux * 6)\n            nvgLineTo(ctx, xx, yy); nvgLineTo(ctx, xx - ux * 10 + uy * 6, yy - uy * 10 - ux * 6)\n            nvgStrokeColor(ctx, nvgRGBA(255, 240, 170, alpha)); nvgStrokeWidth(ctx, 3); nvgStroke(ctx)\n        end\n    end\n    nvgRestore(ctx)\nend\nreturn M\n"],["ringcore/ui/widgets.lua","\n\nlocal M = {}\nlocal G = require \"ringcore.state\"\nlocal Th = require \"ringcore.ui.theme\"\nlocal C = require \"ringcore.config\"\nlocal Draw = require \"ringcore.draw\"\nlocal col, rgba = Th.col, Th.rgba\nlocal AT = C.ATLAS\n\n\nlocal PAINT = {\n    boost_gold = { top = { 255, 232, 150 }, bot = { 246, 180, 54 }, lip = { 167, 103, 23 } },\n    boost_cyan = { top = { 155, 255, 242 }, bot = { 45, 205, 207 }, lip = { 20, 120, 142 } },\n    boost_gold_off = { top = { 249, 236, 197 }, bot = { 218, 193, 139 }, lip = { 149, 125, 82 } },\n    boost_cyan_off = { top = { 210, 245, 237 }, bot = { 142, 209, 203 }, lip = { 78, 144, 144 } },\n    primary   = { top = { 255, 218, 128 }, bot = { 206, 112, 30 }, lip = { 120, 58, 14 }, glow = { 255, 176, 70 } },\n    secondary = { top = { 104, 214, 204 }, bot = { 22, 112, 122 }, lip = { 10, 60, 70 } },\n    neutral   = { top = { 70, 136, 158 },  bot = { 18, 52, 74 },   lip = { 8, 28, 42 } },\n    danger    = { top = { 255, 140, 110 }, bot = { 170, 52, 44 },  lip = { 96, 24, 22 } },\n}\nM.PAINT = PAINT\nlocal ICON_IMG = { pause = \"pause\", play = \"right\", restart = \"return\", back = \"left\", lock = \"locked\", home = \"home\",\n    info = \"information\", trophy = \"trophy\", gear = \"gear\", close = \"cross\" }\nlocal ICON_IDX = {}\nfor i, n in ipairs(AT.icons.names) do ICON_IDX[n] = i - 1 end\n\n\n\n\nfunction M.label(ctx, x, y, str, size, opts)\n    if G.fontId == -1 or not str then return end\n    opts = opts or {}\n    local crisp = opts.crisp ~= false\n    nvgFontBlur(ctx, 0)\n    if crisp then\n        x, y, size = math.floor(x+0.5), math.floor(y+0.5), math.floor(size+0.5)\n    end\n    nvgFontFaceId(ctx, G.fontId)\n    nvgFontSize(ctx, size)\n    nvgTextAlign(ctx, opts.align or (NVG_ALIGN_CENTER + NVG_ALIGN_MIDDLE))\n    local a = opts.alpha or 255\n    local outlined = opts.outline ~= false and (size>=24 or type(opts.outline)==\"table\")\n    local w = opts.ow or math.max(1, math.floor(size/28))\n    nvgFillColor(ctx, rgba(opts.col or col.cream, a))\n    local nativeOutline = rawget(_G, \"nvgTextOutline\")\n    if outlined and nativeOutline then\n        nvgStrokeColor(ctx, rgba(opts.outline or col.ink, a))\n        nvgStrokeWidth(ctx, w*2)\n        nativeOutline(ctx, x, y, str)\n        return\n    end\n    if outlined then\n        nvgFillColor(ctx, rgba(opts.outline or col.ink, a))\n        for _,d in ipairs({{-w,0},{w,0},{0,-w},{0,w}}) do\n            local tx,ty=x+d[1],y+d[2]\n            if crisp then tx,ty=math.floor(tx+.5),math.floor(ty+.5)end\n            nvgText(ctx,tx,ty,str,nil)\n        end\n        nvgFillColor(ctx, rgba(opts.col or col.cream,a))\n    end\n    nvgText(ctx,x,y,str,nil)\nend\n\n\n\nfunction M.measure(ctx, str, size)\n    if G.fontId == -1 or not str then return 0 end\n    nvgFontFaceId(ctx, G.fontId)\n    nvgFontSize(ctx, size)\n    local w = nvgTextBounds(ctx, 0, 0, str, nil)\n    if type(w) ~= \"number\" then w = #str * size * 0.5 end\n    return w\nend\n\n\n\n\nfunction M.fit(ctx, str, size, maxW, minSize)\n    if not str or not maxW then return size, str end\n    local pad = math.max(1.2, size * 0.075) * 2 + 2\n    local w = M.measure(ctx, str, size) + pad\n    if w <= maxW then return size, str end\n    local minS = minSize or math.min(size, math.max(12, math.ceil(size * 0.8)))\n    local s2 = math.max(minS, math.floor(size * (maxW / w)))\n\n    while s2 > minS and M.measure(ctx, str, s2) + pad > maxW do s2 = math.max(minS, s2 - 1) end\n    if M.measure(ctx, str, s2) + pad <= maxW then return s2, str end\n\n    local chars = {}\n    for _, cp in utf8.codes(str) do chars[#chars + 1] = utf8.char(cp) end\n    for n = #chars - 1, 1, -1 do\n        local t = table.concat(chars, \"\", 1, n) .. \"…\"\n        if M.measure(ctx, t, s2) + pad <= maxW then return s2, t end\n    end\n    return s2, \"…\"\nend\n\nlocal rawFit = M.fit\nM.fitQuiet = rawFit\n\n\n\nfunction M.pickFit(ctx, str, size, maxW, minSize)\n    if type(str) ~= \"table\" then return str end\n    for i = 1, #str - 1 do\n        local _, t = rawFit(ctx, str[i], size, maxW, minSize)\n        if t == str[i] then return str[i] end\n    end\n    return str[#str]\nend\n\nfunction M.fitLabel(ctx, x, y, str, size, maxW, opts)\n    str = M.pickFit(ctx, str, size, maxW, opts and opts.min)\n    local s2, t = M.fit(ctx, str, size, maxW, opts and opts.min)\n    if opts and opts.ow and s2 < size then\n        opts = setmetatable({ ow = opts.ow * s2 / size }, { __index = opts })\n    end\n    M.label(ctx, x, y, t, s2, opts)\n    return s2, t\nend\n\n\n\nlocal wrapCache, wrapCount = {}, 0\nfunction M.wrap(ctx, str, size, maxW)\n    local I = package.loaded[\"ringcore.i18n\"]\n    local key = (I and I.lang or \"en\")..\":\"..tostring(G.fontId)..\":\"..size..\":\"..maxW..\":\"..(str or \"\")\n    if wrapCache[key] then return wrapCache[key] end\n    local lines, line = {}, \"\"\n    local function trim(v) return (v:gsub(\"^%s+\", \"\"):gsub(\"%s+$\", \"\")) end\n    local function emit(v) lines[#lines+1] = trim(v) end\n    for _, cp in utf8.codes(str or \"\") do\n        local ch = utf8.char(cp)\n        if ch == \"\\n\" then emit(line); line = \"\"\n        elseif line == \"\" and ch:match(\"%s\") then\n        elseif line ~= \"\" and M.measure(ctx, line..ch, size) > maxW then\n            local split = line:match(\"^.*()%s\")\n            if split and split > 1 and not ch:match(\"%s\") then\n                emit(line:sub(1, split-1)); line = trim(line:sub(split+1))..ch\n            else emit(line); line = trim(ch) end\n        else line = line..ch end\n    end\n    if line ~= \"\" then emit(line) end\n\n    if wrapCount >= 256 then wrapCache, wrapCount = {}, 0 end\n    wrapCache[key], wrapCount = lines, wrapCount+1\n    return lines\nend\n\nfunction M.paragraph(ctx, x, y, str, size, maxW, opts, leading)\n    local lines = M.wrap(ctx, str, size, maxW)\n    leading = leading or math.ceil(size * 1.35)\n    for i, line in ipairs(lines) do M.label(ctx, x, y+(i-1)*leading, line, size, opts) end\n    return y+#lines*leading, #lines\nend\n\nlocal function shadow(ctx, x, y, w, h, r, blur, off, a)\n    nvgBeginPath(ctx)\n    nvgRect(ctx, x - blur, y - blur + off, w + blur * 2, h + blur * 2)\n    nvgFillPaint(ctx, nvgBoxGradient(ctx, x, y + off, w, h, r, blur, nvgRGBA(0, 0, 0, a), nvgRGBA(0, 0, 0, 0)))\n    nvgFill(ctx)\nend\nM.shadow = shadow\n\n\nfunction M.panel(ctx, x, y, w, h, opts)\n    opts = opts or {}\n    local r = opts.r or Th.radius.panel\n    local a = opts.alpha or 255\n    shadow(ctx, x, y, w, h, r, 18, 8, math.floor(150 * a / 255))\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x - 3, y - 3, w + 6, h + 6, r + 3)\n    nvgFillColor(ctx, rgba(col.ink, a))\n    nvgFill(ctx)\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x, y, w, h, r)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, x, y, x, y + h, rgba(opts.rimTop or col.mossLt, a), rgba(opts.rim or col.moss, a)))\n    nvgFill(ctx)\n    local i = opts.inset or 6\n    local top, bot = (opts.body and opts.body[1]) or col.forest, (opts.body and opts.body[2]) or col.forestDk\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x + i, y + i, w - i * 2, h - i * 2, r - i * 0.6)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, x, y + i, x, y + h - i, rgba(top, a), rgba(bot, a)))\n    nvgFill(ctx)\n\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x + i + 4, y + i + 3, w - i * 2 - 8, math.min(26, h * 0.18), r * 0.6)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, x, y + i, x, y + i + 26, nvgRGBA(255, 255, 255, math.floor(26 * a / 255)), nvgRGBA(255, 255, 255, 0)))\n    nvgFill(ctx)\n\n    if opts.frame ~= false then\n        local fh = G.img and G.img[opts.frame == \"cyan\" and \"frameCyan\" or \"frameGold\"]\n        if fh then\n            local de = math.min(opts.frameEdge or 20, w / 4, h / 4)\n            local fi = i + 2\n            Draw.nine(ctx, fh, AT.frame.w, AT.frame.h, AT.frame.edge, x + fi, y + fi, w - fi * 2, h - fi * 2,\n                0.85 * a / 255, de)\n        end\n    end\nend\n\n\nfunction M.chip(ctx, x, y, w, h, a)\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x, y, w, h, h / 2)\n    nvgFillColor(ctx, nvgRGBA(6, 12, 22, a or 170))\n    nvgFill(ctx)\n    nvgStrokeColor(ctx, nvgRGBA(140, 214, 150, 50))\n    nvgStrokeWidth(ctx, 1.5)\n    nvgStroke(ctx)\nend\n\n\nfunction M.tag(ctx, cx, cy, str, top, bot, size)\n    size = size or 11\n    local w = M.measure(ctx, str, size) + 14\n    local h = size + 7\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, cx - w / 2 - 1.5, cy - h / 2 - 1.5, w + 3, h + 3, h / 2 + 1.5)\n    nvgFillColor(ctx, rgba(col.ink)); nvgFill(ctx)\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, cx - w / 2, cy - h / 2, w, h, h / 2)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, 0, cy - h / 2, 0, cy + h / 2, rgba(top), rgba(bot or top)))\n    nvgFill(ctx)\n    M.label(ctx, cx, cy + 0.5, str, size, { col = { 255, 250, 236 }, ow = 1.2, shadow = false })\n    return w\nend\n\n\nfunction M.dot(ctx, x, y, r)\n    r = r or 9\n    local pulse = 1 + 0.12 * math.sin((G.animT or 0) * 5)\n    nvgBeginPath(ctx); nvgCircle(ctx, x, y, (r + 2) * pulse)\n    nvgFillColor(ctx, rgba(col.ink)); nvgFill(ctx)\n    nvgBeginPath(ctx); nvgCircle(ctx, x, y, r * pulse)\n    nvgFillPaint(ctx, nvgRadialGradient(ctx, x - r * 0.3, y - r * 0.3, 1, r * 1.2, nvgRGBA(255, 120, 110, 255), nvgRGBA(214, 30, 40, 255)))\n    nvgFill(ctx)\nend\n\n\nfunction M.ribbon(ctx, cx, cy, w, str, skin, size, badge, textOpts)\n    skin = skin or Th.btn.secondary\n    local h = 50\n    local x0, x1 = cx - w / 2, cx + w / 2\n\n    for s = -1, 1, 2 do\n        local ex = s < 0 and x0 or x1\n        nvgBeginPath(ctx)\n        nvgMoveTo(ctx, ex - s * 10, cy - h * 0.25)\n        nvgLineTo(ctx, ex + s * 34, cy - h * 0.25)\n        nvgLineTo(ctx, ex + s * 22, cy + h * 0.12)\n        nvgLineTo(ctx, ex + s * 34, cy + h * 0.5)\n        nvgLineTo(ctx, ex - s * 10, cy + h * 0.5)\n        nvgClosePath(ctx)\n        nvgFillColor(ctx, rgba(skin.lip))\n        nvgFill(ctx)\n        nvgStrokeColor(ctx, rgba(col.ink))\n        nvgStrokeWidth(ctx, 2.5)\n        nvgStroke(ctx)\n    end\n    shadow(ctx, x0, cy - h / 2, w, h, 12, 10, 5, 120)\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x0, cy - h / 2, w, h, 12)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, 0, cy - h / 2, 0, cy + h / 2, rgba(skin.top), rgba(skin.bot)))\n    nvgFill(ctx)\n    nvgStrokeColor(ctx, rgba(col.ink))\n    nvgStrokeWidth(ctx, 3)\n    nvgStroke(ctx)\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x0 + 6, cy - h / 2 + 4, w - 12, h * 0.34, 8)\n    nvgFillColor(ctx, nvgRGBA(255, 255, 255, 60))\n    nvgFill(ctx)\n    if badge then\n\n\n        local btop, bbot = { 255, 120, 90 }, { 196, 52, 40 }\n        if type(badge) == \"table\" then\n            btop, bbot = badge.top or btop, badge.bot or bbot\n            badge = badge[1]\n        end\n        local bs = 15\n        local bw = M.measure(ctx, badge, bs) + 22\n        local bxc = cx + w / 2 - 10 - bw / 2\n        local bh = 26\n        nvgBeginPath(ctx)\n        nvgRoundedRect(ctx, bxc - bw / 2 - 2, cy - bh / 2 - 1, bw + 4, bh + 4, bh / 2 + 2)\n        nvgFillColor(ctx, rgba(col.ink)); nvgFill(ctx)\n        nvgBeginPath(ctx)\n        nvgRoundedRect(ctx, bxc - bw / 2, cy - bh / 2, bw, bh, bh / 2)\n        nvgFillPaint(ctx, nvgLinearGradient(ctx, 0, cy - bh / 2, 0, cy + bh / 2, rgba(btop), rgba(bbot)))\n        nvgFill(ctx)\n        M.label(ctx, bxc, cy + 1, badge, bs, { col = { 255, 244, 220 }, ow = 1.4, shadow = false })\n        local avail = w - 16 - bw - 10\n        local nameCx = (cx - w / 2 + 8) + avail / 2\n        M.fitLabel(ctx, nameCx, cy + 1, str, size or Th.type.h1, avail, textOpts or { col = col.cream })\n        return\n    end\n    M.fitLabel(ctx, cx, cy + 1, str, size or Th.type.h1, w - 16, textOpts or { col = col.cream })\nend\n\n\nlocal function iconPath(ctx, kind, x, y, s)\n    local h = s / 2\n    nvgBeginPath(ctx)\n    if kind == \"pause\" then\n        nvgRoundedRect(ctx, x - h * 0.6, y - h * 0.7, h * 0.42, h * 1.4, 3)\n        nvgRoundedRect(ctx, x + h * 0.18, y - h * 0.7, h * 0.42, h * 1.4, 3)\n        return \"fill\"\n    elseif kind == \"play\" then\n        nvgMoveTo(ctx, x - h * 0.45, y - h * 0.7); nvgLineTo(ctx, x + h * 0.7, y); nvgLineTo(ctx, x - h * 0.45, y + h * 0.7)\n        nvgClosePath(ctx)\n        return \"fill\"\n    elseif kind == \"map\" then\n        nvgMoveTo(ctx, x - h * 0.8, y - h * 0.5); nvgLineTo(ctx, x - h * 0.27, y - h * 0.75)\n        nvgLineTo(ctx, x + h * 0.27, y - h * 0.5); nvgLineTo(ctx, x + h * 0.8, y - h * 0.75)\n        nvgLineTo(ctx, x + h * 0.8, y + h * 0.5); nvgLineTo(ctx, x + h * 0.27, y + h * 0.75)\n        nvgLineTo(ctx, x - h * 0.27, y + h * 0.5); nvgLineTo(ctx, x - h * 0.8, y + h * 0.75)\n        nvgClosePath(ctx)\n        return \"fill\"\n    elseif kind == \"restart\" then\n        nvgArc(ctx, x, y, h * 0.58, -math.pi * 0.35, math.pi * 1.45, NVG_CW)\n        return \"stroke\", function()\n            local ax, ay = x + math.cos(-math.pi * 0.35) * h * 0.58, y + math.sin(-math.pi * 0.35) * h * 0.58\n            nvgBeginPath(ctx)\n            nvgMoveTo(ctx, ax + h * 0.32, ay - h * 0.05); nvgLineTo(ctx, ax - h * 0.2, ay - h * 0.32); nvgLineTo(ctx, ax - h * 0.05, ay + h * 0.3)\n            nvgClosePath(ctx)\n        end\n    elseif kind == \"music\" then\n        nvgCircle(ctx, x - h * 0.3, y + h * 0.45, h * 0.26)\n        nvgCircle(ctx, x + h * 0.45, y + h * 0.3, h * 0.26)\n        nvgRect(ctx, x - h * 0.12, y - h * 0.7, h * 0.14, h * 1.15)\n        nvgRect(ctx, x + h * 0.63, y - h * 0.85, h * 0.14, h * 1.15)\n        nvgMoveTo(ctx, x - h * 0.12, y - h * 0.7); nvgLineTo(ctx, x + h * 0.77, y - h * 0.85)\n        nvgLineTo(ctx, x + h * 0.77, y - h * 0.55); nvgLineTo(ctx, x - h * 0.12, y - h * 0.4); nvgClosePath(ctx)\n        return \"fill\"\n    elseif kind == \"sfx\" then\n        nvgMoveTo(ctx, x - h * 0.75, y - h * 0.28); nvgLineTo(ctx, x - h * 0.35, y - h * 0.28)\n        nvgLineTo(ctx, x + h * 0.1, y - h * 0.7); nvgLineTo(ctx, x + h * 0.1, y + h * 0.7)\n        nvgLineTo(ctx, x - h * 0.35, y + h * 0.28); nvgLineTo(ctx, x - h * 0.75, y + h * 0.28); nvgClosePath(ctx)\n        return \"fill\", function()\n            nvgBeginPath(ctx)\n            nvgArc(ctx, x + h * 0.1, y, h * 0.45, -math.pi * 0.3, math.pi * 0.3, NVG_CW)\n            nvgArc(ctx, x + h * 0.1, y, h * 0.75, math.pi * 0.3, -math.pi * 0.3, NVG_CCW)\n            nvgClosePath(ctx)\n        end\n    elseif kind == \"back\" then\n        nvgMoveTo(ctx, x + h * 0.3, y - h * 0.7); nvgLineTo(ctx, x - h * 0.4, y); nvgLineTo(ctx, x + h * 0.3, y + h * 0.7)\n        return \"stroke\"\n    elseif kind == \"lock\" then\n        nvgRoundedRect(ctx, x - h * 0.55, y - h * 0.1, h * 1.1, h * 0.85, 4)\n        return \"fill\", function()\n            nvgBeginPath(ctx)\n            nvgArc(ctx, x, y - h * 0.1, h * 0.36, math.pi, 0, NVG_CW)\n        end, true\n    end\nend\n\nlocal function iconImage(ctx, kind, x, y, s, off, alpha)\n    local name = ICON_IMG[kind]\n    if kind == \"music\" then name = off and \"musicOff\" or \"musicOn\" end\n    if kind == \"sfx\" then name = off and \"audioOff\" or \"audioOn\" end\n    local idx = name and ICON_IDX[name]\n    local h = G.img and G.img.icons\n    if not idx or not h then return false end\n    local A = AT.icons\n    local d = s * 1.15\n    return Draw.drawSub(ctx, h, A.w, A.h, idx * A.cell, 0, A.cell, A.cell, x - d / 2, y - d / 2, d, d, alpha or 1)\nend\nM.iconImage = iconImage\n\nfunction M.icon(ctx, kind, x, y, s, c, off)\n    if iconImage(ctx, kind, x, y, s, off, (c and c[1] == 210) and 0.7 or 1) then return end\n    c = c or col.cream\n    local mode, extra, extraStroke = iconPath(ctx, kind, x, y, s)\n    nvgStrokeColor(ctx, rgba(c))\n    nvgFillColor(ctx, rgba(c))\n    nvgStrokeWidth(ctx, math.max(2.5, s * 0.12))\n    nvgLineCap(ctx, NVG_ROUND)\n    if mode == \"fill\" then nvgFill(ctx) else nvgStroke(ctx) end\n    if extra then\n        extra()\n        if extraStroke then nvgStroke(ctx) else nvgFill(ctx) end\n    end\n    if off then\n        nvgBeginPath(ctx)\n        nvgMoveTo(ctx, x - s * 0.45, y - s * 0.45); nvgLineTo(ctx, x + s * 0.45, y + s * 0.45)\n        nvgStrokeColor(ctx, rgba(col.ember))\n        nvgStrokeWidth(ctx, 4)\n        nvgStroke(ctx)\n    end\nend\n\n\n\n\nfunction M.button(ctx, b)\n    local skin = (b.enabled == false) and (PAINT[b.disabledSkin] or Th.btn.disabled) or PAINT[b.skin or \"primary\"] or Th.btn[b.skin] or PAINT.primary\n    local pressed = G.ui and G.ui.press == b.id\n    local pop = (G.ui and G.ui.popId == b.id) and math.max(0, G.ui.popT) or 0\n    local sc = pressed and Th.anim.press or (1 + 0.08 * math.sin(math.min(1, pop / 0.18) * math.pi) * (pop > 0 and 1 or 0))\n    if b.pulse and not pressed then sc = sc * (1 + 0.035 * math.sin(G.animT * 4)) end\n    nvgSave(ctx)\n    nvgTranslate(ctx, b.x, b.y)\n    nvgScale(ctx, sc, sc)\n    local w, h = b.w, b.h\n    local r = b.round and h / 2 or h * Th.radius.button\n    local lipH = math.max(4, h * 0.1)\n    local sink = pressed and lipH * 0.7 or 0\n    local x0, y0 = -w / 2, -h / 2\n    shadow(ctx, x0, y0, w, h, r, 10, 6 - sink * 0.5, 120)\n    if skin.glow and not pressed then\n        local g = 0.7 + 0.3 * math.sin(G.animT * 3)\n        nvgBeginPath(ctx)\n        nvgRect(ctx, x0 - 30, y0 - 30, w + 60, h + 60)\n        nvgFillPaint(ctx, nvgBoxGradient(ctx, x0, y0, w, h + lipH, r, 26,\n            rgba(skin.glow, math.floor(110 * g)), rgba(skin.glow, 0)))\n        nvgFill(ctx)\n    end\n\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x0 - 3, y0 - 3, w + 6, h + 6 + lipH - sink * 0.2, r + 3)\n    nvgFillColor(ctx, rgba(col.ink))\n    nvgFill(ctx)\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x0, y0 + lipH * 0.5, w, h + lipH * 0.5, r)\n    nvgFillColor(ctx, rgba(skin.lip))\n    nvgFill(ctx)\n\n    local fy = y0 + sink\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x0, fy, w, h - lipH * 0.2, r)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, 0, fy, 0, fy + h, rgba(skin.top), rgba(skin.bot)))\n    nvgFill(ctx)\n\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x0 + w * 0.06, fy + h * 0.08, w * 0.88, h * 0.36, r * 0.7)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, 0, fy + h * 0.08, 0, fy + h * 0.44, nvgRGBA(255, 255, 255, 110), nvgRGBA(255, 255, 255, 18)))\n    nvgFill(ctx)\n\n    if b.enabled ~= false or b.disabledSkin then\n        local fh = G.img and G.img.frameGold\n        if b.round then\n            nvgBeginPath(ctx)\n            nvgRoundedRect(ctx, x0 + 1.5, fy + 1.5, w - 3, h - lipH * 0.2 - 3, r - 1.5)\n            nvgStrokeColor(ctx, nvgRGBA(255, 214, 130, 230)); nvgStrokeWidth(ctx, 2.2); nvgStroke(ctx)\n        elseif fh then\n            Draw.nine(ctx, fh, AT.frame.w, AT.frame.h, AT.frame.edge, x0 + 1, fy + 1, w - 2, h - lipH * 0.2 - 2, 0.95,\n                math.min(16, h * 0.34))\n        else\n            nvgBeginPath(ctx)\n            nvgRoundedRect(ctx, x0 + 2, fy + 2, w - 4, h - lipH * 0.2 - 4, r - 2)\n            nvgStrokeColor(ctx, nvgRGBA(255, 214, 130, 220)); nvgStrokeWidth(ctx, 2); nvgStroke(ctx)\n        end\n    end\n    local cy = fy + h * 0.47\n    local tc = b.labelColor or ((b.enabled == false) and { 210, 214, 222 } or col.cream)\n    local textOpts = { col = tc, min = b.minSize, outline = col.ink, ow = 0.65 }\n    if b.crisp then textOpts.crisp, textOpts.shadow, textOpts.ow = true, false, 1 end\n    if b.labelColor then textOpts.outline = false end\n    if b.icon and b.label then\n        local s = h * 0.46\n        M.icon(ctx, b.icon, -w / 2 + h * 0.55, cy, s, tc, b.iconOff)\n        M.fitLabel(ctx, h * 0.25, cy, b.label, b.size or Th.type.h2, w - h * 1.1 - 6, textOpts)\n    elseif b.icon then\n        M.icon(ctx, b.icon, 0, cy, h * 0.5, tc, b.iconOff)\n    elseif b.label then\n        M.fitLabel(ctx, 0, cy - (b.sublabel and 9 or 0), b.label, b.size or Th.type.h2, w - 14, textOpts)\n        if b.sublabel then M.fitLabel(ctx, 0, cy + 11, b.sublabel, 12, w - 10, { col = tc, outline = false, crisp = b.crisp, shadow = false, min = 10 }) end\n    end\n    nvgRestore(ctx)\nend\n\nfunction M.hit(b, x, y)\n    local hw, hh = (b.hitW or math.max(b.w, Th.minHit)) / 2, (b.hitH or math.max(b.h, Th.minHit)) / 2\n    return b.enabled ~= false and math.abs(x - b.x) <= hw and math.abs(y - b.y) <= hh + 4\nend\n\n\nfunction M.star(ctx, x, y, r, filled, alpha)\n    local a = alpha or 255\n    local sh = G.img and G.img[filled and \"starOn\" or \"starOff\"]\n    if sh then\n\n        Draw.drawImage(ctx, sh, x, y + r * 0.04, r * 2.3, r * 2.3 * 60 / 64, (filled and 1 or 0.55) * a / 255)\n        return\n    end\n    nvgBeginPath(ctx)\n    for k = 0, 9 do\n        local ang = -math.pi / 2 + k * math.pi / 5\n        local rr = (k % 2 == 0) and r or r * 0.48\n        local px, py = x + math.cos(ang) * rr, y + math.sin(ang) * rr\n        if k == 0 then nvgMoveTo(ctx, px, py) else nvgLineTo(ctx, px, py) end\n    end\n    nvgClosePath(ctx)\n    nvgLineJoin(ctx, NVG_ROUND)\n    if filled then\n        nvgFillPaint(ctx, nvgLinearGradient(ctx, x, y - r, x, y + r, nvgRGBA(255, 240, 150, a), nvgRGBA(240, 150, 30, a)))\n    else\n        nvgFillColor(ctx, nvgRGBA(20, 34, 44, math.floor(a * 0.85)))\n    end\n    nvgFill(ctx)\n    nvgStrokeColor(ctx, rgba(col.ink, a))\n    nvgStrokeWidth(ctx, math.max(2, r * 0.16))\n    nvgStroke(ctx)\n    if filled then\n        nvgBeginPath(ctx)\n        nvgEllipse(ctx, x - r * 0.18, y - r * 0.3, r * 0.22, r * 0.12)\n        nvgFillColor(ctx, nvgRGBA(255, 255, 255, math.floor(a * 0.7)))\n        nvgFill(ctx)\n    end\nend\n\nfunction M.bar(ctx, x, y, w, h, frac, c1, c2)\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x - 2, y - 2, w + 4, h + 4, (h + 4) / 2)\n    nvgFillColor(ctx, rgba(col.ink))\n    nvgFill(ctx)\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x, y, w, h, h / 2)\n    nvgFillColor(ctx, nvgRGBA(18, 30, 40, 255))\n    nvgFill(ctx)\n    local fw = math.max(h, w * math.max(0, math.min(1, frac)))\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x, y, fw, h, h / 2)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, x, y, x, y + h, rgba(c1 or col.mossLt), rgba(c2 or col.moss)))\n    nvgFill(ctx)\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x + 3, y + 2, fw - 6, h * 0.35, h * 0.2)\n    nvgFillColor(ctx, nvgRGBA(255, 255, 255, 70))\n    nvgFill(ctx)\nend\n\n\n\n\n\nfunction M.dim(ctx, a)\n    local al = math.floor(a or 0)\n    if al <= 0 then return end\n    nvgBeginPath(ctx)\n    nvgRect(ctx, -G.offX, -G.offY, G.screenDW, G.screenDH)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, 0, 0, 0, G.screenDH,\n        nvgRGBA(3, 8, 16, math.floor(al * 0.55)), nvgRGBA(2, 5, 12, math.floor(al * 1.25))))\n    nvgFill(ctx)\nend\n\n\nfunction M.tint(ctx, t)\n    nvgBeginPath(ctx)\n    nvgRect(ctx, -G.offX, -G.offY, G.screenDW, G.screenDH)\n    nvgFillColor(ctx, nvgRGBA(t[1], t[2], t[3], t[4] or 60))\n    nvgFill(ctx)\nend\n\n\nfunction M.cover(ctx, handle, alpha)\n    if not handle then return false end\n    local iw, ih = 720, 1280\n    local sw, sh = G.screenDW, G.screenDH\n    local s = math.max(sw / iw, sh / ih)\n    local w, h = iw * s, ih * s\n    local x, y = -G.offX + (sw - w) / 2, -G.offY + (sh - h) / 2\n    nvgBeginPath(ctx)\n    nvgRect(ctx, -G.offX, -G.offY, sw, sh)\n    nvgFillPaint(ctx, nvgImagePattern(ctx, x, y, w, h, 0, handle, alpha or 1))\n    nvgFill(ctx)\n    return true\nend\n\n\n\nfunction M.mist(ctx, k)\n    for i, L in ipairs(AT.mist) do\n        local h = G.img and G.img[L.key]\n        if h then\n            local a = (L.alpha + L.pulse * math.sin(G.animT * L.rate + i * 1.7)) * (k or 1)\n            local off = (G.animT * L.speed) % L.w\n            local x = -G.offX - off\n            while x < G.screenDW - G.offX do\n                nvgBeginPath(ctx)\n                nvgRect(ctx, x, L.y, L.w, L.h)\n                nvgFillPaint(ctx, nvgImagePattern(ctx, x, L.y, L.w, L.h, 0, h, math.max(0, a)))\n                nvgFill(ctx)\n                x = x + L.w\n            end\n        end\n    end\nend\n\n\nfunction M.backOut(u)\n    u = math.max(0, math.min(1, u))\n    local s = 1.70158\n    u = u - 1\n    return u * u * ((s + 1) * u + s) + 1\nend\n\nreturn M\n"],["ringcore/world.lua","\nlocal M = {}\nlocal C = require \"ringcore.config\"\nlocal G = require \"ringcore.state\"\nlocal Ring = require \"ringcore.ring\"\nlocal Board = require \"ringcore.activeboard\"\nlocal Draw = require \"ringcore.draw\"\nlocal Fx = require \"ringcore.fx\"\nlocal Layout=require \"ringcore.layout\"\nlocal W = require \"ringcore.ui.widgets\"\nlocal tr = require(\"ringcore.i18n\").t\nlocal T, BALL_D, N, COLORS = C.T, C.BALL_D, C.N, C.COLORS\nlocal FEEDBACK_KEYS = {\n    clear = \"feedback.clear\", pulse = \"feedback.pulse\", hit = \"feedback.hit\", ammo = \"feedback.ammo\",\n    loaded = \"feedback.loaded\", full = \"feedback.full\", noTarget = \"feedback.noTarget\",\n}\n\nlocal function drawRing(ctx)\n    local head = Ring.headBall()\n    local danger = head and head.dist >= G.pathLen * T.WARN_AT\n\n    Draw.strokePath(ctx, BALL_D + 16, nvgRGBA(14, 16, 30, 230))\n    Draw.strokePath(ctx, BALL_D + 11, nvgRGBA(64, 120, 84, 255))\n    Draw.strokePath(ctx, BALL_D + 4, nvgRGBA(20, 44, 42, 255))\n    Draw.strokePath(ctx, BALL_D - 6, nvgRGBA(28, 58, 54, 255))\n    Draw.strokePath(ctx, 2, nvgRGBA(140, 214, 150, 70))\n    local ex, ey = G.path[1].x, G.path[1].y\n    nvgBeginPath(ctx)\n    nvgCircle(ctx, ex, ey, T.BALL_R + 6)\n    nvgFillColor(ctx, nvgRGBA(10, 16, 34, 255))\n    nvgFill(ctx)\n    local wx, wy, wa = Ring.pointOnPath(G.pathLen * T.WARN_AT)\n    local nx, ny = -math.sin(wa), math.cos(wa)\n    nvgBeginPath(ctx)\n    nvgMoveTo(ctx, wx - nx * 24, wy - ny * 24)\n    nvgLineTo(ctx, wx + nx * 24, wy + ny * 24)\n    nvgStrokeColor(ctx, nvgRGBA(255, 80, 90, danger and (160 + math.floor(math.sin(G.animT * 10) * 80)) or 170))\n    nvgStrokeWidth(ctx, 4)\n    nvgStroke(ctx)\n    local cx, cy = G.path[#G.path].x, G.path[#G.path].y\n    local pulse = 1 + math.sin(G.animT * (danger and 9 or 3)) * 0.08\n    local cc = danger and { 255, 110, 90 } or { 255, 225, 140 }\n    if G.stunTimer > 0 then cc = { 180, 220, 255 } end\n    nvgBeginPath(ctx)\n    nvgCircle(ctx, cx, cy, 58 * pulse)\n    nvgFillPaint(ctx, nvgRadialGradient(ctx, cx, cy, 10, 58 * pulse, Draw.rgba(cc, 120), Draw.rgba(cc, 0)))\n    nvgFill(ctx)\n    nvgBeginPath(ctx)\n    nvgCircle(ctx, cx, cy, 24 * pulse)\n    nvgFillPaint(ctx, nvgRadialGradient(ctx, cx - 6, cy - 6, 2, 24 * pulse, nvgRGBA(255, 255, 240, 255), Draw.rgba(cc, 255)))\n    nvgFill(ctx)\n    if G.img.core then\n        Draw.drawImage(ctx, G.img.core, cx, cy, 72 * pulse, 72 * pulse, danger and 0.95 or 0.88)\n    end\n    for _, b in ipairs(G.balls) do\n        if b.vdist >= -2 then\n            local x, y = Ring.pointOnPath(b.vdist)\n            Draw.drawBead(ctx, x, y, b.color, T.BALL_R)\n            if b.power then Draw.drawPowerMark(ctx, x, y, b.power, T.BALL_R, G.animT) end\n        end\n    end\n\n    if G.danger > 0.01 then\n        local d, hb = G.danger, G.heartBeat\n        nvgBeginPath(ctx)\n        nvgCircle(ctx, cx, cy, 70 + 26 * (1 - hb))\n        nvgStrokeColor(ctx, nvgRGBA(255, 60, 70, math.floor((90 + 140 * d) * hb)))\n        nvgStrokeWidth(ctx, 3 + 4 * hb)\n        nvgStroke(ctx)\n        nvgBeginPath(ctx)\n        nvgCircle(ctx, cx, cy, 64 + 10 * hb)\n        nvgFillPaint(ctx, nvgRadialGradient(ctx, cx, cy, 16, 64 + 10 * hb,\n            nvgRGBA(255, 50, 60, math.floor(60 + 110 * d * (0.5 + 0.5 * hb))), nvgRGBA(255, 50, 60, 0)))\n        nvgFill(ctx)\n        if d > 0.25 and math.floor(G.animT * 4) % 2 == 0 then\n            Draw.text(ctx, cx, cy + 52, tr(\"fx.danger\"), 18, nil, nvgRGBA(255, 120, 120, 230))\n        end\n    end\n    for _, bm in ipairs(G.beams) do\n        local a = math.floor(255 * math.min(1, bm.life / 0.3))\n        nvgBeginPath(ctx)\n        nvgMoveTo(ctx, bm.x0, bm.y0)\n        nvgLineTo(ctx, bm.x1, bm.y1)\n        nvgStrokeColor(ctx, Draw.rgba(COLORS[bm.color] or { 255, 255, 255 }, a))\n        nvgStrokeWidth(ctx, 4)\n        nvgLineCap(ctx, NVG_ROUND)\n        nvgStroke(ctx)\n    end\n    local rs = Ring.runs()\n    local marked = {}\n    for k = #rs, 1, -1 do\n        local r = rs[k]\n        if not marked[r.color] and G.balls[r.i2].vdist >= 0 then\n            marked[r.color] = true\n            local x, y = Ring.pointOnPath(G.balls[r.i2].vdist)\n            local yy = y - T.BALL_R - 7 + math.sin(G.animT * 5) * 1.5\n            nvgBeginPath(ctx)\n            nvgMoveTo(ctx, x, yy + 5); nvgLineTo(ctx, x + 5, yy - 3); nvgLineTo(ctx, x - 5, yy - 3)\n            nvgClosePath(ctx)\n            nvgFillColor(ctx, Draw.rgba(COLORS[r.color], 230))\n            nvgFill(ctx)\n        end\n    end\n    if danger then\n        local y,h=C.ORIENT==\"landscape\" and 76 or 56,C.ORIENT==\"landscape\" and 394 or 414\n        nvgBeginPath(ctx)\n        nvgRect(ctx, 0, y, C.ORIENT==\"landscape\" and 526 or C.DESIGN_W, h)\n        nvgFillPaint(ctx, nvgBoxGradient(ctx, 0, y, C.ORIENT==\"landscape\" and 526 or C.DESIGN_W, h, 40, 80, nvgRGBA(0, 0, 0, 0),\n            nvgRGBA(255, 40, 60, 50 + math.floor(math.sin(G.animT * 6) * 30))))\n        nvgFill(ctx)\n    end\nend\n\nlocal function leaf(ctx, x, y, ang, len, c)\n    nvgSave(ctx)\n    nvgTranslate(ctx, x, y)\n    nvgRotate(ctx, ang)\n    nvgBeginPath(ctx)\n    nvgMoveTo(ctx, 0, 0)\n    nvgBezierTo(ctx, len * 0.3, -len * 0.38, len * 0.75, -len * 0.3, len, 0)\n    nvgBezierTo(ctx, len * 0.75, len * 0.3, len * 0.3, len * 0.38, 0, 0)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, 0, -len * 0.3, 0, len * 0.3, Draw.rgba(c[1]), Draw.rgba(c[2])))\n    nvgFill(ctx)\n    nvgStrokeColor(ctx, nvgRGBA(14, 16, 30, 255))\n    nvgStrokeWidth(ctx, 1.5)\n    nvgStroke(ctx)\n    nvgBeginPath(ctx)\n    nvgMoveTo(ctx, len * 0.1, 0); nvgLineTo(ctx, len * 0.85, 0)\n    nvgStrokeColor(ctx, nvgRGBA(30, 70, 40, 200))\n    nvgStroke(ctx)\n    nvgRestore(ctx)\nend\n\nlocal LEAF_A = { { 150, 220, 120 }, { 60, 140, 80 } }\nlocal LEAF_B = { { 110, 196, 110 }, { 40, 110, 70 } }\n\n\nlocal function drawFrame(ctx, bx, by, bw)\n    local x, y, w, h = bx - 14, by - 14, bw + 28, bw + 26\n    nvgBeginPath(ctx)\n    nvgRect(ctx, x - 20, y - 10, w + 40, h + 40)\n    nvgFillPaint(ctx, nvgBoxGradient(ctx, x, y + 8, w, h, 24, 18, nvgRGBA(0, 0, 0, 160), nvgRGBA(0, 0, 0, 0)))\n    nvgFill(ctx)\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x - 3, y - 3, w + 6, h + 6, 25)\n    nvgFillColor(ctx, nvgRGBA(14, 16, 30, 255))\n    nvgFill(ctx)\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x, y, w, h, 22)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, 0, y, 0, y + h, nvgRGBA(178, 128, 74, 255), nvgRGBA(98, 64, 38, 255)))\n    nvgFill(ctx)\n\n    nvgBeginPath(ctx); nvgRoundedRect(ctx, x + 1, y + 1, w - 2, h - 2, 21)\n    nvgStrokeColor(ctx, nvgRGBA(255, 214, 150, 120)); nvgStrokeWidth(ctx, 1.5); nvgStroke(ctx)\n\n    for k = 1, 6 do\n        local gy = y + k * h / 7\n        nvgBeginPath(ctx)\n        nvgMoveTo(ctx, x + 4, gy)\n        nvgBezierTo(ctx, x + w * 0.3, gy - 3, x + w * 0.7, gy + 3, x + w - 4, gy)\n        nvgStrokeColor(ctx, nvgRGBA(60, 36, 20, 60))\n        nvgStrokeWidth(ctx, 1.5)\n        nvgStroke(ctx)\n    end\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x + 4, y + 3, w - 8, 10, 8)\n    nvgFillColor(ctx, nvgRGBA(255, 220, 170, 50))\n    nvgFill(ctx)\n\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, bx - 5, by - 5, bw + 10, bw + 10, 14)\n    nvgFillColor(ctx, nvgRGBA(14, 16, 30, 255))\n    nvgFill(ctx)\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, bx - 3, by - 3, bw + 6, bw + 6, 12)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, 0, by, 0, by + bw, nvgRGBA(18, 40, 46, 255), nvgRGBA(9, 20, 28, 255)))\n    nvgFill(ctx)\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, bx - 3, by - 3, bw + 6, bw + 6, 12)\n    nvgFillPaint(ctx, nvgBoxGradient(ctx, bx - 3, by - 1, bw + 6, bw + 6, 12, 10, nvgRGBA(0, 0, 0, 0), nvgRGBA(0, 0, 0, 120)))\n    nvgFill(ctx)\n\n\n    nvgBeginPath(ctx)\n    nvgRoundedRect(ctx, x + 10, y - 5, w - 20, 10, 5)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, 0, y - 5, 0, y + 5, nvgRGBA(120, 196, 170, 255), nvgRGBA(40, 96, 86, 255)))\n    nvgFill(ctx)\n    nvgStrokeColor(ctx, nvgRGBA(14, 16, 30, 255))\n    nvgStrokeWidth(ctx, 2)\n    nvgStroke(ctx)\n    local sx = x + w / 2\n    nvgBeginPath(ctx); nvgRoundedRect(ctx, sx - 30, y - 9, 60, 16, 8)\n    nvgFillColor(ctx, nvgRGBA(14, 16, 30, 255)); nvgFill(ctx)\n    nvgBeginPath(ctx); nvgRoundedRect(ctx, sx - 26, y - 6, 52, 10, 5)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, sx - 26, 0, sx + 26, 0, nvgRGBA(120, 230, 210, 60),\n        nvgRGBA(200, 255, 235, math.floor(140 + 110 * (G.conduitGlow or 0)))))\n    nvgFill(ctx)\n\n    leaf(ctx, x + 4, y + 10, -2.75, 22, LEAF_B)\n    leaf(ctx, x + w - 4, y + 10, -0.4, 22, LEAF_B)\n    leaf(ctx, x + 6, y + h - 6, 2.6, 24, LEAF_B)\n    leaf(ctx, x + w - 6, y + h - 6, 0.55, 24, LEAF_B)\nend\n\nlocal function drawBoard(ctx)\n    if Board.isHex and Board.isHex() then\n        Board.draw(ctx)\n        return\n    end\n    local bx, by, bw = T.BOARD_X, T.BOARD_Y, N * T.CELL\n    drawFrame(ctx, bx, by, bw)\n    for r = 1, N do for c = 1, N do\n        if (r + c) % 2 == 0 then\n            nvgBeginPath(ctx)\n            nvgRoundedRect(ctx, bx + (c - 1) * T.CELL + 1, by + (r - 1) * T.CELL + 1, T.CELL - 2, T.CELL - 2, 8)\n            nvgFillColor(ctx, nvgRGBA(255, 255, 255, 10))\n            nvgFill(ctx)\n        end\n    end end\n    nvgSave(ctx)\n    nvgScissor(ctx, bx, by, bw, bw)\n    for r = 1, N do for c = 1, N do\n        local cell = G.board[r][c]\n        if cell then\n            local x, y = Board.cellXY(r, c)\n            y = y + (cell.oy or 0)\n            if G.swapAnim then\n                local u = math.min(1, G.swapAnim.t / T.SWAP_TIME)\n                if r == G.swapAnim.r1 and c == G.swapAnim.c1 then\n                    local ox, oy = Board.cellXY(G.swapAnim.r2, G.swapAnim.c2)\n                    x, y = ox + (x - ox) * u, oy + (y - oy) * u\n                elseif r == G.swapAnim.r2 and c == G.swapAnim.c2 then\n                    local ox, oy = Board.cellXY(G.swapAnim.r1, G.swapAnim.c1)\n                    x, y = ox + (x - ox) * u, oy + (y - oy) * u\n                end\n            end\n            local s = T.CELL - 7\n            local alpha = 255\n            if cell.clearing then\n\n\n\n                local u = math.min(1, math.max(0, G.stateTimer / (G.clearDur or T.CLEAR_TIME)))\n                local p = 1 - u\n                if p < 0.25 then\n                    s = s * (1 + 0.18 * (p / 0.25))\n                else\n                    local k = (p - 0.25) / 0.75\n                    s = s * (1.18 - 0.95 * k)\n                    alpha = math.floor(255 * (1 - k) ^ 1.4)\n                end\n            end\n            if G.hint and G.boardState == \"idle\" and ((r == G.hint[1] and c == G.hint[2]) or (r == G.hint[3] and c == G.hint[4])) then\n                s = s * (1 + math.sin(G.animT * 8) * 0.06)\n            end\n            Draw.drawTile(ctx, x, y, cell.color, s, alpha, cell.special)\n            if cell.locked and not cell.clearing then\n                if not Draw.drawImage(ctx, G.img.lock, x, y, T.CELL - 8, T.CELL - 8, 0.92) then\n                    local h = (T.CELL - 6) / 2\n                    nvgBeginPath(ctx)\n                    nvgRoundedRect(ctx, x - h, y - h, h * 2, h * 2, 14)\n                    nvgFillColor(ctx, nvgRGBA(8, 12, 26, 95))\n                    nvgFill(ctx)\n                    nvgStrokeColor(ctx, nvgRGBA(60, 140, 90, 230))\n                    nvgStrokeWidth(ctx, 4)\n                    nvgStroke(ctx)\n                end\n            end\n        end\n    end end\n    nvgRestore(ctx)\n    if G.selected and G.board[G.selected.r] and G.board[G.selected.r][G.selected.c] then\n        local x, y = Board.cellXY(G.selected.r, G.selected.c)\n        nvgBeginPath(ctx)\n        nvgRoundedRect(ctx, x - T.CELL / 2 + 2, y - T.CELL / 2 + 2, T.CELL - 4, T.CELL - 4, 16)\n        nvgStrokeColor(ctx, nvgRGBA(255, 245, 200, 220))\n        nvgStrokeWidth(ctx, 3)\n        nvgStroke(ctx)\n    end\nend\n\n\nfunction M.drawPreview(ctx)\n    local groups=require(\"ringcore.input\").preview()\n    if not groups then return end\n    local p=G.press\n    local a,b=G.board[p.r][p.c],G.board[p.r2][p.c2]\n    for _,rc in ipairs({{p.r,p.c,b},{p.r2,p.c2,a}})do\n        local x,y=Board.cellXY(rc[1],rc[2])\n        if Board.isHex() then Draw.drawGem(ctx,x,y,rc[3].color,46,\"hex\",150,rc[3].special)\n        else Draw.drawTile(ctx,x,y,rc[3].color,T.CELL-7,150,rc[3].special) end\n    end\n    local marked={}\n    for _,g in ipairs(groups)do\n        for _,rc in ipairs(g.cells)do\n            local x,y=Board.cellXY(rc[1],rc[2]);local size=Board.isHex() and 48 or T.CELL-5\n            nvgBeginPath(ctx);nvgRoundedRect(ctx,x-size/2,y-size/2,size,size,12)\n            nvgStrokeColor(ctx,Draw.rgba(COLORS[g.color],255));nvgStrokeWidth(ctx,3);nvgStroke(ctx)\n        end\n        for _,id in ipairs(require(\"ringcore.interlock\").previewTarget(g.color) or {})do marked[id]=g.color end\n    end\n    for _,ball in ipairs(G.balls)do if marked[ball.id] then\n        local x,y=Ring.pointOnPath(ball.vdist)\n        nvgBeginPath(ctx);nvgCircle(ctx,x,y,T.BALL_R+5)\n        nvgStrokeColor(ctx,Draw.rgba(COLORS[marked[ball.id]],180));nvgStrokeWidth(ctx,2);nvgStroke(ctx)\n    end end\n    local x,y=Board.cellXY(p.r,p.c)\n    W.fitLabel(ctx,C.ORIENT==\"landscape\" and 268 or math.max(112,math.min(C.DESIGN_W-112,x)),C.ORIENT==\"landscape\" and 462 or y-30,tr(\"preview.label\"),13,C.ORIENT==\"landscape\" and 430 or 208,{col={255,246,222},ow=1.2,shadow=false})\nend\n\nlocal function drawStrip(ctx)\n    local x, y = T.CANNON_X, T.CANNON_Y\n    local shakeOff = G.cannonShake > 0 and math.sin(G.animT * 40) * 3 or 0\n\n    local lit = #G.cannonAmmo > 0 and G.lvl.cannon\n    if not Draw.drawImage(ctx, G.img.lantern, x + shakeOff, y, 52, 52, lit and 1 or 0.55) then\n        nvgBeginPath(ctx)\n        nvgRoundedRect(ctx, x - 16 + shakeOff, y - 14, 32, 28, 10)\n        nvgFillColor(ctx, nvgRGBA(60, 70, 110, 255))\n        nvgFill(ctx)\n        nvgBeginPath(ctx)\n        nvgCircle(ctx, x + shakeOff, y, 8)\n        nvgFillColor(ctx, nvgRGBA(255, 220, 140, lit and 255 or 90))\n        nvgFill(ctx)\n    elseif lit then\n        nvgBeginPath(ctx)\n        nvgCircle(ctx, x + shakeOff, y, 22)\n        nvgFillPaint(ctx, nvgRadialGradient(ctx, x, y, 4, 22, nvgRGBA(255, 230, 140, 90), nvgRGBA(255, 200, 80, 0)))\n        nvgFill(ctx)\n    end\n    if G.lvl.cannon then\n\n        if G.state == \"playing\" and #G.cannonAmmo > 0 and not G.press and not G.ui.press then\n            local top = 84 + (G.safeShift or 0)\n            local rx,ry,rw,rh=Layout.fireRect()\n            nvgBeginPath(ctx); nvgRoundedRect(ctx,rx,ry,rw,rh,16)\n            nvgStrokeColor(ctx, nvgRGBA(255, 200, 87, 85)); nvgStrokeWidth(ctx, 1.5); nvgStroke(ctx)\n            W.fitLabel(ctx, rx+rw/2, ry+10, tr(\"hud.fireArea\"), 13, 260, { col = { 255, 246, 222 }, ow = 1.2, shadow = false })\n        end\n\n        if #G.cannonAmmo > 0 and not G.flying then\n            nvgBeginPath(ctx)\n            nvgMoveTo(ctx, x, y - 14)\n            nvgLineTo(ctx, G.aimX, G.aimY)\n            nvgStrokeColor(ctx, Draw.rgba(COLORS[G.cannonAmmo[1]], 150))\n            nvgStrokeWidth(ctx, 2)\n            nvgStroke(ctx)\n        end\n        if G.cannonAmmo[1] then\n            local focus = math.max(0, math.min(1, ((G.ui.ammoFocusUntil or 0) - G.now) / 0.45))\n            Draw.drawBead(ctx, x + shakeOff, y + 2, G.cannonAmmo[1], 13 + 2 * focus)\n        end\n    end\nend\n\nlocal function drawFlying(ctx)\n    for _, d in ipairs(G.lockDrops) do\n        local u = d.t / T.LOCK_FALL_TIME\n        local tx, ty = Board.cellXY(d.r, d.c)\n        local x = d.x0 + (tx - d.x0) * u\n        local y = d.y0 + (ty - d.y0) * u * u\n        nvgBeginPath(ctx)\n        nvgCircle(ctx, x, y, 11)\n        nvgFillColor(ctx, nvgRGBA(20, 30, 40, 255))\n        nvgFill(ctx)\n        nvgStrokeColor(ctx, nvgRGBA(60, 140, 90, 255))\n        nvgStrokeWidth(ctx, 3)\n        nvgStroke(ctx)\n    end\n    if G.flying then\n        Draw.drawBead(ctx, G.flying.x, G.flying.y, G.flying.color, T.BALL_R)\n    end\n    for _, p in ipairs(G.pulses) do\n        local c = COLORS[p.color]\n        for i, tp in ipairs(p.trail) do\n            local u = 1 - i / (#p.trail + 1)\n            nvgBeginPath(ctx)\n            nvgCircle(ctx, tp[1], tp[2], 4 + 10 * u)\n            nvgFillColor(ctx, Draw.rgba(c, math.floor(40 + 140 * u)))\n            nvgFill(ctx)\n        end\n        nvgBeginPath(ctx)\n        nvgCircle(ctx, p.x, p.y, 26)\n        nvgFillPaint(ctx, nvgRadialGradient(ctx, p.x, p.y, 2, 26, nvgRGBA(255, 255, 255, 240), Draw.rgba(c, 0)))\n        nvgFill(ctx)\n        Draw.drawBead(ctx, p.x, p.y, p.color, 10)\n        W.fitLabel(ctx, math.max(62, math.min(C.DESIGN_W-62, p.x)), math.max(94, p.y - 20), tr(\"feedback.pulse\"), 13, 116,\n            { col = { 255, 246, 222 }, ow = 1.2, shadow = false })\n    end\n    M.drawJuice(ctx)\n    M.drawFxLayer(ctx)\n    for _, p in ipairs(G.popups) do\n        local a = math.floor(255 * math.min(1, p.life / 0.4))\n\n        local age = p.age or 1\n        local sc = 1\n        if p.big then sc = age < 0.15 and (1.5 - 0.6 * age / 0.15) or math.min(1, 0.9 + (age - 0.15) * 2) end\n        local sz = (p.size or (p.big and 22 or 17)) * sc\n\n\n        local py = p.big and (p.y - 26) or p.y\n        W.label(ctx, p.x, py, p.text, sz, { col = { 255, 245, 210 }, alpha = a, ow = 1.6 + (p.big and 0.6 or 0) })\n    end\n    if G.state == \"playing\" then\n        for _, v in ipairs(G.causalFX or {}) do\n            local alpha = math.floor(255 * math.min(1, v.life / 0.25))\n            if v.stage == \"pulse\" then\n\n                nvgBeginPath(ctx); nvgCircle(ctx, v.x, v.y + 28, 12 + 24 * (1 - v.life / v.max))\n                nvgStrokeColor(ctx, Draw.rgba(COLORS[v.ci], alpha)); nvgStrokeWidth(ctx, 2); nvgStroke(ctx)\n            else\n                local width = v.stage == \"clear\" and 100 or 174\n                local x = math.max(width / 2 + 8, math.min(C.DESIGN_W-8 - width / 2, v.x))\n                local y = math.max(98, v.y + (v.stage == \"clear\" and 24 or 0))\n                nvgSave(ctx); nvgGlobalAlpha(ctx, alpha / 255)\n                W.chip(ctx, x - width / 2, y - 14, width, 28, 225)\n                Draw.drawBead(ctx, x - width / 2 + 15, y, v.ci, 9)\n                W.fitLabel(ctx, x + 10, y, tr(FEEDBACK_KEYS[v.stage]), 14, width - 34,\n                    { col = { 255, 246, 222 }, ow = 1.2, shadow = false })\n                nvgRestore(ctx)\n            end\n        end\n    end\nend\n\n\nlocal function additive2(ctx, on)\n    if nvgGlobalCompositeOperation and NVG_LIGHTER and NVG_SOURCE_OVER then\n        nvgGlobalCompositeOperation(ctx, on and NVG_LIGHTER or NVG_SOURCE_OVER)\n    end\nend\n\nfunction M.drawBackdrop(ctx)\n    local L = T.LAYOUT or {}\n\n\n    nvgBeginPath(ctx); nvgRect(ctx, -G.offX, -G.offY, G.screenDW, G.screenDH)\n    nvgFillPaint(ctx, nvgRadialGradient(ctx, L.ringX or 270, L.ringY or 420, 300, 700, nvgRGBA(0, 0, 0, 0), nvgRGBA(0, 2, 6, 120)))\n    nvgFill(ctx)\n    local by = T.BOARD_Y\n    local bw = N * T.CELL\n    nvgBeginPath(ctx); nvgRect(ctx, -G.offX, by - 60, G.screenDW, bw + 140)\n    nvgFillPaint(ctx, nvgBoxGradient(ctx, T.BOARD_X - 10, by - 10, bw + 20, bw + 20, 30, 60, nvgRGBA(0, 3, 8, 120), nvgRGBA(0, 3, 8, 0)))\n    nvgFill(ctx)\nend\n\nfunction M.drawConduit(ctx)\n\n    if C.ORIENT == \"landscape\" then return M.drawConduitLandscape(ctx) end\n    local x, y0, y1, yc = T.CANNON_X, T.BOARD_Y - 2, T.CANNON_Y, 300\n    local glow = G.conduitGlow or 0\n    local t = G.animT\n\n    nvgBeginPath(ctx); nvgEllipse(ctx, x, y0 - 4, 150, 26)\n    nvgFillPaint(ctx, nvgRadialGradient(ctx, x, y0 - 4, 10, 150, nvgRGBA(120, 230, 210, math.floor(46 + 70 * glow)), nvgRGBA(120, 230, 210, 0)))\n    nvgFill(ctx)\n\n    nvgBeginPath(ctx); nvgRoundedRect(ctx, x - 13, yc, 26, y0 - yc, 13)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, 0, yc, 0, y0, nvgRGBA(120, 230, 210, 0), nvgRGBA(120, 230, 210, math.floor(70 + 70 * glow))))\n    nvgFill(ctx)\n    nvgBeginPath(ctx); nvgMoveTo(ctx, x - 13, y0); nvgLineTo(ctx, x - 13, yc + 40)\n    nvgMoveTo(ctx, x + 13, y0); nvgLineTo(ctx, x + 13, yc + 40)\n    nvgStrokePaint(ctx, nvgLinearGradient(ctx, 0, yc + 40, 0, y0, nvgRGBA(190, 255, 230, 0), nvgRGBA(190, 255, 230, math.floor(70 + 80 * glow))))\n    nvgStrokeWidth(ctx, 1.5); nvgStroke(ctx)\n\n    additive2(ctx, true)\n    for k = 0, 5 do\n        local u = ((t * 0.9 + k / 6) % 1)\n        local yy = y0 - u * (y0 - yc)\n        local a = math.floor((1 - u) * (60 + 120 * glow))\n        nvgBeginPath(ctx); nvgRoundedRect(ctx, x - 3, yy - 9, 6, 18, 3)\n        nvgFillColor(ctx, nvgRGBA(200, 255, 235, a)); nvgFill(ctx)\n    end\n    if glow > 0 then\n        nvgBeginPath(ctx); nvgCircle(ctx, x, y1, 40)\n        nvgFillPaint(ctx, nvgRadialGradient(ctx, x, y1, 4, 40, nvgRGBA(255, 236, 160, math.floor(200 * glow)), nvgRGBA(255, 200, 80, 0)))\n        nvgFill(ctx)\n    end\n    additive2(ctx, false)\nend\n\n\nfunction M.drawConduitLandscape(ctx)\n    local L = T.LAYOUT or {}\n    local x0 = T.BOARD_X - 6\n    local x1 = (L.ringX or 268) + (L.ringRX or 214) * 0.55\n    local y = T.CANNON_Y\n    local glow = G.conduitGlow or 0\n    local t = G.animT\n    if x1 >= x0 - 20 then return end\n    nvgBeginPath(ctx); nvgEllipse(ctx, x0 + 4, y, 26, 88)\n    nvgFillPaint(ctx, nvgRadialGradient(ctx, x0 + 4, y, 10, 88, nvgRGBA(120, 230, 210, math.floor(40 + 60 * glow)), nvgRGBA(120, 230, 210, 0)))\n    nvgFill(ctx)\n    nvgBeginPath(ctx); nvgRoundedRect(ctx, x1, y - 13, x0 - x1, 26, 13)\n    nvgFillPaint(ctx, nvgLinearGradient(ctx, x1, 0, x0, 0, nvgRGBA(120, 230, 210, 0), nvgRGBA(120, 230, 210, math.floor(70 + 70 * glow))))\n    nvgFill(ctx)\n    nvgBeginPath(ctx)\n    nvgMoveTo(ctx, x0, y - 13); nvgLineTo(ctx, x1 + 40, y - 13)\n    nvgMoveTo(ctx, x0, y + 13); nvgLineTo(ctx, x1 + 40, y + 13)\n    nvgStrokePaint(ctx, nvgLinearGradient(ctx, x1 + 40, 0, x0, 0, nvgRGBA(190, 255, 230, 0), nvgRGBA(190, 255, 230, math.floor(70 + 80 * glow))))\n    nvgStrokeWidth(ctx, 1.5); nvgStroke(ctx)\n    additive2(ctx, true)\n    for k = 0, 5 do\n        local u = ((t * 0.9 + k / 6) % 1)\n        local xx = x0 - u * (x0 - x1)\n        local a = math.floor((1 - u) * (60 + 120 * glow))\n        nvgBeginPath(ctx); nvgRoundedRect(ctx, xx - 9, y - 3, 18, 6, 3)\n        nvgFillColor(ctx, nvgRGBA(200, 255, 235, a)); nvgFill(ctx)\n    end\n    additive2(ctx, false)\nend\n\n\nfunction M.drawJuice(ctx)\n    for _, p in ipairs(G.shards or {}) do\n        local k = math.max(0, p.life / p.max)\n        local c = COLORS[p.ci] or { 255, 255, 255 }\n        local sz = p.sz * (0.6 + 0.4 * k)\n        nvgSave(ctx); nvgTranslate(ctx, p.x, p.y); nvgRotate(ctx, p.rot)\n        nvgBeginPath(ctx)\n        nvgMoveTo(ctx, 0, -sz); nvgLineTo(ctx, sz * 0.55, sz * 0.3); nvgLineTo(ctx, -sz * 0.45, sz * 0.55); nvgClosePath(ctx)\n        nvgFillColor(ctx, nvgRGBA(math.min(255, c[1] + 40), math.min(255, c[2] + 40), math.min(255, c[3] + 40), math.floor(255 * k)))\n        nvgFill(ctx)\n        nvgBeginPath(ctx); nvgMoveTo(ctx, 0, -sz); nvgLineTo(ctx, sz * 0.55, sz * 0.3)\n        nvgStrokeColor(ctx, nvgRGBA(255, 255, 255, math.floor(220 * k))); nvgStrokeWidth(ctx, 1.2); nvgStroke(ctx)\n        nvgRestore(ctx)\n    end\n    if not G.motes or #G.motes == 0 then return end\n    additive2(ctx, true)\n    for _, m in ipairs(G.motes) do\n        if m.t > 0 then\n            local u = math.min(1, m.t / m.dur)\n            local e = u * u * (3 - 2 * u)\n            local c = COLORS[m.ci] or { 255, 255, 255 }\n            for k = 3, 0, -1 do\n                local uk = math.max(0, e - k * 0.06)\n                local x, y = Fx.moteAt(m, uk)\n                local r = (k == 0) and 7.5 or (6 - k)\n                nvgBeginPath(ctx); nvgCircle(ctx, x, y, r)\n                nvgFillColor(ctx, Draw.rgba(c, math.floor((k == 0 and 230 or 150 - 40 * k))))\n                nvgFill(ctx)\n            end\n            local x, y = Fx.moteAt(m, e)\n            nvgBeginPath(ctx); nvgCircle(ctx, x, y, 16)\n            nvgFillPaint(ctx, nvgRadialGradient(ctx, x, y, 2, 16, Draw.rgba(c, 150), Draw.rgba(c, 0))); nvgFill(ctx)\n            nvgBeginPath(ctx); nvgCircle(ctx, x, y, 3); nvgFillColor(ctx, nvgRGBA(255, 255, 255, 245)); nvgFill(ctx)\n        end\n    end\n    additive2(ctx, false)\nend\n\n\nlocal AT = C.ATLAS\nlocal function additive(ctx, on)\n    if nvgGlobalCompositeOperation and NVG_LIGHTER and NVG_SOURCE_OVER then\n        nvgGlobalCompositeOperation(ctx, on and NVG_LIGHTER or NVG_SOURCE_OVER)\n    end\nend\nfunction M.drawFxLayer(ctx)\n    local img = G.img\n    for _, a in ipairs(G.anims or {}) do\n        local u = a.t / a.dur\n        if a.kind == \"smoke\" and img.fxSmoke then\n            local A = AT.smoke\n            local f = math.min(A.frames - 1, math.floor(u * A.frames))\n            local sz = a.size * (0.7 + 0.5 * u)\n            nvgSave(ctx); nvgTranslate(ctx, a.x, a.y); nvgRotate(ctx, a.rot)\n            Draw.drawSub(ctx, img.fxSmoke, A.w, A.h, f * A.cell, 0, A.cell, A.cell, -sz / 2, -sz / 2, sz, sz, 0.45 * (1 - u))\n            nvgRestore(ctx)\n        end\n    end\n    additive(ctx, true)\n    for _, a in ipairs(G.anims or {}) do\n        if a.kind == \"sparkle\" and img.fxSparkle then\n            local A = AT.sparkle\n            local f = math.min(A.frames - 1, math.floor(a.t / a.dur * A.frames))\n            local sz = a.size\n            Draw.drawSub(ctx, img.fxSparkle, A.w, A.h, f * A.cell, (a.row - 1) * A.cell, A.cell, A.cell,\n                a.x - sz / 2, a.y - sz / 2, sz, sz, 1)\n        end\n    end\n    for _, r in ipairs(G.ringsFX) do\n        local u = 1 - r.life / r.max\n        local rad = r.r0 + (r.r1 - r.r0) * u\n        nvgBeginPath(ctx)\n        nvgCircle(ctx, r.x, r.y, rad)\n        nvgStrokeColor(ctx, Draw.rgba(r.c, math.floor(220 * (1 - u))))\n        nvgStrokeWidth(ctx, 3.5 * (1 - u * 0.7))\n        nvgStroke(ctx)\n        if img.fxRing then\n            Draw.drawImage(ctx, img.fxRing, r.x, r.y, rad * 2.3, rad * 2.3, 0.55 * (1 - u))\n        end\n    end\n    local PA = AT.particles\n    for _, sp in ipairs(G.sparks) do\n        local k = math.max(0, sp.life / sp.max)\n        if img.fxParticles then\n            local sz = 10 + 12 * k\n            nvgSave(ctx); nvgTranslate(ctx, sp.x, sp.y); nvgRotate(ctx, sp.rot * 0.3)\n            Draw.drawSub(ctx, img.fxParticles, PA.w, PA.h, 5 * PA.cell, PA.cell, PA.cell, PA.cell, -sz / 2, -sz / 2, sz, sz, k)\n            nvgRestore(ctx)\n        else\n            local a = math.floor(255 * k)\n            local sz = 3 + 2 * k\n            nvgSave(ctx)\n            nvgTranslate(ctx, sp.x, sp.y)\n            nvgRotate(ctx, sp.rot)\n            nvgBeginPath(ctx)\n            nvgMoveTo(ctx, 0, -sz); nvgLineTo(ctx, sz * 0.35, 0); nvgLineTo(ctx, 0, sz); nvgLineTo(ctx, -sz * 0.35, 0)\n            nvgClosePath(ctx)\n            nvgFillColor(ctx, nvgRGBA(255, 245, 180, a))\n            nvgFill(ctx)\n            nvgRestore(ctx)\n        end\n    end\n    for _, f in ipairs(G.flashes) do\n        nvgBeginPath(ctx)\n        nvgCircle(ctx, f.x, f.y, 34 * (1.2 - f.life * 2))\n        nvgFillColor(ctx, nvgRGBA(255, 255, 255, math.floor(255 * f.life / 0.2 * 0.6)))\n        nvgFill(ctx)\n    end\n\n    for _, p in ipairs(G.impacts or {}) do\n        local u = 1 - p.life / p.max\n        local a = 1 - u\n        local col = C.COLORS[p.ci] or { 255, 240, 180 }\n        local rr = p.r0 + (p.r1 - p.r0) * u\n        nvgBeginPath(ctx)\n        nvgCircle(ctx, p.x, p.y, rr)\n        nvgFillColor(ctx, Draw.rgba(col, math.floor(150 * a * a)))\n        nvgFill(ctx)\n        nvgBeginPath(ctx)\n        nvgCircle(ctx, p.x, p.y, rr * 0.42)\n        nvgFillColor(ctx, nvgRGBA(255, 252, 235, math.floor(235 * a)))\n        nvgFill(ctx)\n    end\n    for _, p in ipairs(G.particles) do\n        local k = math.max(0, p.life / p.max)\n        if img.fxParticles and p.ci and p.ci >= 1 and p.ci <= 5 then\n            local sz = (p.r or 2.5) * 6\n            Draw.drawSub(ctx, img.fxParticles, PA.w, PA.h, (p.ci - 1) * PA.cell, 0, PA.cell, PA.cell,\n                p.x - sz / 2, p.y - sz / 2, sz, sz, k)\n        else\n            nvgBeginPath(ctx)\n            nvgCircle(ctx, p.x, p.y, p.r or 2.5)\n            nvgFillColor(ctx, Draw.rgba(p.c, math.floor(255 * k)))\n            nvgFill(ctx)\n        end\n    end\n    additive(ctx, false)\nend\n\nM.drawRing = drawRing\nM.drawBoard = drawBoard\nM.drawStrip = drawStrip\nM.drawFlying = drawFlying\n\nreturn M\n"],["web/json.lua","\n\nlocal M = {}\n\nlocal escapes = { ['\"'] = '\\\\\"', ['\\\\'] = '\\\\\\\\', ['\\b'] = '\\\\b', ['\\f'] = '\\\\f', ['\\n'] = '\\\\n', ['\\r'] = '\\\\r', ['\\t'] = '\\\\t' }\nlocal function escStr(s)\n    return '\"' .. s:gsub('[%c\"\\\\]', function(c) return escapes[c] or string.format(\"\\\\u%04x\", c:byte()) end) .. '\"'\nend\n\nlocal function isArray(t)\n    local n = 0\n    for k in pairs(t) do\n        if type(k) ~= \"number\" or k <= 0 or k % 1 ~= 0 then return false end\n        n = n + 1\n    end\n    if n == 0 then return false end\n    for i = 1, n do if t[i] == nil then return false end end\n    return true, n\nend\n\nlocal function enc(v, out)\n    local tv = type(v)\n    if tv == \"nil\" then out[#out + 1] = \"null\"\n    elseif tv == \"boolean\" then out[#out + 1] = v and \"true\" or \"false\"\n    elseif tv == \"number\" then\n        if v ~= v or v == math.huge or v == -math.huge then out[#out + 1] = \"null\"\n        elseif math.type(v) == \"integer\" or (v % 1 == 0 and math.abs(v) < 2^53) then out[#out + 1] = string.format(\"%d\", v)\n        else out[#out + 1] = string.format(\"%.14g\", v) end\n    elseif tv == \"string\" then out[#out + 1] = escStr(v)\n    elseif tv == \"table\" then\n        local arr, n = isArray(v)\n        if arr then\n            out[#out + 1] = \"[\"\n            for i = 1, n do if i > 1 then out[#out + 1] = \",\" end; enc(v[i], out) end\n            out[#out + 1] = \"]\"\n        else\n            out[#out + 1] = \"{\"\n            local first = true\n            for k, val in pairs(v) do\n                if type(val) ~= \"function\" then\n                    if not first then out[#out + 1] = \",\" end\n                    first = false\n                    out[#out + 1] = escStr(tostring(k)); out[#out + 1] = \":\"\n                    enc(val, out)\n                end\n            end\n            out[#out + 1] = \"}\"\n        end\n    else error(\"cannot encode \" .. tv) end\nend\nfunction M.encode(v) local out = {}; enc(v, out); return table.concat(out) end\n\nlocal function skip(s, i) return s:find(\"[^ \\t\\r\\n]\", i) or #s + 1 end\nlocal dec\nlocal function decStr(s, i)\n    local out, j = {}, i + 1\n    while true do\n        local c = s:sub(j, j)\n        if c == \"\" then error(\"unterminated string\") end\n        if c == '\"' then return table.concat(out), j + 1 end\n        if c == \"\\\\\" then\n            local e = s:sub(j + 1, j + 1)\n            local map = { b = \"\\b\", f = \"\\f\", n = \"\\n\", r = \"\\r\", t = \"\\t\", ['\"'] = '\"', [\"\\\\\"] = \"\\\\\", [\"/\"] = \"/\" }\n            if e == \"u\" then\n                local cp = tonumber(s:sub(j + 2, j + 5), 16)\n                if cp >= 0xD800 and cp <= 0xDBFF and s:sub(j + 6, j + 7) == \"\\\\u\" then\n                    local lo = tonumber(s:sub(j + 8, j + 11), 16)\n                    cp = 0x10000 + (cp - 0xD800) * 0x400 + (lo - 0xDC00)\n                    j = j + 6\n                end\n                out[#out + 1] = utf8.char(cp); j = j + 6\n            else out[#out + 1] = map[e] or e; j = j + 2 end\n        else\n            local k = s:find('[\"\\\\]', j) or #s + 1\n            out[#out + 1] = s:sub(j, k - 1); j = k\n        end\n    end\nend\ndec = function(s, i)\n    i = skip(s, i)\n    local c = s:sub(i, i)\n    if c == \"{\" then\n        local t = {}\n        i = skip(s, i + 1)\n        if s:sub(i, i) == \"}\" then return t, i + 1 end\n        while true do\n            local k; k, i = decStr(s, skip(s, i))\n            i = skip(s, i); if s:sub(i, i) ~= \":\" then error(\"expected :\") end\n            t[k], i = dec(s, i + 1)\n            i = skip(s, i)\n            local d = s:sub(i, i)\n            if d == \"}\" then return t, i + 1 end\n            if d ~= \",\" then error(\"expected , or }\") end\n            i = i + 1\n        end\n    elseif c == \"[\" then\n        local t = {}\n        i = skip(s, i + 1)\n        if s:sub(i, i) == \"]\" then return t, i + 1 end\n        while true do\n            t[#t + 1], i = dec(s, i)\n            i = skip(s, i)\n            local d = s:sub(i, i)\n            if d == \"]\" then return t, i + 1 end\n            if d ~= \",\" then error(\"expected , or ]\") end\n            i = i + 1\n        end\n    elseif c == '\"' then return decStr(s, i)\n    elseif s:sub(i, i + 3) == \"true\" then return true, i + 4\n    elseif s:sub(i, i + 4) == \"false\" then return false, i + 5\n    elseif s:sub(i, i + 3) == \"null\" then return nil, i + 4\n    else\n        local num = s:match(\"^-?%d+%.?%d*[eE]?[-+]?%d*\", i)\n        if not num or num == \"\" then error(\"bad json at \" .. i) end\n        return tonumber(num), i + #num\n    end\nend\nfunction M.decode(s) local v = dec(s, 1); return v end\n\nreturn M\n"],["web/monetize_host.lua","\nlocal H = { name = \"none\", iap = false }\nfunction H.rewarded() return false end\nfunction H.showRewarded(_, cb) cb({ ok = false, reason = \"unsupported\" }) end\nfunction H.priceText() return nil end\nfunction H.buy(_, cb) cb({ ok = false, reason = \"unsupported\" }) end\nfunction H.restore(cb) cb({ ok = false, reason = \"unsupported\" }) end\nreturn H\n"],["web/platform.lua","\n\n\n\n\n\n\n\n\nlocal json = require \"web.json\"\ncjson = json\n\n\n\nHOST_SAFE_AREA = true\nHOST_PHONE = __js_phone()\n\nMONETIZE_HOST = require \"web.monetize_host\"\nHAPTICS_HOST = { pulse = function(kind) return __js_vibrate(kind) end, long = function() return __js_vibrate(\"long\") end }\n\n\n\n\nMOUSEB_LEFT, MOUSEB_MIDDLE, MOUSEB_RIGHT = 1, 2, 4\nMM_ABSOLUTE, MM_RELATIVE, MM_WRAP, MM_FREE = 0, 1, 2, 3\nKEY_ESCAPE, KEY_SPACE, KEY_RETURN = 27, 32, 13\nfor c = string.byte(\"a\"), string.byte(\"z\") do _G[\"KEY_\" .. string.char(c):upper()] = c end\nFILE_READ, FILE_WRITE, FILE_READWRITE = 0, 1, 2\n\nNVG_ALIGN_LEFT, NVG_ALIGN_CENTER, NVG_ALIGN_RIGHT = 1, 2, 4\nNVG_ALIGN_TOP, NVG_ALIGN_MIDDLE, NVG_ALIGN_BOTTOM, NVG_ALIGN_BASELINE = 8, 16, 32, 64\nNVG_BUTT, NVG_ROUND, NVG_SQUARE, NVG_BEVEL, NVG_MITER = 0, 1, 2, 3, 4\nNVG_CCW, NVG_CW = 1, 2\nNVG_SOLID, NVG_HOLE = 1, 2\nNVG_SOURCE_OVER, NVG_LIGHTER = 0, 7\nNVG_IMAGE_GENERATE_MIPMAPS, NVG_IMAGE_REPEATX, NVG_IMAGE_REPEATY = 1, 2, 4\n\n\n\n\nlocal B = {}\nlocal S, ns = {}, 0\nlocal fontSize, fontFace = 16, 0\nlocal VG = { id = 1 }\n\n\n\n\nlocal QS = 4096\nlocal gen = { \"local B, QS = ...; local n = 0; local F = {}\" }\nlocal function qexpr(v) return (\"%s = %s * QS + 0.5; if %s ~= %s or %s > 9e15 or %s < -9e15 then %s = 0 end; \"):format(v, v, v, v, v, v, v) end\nfor k = 1, 5 do\n    local args, body = {}, {}\n    for j = 1, k do args[j] = \"a\" .. j; body[#body + 1] = qexpr(\"a\" .. j) .. (\"B[n + %d] = (a%d // 1) | 0; \"):format(j, j) end\n    gen[#gen + 1] = (\"F.push%d = function(%s) %s n = n + %d end\"):format(k, table.concat(args, \",\"), table.concat(body), k)\nend\ngen[#gen + 1] = \"F.col = function(c) local a1, a2, a3, a4 = c.r, c.g, c.b, c.a; \" ..\n    qexpr(\"a1\") .. qexpr(\"a2\") .. qexpr(\"a3\") .. qexpr(\"a4\") ..\n    \"B[n + 1] = (a1 // 1) | 0; B[n + 2] = (a2 // 1) | 0; B[n + 3] = (a3 // 1) | 0; B[n + 4] = (a4 // 1) | 0; n = n + 4 end\"\ngen[#gen + 1] = \"F.getn = function() return n end; F.reset = function() n = 0 end; return F\"\nlocal PF = load(table.concat(gen, \"\\n\"), \"=nvgpush\")(B, QS)\nlocal push1, push2, push3, push4, push5, col = PF.push1, PF.push2, PF.push3, PF.push4, PF.push5, PF.col\n\nfunction nvgRGBA(r, g, b, a) return { r = (r or 0) / 255, g = (g or 0) / 255, b = (b or 0) / 255, a = (a or 255) / 255 } end\nfunction nvgRGB(r, g, b) return nvgRGBA(r, g, b, 255) end\nfunction nvgRGBAf(r, g, b, a) return { r = r, g = g, b = b, a = a } end\nfunction nvgRGBf(r, g, b) return { r = r, g = g, b = b, a = 1 } end\nfunction nvgTransRGBA(c, a) return { r = c.r, g = c.g, b = c.b, a = (a or 255) / 255 } end\nfunction nvgLerpRGBA(c0, c1, u)\n    return { r = c0.r + (c1.r - c0.r) * u, g = c0.g + (c1.g - c0.g) * u, b = c0.b + (c1.b - c0.b) * u, a = c0.a + (c1.a - c0.a) * u }\nend\nfunction nvgDegToRad(d) return d * math.pi / 180 end\n\nfunction nvgCreate() return VG end\nfunction nvgDelete() end\n\n\nlocal segHead, segEndN = 0, -1\n\n\nlocal seen, seenFrame = {}, 0\nlocal function segBegin()\n    local n = PF.getn()\n    if n == segEndN and segHead > 0 then return end\n    push2(50, 0); segHead = n + 1\nend\nlocal function segEnd()\n    local n = PF.getn()\n    B[segHead + 1] = (n - segHead + 1) * QS\n    segEndN = n\nend\nfunction nvgBeginFrame(_, w, h, dpr)\n    PF.reset(); ns = 0; segHead, segEndN = 0, -1\n    seenFrame = seenFrame + 1\n    if seenFrame % 600 == 0 then seen = {} end\n    push5(1, w, h, dpr, 0)\nend\nfunction nvgEndFrame()\n\n    if __PERF then\n        local t0 = __js_now()\n        local n = PF.getn()\n        local cs, ss = table.concat(B, \",\", 1, n), table.concat(S, \"\\31\", 1, ns)\n        __perf.concat = (__perf.concat or 0) + (__js_now() - t0); __perf.nums = n\n        local t1 = __js_now(); __js_flush(cs, ss); __perf.flush = __perf.flush + (__js_now() - t1)\n        return\n    end\n\n    __js_flush(table.concat(B, \",\", 1, PF.getn()), table.concat(S, \"\\31\", 1, ns))\nend\nfunction nvgCancelFrame() PF.reset(); ns = 0 end\n\nfunction nvgSave() push1(2) end\nfunction nvgRestore() push1(3) end\nfunction nvgReset() push1(41) end\nfunction nvgResetTransform() push1(4) end\nfunction nvgTranslate(_, x, y) push3(5, x, y) end\nfunction nvgRotate(_, a) push2(6, a) end\nfunction nvgScale(_, x, y) push3(7, x, y) end\nfunction nvgSkewX(_, a) push2(42, a) end\nfunction nvgSkewY(_, a) push2(43, a) end\nfunction nvgTransform(_, a, b, c, d, e, f) push1(44); push3(a, b, c); push3(d, e, f) end\n\nfunction nvgBeginPath() push1(8) end\nfunction nvgMoveTo(_, x, y) push3(9, x, y) end\nfunction nvgLineTo(_, x, y) push3(10, x, y) end\nfunction nvgBezierTo(_, a, b, c, d, e, f) push1(11); push3(a, b, c); push3(d, e, f) end\nfunction nvgQuadTo(_, a, b, c, d) push5(36, a, b, c, d) end\nfunction nvgArcTo(_, a, b, c, d, r) push3(38, a, b); push3(c, d, r) end\nfunction nvgClosePath() push1(12) end\nfunction nvgPathWinding(_, d) push2(37, d) end\nfunction nvgRect(_, x, y, w, h) push5(13, x, y, w, h) end\nfunction nvgRoundedRect(_, x, y, w, h, r) push3(14, x, y); push3(w, h, r) end\nfunction nvgRoundedRectVarying(_, x, y, w, h, a, b, c, d) push5(39, x, y, w, h); push3(a, b, c); push1(d) end\nfunction nvgCircle(_, cx, cy, r) push2(15, cx); push2(cy, r) end\nfunction nvgEllipse(_, cx, cy, rx, ry) push5(16, cx, cy, rx, ry) end\nfunction nvgArc(_, cx, cy, r, a0, a1, dir) push3(17, cx, cy); push2(r, a0); push2(a1, dir or NVG_CW) end\n\nfunction nvgFillColor(_, c) push1(18); col(c) end\nfunction nvgStrokeColor(_, c) push1(19); col(c) end\n\n\nfunction nvgLinearGradient(_, sx, sy, ex, ey, c0, c1) return { 1, sx, sy, ex, ey, c0, c1 } end\nfunction nvgRadialGradient(_, cx, cy, inr, outr, c0, c1) return { 2, cx, cy, inr, outr, c0, c1 } end\nfunction nvgBoxGradient(_, x, y, w, h, r, f, c0, c1) return { 3, x, y, w, h, r, f, c0, c1 } end\nfunction nvgImagePattern(_, ox, oy, ex, ey, angle, img, alpha) return { 4, ox, oy, ex, ey, angle, img or -1, alpha or 1 } end\nlocal function paint(p)\n    local k = p[1]\n    push1(k)\n    if k == 1 or k == 2 then push2(p[2], p[3]); push2(p[4], p[5]); col(p[6]); col(p[7])\n    elseif k == 3 then push3(p[2], p[3], p[4]); push3(p[5], p[6], p[7]); col(p[8]); col(p[9])\n    else push3(p[2], p[3], p[4]); push3(p[5], p[6], p[7]); push1(p[8]) end\nend\nfunction nvgFillPaint(_, p) push1(20); paint(p) end\nfunction nvgStrokePaint(_, p) push1(21); paint(p) end\n\nfunction nvgFill() push1(22) end\nfunction nvgStroke() push1(23) end\nfunction nvgStrokeWidth(_, w) push2(24, w) end\nfunction nvgLineCap(_, c) push2(25, c) end\nfunction nvgLineJoin(_, j) push2(26, j) end\nfunction nvgMiterLimit(_, m) push2(40, m) end\nfunction nvgGlobalAlpha(_, a) push2(27, a) end\nfunction nvgGlobalCompositeOperation(_, op) push2(28, op) end\nfunction nvgScissor(_, x, y, w, h) push5(29, x, y, w, h) end\nfunction nvgIntersectScissor(_, x, y, w, h) push5(45, x, y, w, h) end\nfunction nvgResetScissor() push1(30) end\n\n\nlocal fonts = {}\nfunction nvgCreateFont(_, name, path) fonts[#fonts + 1] = name; return #fonts - 1 end\nfunction nvgFindFont(_, name) for i, f in ipairs(fonts) do if f == name then return i - 1 end end return -1 end\nfunction nvgFontFaceId(_, id) fontFace = id; push2(34, id) end\nfunction nvgFontFace(_, name) nvgFontFaceId(_, math.max(0, nvgFindFont(_, name))) end\nfunction nvgFontSize(_, s) fontSize = s; push2(31, s) end\nfunction nvgFontBlur(_, b) push2(32, b) end\nfunction nvgTextAlign(_, a) push2(33, a) end\nfunction nvgTextLetterSpacing() end\nfunction nvgTextLineHeight() end\nfunction nvgText(_, x, y, str)\n    if str == nil then return x end\n    str = tostring(str)\n    ns = ns + 1; S[ns] = str\n    push3(35, x, y); push1(ns - 1)\n    return x\nend\nfunction nvgTextOutline(_, x, y, str)\n    if str == nil then return x end\n    ns=ns+1; S[ns]=tostring(str);push3(46,x,y);push1(ns-1)\n    return x\nend\nlocal mcache, mcount = {}, 0\nlocal function measure(str)\n    local I=package.loaded[\"ringcore.i18n\"]\n    local key = (I and I.lang or \"en\") .. \"\\0\" .. fontSize .. \"\\0\" .. str\n    local w = mcache[key]\n    if not w then\n        if mcount > 6000 then mcache, mcount = {}, 0 end\n        w = __js_measure(fontSize, str)\n        mcache[key] = w; mcount = mcount + 1\n    end\n    return w\nend\nfunction nvgTextBounds(_, x, y, str)\n    if str == nil then return 0 end\n    local w = measure(tostring(str))\n    return w, x, y - fontSize * 0.8, x + w, y + fontSize * 0.25\nend\nfunction nvgTextMetrics() return fontSize * 0.88, -fontSize * 0.24, fontSize * 1.2 end\n\n\nlocal imgSize = {}\nfunction nvgCreateImage(_, path, flags)\n    local h = __js_image(path) or -1\n    if h >= 0 then imgSize[h] = { __js_imgdim(h, 0), __js_imgdim(h, 1) } end\n    return h\nend\nfunction nvgImageSize(_, h) local s = imgSize[h]; if s then return s[1], s[2] end return 0, 0 end\nfunction nvgDeleteImage() end\n\n\n\n\nlocal Variant = {}\nVariant.__index = Variant\nfunction Variant:GetInt() return math.floor(self.v or 0) end\nfunction Variant:GetFloat() return self.v or 0 end\nfunction Variant:GetBool() return not not self.v end\nfunction Variant:GetString() return tostring(self.v or \"\") end\nlocal function V(v) return setmetatable({ v = v }, Variant) end\n\nlocal handlers = {}\nfunction SubscribeToEvent(a, b, c)\n    if type(a) == \"string\" then handlers[a] = b else handlers[b] = c end\nend\nfunction UnsubscribeFromEvent(a, b) handlers[type(a) == \"string\" and a or b] = nil end\nlocal function fire(name, data)\n    local h = handlers[name]\n    if type(h) == \"string\" then h = _G[h] end\n    if h then h(name, data) end\nend\n\nlocal mouse = { x = 0, y = 0 }\ninput = setmetatable({}, { __index = {\n    GetMousePosition = function() return { x = mouse.x, y = mouse.y } end,\n    GetNumTouches = function() return 0 end,\n    GetKeyDown = function() return false end,\n    SetMouseVisible = function() end,\n} })\ngraphics = setmetatable({}, {\n    __index = {\n        GetWidth = function() return __js_screen(0) end,\n        GetHeight = function() return __js_screen(1) end,\n        GetDPR = function() return __js_screen(2) end,\n    },\n    __newindex = function(t, k, v) if k == \"windowTitle\" then __js_title(tostring(v)) else rawset(t, k, v) end end,\n})\nengine = { Exit = function() end }\ntime = setmetatable({}, { __index = { GetElapsedTime = function() return __js_screen(3) end } })\n\n\nlocal Sound = {}\nSound.__index = Sound\nfunction Sound:GetFrequency() return 44100 end\nfunction Sound:GetLength() return 1 end\n\nlocal srcSeq = 0\nlocal function newSource()\n    srcSeq = srcSeq + 1\n    local id = srcSeq\n    local fields = { gain = 1 }\n    local src = {}\n    function src.Play(self, snd, freq, gain, panning)\n        if not snd then return end\n        if gain then fields.gain = gain end\n        __js_audio(1, id, snd.path, (freq or 44100) / 44100, fields.gain, rawget(snd, \"looped\") and 1 or 0)\n    end\n    function src.Stop() __js_audio(2, id, \"\", 1, 0, 0) end\n    function src.IsPlaying() return false end\n    return setmetatable(src, {\n        __index = fields,\n        __newindex = function(_, k, v)\n            fields[k] = v\n            if k == \"gain\" then __js_audio(3, id, \"\", 1, v, 0) end\n        end,\n    })\nend\nlocal function newNode()\n    local node = {}\n    function node.CreateChild() return newNode() end\n    function node.CreateComponent(_, kind)\n        if kind == \"SoundSource\" or kind == \"SoundSource3D\" then return newSource() end\n        return {}\n    end\n    return node\nend\nfunction Scene() return newNode() end\n\ncache = setmetatable({}, { __index = {\n    GetResource = function(_, kind, path)\n        if kind == \"Sound\" then\n            if __js_audio(0, 0, path, 1, 0, 0) == 1 then return setmetatable({ path = path, looped = false }, Sound) end\n            return nil\n        end\n        return nil\n    end,\n} })\n\n\nfileSystem = setmetatable({}, { __index = {\n    FileExists = function(_, name) return __js_store(0, name, \"\") == \"1\" end,\n    GetProgramDir = function() return \"\" end,\n    GetUserDocumentsDir = function() return \"\" end,\n} })\nfunction File(name, mode)\n    local f, buf = {}, {}\n    function f.IsOpen() return true end\n    function f.ReadString() return __js_store(1, name, \"\") end\n    function f.WriteString(_, s) buf[#buf + 1] = s end\n    function f.Close() if mode ~= FILE_READ then __js_store(2, name, table.concat(buf)) end end\n    function f.GetSize() return #(__js_store(1, name, \"\")) end\n    return f\nend\n\n\n\n\nlocal started = false\n\nlocal function wrapStatic(mod, name)\n    local ok, M = pcall(require, mod)\n    if not ok or type(M) ~= \"table\" or type(M[name]) ~= \"function\" then return end\n    local f = M[name]\n    M[name] = function(...) segBegin(); f(...); segEnd() end\nend\n\n\n\nlocal MEMO_MAX = 512\nlocal memo, memoN = {}, 0\nlocal function wrapSprite(mod, name, specialPos)\n    local ok, M = pcall(require, mod)\n    if not ok or type(M) ~= \"table\" or type(M[name]) ~= \"function\" then return end\n    local f = M[name]\n    M[name] = function(ctx, x, y, ...)\n        local na = select(\"#\", ...)\n        if (specialPos and select(specialPos, ...) ~= nil) or type(x) ~= \"number\" or type(y) ~= \"number\" then return f(ctx, x, y, ...) end\n        local parts = { name }\n        for k = 1, na do local v = select(k, ...); parts[k + 1] = tostring(v) end\n        local key = table.concat(parts, \"|\")\n        local id = memo[key]\n        if id then push4(51, id, x, y); return end\n        local sf = seen[key]\n        if not sf then seen[key] = seenFrame; return f(ctx, x, y, ...) end\n        if sf == seenFrame then return f(ctx, x, y, ...) end\n        if memoN >= MEMO_MAX then memo, memoN = {}, 0 end\n        memoN = memoN + 1; id = memoN; memo[key] = id\n        push5(52, id, x, y, 0)\n        local head = PF.getn()\n        f(ctx, 0, 0, ...)\n        B[head] = (PF.getn() - head) * QS\n    end\nend\nfunction __web_start()\n    dofile(\"/lua/main.lua\")\n    wrapStatic(\"ringcore.draw\", \"strokePath\")\n    if not __NO_SPRITE_MEMO then\n        wrapSprite(\"ringcore.draw\", \"drawGem\", 5)\n        wrapSprite(\"ringcore.draw\", \"drawTile\", 4)\n        wrapSprite(\"ringcore.draw\", \"drawBead\", nil)\n    end\n\n\n    local Save = require \"ringcore.savegame\"\n    local clearRun = Save.clearRun\n    Save.clearRun = function() return true end\n    local ok, err = pcall(Start)\n    Save.clearRun = clearRun\n    if not ok then error(err) end\n    started = true\nend\n\n\n__perf = { update = 0, render = 0, flush = 0, n = 0 }\n\nfunction __web_frame(dt)\n    if not started then return end\n\n    if __PERF then\n        local now = __js_now\n        local t0 = now()\n        fire(\"Update\", { TimeStep = V(dt) })\n        fire(\"PostUpdate\", { TimeStep = V(dt) })\n        local t1 = now()\n        fire(\"NanoVGRender\", {})\n        local p = __perf\n        p.update = p.update + (t1 - t0); p.render = p.render + (now() - t1); p.n = p.n + 1\n        return\n    end\n\n    fire(\"Update\", { TimeStep = V(dt) })\n    fire(\"PostUpdate\", { TimeStep = V(dt) })\n    fire(\"NanoVGRender\", {})\nend\n\nfunction __web_pointer(kind, x, y, touch)\n    mouse.x, mouse.y = x, y\n\n\n    if kind == 2 then fire(\"MouseMove\", { X = V(x), Y = V(y), DX = V(0), DY = V(0) })\n    else fire(kind == 1 and \"MouseButtonDown\" or \"MouseButtonUp\", { Button = V(MOUSEB_LEFT), Buttons = V(MOUSEB_LEFT) }) end\nend\nfunction __web_cancel()\n    local G = require \"ringcore.state\"\n    G.press, G.selected = nil, nil\n    if G.ui then G.ui.press = nil end\nend\n\n\n\n\nfunction __web_back()\n    local okI, Input = pcall(require, \"ringcore.input\")\n    if not (okI and Input.back) then return \"none\" end\n    return Input.back()\nend\n\nfunction __web_background()\n    local okG, G = pcall(require, \"ringcore.state\")\n    local okF, Flow = pcall(require, \"ringcore.flow\")\n    if okG and okF and G.state == \"playing\" then Flow.pause(); return \"paused\" end\n    return \"none\"\nend\n\nfunction __web_fonts_changed() mcache, mcount = {}, 0 end\nfunction __web_key(code) fire(\"KeyDown\", { Key = V(code), Scancode = V(code), Repeat = V(false) }) end\nfunction __web_resize() HOST_PHONE=__js_phone();fire(\"ScreenMode\", {}) end\n\n\nfunction __web_eval(src)\n    local fn, err = load(src, \"=eval\")\n    if not fn then return \"ERR \" .. tostring(err) end\n    local ok, res = pcall(fn)\n    if not ok then return \"ERR \" .. tostring(res) end\n    return tostring(res)\nend\n\n\n\n\nfunction __web_status()\n    local G = require \"ringcore.state\"\n    local C = require \"ringcore.config\"\n    local I = require \"ringcore.i18n\"\n    local scene = C.IMG.titleBg\n    if G.state == \"select\" then\n        local theme = G.mode == \"hex\" and C.hexPageOf(G.levelIdx or 1) or C.chapterOf((G.chapter or 1) * 10)\n        scene = C.IMG[theme.map or \"mapBg\"] or scene\n    elseif G.state ~= \"title\" and G.state ~= \"modesel\" and G.lvl then\n        local theme = G.mode == \"hex\" and C.hexPageOf(G.levelIdx or 1) or C.chapterOf(math.max(1,G.levelIdx or 1))\n        scene = C.IMG[theme.bg or \"gameBg\"] or scene\n    end\n    local u = G.ui or {}\n    local playing = G.state == \"playing\" and not (u.settings or u.credits or u.funnel or u.peek or u.hiveTip)\n    return (G.state or \"title\") .. \"|\" .. (I.lang or \"en\") .. \"|\" .. scene .. \"|\" .. (playing and \"1\" or \"0\")\nend\n"]]