idle No game selected never saved
`; } if (typeof window !== "undefined") window.onePagerHtml = onePagerHtml; // Jurisdiction presets. Numbers are conservative educational defaults so the // tool behaves sensibly out of the box; actual regulatory bands vary by // product class and each client should override with their legal filing. const JURISDICTIONS = { "custom": { label: "Custom", band: null, maxPrize: null, minHitPct: null }, "nj": { label: "New Jersey (NJLC)", band: {min:70.00, max:80.00}, maxPrize: null, minHitPct: 20.00 }, "pa": { label: "Pennsylvania (PLB)", band: {min:70.00, max:80.00}, maxPrize: null, minHitPct: 20.00 }, "mi": { label: "Michigan (MLO)", band: {min:65.00, max:80.00}, maxPrize: null, minHitPct: 18.00 }, "ontario": { label: "Ontario (iGO)", band: {min:85.00, max:95.00}, maxPrize: null, minHitPct: 25.00 }, "uk": { label: "UK (UKGC)", band: {min:85.00, max:95.00}, maxPrize: null, minHitPct: 25.00 } }; if (typeof window !== "undefined") window.JURISDICTIONS = JURISDICTIONS; // Compute top-prize odds as "1 in N". Returns {n, deals, totalDeals}. function topPrizeOdds(variant){ const totalDeals = variant.rows.reduce((a,r)=>a+r.deals,0); const top = Math.max(0, ...variant.rows.map(r=>r.prize)); const topDeals = variant.rows .filter(r => r.prize === top && r.type === "WIN" && r.deals > 0) .reduce((a,r)=>a+r.deals, 0); if (topDeals <= 0 || totalDeals <= 0) return {n: Infinity, deals: 0, totalDeals}; return { n: totalDeals / topDeals, deals: topDeals, totalDeals }; } if (typeof window !== "undefined") window.topPrizeOdds = topPrizeOdds; // Pure validator: returns list of {severity, code, message} for this variant. // severity ∈ {"error","warn","info"}. Pure — no DOM writes. function validateVariant(variant){ const issues = []; const band = variant.rtpBand || {min:82.0, max:88.0}; const totalDeals = variant.rows.reduce((a,r)=>a+r.deals,0); const wager = totalDeals * variant.denom; const payout = variant.rows.reduce((a,r)=>a+r.prize*r.deals,0); const rtp = wager>0 ? (payout/wager*100) : 0; if (wager > 0) { if (rtp < band.min) issues.push({severity:"error", code:"rtp-below-band", message:`RTP ${rtp.toFixed(2)}% is below target band (${band.min.toFixed(2)}% – ${band.max.toFixed(2)}%)`}); else if (rtp > band.max) issues.push({severity:"error", code:"rtp-above-band", message:`RTP ${rtp.toFixed(2)}% is above target band (${band.min.toFixed(2)}% – ${band.max.toFixed(2)}%)`}); } // Prize ladder: WIN prizes should strictly increase as tier increases. const wins = variant.rows .map((r,i)=>({r,i})) .filter(x => x.r.type === "WIN") .sort((a,b)=>a.r.tier-b.r.tier); for (let k=1; k 0){ for (const r of variant.rows){ if (r.type !== "WIN") continue; if (r.deals <= 0 || r.prize <= 0) continue; const contribPct = (r.prize * r.deals) / wager * 100; if (contribPct > 0 && contribPct < 0.01){ issues.push({severity:"info", code:"dust", message:`Tier ${r.tier} contributes only ${contribPct.toFixed(5)}% of wager (dust)`}); } } } // Max-prize cap (jurisdiction-specific). Any WIN prize above variant.maxPrizeCap is error. if (typeof variant.maxPrizeCap === "number" && variant.maxPrizeCap > 0){ for (const r of variant.rows){ if (r.type === "WIN" && r.prize > variant.maxPrizeCap + 1e-6){ issues.push({severity:"error", code:"max-prize-cap", message:`Tier ${r.tier} prize $${r.prize.toFixed(2)} exceeds jurisdiction cap $${variant.maxPrizeCap.toFixed(2)}`}); } } } // Hit-rate floor (jurisdiction-specific). Warn if below. if (typeof variant.minHitPct === "number" && variant.minHitPct > 0 && totalDeals > 0){ const winDeals = variant.rows.filter(r=>r.type==="WIN").reduce((a,r)=>a+r.deals,0); const hitPct = winDeals/totalDeals*100; if (hitPct < variant.minHitPct - 1e-9){ issues.push({severity:"warn", code:"hit-rate-floor", message:`Hit rate ${hitPct.toFixed(2)}% is below jurisdiction floor ${variant.minHitPct.toFixed(2)}%`}); } } // Pool integrity. Rows with pool === 0 are treated as "unpooled" and skipped. // For each pool id > 0: // - Pool with ZERO WIN rows → warn (dead pool). // - Pool with only ONE row → info (pool-of-one is a smell). // - Mixed types INSIDE a pool → info (LOSE rows pooled with WIN rows). { const byPool = {}; for (const r of variant.rows){ const p = r.pool || 0; if (p <= 0) continue; (byPool[p] = byPool[p] || []).push(r); } for (const pid of Object.keys(byPool)){ const rs = byPool[pid]; const wins = rs.filter(r=>r.type==="WIN"); const loses = rs.filter(r=>r.type==="LOSE"); if (rs.length === 1){ issues.push({severity:"info", code:"pool-integrity", message:`Pool ${pid} has only 1 row — consider merging or removing the pool id`}); } else if (wins.length === 0){ issues.push({severity:"warn", code:"pool-integrity", message:`Pool ${pid} has no WIN rows (dead pool)`}); } else if (loses.length > 0 && wins.length > 0){ issues.push({severity:"info", code:"pool-integrity", message:`Pool ${pid} mixes ${wins.length} WIN and ${loses.length} LOSE rows`}); } } } return issues; } if (typeof window !== "undefined") window.validateVariant = validateVariant; // Pure: build a dimension-less "shape" from a variant that can be stored as a template. // refDenom is captured so we can reconstruct concrete prizes at any target denom. function variantToTemplateShape(variant){ const totalDeals = variant.rows.reduce((a,r)=>a+r.deals,0) || 1; const denom = variant.denom || 1; return { refDenom: denom, rows: variant.rows.map(r => ({ tier: r.tier, type: r.type, prizeRatio: denom>0 ? (r.prize / denom) : 0, // prize as multiple of denom dealsRatio: r.deals / totalDeals, // share of total deals multiplier: r.multiplier || 0, pool: r.pool || 0, desc: r.desc || "" })) }; } if (typeof window !== "undefined") window.variantToTemplateShape = variantToTemplateShape; // Pure: instantiate a template at a given denom & total deals count. // Rounds prize to cents and deals to integers; small drift is expected. function instantiateTemplate(tpl, targetDenom, targetTotalDeals){ const rows = (tpl.rows||[]).map(s => ({ tier: s.tier, type: s.type, prize: Math.round((s.prizeRatio||0) * targetDenom * 100) / 100, multiplier: s.multiplier || 0, deals: Math.round((s.dealsRatio||0) * targetTotalDeals), pool: s.pool || 0, desc: s.desc || "" })); return rows; } if (typeof window !== "undefined") window.instantiateTemplate = instantiateTemplate; // RTP-lock helper (pure). Rescales WIN rows OTHER than `excludeIdx` so // total RTP lands on `targetPct`. Returns {ok:boolean, reason?:string}. // Per-row `priceLock === true` rows are auto-added to the exclude set by callers. function applyRtpLockOnVariant(variant, targetPct, excludeIdx){ const totalDeals = variant.rows.reduce((a,r)=>a+r.deals,0); const wager = totalDeals * variant.denom; if (wager <= 0) return {ok:false, reason:"wager=0"}; const targetPayout = (targetPct/100) * wager; const excluded = new Set(excludeIdx||[]); const absorbIdx = []; for (let i=0;ia + variant.rows[i].prize * variant.rows[i].deals, 0); if (currentAbsorb <= 0) return {ok:false, reason:"absorb rows have zero payout"}; if (neededAbsorb < 0) return {ok:false, reason:"target unreachable (negative prizes required)"}; const scale = neededAbsorb / currentAbsorb; for (const i of absorbIdx){ variant.rows[i].prize = Math.round(variant.rows[i].prize * scale * 100) / 100; } return {ok:true, scale}; } // Expose for smoke tests and devtool use if (typeof window !== "undefined") window.applyRtpLockOnVariant = applyRtpLockOnVariant; // Returns indices of rows whose per-row prize lock is on (r.priceLock === true) function lockedIdxsOf(variant){ const out = []; for (let i=0;ia+r.deals,0); const winners=variant.rows.filter(r=>r.type==="WIN").reduce((a,r)=>a+r.deals,0); const hitRate=totalDeals>0?(winners/totalDeals*100):0; const metrics=el("div",{class:"k-grid-4",style:"margin-bottom:14px"}); metrics.append( metric("RTP",rtpNow.toFixed(4)+"%", rtpNow < band.min ? ("< "+band.min.toFixed(2)+"% ✗") : rtpNow > band.max ? ("> "+band.max.toFixed(2)+"% ✗") : (lock.enabled?("locked "+lock.target.toFixed(2)+"%"):"in band ✓")), metric("Hit rate",hitRate.toFixed(2)+"%",null), metric("Total deals",totalDeals.toLocaleString(),null), metric("Top prize","$"+Math.max(...variant.rows.map(r=>r.prize)).toFixed(2), (()=>{ const o = topPrizeOdds(variant); return o.n===Infinity ? "no odds" : `1 in ${o.n.toFixed(o.n<100?1:0)}`; })()) ); panel.appendChild(metrics); // --- Validator chip strip --- const issues = validateVariant(variant); if (issues.length){ const issBar = el("div",{style:"display:flex;gap:6px;flex-wrap:wrap;margin:-6px 0 12px"}); for (const is of issues){ const cls = is.severity === "error" ? "err" : is.severity === "warn" ? "warn" : "info"; issBar.appendChild(el("span",{class:"k-tag "+cls, title:is.code}, is.message)); } panel.appendChild(issBar); } else { panel.appendChild(el("div",{class:"k-tag ok",style:"margin:-6px 0 12px"},"✓ All validators passing")); } // --- RTP lock panel --- const lockPanel = el("div",{class:"k-panel",style:"margin-bottom:10px"}); lockPanel.appendChild(el("h3",{class:"k-panel-title"},"RTP lock")); const lockRow = el("div",{style:"display:flex;gap:10px;flex-wrap:wrap;align-items:center"}); const lockCb = el("input",{type:"checkbox"}); lockCb.checked = !!lock.enabled; const lockTargetInp = el("input",{type:"text",value:lock.target.toFixed(4),style:"width:90px"}); const statusChip = el("span",{class:"k-tag "+(lock.enabled?"ok":"")}, lock.enabled?("LOCKED @ "+lock.target.toFixed(4)+"%"):"off"); const snapBtn = el("button",{class:"k-btn",onClick:async ()=>{ lock.target = parseFloat(lockTargetInp.value) || 85.0; const res = applyRtpLockOnVariant(variant, lock.target, lockedIdxsOf(variant)); if (!res.ok){ toast("Cannot snap to target: "+res.reason,"warn"); return; } await saveVariant(variant); toast("Snapped to "+lock.target.toFixed(4)+"%","ok"); renderVariant(variant); }},"Snap now"); lockCb.onchange = async (e)=>{ lock.enabled = !!e.target.checked; await saveVariant(variant); renderVariant(variant); }; lockTargetInp.onchange = async (e)=>{ const v = parseFloat(e.target.value); if (!isFinite(v) || v <= 0){ toast("Invalid target","warn"); return; } lock.target = v; await saveVariant(variant); renderVariant(variant); }; const bandMin = el("input",{type:"text",value:band.min.toFixed(2),style:"width:60px"}); const bandMax = el("input",{type:"text",value:band.max.toFixed(2),style:"width:60px"}); bandMin.onchange = async (e)=>{ const v = parseFloat(e.target.value); if (!isFinite(v)){ toast("Invalid min","warn"); return; } band.min = v; await saveVariant(variant); renderVariant(variant); }; bandMax.onchange = async (e)=>{ const v = parseFloat(e.target.value); if (!isFinite(v)){ toast("Invalid max","warn"); return; } band.max = v; await saveVariant(variant); renderVariant(variant); }; lockRow.append( el("label",{style:"display:inline-flex;align-items:center;gap:4px"},lockCb,el("span",{},"Lock RTP")), el("span",{},"Target %"), lockTargetInp, snapBtn, statusChip, el("span",{style:"opacity:.55;margin-left:10px"},"│ RTP band:"), el("span",{},"min"), bandMin, el("span",{},"max"), bandMax ); lockPanel.appendChild(lockRow); lockPanel.appendChild(el("div",{class:"k-help",style:"margin-top:6px"}, "When locked, editing a prize or deals cell auto-rescales the OTHER unlocked WIN rows so the RTP stays at the target. Use the per-row \"Lock\" checkbox to pin any prize(s) you want kept FIXED during rebalancing. Bulk edits also exclude their selected rows + locked rows from the absorb set.")); // --- Jurisdiction preset row --- variant.jurisdiction = variant.jurisdiction || "custom"; const jurRow = el("div",{style:"display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:8px;padding-top:8px;border-top:1px dashed var(--k-line, #ccc)"}); const jurSel = el("select",{style:"min-width:180px"}); for (const k of Object.keys(JURISDICTIONS)){ const j = JURISDICTIONS[k]; const opt = el("option",{value:k}, j.label); if (k === variant.jurisdiction) opt.selected = true; jurSel.appendChild(opt); } const maxPrizeInp = el("input",{type:"text", value: (typeof variant.maxPrizeCap === "number" ? variant.maxPrizeCap.toFixed(2) : ""), placeholder:"(none)", style:"width:90px"}); const minHitInp = el("input",{type:"text", value: (typeof variant.minHitPct === "number" ? variant.minHitPct.toFixed(2) : ""), placeholder:"(none)", style:"width:70px"}); jurSel.onchange = async (e)=>{ const k = e.target.value; variant.jurisdiction = k; const j = JURISDICTIONS[k]; if (j && j.band){ variant.rtpBand = { min:j.band.min, max:j.band.max }; } variant.maxPrizeCap = (j && j.maxPrize != null) ? j.maxPrize : variant.maxPrizeCap; variant.minHitPct = (j && j.minHitPct != null) ? j.minHitPct : variant.minHitPct; await saveVariant(variant); await audit("edit","tiers",`Jurisdiction → ${j ? j.label : k} (${variant.volatility}/${variant.denom})`); renderVariant(variant); }; maxPrizeInp.onchange = async (e)=>{ const v = e.target.value.trim(); variant.maxPrizeCap = v === "" ? null : (parseFloat(v) || null); await saveVariant(variant); renderVariant(variant); }; minHitInp.onchange = async (e)=>{ const v = e.target.value.trim(); variant.minHitPct = v === "" ? null : (parseFloat(v) || null); await saveVariant(variant); renderVariant(variant); }; const curJ = JURISDICTIONS[variant.jurisdiction] || JURISDICTIONS.custom; const jurChip = el("span",{class:"k-tag "+(variant.jurisdiction==="custom"?"":"info")}, curJ.label); jurRow.append( el("span",{},"Jurisdiction"), jurSel, el("span",{style:"opacity:.55;margin-left:4px"},"│ Max prize cap ($)"), maxPrizeInp, el("span",{style:"opacity:.55;margin-left:4px"},"Min hit-rate (%)"), minHitInp, jurChip ); lockPanel.appendChild(jurRow); lockPanel.appendChild(el("div",{class:"k-help",style:"margin-top:4px"}, "Picking a jurisdiction stamps its typical RTP band and (when set) caps/floors. Override manually for internal targets or client-negotiated filings.")); panel.appendChild(lockPanel); // --- Variant actions panel (clone + templates) --- const actPanel = el("div",{class:"k-panel",style:"margin-bottom:10px"}); actPanel.appendChild(el("h3",{class:"k-panel-title"},"Variant actions")); const actRow = el("div",{style:"display:flex;gap:8px;flex-wrap:wrap;align-items:center"}); const cloneBtn = el("button",{class:"k-btn",onClick:async ()=>{ const newVol = (prompt("New volatility (Low / Med / High):", variant.volatility) || "").trim(); if (!newVol) return; const newDenomStr = (prompt("New denom ($):", variant.denom.toFixed(2)) || "").trim(); const newDenom = parseFloat(newDenomStr); if (!isFinite(newDenom) || newDenom <= 0){ toast("Invalid denom","warn"); return; } const g = await currentGame(); const totalDeals = variant.rows.reduce((a,r)=>a+r.deals,0); const rows = variant.rows.map(r => ({ tier: r.tier, type: r.type, prize: Math.round(r.prize * (newDenom/variant.denom) * 100) / 100, multiplier: r.multiplier || 0, deals: r.deals, pool: r.pool || 0, desc: r.desc || "" })); const newVariantId = `${newVol}_${String(Math.round(newDenom*100)).padStart(3,"0")}`; const payload = { gameId: g.id, version: g.currentVersion, volatility: newVol, denom: newDenom, variantId: newVariantId, rows, deckSize: totalDeals, topPrizeCents: Math.round((Math.max(...rows.map(r=>r.prize))||0)*100), totalPrizeCents: rows.reduce((s,r)=>s+Math.round(r.prize*100)*r.deals,0), wins: rows.filter(r=>r.type==="WIN").reduce((s,r)=>s+r.deals,0), losers: rows.filter(r=>r.type==="LOSE").reduce((s,r)=>s+r.deals,0) }; await dbAdd("tiers", payload); await audit("clone","tiers",`Cloned ${variant.variantId||variant.volatility+"/"+variant.denom} → ${newVariantId}`); toast(`Cloned → ${newVariantId}`,"ok"); renderMain(); }},"⎘ Clone variant"); const saveTplBtn = el("button",{class:"k-btn",onClick:async ()=>{ const name = (prompt("Template name:", `${variant.volatility} @ ${variant.denom.toFixed(2)}`) || "").trim(); if (!name) return; const note = (prompt("Optional note:", "") || "").trim(); const shape = variantToTemplateShape(variant); await dbAdd("templates", { name, note, createdAt: Date.now(), ...shape }); await audit("save","templates",`Saved template '${name}'`); toast(`Saved template '${name}'`,"ok"); renderVariant(variant); }},"☆ Save as template"); const tplSel = el("select",{style:"min-width:180px"}); tplSel.appendChild(el("option",{value:""},"(pick template to apply…)")); const tpls = await dbAll("templates"); tpls.sort((a,b)=>(b.createdAt||0)-(a.createdAt||0)); for (const t of tpls){ tplSel.appendChild(el("option",{value:String(t.id)}, `${t.name} [${(t.rows||[]).length} rows]`)); } const applyTplBtn = el("button",{class:"k-btn",onClick:async ()=>{ const id = parseInt(tplSel.value,10); if (!id){ toast("Pick a template first","warn"); return; } const tpl = tpls.find(t => t.id === id); if (!tpl){ toast("Template not found","warn"); return; } const newVol = (prompt("New variant volatility:", "Med") || "").trim() || "Med"; const newDenomStr = (prompt("New variant denom ($):", variant.denom.toFixed(2)) || "").trim(); const newDenom = parseFloat(newDenomStr); if (!isFinite(newDenom) || newDenom <= 0){ toast("Invalid denom","warn"); return; } const totStr = (prompt("Total deals (tickets):", String(variant.rows.reduce((a,r)=>a+r.deals,0))) || "").trim(); const totalDeals = parseInt(totStr,10) || 1000; const rows = instantiateTemplate(tpl, newDenom, totalDeals); const g = await currentGame(); const newVariantId = `${newVol}_${String(Math.round(newDenom*100)).padStart(3,"0")}`; const payload = { gameId: g.id, version: g.currentVersion, volatility: newVol, denom: newDenom, variantId: newVariantId, rows, deckSize: rows.reduce((a,r)=>a+r.deals,0), topPrizeCents: Math.round((Math.max(...rows.map(r=>r.prize))||0)*100), totalPrizeCents: rows.reduce((s,r)=>s+Math.round(r.prize*100)*r.deals,0), wins: rows.filter(r=>r.type==="WIN").reduce((s,r)=>s+r.deals,0), losers: rows.filter(r=>r.type==="LOSE").reduce((s,r)=>s+r.deals,0) }; await dbAdd("tiers", payload); await audit("apply","templates",`Applied template '${tpl.name}' → ${newVariantId}`); toast(`Applied '${tpl.name}' → ${newVariantId}`,"ok"); renderMain(); }},"↙ Apply template"); const onePagerBtn = el("button",{class:"k-btn",onClick:async ()=>{ const g = await currentGame(); const rtpNow2 = tierRTP(variant); const totalDeals2 = variant.rows.reduce((a,r)=>a+r.deals,0); const winners2 = variant.rows.filter(r=>r.type==="WIN").reduce((a,r)=>a+r.deals,0); const hitRatePct2 = totalDeals2 > 0 ? (winners2/totalDeals2*100) : 0; const topPrize2 = Math.max(0, ...variant.rows.map(r=>r.prize)); const topOdds2 = topPrizeOdds(variant); const issues2 = validateVariant(variant); const jKey = variant.jurisdiction || "custom"; const jur = JURISDICTIONS[jKey] || JURISDICTIONS.custom; const nowStr = new Date().toISOString().replace("T"," ").replace(/\.\d+Z$/, " UTC"); let userName = "—"; try { const uid = sessionStorage.getItem("userId"); if (uid){ const u = await dbGet("users", parseInt(uid,10)); if (u) userName = u.name || u.email || "#"+uid; } } catch(_) {} const html = onePagerHtml({ gameName: (g && (g.name||g.code)) || "(untitled game)", version: (g && g.currentVersion) || "", variant, rtp: rtpNow2, hitRatePct: hitRatePct2, totalDeals: totalDeals2, topPrize: topPrize2, topOdds: topOdds2, issues: issues2, jurisdiction: jur, generatedAt: nowStr, generatedBy: userName }); const w = window.open("", "_blank", "width=900,height=1100"); if (!w){ toast("Pop-up blocked — allow pop-ups to open the one-pager","warn"); return; } w.document.open(); w.document.write(html); w.document.close(); await audit("export","tiers",`One-pager for ${variant.variantId||variant.volatility+"/"+variant.denom}`); }},"📄 Print one-pager"); actRow.append(cloneBtn, saveTplBtn, el("span",{style:"opacity:.55"},"│"), tplSel, applyTplBtn, el("span",{style:"opacity:.55"},"│"), onePagerBtn); actPanel.appendChild(actRow); actPanel.appendChild(el("div",{class:"k-help",style:"margin-top:6px"}, `Templates capture the shape (prize ratios per denom, deal share per row). Applying a template creates a NEW variant at the chosen denom + total deals.`)); panel.appendChild(actPanel); // --- Bulk action toolbar --- const selected = new Set(); // indices of variant.rows currently checked const bulk = el("div",{class:"k-panel",style:"margin-bottom:10px"}); bulk.appendChild(el("h3",{class:"k-panel-title"},"Bulk edit")); const bulkRow = el("div",{style:"display:flex;gap:8px;flex-wrap:wrap;align-items:center"}); const actSel = el("select"); [ ["scale","Scale prize by %"], ["setPrize","Set prize to $"], ["round","Round prize to cents"], ["shiftDeals","Shift deals by ±N"], ["setDeals","Set deals to N"], ].forEach(o => { const op = el("option",{value:o[0]},o[1]); actSel.appendChild(op); }); const valInp = el("input",{type:"text",placeholder:"value",style:"width:90px"}); const onlyWinCb = el("input",{type:"checkbox"}); const onlyWinWrap = el("label",{style:"display:inline-flex;align-items:center;gap:4px;font-size:12.5px"}, onlyWinCb, el("span",{},"WIN rows only")); const countChip = el("span",{class:"k-tag info"},"0 selected"); const previewChip = el("span",{class:"k-tag"},"preview: —"); const applyBtn = el("button",{class:"k-btn primary",onClick:()=>runBulk(true)},"Apply"); const previewBtn = el("button",{class:"k-btn",onClick:()=>runBulk(false)},"Preview"); bulkRow.append( el("span",{},"Action"), actSel, valInp, onlyWinWrap, previewBtn, applyBtn, countChip, previewChip ); bulk.appendChild(bulkRow); panel.appendChild(bulk); function pickTargets(){ let idx = Array.from(selected); if (onlyWinCb.checked){ idx = variant.rows.map((r,i)=>({r,i})).filter(x => x.r.type === "WIN").map(x=>x.i); } return idx; } function simulate(rowsSrc){ const out = rowsSrc.map(r=>({...r})); const act = actSel.value; const raw = valInp.value.trim(); const num = parseFloat(raw); const int = parseInt(raw,10); const idxs = pickTargets(); for (const i of idxs){ const r = out[i]; if (!r) continue; if (act === "scale" && !isNaN(num)) r.prize = Math.round(r.prize * (1 + num/100) * 100)/100; else if (act === "setPrize" && !isNaN(num)) r.prize = Math.round(num*100)/100; else if (act === "round") r.prize = Math.round(r.prize*100)/100; else if (act === "shiftDeals" && !isNaN(int)) r.deals = Math.max(0, r.deals + int); else if (act === "setDeals" && !isNaN(int)) r.deals = Math.max(0, int); } return out; } function rtpOf(rows){ const deals = rows.reduce((a,r)=>a+r.deals,0); const payout = rows.reduce((a,r)=>a+r.prize*r.deals,0); const wager = deals*variant.denom; return wager>0 ? (payout/wager*100) : 0; } async function runBulk(commit){ const idxs = pickTargets(); if (!idxs.length){ toast("No rows selected","warn"); return; } let next = simulate(variant.rows); let rtpBeforeLock = rtpOf(next); if (lock.enabled){ // Temporarily stash next rows on variant, run lock, then restore reference semantics const tmp = { rows: next, denom: variant.denom }; const _lockedFromRows = lockedIdxsOf({rows: next}); const _exclude = Array.from(new Set([...idxs, ..._lockedFromRows])); const res = applyRtpLockOnVariant(tmp, lock.target, _exclude); if (!res.ok){ toast("RTP lock cannot absorb: "+res.reason,"warn"); return; } next = tmp.rows; } const rtpA = rtpNow, rtpB = rtpOf(next); const dlt = (rtpB - rtpA); const lockNote = lock.enabled ? ` [locked]` : ``; previewChip.textContent = `preview RTP: ${rtpA.toFixed(4)}% → ${rtpB.toFixed(4)}% (${dlt>=0?"+":""}${dlt.toFixed(4)})${lockNote}`; if (!commit) return; if (!confirm(`Apply to ${idxs.length} row(s)? RTP ${rtpA.toFixed(4)}% → ${rtpB.toFixed(4)}%${lockNote}.`)) return; variant.rows = next; await saveVariant(variant); await audit("edit","tiers",`Bulk ${actSel.value} val=${valInp.value||"-"} rows=${idxs.length} lock=${lock.enabled?("yes@"+lock.target.toFixed(4)):"no"} variant=${variant.volatility}/${variant.denom}`); renderVariant(variant); } function refreshCount(){ countChip.textContent = `${onlyWinCb.checked? pickTargets().length : selected.size} selected`; } onlyWinCb.onchange = refreshCount; // Single-cell commit wrapper: mutate row[field]=value then (if locked) rebalance others async function commitEdit(rowIdx, field, rawValue){ const r = variant.rows[rowIdx]; if (!r) return; let nv; if (field === "prize") nv = parseFloat(rawValue) || 0; else nv = parseInt(rawValue,10) || 0; r[field] = field === "prize" ? Math.round(nv*100)/100 : nv; if (lock.enabled){ const _ex = Array.from(new Set([rowIdx, ...lockedIdxsOf(variant)])); const res = applyRtpLockOnVariant(variant, lock.target, _ex); if (!res.ok){ toast("RTP lock could not absorb: "+res.reason,"warn"); } } await saveVariant(variant); if (lock.enabled) renderVariant(variant); // re-render so rescaled prizes show } const table=el("table",{class:"k-table"}); table.innerHTML=` Lock TierTypePrize ($)Multiplier DealsPoolDescriptionShare of prize `; const tb=el("tbody"); const wager=totalDeals*variant.denom; variant.rows.forEach((r, idx) => { const share=wager>0?(r.prize*r.deals/wager*100):0; const cb = el("input",{type:"checkbox",onChange:(e)=>{ if(e.target.checked) selected.add(idx); else selected.delete(idx); refreshCount(); }}); const priceLockCb = el("input",{type:"checkbox",onChange:async (e)=>{ r.priceLock = !!e.target.checked; await saveVariant(variant); renderVariant(variant); }}); priceLockCb.checked = !!r.priceLock; const lockCell = el("td",{style:"text-align:center"}, priceLockCb); const tr=el("tr"); if (r.priceLock) tr.style.background = "var(--k-paper-hover, #fbf7ea)"; tr.append( el("td",{},cb), lockCell, el("td",{},r.tier.toString()), el("td",{},el("span",{class:"k-tag "+(r.type==="WIN"?"ok":"")},r.type)), editableCell(r.prize.toFixed(2), v=>commitEdit(idx,"prize",v)), editableCell(r.multiplier.toString(), v=>commitEdit(idx,"multiplier",v)), editableCell(r.deals.toString(), v=>commitEdit(idx,"deals",v)), el("td",{},r.pool.toString()), el("td",{},r.desc||""), el("td",{},share.toFixed(2)+"%") ); tb.appendChild(tr); }); table.appendChild(tb); panel.appendChild(table); const allCb = table.querySelector("#kBulkAll"); if (allCb) allCb.onchange = (e) => { selected.clear(); const boxes = table.querySelectorAll("tbody input[type=checkbox]"); boxes.forEach((b, i) => { b.checked = e.target.checked; if (e.target.checked) selected.add(i); }); refreshCount(); }; refreshCount(); } renderVariant(tiers[0]); }; function editableCell(val,onchange){ const td=el("td",{}); const inp=el("input",{type:"text",value:val,onBlur:e=>onchange(e.target.value),onKeydown:e=>{if(e.key==="Enter")e.target.blur()}}); td.appendChild(inp); return td; } async function saveVariant(variant){ await dbPut("tiers",variant); state.lastSaveAt=Date.now(); toast("Saved","ok"); await audit("edit","tiers","Edited tier variant "+variant.volatility+"/"+variant.denom); renderStatusbar(); } function tierRTP(variant){ const totalDeals=variant.rows.reduce((a,r)=>a+r.deals,0); const payout=variant.rows.reduce((a,r)=>a+r.prize*r.deals,0); const wager=totalDeals*variant.denom; return wager>0?payout/wager*100:0; } /* --- Symbols --- */ views.symbols = async (body,actions)=>{ const g=await currentGame(); if(!g){body.appendChild(emptyState("No game selected","",null));return} const recs=await symbolsForCurrent(); const rec=recs[0]||{gameId:g.id,version:g.currentVersion,symbols:[]}; actions.appendChild(el("button",{class:"k-btn",onClick:()=>{ rec.symbols.push({id:"S"+(rec.symbols.length+1),name:"New symbol",weight:1}); saveSymbols(rec);renderMain(); }},"+ Symbol")); if(!rec.symbols.length){ body.appendChild(emptyState("No symbols","Add a reel symbol to define the game composition.",null)); return; } const totalWeight=rec.symbols.reduce((a,s)=>a+s.weight,0); const panel=el("div",{class:"k-panel"}); const t=el("table",{class:"k-table"}); t.innerHTML=`IDNameWeightProbability`; const tb=el("tbody"); rec.symbols.forEach((s,idx)=>{ const tr=el("tr"); tr.append( el("td",{},s.id), editableCell(s.name,v=>{s.name=v;saveSymbols(rec)}), editableCell(s.weight.toString(),v=>{s.weight=parseInt(v)||0;saveSymbols(rec).then(renderMain)}), el("td",{},((s.weight/totalWeight)*100).toFixed(2)+"%"), el("td",{},el("button",{class:"k-btn danger",onClick:()=>{ rec.symbols.splice(idx,1);saveSymbols(rec).then(renderMain); }},"Remove")) ); tb.appendChild(tr); }); t.appendChild(tb); panel.appendChild(t); body.appendChild(panel); }; async function saveSymbols(rec){ if(rec.id)await dbPut("symbols",rec); else await dbAdd("symbols",rec); state.lastSaveAt=Date.now(); toast("Saved","ok"); await audit("edit","symbols","Edited symbol list"); renderStatusbar(); } /* --- Features --- */ views.features = async (body,actions)=>{ const g=await currentGame(); if(!g){body.appendChild(emptyState("No game selected","",null));return} const recs=await featuresForCurrent(); const rec=recs[0]||{gameId:g.id,version:g.currentVersion,features:[]}; actions.appendChild(el("button",{class:"k-btn",onClick:()=>{ rec.features.push({key:"custom_"+(rec.features.length+1),name:"Custom feature",enabled:false,params:{}}); saveFeatures(rec);renderMain(); }},"+ Feature")); if(!rec.features.length){ body.appendChild(emptyState("No features","Define the feature toggles for this game.",null)); return; } for(const f of rec.features){ const panel=el("div",{class:"k-panel"}); const head=el("div",{style:"display:flex;align-items:center;gap:12px"}); head.append( el("h3",{class:"k-panel-title",style:"margin:0;flex:1"},f.name), el("span",{class:"k-tag "+(f.enabled?"ok":"")},f.enabled?"ON":"OFF"), el("button",{class:"k-btn",onClick:()=>{f.enabled=!f.enabled;saveFeatures(rec).then(renderMain)}},f.enabled?"Disable":"Enable") ); panel.appendChild(head); panel.appendChild(el("div",{style:"margin-top:10px;color:var(--k-muted);font-size:12.5px;font-family:var(--k-mono)"}, "key = "+f.key+" · params = "+JSON.stringify(f.params))); body.appendChild(panel); } }; async function saveFeatures(rec){ if(rec.id)await dbPut("features",rec); else await dbAdd("features",rec); state.lastSaveAt=Date.now(); toast("Saved","ok"); await audit("edit","features","Edited feature set"); renderStatusbar(); } /* --- Validation --- */ views.validation = async (body,actions)=>{ const g=await currentGame(); if(!g){body.appendChild(emptyState("No game selected","",null));return} const vals=await validationsForCurrent(); actions.appendChild(el("button",{class:"k-btn primary",onClick:()=>runValidation()},"Run validation")); const layers=["schema","regulatory","math","game_logic","cross_file"]; const metrics=el("div",{class:"k-grid-4",style:"margin-bottom:14px"}); metrics.appendChild(metric("Runs",vals.length.toString(),"")); const passed=vals.filter(v=>v.status==="pass").length; metrics.appendChild(metric("Passed",passed.toString(),passed+"/"+vals.length)); const latest=vals[vals.length-1]; metrics.appendChild(metric("Latest",latest?latest.status:"—",latest?fmtDate(latest.ranAt):"")); metrics.appendChild(metric("Layers",layers.length.toString(),"schema · reg · math · logic · xfile")); body.appendChild(metrics); if(!vals.length){ body.appendChild(emptyState("No validation runs","Click Run validation to execute the 5-layer check.",()=>runValidation())); return; } const t=el("table",{class:"k-table"}); t.innerHTML=`WhenVersionStatusFindings`; const tb=el("tbody"); vals.slice().reverse().forEach(v=>{ const tr=el("tr"); tr.append( el("td",{},fmtDate(v.ranAt)), el("td",{},v.version||"—"), el("td",{},el("span",{class:"k-tag "+(v.status==="pass"?"ok":v.status==="fail"?"err":"warn")},v.status)), el("td",{},(v.findings||[]).length.toString()+" finding(s)"), el("td",{},el("button",{class:"k-btn",onClick:()=>showFindings(v)},"Details")) ); tb.appendChild(tr); }); t.appendChild(tb); const panel=el("div",{class:"k-panel"});panel.appendChild(t);body.appendChild(panel); }; function showFindings(v){ const body=el("div"); if(!v.findings?.length){ body.appendChild(el("p",{},"No findings.")); } else { const t=el("table",{class:"k-table"}); t.innerHTML=`LayerSeverityMessage`; const tb=el("tbody"); for(const f of v.findings){ const tr=el("tr"); tr.append( el("td",{},f.layer), el("td",{},el("span",{class:"k-tag "+(f.severity==="error"?"err":f.severity==="warn"?"warn":"info")},f.severity)), el("td",{},f.message) ); tb.appendChild(tr); } t.appendChild(tb);body.appendChild(t); } modal({title:"Validation findings · "+fmtDate(v.ranAt),body,okLabel:"Close",cancelLabel:""}); } async function runValidation(){ const g=await currentGame(); if(!g){toast("No game selected","err");return} const tiers=await tiersForCurrent(); const syms=await symbolsForCurrent(); const feats=await featuresForCurrent(); const findings=[]; // Layer 1: schema if(!g.name||!g.code)findings.push({layer:"schema",severity:"error",message:"Game must have a name and code"}); if(!tiers.length)findings.push({layer:"schema",severity:"error",message:"No paytables defined"}); // Layer 2: regulatory if(!g.regulatoryProfile)findings.push({layer:"regulatory",severity:"warn",message:"No regulatory profile set"}); if(!g.market)findings.push({layer:"regulatory",severity:"info",message:"No market specified"}); // Layer 3: math for(const t of tiers){ const rtp=tierRTP(t); if(rtp<70||rtp>95)findings.push({layer:"math",severity:"warn",message:`RTP out of typical band: ${t.volatility}/${t.denom} = ${rtp.toFixed(4)}%`}); const total=t.rows.reduce((a,r)=>a+r.deals,0); if(total===0)findings.push({layer:"math",severity:"error",message:`No deals in ${t.volatility}/${t.denom}`}); } // Layer 4: game logic if(syms[0] && syms[0].symbols.length<3)findings.push({layer:"game_logic",severity:"warn",message:"Fewer than 3 symbols"}); const totalW=syms[0]?syms[0].symbols.reduce((a,s)=>a+s.weight,0):0; if(totalW===0 && syms[0])findings.push({layer:"game_logic",severity:"error",message:"Symbol weights sum to zero"}); // Layer 5: cross-file for(const t of tiers){ if(t.version!==g.currentVersion)findings.push({layer:"cross_file",severity:"info",message:`Tier variant ${t.volatility}/${t.denom} version ${t.version} doesn't match game version ${g.currentVersion}`}); } const hasError=findings.some(f=>f.severity==="error"); const hasWarn=findings.some(f=>f.severity==="warn"); const status=hasError?"fail":hasWarn?"warn":"pass"; await dbAdd("validations",{ gameId:g.id, version:g.currentVersion, ranAt:new Date().toISOString(), status, findings }); await audit("validate","game",`Validation ${status}, ${findings.length} findings`); toast(`Validation ${status} — ${findings.length} finding(s)`, status==="pass"?"ok":status==="fail"?"err":""); state.lastSaveAt=Date.now(); if(state.currentView==="validation")renderMain(); renderStatusbar(); } /* --- Regulatory --- */ views.regulatory = async (body,actions)=>{ const g=await currentGame(); if(!g){body.appendChild(emptyState("No game selected","",null));return} const profiles={ "instant-pulltab":{ name:"Instant pull-tab", rtpMin:85.0,rtpMax:90.0, requires:["preprinted paytable","finite-pool draw","winner/loser ledger","auditor-viewable deals","UAS3 near-miss spec"] }, "instant-scratch":{ name:"Instant scratch ticket", rtpMin:55.0,rtpMax:80.0, requires:["ticket print order","win/lose proportion","book-level security","security void band"] }, "mobile-instant":{ name:"Mobile instant (finite pool)", rtpMin:70.0,rtpMax:95.0, requires:["geolocation","KYC","session limits","pool state persistence"] }, "charitable-pulltab":{ name:"Charitable pull-tab", rtpMin:60.0,rtpMax:85.0, requires:["charity registration","deal book certification","published odds disclosure"] } }; const p=profiles[g.regulatoryProfile]||profiles["instant-pulltab"]; body.appendChild(el("div",{class:"k-panel"}, el("h3",{class:"k-panel-title"},"Active profile"), kv("Profile",p.name), kv("RTP band",`${p.rtpMin.toFixed(2)}% – ${p.rtpMax.toFixed(2)}%`), kv("Market",g.market||"—") )); const req=el("div",{class:"k-panel"},el("h3",{class:"k-panel-title"},"Disclosure & artifacts")); const ul=el("ul",{style:"margin:0;padding-left:18px;line-height:1.9"}); for(const r of p.requires){ ul.appendChild(el("li",{},r)); } req.appendChild(ul); body.appendChild(req); const tiers=await tiersForCurrent(); const compliant=tiers.every(t=>{const r=tierRTP(t);return r>=p.rtpMin && r<=p.rtpMax}); body.appendChild(el("div",{class:"k-panel"}, el("h3",{class:"k-panel-title"},"Compliance check"), kv("Paytables in RTP band", compliant?"Yes":"No") )); }; /* --- History --- */ /* --- version-mgmt v2 --- */ // Find current user name (best effort) — returns string like "Juliano" or "#42" or "anon". async function currentActorName(){ try { const uid = sessionStorage.getItem("userId"); if (!uid) return "anon"; const u = await dbGet("users", parseInt(uid,10)); if (u) return u.name || u.email || "#"+uid; return "#"+uid; } catch(_) { return "anon"; } } // Record a sign-off on a specific version entry. Appends to the version record // and writes an audit row for traceability. async function signOffVersion(g, verNumber, role){ const vers = (g.versions||[]).slice(); const idx = vers.findIndex(v => v.number === verNumber); if (idx < 0) throw new Error(`No such version ${verNumber}`); const actor = await currentActorName(); const ts = new Date().toISOString(); const rec = vers[idx]; // Additional roles accumulate in the signOffs array. Keep the latest sign-off // surfaced as the top-level signedOffBy/At/Role for quick table rendering. rec.signOffs = Array.isArray(rec.signOffs) ? rec.signOffs.slice() : []; rec.signOffs.push({ by: actor, at: ts, role: role || "Reviewer" }); rec.signedOffBy = actor; rec.signedOffAt = ts; rec.signedOffRole = role || "Reviewer"; vers[idx] = rec; g.versions = vers; g.updatedAt = ts; await dbPut("games", g); await audit("signoff","version",`${verNumber} signed off by ${actor} (${role||"Reviewer"})`); return rec; } if (typeof window !== "undefined") window.signOffVersion = signOffVersion; function semverParts(v){ const m = String(v||"").match(/v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/); return m ? [parseInt(m[1])||0, parseInt(m[2])||0, parseInt(m[3])||0] : [0,0,0]; } function semverCmp(a,b){ const pa = semverParts(a), pb = semverParts(b); for(let i=0;i<3;i++) if(pa[i]!==pb[i]) return pa[i]-pb[i]; return 0; } function computeNext(cur, opts){ if (!opts) opts = {type:"patch"}; if (opts.type === "custom") return String(opts.number||"").trim() || "v?.?.?"; const [maj,min,pat] = semverParts(cur); if (opts.type === "major") return `v${maj+1}.0.0`; if (opts.type === "minor") return `v${maj}.${min+1}.0`; return `v${maj}.${min}.${pat+1}`; } function ensureVersionsList(g){ const vers = Array.isArray(g.versions) ? g.versions.slice() : []; if (g.currentVersion && !vers.some(v=>v.number===g.currentVersion)) { vers.push({ number: g.currentVersion, date: (g.updatedAt||new Date().toISOString()).slice(0,10), status: "Draft", note: "Initial version" }); } return vers; } async function bumpVersion(g, opts){ opts = opts || {type:"patch"}; const next = computeNext(g.currentVersion, opts); if (!/^v?\d+\.\d+(\.\d+)?$/.test(next)) throw new Error(`Invalid version: ${next}`); const versList0 = ensureVersionsList(g); if (versList0.some(v=>v.number===next)) throw new Error(`Version ${next} already exists`); const from = g.currentVersion; // Clone tier rows const tiers = await dbAll("tiers","gameId",g.id); let tClones = 0; for (const t of tiers.filter(r => (r.version||from) === from)) { const { id, ...rest } = t; await dbAdd("tiers", { ...rest, version: next }); tClones++; } // Clone symbols record (one per version) const syms = await dbAll("symbols","gameId",g.id); const srec = syms.find(r => (r.version||from) === from); if (srec) { const { id, ...rest } = srec; await dbAdd("symbols", { ...rest, version: next }); } // Clone features record const feats = await dbAll("features","gameId",g.id); const frec = feats.find(r => (r.version||from) === from); if (frec) { const { id, ...rest } = frec; await dbAdd("features", { ...rest, version: next }); } const vers = versList0.slice(); const creator = await currentActorName(); vers.push({ number: next, date: new Date().toISOString().slice(0,10), status: opts.status || "Draft", note: opts.note || `Cloned from ${from}`, why: opts.why || "", createdBy: creator, createdAt: new Date().toISOString() }); g.versions = vers; g.currentVersion = next; g.updatedAt = new Date().toISOString(); await dbPut("games", g); state.activeVersion = next; state.lastSaveAt = Date.now(); await audit("bump","game",`${from} → ${next}${opts.note ? " — " + opts.note : ""}`); toast(`Bumped to ${next} (cloned ${tClones} tiers)`,"ok"); renderStatusbar(); renderMain(); } async function setActiveVersion(ver){ state.activeVersion = ver || null; renderStatusbar(); renderMain(); if (ver) toast(`Viewing ${ver}`,"info"); } async function setCurrentVersion(g, ver){ const vers = ensureVersionsList(g); if(!vers.some(v=>v.number===ver)) throw new Error(`No such version ${ver}`); g.versions = vers; g.currentVersion = ver; g.updatedAt = new Date().toISOString(); await dbPut("games", g); state.activeVersion = ver; state.lastSaveAt = Date.now(); await audit("setCurrent","game",`currentVersion → ${ver}`); toast(`Current version is now ${ver}`,"ok"); renderStatusbar(); renderMain(); } async function deleteVersion(g, ver){ if (ver === g.currentVersion) throw new Error("Cannot delete the current version"); const tiers = await dbAll("tiers","gameId",g.id); for (const t of tiers.filter(r => (r.version||"") === ver)) await dbDel("tiers", t.id); const syms = await dbAll("symbols","gameId",g.id); for (const s of syms.filter(r => (r.version||"") === ver)) await dbDel("symbols", s.id); const feats = await dbAll("features","gameId",g.id); for (const f of feats.filter(r => (r.version||"") === ver)) await dbDel("features", f.id); g.versions = (g.versions||[]).filter(v => v.number !== ver); g.updatedAt = new Date().toISOString(); await dbPut("games", g); if (state.activeVersion === ver) state.activeVersion = g.currentVersion; await audit("deleteVersion","game",`Removed ${ver}`); toast(`Deleted ${ver}`,"warn"); renderStatusbar(); renderMain(); } views.history = async (body, actions)=>{ const g = await currentGame(); if(!g){body.appendChild(emptyState("No game selected","",null));return} // Persist a versions list if missing so the table always has something to show. const vers0 = ensureVersionsList(g); if ((g.versions||[]).length !== vers0.length) { g.versions = vers0; await dbPut("games", g); } const vers = vers0; const active = state.activeVersion || g.currentVersion; // Header panel const hdr = el("div",{class:"k-panel"}); hdr.appendChild(el("h3",{class:"k-panel-title"},"Version control")); const chip = el("div",{style:"display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:6px"}, el("span",{class:"k-tag ok"},`Current: ${g.currentVersion||"—"}`), el("span",{class:"k-tag "+(active===g.currentVersion?"info":"warn")},`Viewing: ${active||"—"}`), el("span",{class:"k-side-meta"},`${vers.length} version${vers.length===1?"":"s"} on record`) ); hdr.appendChild(chip); if (active !== g.currentVersion) { const banner = el("div",{class:"k-help",style:"margin-top:4px"}, `You are viewing a historical snapshot. ` ); banner.appendChild(el("button",{class:"k-btn",style:"margin-left:6px",onClick:()=>setActiveVersion(null)},"Return to current")); hdr.appendChild(banner); } body.appendChild(hdr); // Bump form const bumpPanel = el("div",{class:"k-panel"}); bumpPanel.appendChild(el("h3",{class:"k-panel-title"},"+ Bump to new version")); const bumpState = { type:"patch", number:"", note:"", status:"Draft" }; const grid = el("div",{class:"k-grid-4"}); const typeWrap = el("div",{class:"k-kv"}); typeWrap.appendChild(el("div",{class:"k-kv-k"},"Bump type")); const typeSel = el("select"); [["patch","Patch (x.y.Z+1)"],["minor","Minor (x.Y+1.0)"],["major","Major (X+1.0.0)"],["custom","Custom…"]].forEach(o=>{ const op = el("option",{value:o[0]},o[1]); if(o[0]===bumpState.type) op.selected = true; typeSel.appendChild(op); }); typeWrap.appendChild(typeSel); grid.appendChild(typeWrap); const customWrap = el("div",{class:"k-kv",style:"display:none"}); customWrap.appendChild(el("div",{class:"k-kv-k"},"Custom number")); const customInp = el("input",{type:"text",placeholder:"e.g. v2.9.2"}); customWrap.appendChild(customInp); grid.appendChild(customWrap); const statusWrap = el("div",{class:"k-kv"}); statusWrap.appendChild(el("div",{class:"k-kv-k"},"Status")); const statusSel = el("select"); ["Draft","In review","Delivered","Archived"].forEach(s=>{ const op = el("option",{value:s},s); if(s===bumpState.status) op.selected = true; statusSel.appendChild(op); }); statusWrap.appendChild(statusSel); grid.appendChild(statusWrap); const noteWrap = el("div",{class:"k-kv"}); noteWrap.appendChild(el("div",{class:"k-kv-k"},"Note")); const noteInp = el("input",{type:"text",placeholder:"What's changing?"}); noteWrap.appendChild(noteInp); grid.appendChild(noteWrap); bumpPanel.appendChild(grid); // Why: rationale textarea (reason for the change) — lives below the grid. const whyWrap = el("div",{style:"margin-top:8px"}); whyWrap.appendChild(el("div",{class:"k-kv-k",style:"margin-bottom:4px"},"Why (rationale, audit trail)")); const whyInp = el("textarea",{rows:"2",style:"width:100%;font:inherit;padding:6px;border:1px solid var(--k-line,#ccc);border-radius:4px", placeholder:"e.g. Compliance update for NJ — lowered top prize to $500 to satisfy cap"}); whyWrap.appendChild(whyInp); bumpPanel.appendChild(whyWrap); const previewChip = el("span",{class:"k-tag info"},`→ ${computeNext(g.currentVersion, bumpState)}`); const refreshPreview = () => { bumpState.type = typeSel.value; bumpState.status = statusSel.value; bumpState.note = noteInp.value; bumpState.why = whyInp.value; bumpState.number = customInp.value; customWrap.style.display = (bumpState.type==="custom") ? "" : "none"; previewChip.textContent = `→ ${computeNext(g.currentVersion, bumpState)}`; }; typeSel.onchange = refreshPreview; statusSel.onchange = refreshPreview; noteInp.oninput = refreshPreview; whyInp.oninput = refreshPreview; customInp.oninput = refreshPreview; const bumpRow = el("div",{style:"margin-top:10px;display:flex;gap:10px;align-items:center;flex-wrap:wrap"}, el("span",{},`From ${g.currentVersion||"(unset)"}`), previewChip, el("button",{class:"k-btn primary",onClick:async ()=>{ try { await bumpVersion(g, bumpState); } catch(e){ toast(String(e.message||e),"err"); } }},"Clone & bump"), el("span",{class:"k-help"},"Clones paytables, symbols, and features from the current version under the new number.") ); bumpPanel.appendChild(bumpRow); body.appendChild(bumpPanel); // Version history table const vPanel = el("div",{class:"k-panel"}); vPanel.appendChild(el("h3",{class:"k-panel-title"},"Version history")); const allTiers = await dbAll("tiers","gameId",g.id); const allSyms = await dbAll("symbols","gameId",g.id); const allFeats = await dbAll("features","gameId",g.id); const countTiers = ver => allTiers.filter(r => (r.version||g.currentVersion) === ver).length; const countSyms = ver => { const rec = allSyms.find(r => (r.version||g.currentVersion) === ver); return rec ? (rec.symbols||[]).length : 0; }; const countFeats = ver => { const rec = allFeats.find(r => (r.version||g.currentVersion) === ver); return rec ? (rec.features||[]).length : 0; }; const sortedVers = vers.slice().sort((a,b)=>-semverCmp(a.number, b.number)); const t = el("table",{class:"k-table"}); t.innerHTML = ` VersionDateStatusNote WhySign-off TiersSymbolsFeaturesActions `; const tb = el("tbody"); for (const v of sortedVers) { const isCurrent = v.number === g.currentVersion; const isActive = v.number === active; const statusCls = v.status === "Delivered" ? "ok" : v.status === "Archived" ? "warn" : v.status === "In review" ? "info" : "info"; const tr = el("tr"); const verCell = el("td",{}, el("strong",{},v.number), isCurrent ? el("span",{class:"k-tag ok",style:"margin-left:6px"},"current") : "", (isActive && !isCurrent) ? el("span",{class:"k-tag info",style:"margin-left:6px"},"viewing") : "" ); const actionsCell = el("td",{}, el("button",{class:"k-btn",style:"margin-right:4px",onClick:()=>setActiveVersion(v.number)}, isActive ? "Viewing" : "View"), el("button",{class:"k-btn",style:"margin-right:4px",onClick:async ()=>{ try { await setCurrentVersion(g, v.number); } catch(e){ toast(String(e.message||e),"err"); } }}, isCurrent ? "(current)" : "Set as current") ); if (!isCurrent) { actionsCell.appendChild(el("button",{class:"k-btn danger",onClick:async ()=>{ if (!confirm(`Delete version ${v.number}? This removes its tiers, symbols, and features.`)) return; try { await deleteVersion(g, v.number); } catch(e){ toast(String(e.message||e),"err"); } }},"Delete")); } // Sign-off button — always available (a version can accumulate sign-offs) actionsCell.appendChild(el("button",{class:"k-btn",style:"margin-left:4px",onClick:async ()=>{ const role = (prompt("Role (e.g. Math QA, Compliance, Client):", "Reviewer") || "").trim(); if (!role) return; try { await signOffVersion(g, v.number, role); toast(`Signed off ${v.number} as ${role}`,"ok"); renderMain(); } catch(e){ toast(String(e.message||e),"err"); } }},"✍ Sign off")); const whyCell = el("td",{style:"max-width:260px"}, v.why ? el("span",{title:v.why, style:"font-size:12px"}, v.why.length > 80 ? v.why.slice(0,77)+"…" : v.why) : el("span",{class:"k-help"},"—") ); let signCell; if (v.signedOffBy){ const when = (v.signedOffAt||"").slice(0,10); const count = Array.isArray(v.signOffs) ? v.signOffs.length : 1; const label = `${v.signedOffBy} (${v.signedOffRole||"Reviewer"})${count>1?` +${count-1}`:""}`; signCell = el("td",{}, el("span",{class:"k-tag ok",title:`${when} ${v.signedOffBy} ${v.signedOffRole||""}`}, "✓ "+label) ); } else { signCell = el("td",{},el("span",{class:"k-help"},"unsigned")); } tr.append( verCell, el("td",{},v.date||""), el("td",{},el("span",{class:"k-tag "+statusCls},v.status||"Draft")), el("td",{},v.note||""), whyCell, signCell, el("td",{},String(countTiers(v.number))), el("td",{},String(countSyms(v.number))), el("td",{},String(countFeats(v.number))), actionsCell ); tb.appendChild(tr); } t.appendChild(tb); vPanel.appendChild(t); body.appendChild(vPanel); // Recent activity (shortened to 30) const allAudits = await dbAll("audit"); const recent = allAudits.slice(-30).reverse(); const actPanel = el("div",{class:"k-panel"}); actPanel.appendChild(el("h3",{class:"k-panel-title"},"Recent activity")); const at = el("table",{class:"k-table"}); at.innerHTML = `WhenActorActionTargetDetail`; const atb = el("tbody"); for (const a of recent) { const tr = el("tr"); tr.append( el("td",{},fmtDate(a.ts)), el("td",{},a.actor||""), el("td",{},el("span",{class:"k-tag info"},a.action)), el("td",{},a.target||""), el("td",{},typeof a.payload==="string" ? a.payload : JSON.stringify(a.payload||"")) ); atb.appendChild(tr); } at.appendChild(atb); actPanel.appendChild(at); body.appendChild(actPanel); await renderCompareSection(body, g); }; /* --- Tickets (real finite-pool batch generator + CSV/JSON export) --- */ views.tickets = async (body,actions)=>{ const g=await currentGame(); if(!g){body.appendChild(emptyState("No game selected","",null));return} const tiers=await tiersForCurrent(); if(!tiers.length){body.appendChild(emptyState("No paytables","Add at least one variant first.",null));return} // session state const st={ idx: Math.min(parseInt(sessionStorage.getItem("variantSel:tk")||"0"),tiers.length-1), prefix: (g.code||"KOBOW").toUpperCase().replace(/[^A-Z0-9-]/g,""), bookSize: 250, startBook: 1, seed: "", result: null }; const tabsHost=el("div");body.appendChild(tabsHost); // config panel const cfg=el("div",{class:"k-panel"}); cfg.appendChild(el("h3",{class:"k-panel-title"},"Batch configuration")); const fForm=el("div",{class:"k-grid-4"}); fForm.appendChild(kvInput("Serial prefix", st.prefix, v=>st.prefix=v.toUpperCase().replace(/[^A-Z0-9-]/g,""))); fForm.appendChild(kvInput("Book size (tickets/book)", st.bookSize, v=>st.bookSize=Math.max(1,parseInt(v)||250), "number")); fForm.appendChild(kvInput("Start book #", st.startBook, v=>st.startBook=Math.max(1,parseInt(v)||1), "number")); fForm.appendChild(kvInput("Seed (blank = auto)", st.seed, v=>st.seed=v)); cfg.appendChild(fForm); cfg.appendChild(el("div",{style:"margin-top:12px;display:flex;gap:8px;flex-wrap:wrap"}, el("button",{class:"k-btn primary",onClick:()=>doGenerate()},"Generate batch"), el("button",{class:"k-btn",onClick:()=>{st.result=null;redraw()}},"Clear") )); body.appendChild(cfg); const resultHost=el("div");body.appendChild(resultHost); // Previous batches panel const historyHost=el("div");body.appendChild(historyHost); function kvInput(label, val, onChange, type){ const wrap=el("div",{class:"k-kv"}); wrap.appendChild(el("div",{class:"k-kv-k"},label)); const inp=el("input",{type:type||"text",value:String(val)}); inp.oninput=e=>onChange(e.target.value); wrap.appendChild(inp); return wrap; } async function doGenerate(){ const v=tiers[st.idx]; if(!v){toast("No variant selected","err");return} const res=generateTickets(v,{ prefix:st.prefix, bookSize:st.bookSize, startBook:st.startBook, seed:st.seed }); if(!res.tickets.length){toast("Pool is empty — add deals to the paytable first","err");return} const createdAt=new Date().toISOString(); const record={ gameId:g.id, version:g.currentVersion, volatility:v.volatility, denom:v.denom, createdAt, cfg:{prefix:st.prefix,bookSize:st.bookSize,startBook:st.startBook,seed:res.summary.seed}, summary:res.summary, tickets:res.tickets }; const id=await dbAdd("ticketBatches",record); record.id=id; await audit("tickets","create",`Batch ${id} — ${res.summary.totalTickets} tickets, hash ${res.summary.batchHash}`); st.result=record; toast(`Generated ${res.summary.totalTickets} tickets`,"ok"); redraw(); renderSidebar(); } function redrawTabs(){ tabsHost.innerHTML=""; variantTabs(tabsHost,tiers,st.idx,i=>{st.idx=i;st.result=null;redraw()},"tk"); } async function redrawHistory(){ historyHost.innerHTML=""; const batches=await ticketBatchesForCurrent(); if(!batches.length)return; const panel=el("div",{class:"k-panel"}); panel.appendChild(el("h3",{class:"k-panel-title"},"Previous batches")); const t=el("table",{class:"k-table"}); t.innerHTML=`CreatedVariantTicketsRTPHashSeed`; const tb=el("tbody"); for(const b of batches.slice().reverse()){ const tr=el("tr"); tr.append( el("td",{},fmtDate(b.createdAt)), el("td",{},`${b.volatility} · $${b.denom.toFixed(2)}`), el("td",{},String(b.summary.totalTickets)), el("td",{},b.summary.rtp.toFixed(2)+"%"), el("td",{},el("code",{style:"font-size:11px"},b.summary.batchHash)), el("td",{},el("code",{style:"font-size:11px;color:var(--k-muted)"},b.cfg.seed||"—")), el("td",{style:"white-space:nowrap"}, el("button",{class:"k-btn",onClick:()=>{st.result=b;redraw()}},"Open"), " ", el("button",{class:"k-btn",onClick:()=>downloadBatch(b,"csv")},"CSV"), " ", el("button",{class:"k-btn",onClick:()=>downloadBatch(b,"json")},"JSON") ) ); tb.appendChild(tr); } t.appendChild(tb); panel.appendChild(t); historyHost.appendChild(panel); } function downloadBatch(b,fmt){ const meta={ gameCode:g.code, gameName:g.name, version:b.version, volatility:b.volatility, denom:b.denom, createdAt:b.createdAt, ...b.summary }; const base=`${g.code}_${b.version}_${b.volatility}_${b.denom.toFixed(2)}_batch${b.id}`; if(fmt==="csv"){ downloadBlob(base+".csv", ticketsToCSV(b.tickets,meta), "text/csv"); } else { const payload={ schema:"kobow.finite-pool.ticket-batch/v1", game:{id:g.id,code:g.code,name:g.name,version:b.version}, variant:{volatility:b.volatility,denom:b.denom}, cfg:b.cfg, summary:b.summary, createdAt:b.createdAt, tickets:b.tickets }; downloadBlob(base+".json", JSON.stringify(payload,null,2), "application/json"); } toast(`Exported ${base}.${fmt}`,"ok"); } function redrawResult(){ resultHost.innerHTML=""; if(!st.result)return; const b=st.result; const s=b.summary; const kpis=el("div",{class:"k-grid-4",style:"margin-top:16px"}); kpis.appendChild(metric("Tickets",String(s.totalTickets), `Wager $${s.wager.toFixed(2)}`)); kpis.appendChild(metric("RTP", s.rtp.toFixed(3)+"%", `Payout $${s.totalPayout.toFixed(2)}`)); kpis.appendChild(metric("Winners / losers", `${s.winners} / ${s.losers}`, `Hit rate ${(s.winners/s.totalTickets*100).toFixed(2)}%`)); kpis.appendChild(metric("Biggest prize", `$${s.biggestPrize.toFixed(2)}`, `Batch hash ${s.batchHash}`)); resultHost.appendChild(kpis); const actionsRow=el("div",{class:"k-panel",style:"margin-top:16px;display:flex;gap:8px;flex-wrap:wrap;align-items:center"}); actionsRow.appendChild(el("div",{style:"font-size:12px;color:var(--k-muted);margin-right:auto"}, `Seed: `, el("code",{},s.seed||"—"), ` · `, `Batch #${b.id}` )); actionsRow.appendChild(el("button",{class:"k-btn",onClick:()=>downloadBatch(b,"csv")},"⇩ CSV")); actionsRow.appendChild(el("button",{class:"k-btn",onClick:()=>downloadBatch(b,"json")},"⇩ JSON")); actionsRow.appendChild(el("button",{class:"k-btn",onClick:()=>copyBatchSummary(b)},"Copy summary")); actionsRow.appendChild(el("button",{class:"k-btn",onClick:()=>deleteBatch(b)},"Delete")); resultHost.appendChild(actionsRow); // Tier distribution const dist={}; for(const t of b.tickets){ const k=t.tier+"|"+t.prize; if(!dist[k])dist[k]={tier:t.tier,prize:t.prize,type:t.type,count:0}; dist[k].count++; } const distRows=Object.values(dist).sort((a,b)=>b.tier-a.tier); const distPanel=el("div",{class:"k-panel",style:"margin-top:16px"}); distPanel.appendChild(el("h3",{class:"k-panel-title"},"Prize distribution")); const distTable=el("table",{class:"k-table"}); distTable.innerHTML=`TierTypePrizeCountShareContribution`; const distTb=el("tbody"); for(const r of distRows){ const share=(r.count/s.totalTickets*100).toFixed(2); const contrib=((r.prize*r.count)/s.wager*100).toFixed(3); const tr=el("tr"); tr.append( el("td",{},String(r.tier)), el("td",{},el("span",{class:"k-tag "+(r.type==="WIN"?"ok":"")},r.type)), el("td",{},`$${r.prize.toFixed(2)}`), el("td",{},String(r.count)), el("td",{},share+"%"), el("td",{},contrib+"%") ); distTb.appendChild(tr); } distTable.appendChild(distTb); distPanel.appendChild(distTable); resultHost.appendChild(distPanel); // Preview first 50 tickets const previewPanel=el("div",{class:"k-panel",style:"margin-top:16px"}); const head=el("div",{style:"display:flex;justify-content:space-between;align-items:center"}); head.appendChild(el("h3",{class:"k-panel-title",style:"margin:0"},`Preview — first 50 of ${s.totalTickets}`)); head.appendChild(el("div",{style:"font-size:11px;color:var(--k-muted)"},"full set available via CSV / JSON export")); previewPanel.appendChild(head); const pt=el("table",{class:"k-table"}); pt.innerHTML=`SerialBookPosTierTypePrizeValidation`; const ptb=el("tbody"); for(const t of b.tickets.slice(0,50)){ const tr=el("tr"); tr.append( el("td",{},el("code",{style:"font-size:11px"},t.serial)), el("td",{},String(t.book)), el("td",{},String(t.position)), el("td",{},String(t.tier)), el("td",{},el("span",{class:"k-tag "+(t.type==="WIN"?"ok":"")},t.type)), el("td",{},t.prize>0?`$${t.prize.toFixed(2)}`:"—"), el("td",{},el("code",{style:"font-size:11px;color:var(--k-muted)"},t.validation)) ); ptb.appendChild(tr); } pt.appendChild(ptb); previewPanel.appendChild(pt); resultHost.appendChild(previewPanel); } async function copyBatchSummary(b){ const s=b.summary; const txt=[ `Kobow Finite Pool — Batch #${b.id}`, `Game: ${g.code} (${g.name}) · Version: ${b.version}`, `Variant: ${b.volatility} · $${b.denom.toFixed(2)}`, `Tickets: ${s.totalTickets} · Winners: ${s.winners} · Losers: ${s.losers}`, `Wager: $${s.wager.toFixed(2)} · Payout: $${s.totalPayout.toFixed(2)} · RTP: ${s.rtp}%`, `Biggest prize: $${s.biggestPrize.toFixed(2)}`, `Seed: ${s.seed} · Batch hash: ${s.batchHash}`, `Generated: ${b.createdAt}` ].join("\n"); try{await navigator.clipboard.writeText(txt);toast("Summary copied","ok")} catch(e){toast("Copy failed","err")} } async function deleteBatch(b){ if(!confirm(`Delete batch #${b.id} (${b.summary.totalTickets} tickets)? This cannot be undone.`))return; await dbDel("ticketBatches",b.id); await audit("tickets","delete",`Batch ${b.id} deleted`); st.result=null; toast("Batch deleted","ok"); redraw(); } function redraw(){ redrawTabs(); redrawResult(); redrawHistory(); } redraw(); }; /* --- Delivery --- */ views.delivery = async (body,actions)=>{ const g=await currentGame(); if(!g){body.appendChild(emptyState("No game selected","",null));return} const dels=await deliveriesForCurrent(); actions.appendChild(el("button",{class:"k-btn primary",onClick:()=>createDelivery(g)},"+ Prepare delivery")); if(!dels.length){ body.appendChild(emptyState("No deliveries yet","Prepare a delivery package to track hand-offs.",()=>createDelivery(g))); return; } const t=el("table",{class:"k-table"}); t.innerHTML=`CreatedVersionRecipientStatusFiles`; const tb=el("tbody"); for(const d of dels.slice().reverse()){ const tr=el("tr"); const actionsTd=el("td",{style:"white-space:nowrap"}); actionsTd.appendChild(el("button",{class:"k-btn",onClick:()=>downloadDelivery(g,d)},"⇩ Package")); actionsTd.appendChild(document.createTextNode(" ")); actionsTd.appendChild(el("button",{class:"k-btn",onClick:()=>markSent(d)},"Mark sent")); tr.append( el("td",{},fmtDate(d.createdAt)), el("td",{},d.version||"—"), el("td",{},d.recipient||"—"), el("td",{},el("span",{class:"k-tag "+(d.status==="sent"?"ok":"info")},d.status)), el("td",{},(d.files||[]).length+" files"), actionsTd ); tb.appendChild(tr); } t.appendChild(tb); const panel=el("div",{class:"k-panel"});panel.appendChild(t);body.appendChild(panel); }; async function downloadDelivery(g,d){ // Assemble delivery package as a single JSON manifest with embedded artifacts. // Real files, not placeholders: paytables, symbols, features, validations, ticket batches. const tiers=await dbAll("tiers","gameId",g.id); const syms=await dbAll("symbols","gameId",g.id); const feats=await dbAll("features","gameId",g.id); const vals=await dbAll("validations","gameId",g.id); const batches=await dbAll("ticketBatches","gameId",g.id); const artifacts={}; // paytables CSV (one row per tier × variant) const ptRows=["version,volatility,denom,tier,type,prize,multiplier,deals,desc"]; for(const t of tiers){ for(const r of t.rows){ ptRows.push([t.version,t.volatility,t.denom,r.tier,r.type,r.prize,r.multiplier,r.deals,`"${(r.desc||"").replace(/"/g,'""')}"`].join(",")); } } artifacts[`${g.code}_${d.version}_paytables.csv`]=ptRows.join("\n"); artifacts[`${g.code}_${d.version}_symbols.json`]=JSON.stringify(syms,null,2); artifacts[`${g.code}_${d.version}_features.json`]=JSON.stringify(feats,null,2); artifacts[`${g.code}_${d.version}_validations.json`]=JSON.stringify(vals,null,2); // ticket batches: embed summaries + emit per-batch CSV as separate entries for(const b of batches){ const meta={ gameCode:g.code, gameName:g.name, version:b.version, volatility:b.volatility, denom:b.denom, createdAt:b.createdAt, ...b.summary }; artifacts[`tickets/${g.code}_${b.version}_${b.volatility}_${b.denom.toFixed(2)}_batch${b.id}.csv`]=ticketsToCSV(b.tickets,meta); } const manifest={ schema:"kobow.finite-pool.delivery/v1", delivery:{ id:d.id, createdAt:d.createdAt, status:d.status, recipient:d.recipient, notes:d.notes||"" }, game:{id:g.id,code:g.code,name:g.name,version:d.version, theme:g.theme,market:g.market,regulatoryProfile:g.regulatoryProfile}, summary:{ variants:tiers.length, ticketBatches:batches.length, totalTickets:batches.reduce((a,b)=>a+b.summary.totalTickets,0), validationsRun:vals.length }, files:Object.keys(artifacts) }; artifacts[`${g.code}_${d.version}_manifest.json`]=JSON.stringify(manifest,null,2); // Build a single portable JSON "package" with inlined artifacts so the user // can always recover the individual files. const pack={ schema:"kobow.finite-pool.delivery-package/v1", manifest, artifacts // {filename: content} }; const base=`${g.code}_${d.version}_delivery${d.id}`; downloadBlob(base+".json", JSON.stringify(pack,null,2), "application/json"); // Also drop the manifest and paytable CSV as standalone files for convenience. downloadBlob(`${g.code}_${d.version}_manifest.json`, artifacts[`${g.code}_${d.version}_manifest.json`], "application/json"); downloadBlob(`${g.code}_${d.version}_paytables.csv`, artifacts[`${g.code}_${d.version}_paytables.csv`], "text/csv"); await audit("delivery","export",`Package ${d.id} downloaded (${Object.keys(artifacts).length} files)`); toast(`Package with ${Object.keys(artifacts).length} files downloaded`,"ok"); } async function markSent(d){ d.status="sent"; await dbPut("deliveries",d); await audit("delivery","update",`Marked delivery ${d.id} sent`); toast("Marked as sent","ok"); renderMain(); } async function createDelivery(g){ const body=el("div"); body.appendChild(el("label",{},"Recipient")); const rec=el("input",{type:"text",placeholder:"Recipient name or email"}); body.appendChild(rec); body.appendChild(el("label",{},"Notes (optional)")); const notes=el("textarea",{rows:"3"}); body.appendChild(notes); modal({ title:"Prepare delivery", body,okLabel:"Create",cancelLabel:"Cancel", onOk:async()=>{ const batches=await dbAll("ticketBatches","gameId",g.id); const files=[ `${g.code}_${g.currentVersion}_manifest.json`, `${g.code}_${g.currentVersion}_paytables.csv`, `${g.code}_${g.currentVersion}_symbols.json`, `${g.code}_${g.currentVersion}_features.json`, `${g.code}_${g.currentVersion}_validations.json`, ]; for(const b of batches){ files.push(`tickets/${g.code}_${b.version}_${b.volatility}_${b.denom.toFixed(2)}_batch${b.id}.csv`); } await dbAdd("deliveries",{ gameId:g.id,version:g.currentVersion, createdAt:new Date().toISOString(), recipient:rec.value, notes:notes.value, status:"draft", files }); await audit("delivery","create",`Created delivery for ${rec.value} (${files.length} files)`); toast(`Delivery created · ${files.length} files`,"ok"); renderMain(); } }); } /* --- version-diff v1 --- */ /* * Version diff + auto-changelog. * * Compares two versions of a game's math: paytable variants (matched by * variantId with a volatility/denom fallback), symbols (by id) and features * (by key). Produces a structured diff and renders a compare panel at the * bottom of the history view, with a Markdown export. */ async function buildVersionDiff(g, verA, verB){ const curr = g.currentVersion; const allTiers = await dbAll("tiers","gameId",g.id); const tiersA = allTiers.filter(t => (t.version||curr) === verA); const tiersB = allTiers.filter(t => (t.version||curr) === verB); const allSyms = await dbAll("symbols","gameId",g.id); const symsA = allSyms.find(r => (r.version||curr) === verA); const symsB = allSyms.find(r => (r.version||curr) === verB); const allFeats = await dbAll("features","gameId",g.id); const featsA = allFeats.find(r => (r.version||curr) === verA); const featsB = allFeats.find(r => (r.version||curr) === verB); const keyOf = t => t.variantId || (t.volatility+"_"+t.denom); const mapA = new Map(tiersA.map(t => [keyOf(t), t])); const mapB = new Map(tiersB.map(t => [keyOf(t), t])); const allKeys = Array.from(new Set([...mapA.keys(), ...mapB.keys()])).sort(); const variantDiffs = []; for (const k of allKeys){ const a = mapA.get(k), b = mapB.get(k); if (!a) { variantDiffs.push({key:k, kind:"added", b, rtpB:tierRTP(b)}); continue; } if (!b) { variantDiffs.push({key:k, kind:"removed", a, rtpA:tierRTP(a)}); continue; } const rowChanges = []; const maxRows = Math.max((a.rows||[]).length, (b.rows||[]).length); const fields = ["prize","multiplier","deals","type","pool","desc"]; for (let i = 0; i < maxRows; i++){ const ra = (a.rows||[])[i], rb = (b.rows||[])[i]; if (!ra){ rowChanges.push({idx:i, kind:"added", rb}); continue; } if (!rb){ rowChanges.push({idx:i, kind:"removed", ra}); continue; } const changed = {}; for (const f of fields){ if ((ra[f]||0) !== (rb[f]||0)){ changed[f] = { from: ra[f], to: rb[f] }; } } if (Object.keys(changed).length){ rowChanges.push({idx:i, kind:"changed", changed, ra, rb}); } } if (rowChanges.length){ variantDiffs.push({key:k, kind:"changed", a, b, rowChanges, rtpA:tierRTP(a), rtpB:tierRTP(b)}); } } const symsAarr = (symsA && symsA.symbols) || []; const symsBarr = (symsB && symsB.symbols) || []; const sA = new Map(symsAarr.map(s => [s.id, s])); const sB = new Map(symsBarr.map(s => [s.id, s])); const symChanges = []; for (const id of new Set([...sA.keys(), ...sB.keys()])){ const a = sA.get(id), b = sB.get(id); if (!a) symChanges.push({id, kind:"added", b}); else if (!b) symChanges.push({id, kind:"removed", a}); else { const changed = {}; for (const f of ["name","weight"]) if ((a[f]||"") !== (b[f]||"")) changed[f] = {from:a[f], to:b[f]}; if (Object.keys(changed).length) symChanges.push({id, kind:"changed", changed, a, b}); } } const featsAarr = (featsA && featsA.features) || []; const featsBarr = (featsB && featsB.features) || []; const fA = new Map(featsAarr.map(f => [f.key || f.name, f])); const fB = new Map(featsBarr.map(f => [f.key || f.name, f])); const featChanges = []; for (const k of new Set([...fA.keys(), ...fB.keys()])){ const a = fA.get(k), b = fB.get(k); if (!a) featChanges.push({key:k, kind:"added", b}); else if (!b) featChanges.push({key:k, kind:"removed", a}); else if ((!!a.enabled) !== (!!b.enabled) || JSON.stringify(a.params||{}) !== JSON.stringify(b.params||{})){ featChanges.push({key:k, kind:"changed", a, b}); } } return { verA, verB, variantDiffs, symChanges, featChanges }; } function changelogMarkdown(g, diff){ const L = []; L.push(`# CHANGELOG — ${g.name || g.code || "game"}`); L.push(`### ${diff.verA} → ${diff.verB}`); L.push(``); L.push(`_Generated ${new Date().toISOString()} · ${g.code||""}_`); L.push(``); if (!diff.variantDiffs.length && !diff.symChanges.length && !diff.featChanges.length){ L.push(`No math changes detected between ${diff.verA} and ${diff.verB}.`); return L.join("\n"); } if (diff.variantDiffs.length){ L.push(`## Paytable variants`); for (const v of diff.variantDiffs){ if (v.kind === "added"){ L.push(`- **Added** \`${v.key}\` (RTP ${(v.rtpB||0).toFixed(4)}%)`); } else if (v.kind === "removed"){ L.push(`- **Removed** \`${v.key}\` (was RTP ${(v.rtpA||0).toFixed(4)}%)`); } else { L.push(`- **Changed** \`${v.key}\` · RTP ${v.rtpA.toFixed(4)}% → ${v.rtpB.toFixed(4)}% (${(v.rtpB-v.rtpA>=0?"+":"")}${(v.rtpB-v.rtpA).toFixed(4)})`); for (const rc of v.rowChanges){ if (rc.kind === "added") L.push(` - Row ${rc.idx}: **added** prize=$${(rc.rb.prize||0).toFixed(2)} deals=${rc.rb.deals||0}`); else if (rc.kind === "removed") L.push(` - Row ${rc.idx}: **removed** prize=$${(rc.ra.prize||0).toFixed(2)} deals=${rc.ra.deals||0}`); else { const bits = Object.keys(rc.changed).map(f => { const c = rc.changed[f]; if (f === "prize") return `prize $${(c.from||0).toFixed(2)} → $${(c.to||0).toFixed(2)}`; return `${f} ${JSON.stringify(c.from)} → ${JSON.stringify(c.to)}`; }); L.push(` - Tier ${rc.ra.tier}: ${bits.join(", ")}`); } } } } L.push(``); } if (diff.symChanges.length){ L.push(`## Symbols`); for (const s of diff.symChanges){ if (s.kind === "added") L.push(`- **Added** ${s.id} (weight ${s.b.weight})`); else if (s.kind === "removed") L.push(`- **Removed** ${s.id}`); else { const bits = Object.keys(s.changed).map(f => `${f} ${JSON.stringify(s.changed[f].from)} → ${JSON.stringify(s.changed[f].to)}`); L.push(`- **Changed** ${s.id}: ${bits.join(", ")}`); } } L.push(``); } if (diff.featChanges.length){ L.push(`## Features`); for (const f of diff.featChanges){ if (f.kind === "added") L.push(`- **Added** ${f.key} (${f.b.enabled?"ON":"OFF"})`); else if (f.kind === "removed") L.push(`- **Removed** ${f.key}`); else L.push(`- **Toggled** ${f.key}: ${f.a.enabled?"ON":"OFF"} → ${f.b.enabled?"ON":"OFF"}`); } L.push(``); } return L.join("\n"); } async function renderCompareSection(body, g){ const vers = (g.versions||[]).slice().sort((a,b)=>semverCmp(a.number,b.number)); if (vers.length < 2){ const hint = el("div",{class:"k-panel"}, el("h3",{class:"k-panel-title"},"Compare versions"), el("div",{class:"k-help"},"At least two versions are needed to compare. Bump a new version to start tracking diffs.")); body.appendChild(hint); return; } const panel = el("div",{class:"k-panel"}); panel.appendChild(el("h3",{class:"k-panel-title"},"Compare versions")); const row = el("div",{style:"display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-bottom:10px"}); const selA = el("select"); const selB = el("select"); vers.forEach(v => { [selA,selB].forEach(sel => { const op = el("option",{value:v.number}, v.number + (v.status?` (${v.status})`:"")); sel.appendChild(op); }); }); selA.value = vers[vers.length-2].number; selB.value = vers[vers.length-1].number; row.append( el("span",{},"From"), selA, el("span",{},"→"), selB, el("button",{class:"k-btn primary",onClick:()=>runCompare()},"Compare"), el("button",{class:"k-btn",onClick:()=>downloadCurrent()},"Export CHANGELOG.md"), el("button",{class:"k-btn",onClick:()=>appendToNote()},"Append summary to version note") ); panel.appendChild(row); const out = el("div",{}); panel.appendChild(out); body.appendChild(panel); let lastDiff = null; let lastMd = ""; async function runCompare(){ out.innerHTML = ""; if (selA.value === selB.value){ out.appendChild(el("div",{class:"k-help"},"Pick two different versions.")); lastDiff = null; return; } lastDiff = await buildVersionDiff(g, selA.value, selB.value); lastMd = changelogMarkdown(g, lastDiff); const d = lastDiff; const sum = el("div",{style:"display:flex;gap:12px;flex-wrap:wrap;margin-bottom:10px"}); sum.append( el("span",{class:"k-tag info"},`${d.variantDiffs.length} variant change${d.variantDiffs.length===1?"":"s"}`), el("span",{class:"k-tag info"},`${d.symChanges.length} symbol change${d.symChanges.length===1?"":"s"}`), el("span",{class:"k-tag info"},`${d.featChanges.length} feature change${d.featChanges.length===1?"":"s"}`) ); out.appendChild(sum); if (d.variantDiffs.length){ const t = el("table",{class:"k-table"}); t.innerHTML = `VariantChangeRTP ${d.verA}RTP ${d.verB}Δ RTPRow changes`; const tb = el("tbody"); for (const v of d.variantDiffs){ const tr = el("tr"); const rtpA = v.rtpA!=null?v.rtpA.toFixed(4)+"%":"—"; const rtpB = v.rtpB!=null?v.rtpB.toFixed(4)+"%":"—"; const delta = (v.rtpA!=null&&v.rtpB!=null)?((v.rtpB-v.rtpA>=0?"+":"")+(v.rtpB-v.rtpA).toFixed(4)):"—"; const rcDesc = (v.rowChanges||[]).length ? v.rowChanges.map(rc => { if (rc.kind==="added") return `+row${rc.idx}`; if (rc.kind==="removed") return `-row${rc.idx}`; const keys = Object.keys(rc.changed); return `row${rc.idx}:${keys.join(",")}`; }).join(" · ") : "—"; const cls = v.kind==="added"?"ok":v.kind==="removed"?"warn":"info"; tr.append( el("td",{},el("code",{},v.key)), el("td",{},el("span",{class:"k-tag "+cls},v.kind)), el("td",{},rtpA), el("td",{},rtpB), el("td",{},delta), el("td",{},rcDesc) ); tb.appendChild(tr); } t.appendChild(tb); out.appendChild(t); for (const v of d.variantDiffs){ if (v.kind !== "changed" || !v.rowChanges.length) continue; const sub = el("div",{class:"k-panel",style:"margin-top:10px"}); sub.appendChild(el("h3",{class:"k-panel-title"},`Variant ${v.key}`)); const st = el("table",{class:"k-table"}); st.innerHTML = `TierField${d.verA}${d.verB}Δ`; const stb = el("tbody"); for (const rc of v.rowChanges){ if (rc.kind === "added"){ stb.appendChild(rowTr(rc.rb.tier, "(new row)", "—", `prize=$${rc.rb.prize} deals=${rc.rb.deals}`, "+")); } else if (rc.kind === "removed"){ stb.appendChild(rowTr(rc.ra.tier, "(row removed)", `prize=$${rc.ra.prize} deals=${rc.ra.deals}`, "—", "−")); } else { for (const f of Object.keys(rc.changed)){ const c = rc.changed[f]; let dlt = ""; if (f === "prize") dlt = ((c.to||0)-(c.from||0)>=0?"+":"")+((c.to||0)-(c.from||0)).toFixed(2); else if (f === "deals" || f === "multiplier") dlt = ((c.to||0)-(c.from||0)>=0?"+":"")+((c.to||0)-(c.from||0)); else dlt = "—"; stb.appendChild(rowTr(rc.ra.tier, f, JSON.stringify(c.from), JSON.stringify(c.to), dlt)); } } } st.appendChild(stb); sub.appendChild(st); out.appendChild(sub); } } if (d.symChanges.length){ const t = el("table",{class:"k-table",style:"margin-top:10px"}); t.innerHTML = `SymbolChange${d.verA}${d.verB}`; const tb = el("tbody"); for (const s of d.symChanges){ const cls = s.kind==="added"?"ok":s.kind==="removed"?"warn":"info"; const fromStr = s.a?`w=${s.a.weight} name=${s.a.name||""}`:"—"; const toStr = s.b?`w=${s.b.weight} name=${s.b.name||""}`:"—"; const tr = el("tr"); tr.append(el("td",{},s.id), el("td",{},el("span",{class:"k-tag "+cls},s.kind)), el("td",{},fromStr), el("td",{},toStr)); tb.appendChild(tr); } t.appendChild(tb); out.appendChild(el("h3",{class:"k-panel-title",style:"margin-top:8px"},"Symbols")); out.appendChild(t); } if (d.featChanges.length){ const t = el("table",{class:"k-table",style:"margin-top:10px"}); t.innerHTML = `FeatureChange${d.verA}${d.verB}`; const tb = el("tbody"); for (const f of d.featChanges){ const cls = f.kind==="added"?"ok":f.kind==="removed"?"warn":"info"; const fromStr = f.a?(f.a.enabled?"ON":"OFF"):"—"; const toStr = f.b?(f.b.enabled?"ON":"OFF"):"—"; const tr = el("tr"); tr.append(el("td",{},f.key), el("td",{},el("span",{class:"k-tag "+cls},f.kind)), el("td",{},fromStr), el("td",{},toStr)); tb.appendChild(tr); } t.appendChild(tb); out.appendChild(el("h3",{class:"k-panel-title",style:"margin-top:8px"},"Features")); out.appendChild(t); } if (!d.variantDiffs.length && !d.symChanges.length && !d.featChanges.length){ out.appendChild(el("div",{class:"k-help"},`No math changes detected between ${d.verA} and ${d.verB}.`)); } } function rowTr(tier, field, a, b, delta){ const tr = el("tr"); tr.append(el("td",{},String(tier)), el("td",{},field), el("td",{},a), el("td",{},b), el("td",{},delta)); return tr; } function downloadCurrent(){ if (!lastDiff){ toast("Run Compare first","warn"); return; } const blob = new Blob([lastMd], {type:"text/markdown"}); const url = URL.createObjectURL(blob); const a = el("a",{href:url, download:`CHANGELOG_${(g.code||g.name||"game").replace(/\W+/g,"_")}_${lastDiff.verA}_to_${lastDiff.verB}.md`}); document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); audit("export","changelog",`CHANGELOG ${lastDiff.verA}→${lastDiff.verB}`); toast("Downloaded CHANGELOG.md","ok"); } async function appendToNote(){ if (!lastDiff){ toast("Run Compare first","warn"); return; } const target = (g.versions||[]).find(v => v.number === lastDiff.verB); if (!target){ toast("Target version not found","warn"); return; } const head = `Auto-summary ${lastDiff.verA} → ${lastDiff.verB}: ${lastDiff.variantDiffs.length} variant / ${lastDiff.symChanges.length} symbol / ${lastDiff.featChanges.length} feature change(s)`; target.note = target.note ? (target.note+" · "+head) : head; await dbPut("games", g); audit("edit","versions",`Appended auto-summary to ${target.number}`); toast("Note updated","ok"); renderMain(); } } /* --- Org / Vault / Audit (real pages, no alerts) --- */ views.org = async (body,actions)=>{ const orgs=await dbAll("orgs"); const o=orgs[0]; actions.appendChild(el("button",{class:"k-btn",onClick:async()=>{ if(!confirm("Wipe all data and re-seed? This cannot be undone."))return; await dbClearAll(); state.currentGameId=null; location.reload(); }},"Reset all data")); if(!o){ body.appendChild(emptyState( "No organization record", "The seed step hasn't populated the orgs store. Use \"Reset all data\" above to re-seed.", null )); return; } const panel=el("div",{class:"k-panel"}); panel.appendChild(el("h3",{class:"k-panel-title"},"Organization")); panel.appendChild(kv("Name",o.name)); panel.appendChild(kv("Contact",o.contactName)); panel.appendChild(kv("Email",o.contactEmail)); body.appendChild(panel); const users=await dbAll("users"); const up=el("div",{class:"k-panel"}); up.appendChild(el("h3",{class:"k-panel-title"},"Members")); const t=el("table",{class:"k-table"}); t.innerHTML=`NameEmailRole`; const tb=el("tbody"); for(const u of users){ const tr=el("tr"); tr.append(el("td",{},u.name),el("td",{},u.email),el("td",{},el("span",{class:"k-tag info"},u.role))); tb.appendChild(tr); } t.appendChild(tb);up.appendChild(t);body.appendChild(up); const sec=el("div",{class:"k-panel"}); sec.appendChild(el("h3",{class:"k-panel-title"},"Authentication")); sec.appendChild(kv("Mode","Local + nginx HTTP Basic (when deployed)")); sec.appendChild(kv("Session",state.user.email)); sec.appendChild(el("div",{style:"margin-top:12px"}, el("button",{class:"k-btn",onClick:()=>{localStorage.removeItem("kobow_user_ilottery");location.reload()}},"Sign out") )); body.appendChild(sec); }; views.vault = async (body,actions)=>{ const g=await currentGame(); const tiers=g?await tiersForCurrent():[]; const syms=g?await symbolsForCurrent():[]; const feats=g?await featuresForCurrent():[]; const dels=g?await deliveriesForCurrent():[]; const panel=el("div",{class:"k-panel"}); panel.appendChild(el("h3",{class:"k-panel-title"},"Vault")); panel.appendChild(el("p",{style:"color:var(--k-muted);font-size:13px;margin-top:0"},"Seeds, paytables, and delivery packages for the current game.")); if(!g){panel.appendChild(el("p",{},"Select a game to view vault contents."));body.appendChild(panel);return} const list=el("div"); const items=[ {label:"Game record",count:1,size:JSON.stringify(g).length}, {label:"Paytable variants",count:tiers.length,size:JSON.stringify(tiers).length}, {label:"Symbols",count:(syms[0]?.symbols.length||0),size:JSON.stringify(syms).length}, {label:"Features",count:(feats[0]?.features.length||0),size:JSON.stringify(feats).length}, {label:"Deliveries",count:dels.length,size:JSON.stringify(dels).length} ]; const t=el("table",{class:"k-table"}); t.innerHTML=`ArtifactCountApprox size`; const tb=el("tbody"); for(const it of items){ const tr=el("tr"); tr.append(el("td",{},it.label),el("td",{},it.count.toString()),el("td",{},fmtBytes(it.size))); tb.appendChild(tr); } t.appendChild(tb);panel.appendChild(t);body.appendChild(panel); const ex=el("div",{class:"k-panel"}); ex.appendChild(el("h3",{class:"k-panel-title"},"Export")); ex.appendChild(el("button",{class:"k-btn",onClick:exportAll},"Download full vault (JSON)")); body.appendChild(ex); }; views.audit = async (body,actions)=>{ const rows=(await dbAll("audit")).slice(-200).reverse(); const panel=el("div",{class:"k-panel"}); panel.appendChild(el("h3",{class:"k-panel-title"},`Audit log · ${rows.length} most recent`)); const t=el("table",{class:"k-table"}); t.innerHTML=`WhenActorActionTargetDetail`; const tb=el("tbody"); for(const a of rows){ const tr=el("tr"); tr.append( el("td",{},fmtDate(a.ts)), el("td",{},a.actor||""), el("td",{},el("span",{class:"k-tag info"},a.action)), el("td",{},a.target||""), el("td",{},typeof a.payload==="string"?a.payload:JSON.stringify(a.payload||"")) ); tb.appendChild(tr); } t.appendChild(tb);panel.appendChild(t);body.appendChild(panel); }; /* ============================================================ MATH STUDIO — SVG helpers & editor views ============================================================ */ /* ---- SVG helpers ---- */ const SVG="http://www.w3.org/2000/svg"; function svg(attrs={}){ const s=document.createElementNS(SVG,"svg"); for(const k in attrs)s.setAttribute(k,attrs[k]); return s; } function svgEl(tag,attrs={}){ const n=document.createElementNS(SVG,tag); for(const k in attrs){ if(k.startsWith("on")){n.addEventListener(k.slice(2).toLowerCase(),attrs[k])} else n.setAttribute(k,attrs[k]); } return n; } function svgText(x,y,text,cls="",extra={}){ const t=svgEl("text",{x,y,class:cls,...extra}); t.textContent=text;return t; } /* ---- Deterministic RNG (Mulberry32) ---- */ function mulberry32(seed){ let a=seed>>>0; return function(){ a|=0;a=(a+0x6D2B79F5)|0; let t=a; t=Math.imul(t^t>>>15,t|1); t^=t+Math.imul(t^t>>>7,t|61); return ((t^t>>>14)>>>0)/4294967296; }; } function shuffleInPlace(arr,rng){ for(let i=arr.length-1;i>0;i--){ const j=Math.floor(rng()*(i+1)); [arr[i],arr[j]]=[arr[j],arr[i]]; } return arr; } function buildPool(variant){ const pool=[]; for(const r of variant.rows){ for(let i=0;i uint32 seed let h=0x811C9DC5; for(let i=0;i>>0; } return h>>>0; } function validationCode(seedInt,serial,tier,prize){ // Deterministic per-ticket validation code (not cryptographically secret; treat as // a legibility-oriented integrity tag). Mulberry32 mixed with serial + payload. const rng=mulberry32(seedInt ^ hashSeed(serial+"|"+tier+"|"+prize)); // 4 groups of 4 uppercase alphanum for readability const alpha="ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/1/I/O const groups=[]; for(let g=0;g<4;g++){ let s=""; for(let i=0;i<4;i++)s+=alpha[Math.floor(rng()*alpha.length)]; groups.push(s); } return groups.join("-"); } function batchHash(tickets){ // Rolling FNV-1a over concatenated (serial|tier|prize|validation) for batch integrity. let h=0x811C9DC5; for(const t of tickets){ const s=`${t.serial}|${t.tier}|${t.prize}|${t.validation}`; for(let i=0;i>>0;} } return ("00000000"+h.toString(16)).slice(-8).toUpperCase(); } function pad(n,w){const s=""+n;return s.length>=w?s:"0".repeat(w-s.length)+s;} function generateTickets(variant, cfg){ // cfg: {prefix, bookSize, startBook, seed} const pool=buildPool(variant); const total=pool.length; if(!total)return {tickets:[],summary:{totalTickets:0}}; const seedStr=(cfg.seed&&cfg.seed.length?cfg.seed:"kobow-"+Date.now()); const seedInt=hashSeed(seedStr); const rng=mulberry32(seedInt); shuffleInPlace(pool,rng); const bookSize=Math.max(1, cfg.bookSize|0); const startBook=Math.max(1, cfg.startBook|0); const prefix=(cfg.prefix||"KOBOW").toUpperCase().replace(/[^A-Z0-9-]/g,""); const digits=Math.max(6, String(total).length); const tickets=new Array(total); let winners=0, losers=0, payout=0, biggest=0; for(let i=0;ibiggest)biggest=p.prize;} else losers++; } const wager=total*variant.denom; const rtp=wager>0?(payout/wager*100):0; const hash=batchHash(tickets); return { tickets, summary:{ totalTickets:total, totalPayout:+payout.toFixed(2), wager:+wager.toFixed(2), rtp:+rtp.toFixed(3), winners, losers, biggestPrize:+biggest.toFixed(2), batchHash:hash, seed:seedStr } }; } function ticketsToCSV(tickets, meta){ const hdr=[ `# Kobow Finite Pool — Ticket batch export`, `# Game: ${meta.gameCode} (${meta.gameName})`, `# Version: ${meta.version}`, `# Variant: ${meta.volatility} / $${meta.denom.toFixed(2)}`, `# Seed: ${meta.seed}`, `# Batch hash: ${meta.batchHash}`, `# Generated: ${meta.createdAt}`, `# Total tickets: ${meta.totalTickets} — Winners: ${meta.winners} — RTP: ${meta.rtp}%`, `` ].join("\n"); const cols=["serial","book","position","tier","type","prize","validation"]; const rows=[cols.join(",")]; for(const t of tickets){ rows.push([t.serial,t.book,t.position,t.tier,t.type,t.prize.toFixed(2),t.validation].join(",")); } return hdr+rows.join("\n")+"\n"; } function downloadBlob(filename, content, mime){ const blob=new Blob([content],{type:mime||"application/octet-stream"}); const url=URL.createObjectURL(blob); const a=document.createElement("a"); a.href=url;a.download=filename; document.body.appendChild(a);a.click();a.remove(); setTimeout(()=>URL.revokeObjectURL(url),2000); } /* ---- Selector for current paytable variant ---- */ async function pickVariant(ctxId){ const tiers=await tiersForCurrent(); if(!tiers.length)return null; // remember selection per-view in sessionStorage const stored=parseInt(sessionStorage.getItem("variantSel:"+ctxId)||"0"); return tiers[Math.min(stored,tiers.length-1)]; } function variantTabs(parent,tiers,currentIdx,onPick,ctxId){ const tabs=el("div",{class:"k-tabs"}); tiers.forEach((t,i)=>{ const btn=el("button",{ class:"k-tab"+(i===currentIdx?" active":""), onClick:()=>{ sessionStorage.setItem("variantSel:"+ctxId,i.toString()); onPick(i); } },`${t.volatility} · $${t.denom.toFixed(2)}`); tabs.appendChild(btn); }); parent.appendChild(tabs); } /* ============================================================ 1. Paytable designer (drag bars) ============================================================ */ views["paytable-designer"] = async (body,actions)=>{ const g=await currentGame(); if(!g){body.appendChild(emptyState("No game selected","",null));return} const tiers=await tiersForCurrent(); if(!tiers.length){body.appendChild(emptyState("No paytables","Add a variant first.",null));return} let idx=parseInt(sessionStorage.getItem("variantSel:pd")||"0"); idx=Math.min(idx,tiers.length-1); let v=structuredClone(tiers[idx]); let lockTotal=true; const tabsHost=el("div");body.appendChild(tabsHost); const panel=el("div",{class:"k-panel"});body.appendChild(panel); actions.appendChild(el("label",{style:"font-size:12px;color:var(--k-muted);margin-right:8px"}, el("input",{type:"checkbox",checked:!!lockTotal,onChange:e=>{lockTotal=e.target.checked;redraw()}}), " Lock total deals" )); actions.appendChild(el("button",{class:"k-btn",onClick:()=>{ v=structuredClone(tiers[idx]);redraw(); }},"Reset")); actions.appendChild(el("button",{class:"k-btn primary",onClick:async()=>{ await dbPut("tiers",{...tiers[idx],rows:v.rows}); tiers[idx].rows=structuredClone(v.rows); state.lastSaveAt=Date.now(); await audit("edit","tiers","Paytable designer save "+v.volatility+"/"+v.denom); toast("Saved","ok");renderStatusbar(); }},"Save")); function redrawTabs(){ tabsHost.innerHTML=""; variantTabs(tabsHost,tiers,idx,(i)=>{idx=i;v=structuredClone(tiers[i]);redraw()},"pd"); } function redraw(){ redrawTabs(); panel.innerHTML=""; const total=v.rows.reduce((a,r)=>a+r.deals,0); const winners=v.rows.filter(r=>r.type==="WIN").reduce((a,r)=>a+r.deals,0); const wager=total*v.denom; const rtp=wager>0?v.rows.reduce((a,r)=>a+r.prize*r.deals,0)/wager*100:0; const hit=total>0?winners/total*100:0; // Metrics const m=el("div",{class:"k-grid-4",style:"margin-bottom:18px"}); m.append( metric("RTP",rtp.toFixed(4)+"%",rtp<85?"below band":rtp>90?"above band":"in band"), metric("Hit rate",hit.toFixed(2)+"%",null), metric("Total deals",total.toLocaleString(),lockTotal?"locked":"free"), metric("Top prize","$"+Math.max(...v.rows.map(r=>r.prize)).toFixed(2),null) ); panel.appendChild(m); // SVG bar chart const W=820,H=360,PAD_L=56,PAD_R=24,PAD_T=20,PAD_B=42; const bars=v.rows.slice().sort((a,b)=>b.tier-a.tier); const maxDeals=Math.max(...bars.map(r=>r.deals),1); const barW=(W-PAD_L-PAD_R)/bars.length; const chartH=H-PAD_T-PAD_B; const s=svg({viewBox:`0 0 ${W} ${H}`,style:"width:100%;height:auto;user-select:none",xmlns:SVG}); // y-axis grid for(let i=0;i<=5;i++){ const y=PAD_T+chartH-(chartH*i/5); s.appendChild(svgEl("line",{x1:PAD_L,y1:y,x2:W-PAD_R,y2:y,stroke:"#eee",class:"grid"})); const lbl=svgText(PAD_L-6,y+3,Math.round(maxDeals*i/5).toLocaleString(),"",{"text-anchor":"end","font-size":"10","fill":"#999","font-family":"JetBrains Mono, monospace"}); s.appendChild(lbl); } bars.forEach((r,i)=>{ const x=PAD_L+i*barW+barW*0.1; const bw=barW*0.8; const bh=chartH*(r.deals/maxDeals); const y=PAD_T+chartH-bh; const color=r.type==="WIN"?(r.tier>=6?"#f97316":r.tier>=3?"#fb923c":"#fdba74"):"#cbd5e1"; const rect=svgEl("rect",{x,y,width:bw,height:bh,fill:color,rx:2,style:"cursor:ns-resize"}); const handle=svgEl("rect",{x,y:y-3,width:bw,height:6,fill:"#11131a",opacity:"0",style:"cursor:ns-resize"}); // drag let startY=0,startDeals=0; function onMove(e){ const dy=startY-(e.clientY||e.touches[0].clientY); const dealsPerPx=maxDeals/chartH; let newDeals=Math.max(0,Math.round(startDeals+dy*dealsPerPx)); // if lockTotal, redistribute to the loser tier (tier 0) or first loser row const diff=newDeals-r.deals; if(lockTotal){ const loser=v.rows.find(x=>x.type==="LOSE")||v.rows[v.rows.length-1]; if(loser===r)return; // can't adjust loser directly when locked if(loser.deals-diff<0){ newDeals=r.deals+loser.deals; loser.deals=0; } else { loser.deals-=diff; } } r.deals=newDeals; redraw(); } function onUp(){ window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); window.removeEventListener("touchmove",onMove); window.removeEventListener("touchend",onUp); } function onDown(e){ e.preventDefault(); startY=e.clientY||e.touches[0].clientY; startDeals=r.deals; window.addEventListener("mousemove",onMove); window.addEventListener("mouseup",onUp); window.addEventListener("touchmove",onMove,{passive:false}); window.addEventListener("touchend",onUp); } rect.addEventListener("mousedown",onDown); rect.addEventListener("touchstart",onDown,{passive:false}); handle.addEventListener("mousedown",onDown); s.appendChild(rect); s.appendChild(handle); // top label (deals) s.appendChild(svgText(x+bw/2,Math.max(y-6,PAD_T+10),r.deals.toLocaleString(),"",{"text-anchor":"middle","font-size":"10","fill":"#11131a","font-family":"JetBrains Mono, monospace"})); // prize label s.appendChild(svgText(x+bw/2,PAD_T+chartH+14,"$"+(r.prize>=100?r.prize.toFixed(0):r.prize.toFixed(2)),"",{"text-anchor":"middle","font-size":"10","fill":"#6b7280"})); // tier label s.appendChild(svgText(x+bw/2,PAD_T+chartH+30,"T"+r.tier,"",{"text-anchor":"middle","font-size":"10","fill":"#11131a","font-weight":"600"})); }); // axis line s.appendChild(svgEl("line",{x1:PAD_L,y1:PAD_T+chartH,x2:W-PAD_R,y2:PAD_T+chartH,stroke:"#11131a"})); panel.appendChild(s); panel.appendChild(el("div",{style:"margin-top:8px;font-size:11.5px;color:var(--k-muted);font-family:var(--k-mono)"}, "Tip: drag any bar vertically to change its deal count. "+(lockTotal?"Loser tier auto-adjusts.":"Total is free."))); renderStatusbar(); } redraw(); }; /* ============================================================ 2. Volatility compare ============================================================ */ views["volatility-compare"] = async (body,actions)=>{ const g=await currentGame(); if(!g){body.appendChild(emptyState("No game selected","",null));return} const tiers=await tiersForCurrent(); if(tiers.length<1){body.appendChild(emptyState("No paytables","Add variants to compare.",null));return} // default: all variants selected let sel = tiers.map((_,i)=>i); const tools=el("div",{style:"display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px"}); tiers.forEach((t,i)=>{ const chip=el("label",{class:"k-tag "+(sel.includes(i)?"accent":""),style:"cursor:pointer"}, el("input",{type:"checkbox",checked:sel.includes(i)?"checked":null,style:"margin-right:4px",onChange:e=>{ if(e.target.checked){if(!sel.includes(i))sel.push(i)}else{sel=sel.filter(x=>x!==i)} redraw(); }}), `${t.volatility} · $${t.denom.toFixed(2)}`); tools.appendChild(chip); }); body.appendChild(tools); const grid=el("div",{class:"k-grid-2"});body.appendChild(grid); const palette=["#f97316","#3b82f6","#10b981","#ef4444","#8b5cf6","#f59e0b","#ec4899","#14b8a6","#64748b"]; function redraw(){ grid.innerHTML=""; if(!sel.length){grid.appendChild(emptyState("Pick at least one variant","",null));return} const chosen=sel.map(i=>tiers[i]); // Chart 1: Deals per tier (log-ish) grid.appendChild(makeLinePanel("Deal count by tier",chosen,(t,r)=>r.deals,palette,"deals","log")); // Chart 2: Prize by tier grid.appendChild(makeLinePanel("Prize ($) by tier",chosen,(t,r)=>r.prize,palette,"prize","log")); // Chart 3: Prize share per tier (% of total payout) grid.appendChild(makeLinePanel("Prize share by tier",chosen,(t,r)=>{ const total=t.rows.reduce((a,x)=>a+x.prize*x.deals,0); return total>0?(r.prize*r.deals/total*100):0; },palette,"share","linear")); // Chart 4: RTP bar per variant grid.appendChild(makeBarPanel("RTP by variant",chosen,t=>tierRTP(t),palette,"RTP %")); } function makeLinePanel(title,variants,yFn,palette,units,scale){ const panel=el("div",{class:"k-panel"}); panel.appendChild(el("h3",{class:"k-panel-title"},title)); const W=420,H=240,PL=44,PR=12,PT=10,PB=34; const allTiers=new Set(); variants.forEach(v=>v.rows.forEach(r=>allTiers.add(r.tier))); const tiersArr=Array.from(allTiers).sort((a,b)=>b-a); let maxY=0,minY=Infinity; variants.forEach(v=>v.rows.forEach(r=>{ const y=yFn(v,r); if(y>maxY)maxY=y; if(y>0 && y0 && maxY/minY>50; const chartW=W-PL-PR,chartH=H-PT-PB; const x=i=>PL+(tiersArr.length>1?i*chartW/(tiersArr.length-1):chartW/2); const y=v=>{ if(v<=0)return PT+chartH; if(logScale){ const lv=Math.log10(v),lmin=Math.log10(minY||0.01),lmax=Math.log10(maxY||1); return PT+chartH-((lv-lmin)/(lmax-lmin))*chartH; } return PT+chartH-(v/(maxY||1))*chartH; }; const s=svg({viewBox:`0 0 ${W} ${H}`,style:"width:100%;height:auto"}); // grid for(let i=0;i<=4;i++){ const yy=PT+chartH*i/4; s.appendChild(svgEl("line",{x1:PL,y1:yy,x2:W-PR,y2:yy,stroke:"#f0f0f0"})); } // axes s.appendChild(svgEl("line",{x1:PL,y1:PT+chartH,x2:W-PR,y2:PT+chartH,stroke:"#999"})); // tier labels tiersArr.forEach((t,i)=>{ s.appendChild(svgText(x(i),PT+chartH+16,"T"+t,"",{"text-anchor":"middle","font-size":"9","fill":"#6b7280"})); }); // y-axis max label s.appendChild(svgText(PL-4,PT+10,logScale?maxY.toFixed(1):maxY.toFixed(units==="share"?1:0),"",{"text-anchor":"end","font-size":"9","fill":"#6b7280","font-family":"JetBrains Mono, monospace"})); s.appendChild(svgText(PL-4,PT+chartH-2,logScale?minY.toFixed(2):"0","",{"text-anchor":"end","font-size":"9","fill":"#6b7280","font-family":"JetBrains Mono, monospace"})); variants.forEach((v,vi)=>{ const color=palette[vi%palette.length]; const pts=tiersArr.map((t,i)=>{ const row=v.rows.find(r=>r.tier===t); return {i,x:x(i),y:y(row?yFn(v,row):0),v:row?yFn(v,row):0}; }); const path=pts.map((p,i)=>(i===0?"M":"L")+p.x+","+p.y).join(""); s.appendChild(svgEl("path",{d:path,stroke:color,fill:"none","stroke-width":"1.75"})); pts.forEach(p=>s.appendChild(svgEl("circle",{cx:p.x,cy:p.y,r:3,fill:color}))); }); // legend const legend=el("div",{style:"display:flex;gap:12px;flex-wrap:wrap;margin-top:6px;font-size:11px"}); variants.forEach((v,vi)=>{ const chip=el("span",{style:"display:inline-flex;align-items:center;gap:4px"}, el("span",{style:`display:inline-block;width:10px;height:10px;background:${palette[vi%palette.length]};border-radius:2px`}), `${v.volatility} · $${v.denom.toFixed(2)}` ); legend.appendChild(chip); }); panel.appendChild(s); panel.appendChild(legend); return panel; } function makeBarPanel(title,variants,yFn,palette,units){ const panel=el("div",{class:"k-panel"}); panel.appendChild(el("h3",{class:"k-panel-title"},title)); const W=420,H=240,PL=48,PR=12,PT=12,PB=60; const chartW=W-PL-PR,chartH=H-PT-PB; const vals=variants.map(v=>yFn(v)); const maxV=Math.max(...vals,1); const barW=chartW/variants.length*0.7; const gap=chartW/variants.length*0.3; const s=svg({viewBox:`0 0 ${W} ${H}`,style:"width:100%;height:auto"}); for(let i=0;i<=4;i++){ const yy=PT+chartH*i/4; s.appendChild(svgEl("line",{x1:PL,y1:yy,x2:W-PR,y2:yy,stroke:"#f0f0f0"})); } variants.forEach((v,vi)=>{ const bx=PL+vi*(barW+gap)+gap/2; const vh=chartH*(vals[vi]/maxV); const by=PT+chartH-vh; s.appendChild(svgEl("rect",{x:bx,y:by,width:barW,height:vh,fill:palette[vi%palette.length],rx:2})); s.appendChild(svgText(bx+barW/2,by-4,vals[vi].toFixed(2)+"%","",{"text-anchor":"middle","font-size":"10","fill":"#11131a","font-family":"JetBrains Mono, monospace"})); s.appendChild(svgText(bx+barW/2,PT+chartH+14,v.volatility,"",{"text-anchor":"middle","font-size":"10","fill":"#11131a"})); s.appendChild(svgText(bx+barW/2,PT+chartH+28,"$"+v.denom.toFixed(2),"",{"text-anchor":"middle","font-size":"9","fill":"#6b7280"})); }); s.appendChild(svgEl("line",{x1:PL,y1:PT+chartH,x2:W-PR,y2:PT+chartH,stroke:"#999"})); panel.appendChild(s); return panel; } redraw(); }; /* ============================================================ 3. Symbol wheel ============================================================ */ views["symbol-wheel"] = async (body,actions)=>{ const g=await currentGame(); if(!g){body.appendChild(emptyState("No game selected","",null));return} const recs=await symbolsForCurrent(); const rec=recs[0]||{gameId:g.id,version:g.currentVersion,symbols:[{id:"S1",name:"A",weight:30},{id:"S2",name:"B",weight:30},{id:"S3",name:"C",weight:40}]}; if(!rec.symbols.length){body.appendChild(emptyState("No symbols","Add symbols first in the Symbols view.",null));return} actions.appendChild(el("button",{class:"k-btn primary",onClick:async()=>{ if(rec.id)await dbPut("symbols",rec);else await dbAdd("symbols",rec); state.lastSaveAt=Date.now(); await audit("edit","symbols","Symbol wheel save"); toast("Saved","ok");renderStatusbar(); }},"Save")); const two=el("div",{class:"k-grid-2"});body.appendChild(two); const wheelPanel=el("div",{class:"k-panel"}); const listPanel=el("div",{class:"k-panel"}); two.append(wheelPanel,listPanel); const palette=["#f97316","#3b82f6","#10b981","#ef4444","#8b5cf6","#f59e0b","#ec4899","#14b8a6","#64748b","#84cc16","#06b6d4"]; function redraw(){ wheelPanel.innerHTML=""; listPanel.innerHTML=""; wheelPanel.appendChild(el("h3",{class:"k-panel-title"},"Weight wheel")); const W=360,H=360,CX=W/2,CY=H/2,R=140,INNER=75; const s=svg({viewBox:`0 0 ${W} ${H}`,style:"width:100%;height:auto;user-select:none"}); const total=rec.symbols.reduce((a,x)=>a+x.weight,0)||1; let a0=-Math.PI/2; const arcs=[]; rec.symbols.forEach((sym,i)=>{ const frac=sym.weight/total; const a1=a0+frac*Math.PI*2; const color=palette[i%palette.length]; const path=arcPath(CX,CY,R,INNER,a0,a1); const p=svgEl("path",{d:path,fill:color,opacity:"0.92",stroke:"#fff","stroke-width":"2"}); s.appendChild(p); const mid=(a0+a1)/2; const lx=CX+Math.cos(mid)*(R+INNER)/2; const ly=CY+Math.sin(mid)*(R+INNER)/2; s.appendChild(svgText(lx,ly,sym.name,"",{"text-anchor":"middle","font-size":"12","font-weight":"600","fill":"#fff","pointer-events":"none"})); s.appendChild(svgText(lx,ly+14,(frac*100).toFixed(1)+"%","",{"text-anchor":"middle","font-size":"10","fill":"#fff","pointer-events":"none"})); arcs.push({sym,a0,a1,i}); a0=a1; }); // draggable radial edges (between arcs) arcs.forEach((arc,i)=>{ const nextArc=arcs[(i+1)%arcs.length]; const sep=arc.a1; const hx=CX+Math.cos(sep)*R; const hy=CY+Math.sin(sep)*R; const handle=svgEl("circle",{cx:hx,cy:hy,r:6,fill:"#fff",stroke:"#11131a","stroke-width":"2",style:"cursor:move"}); let start=0; function onMove(ev){ const rect=s.getBoundingClientRect(); const sx=rect.left+rect.width/2,sy=rect.top+rect.height/2; const mx=(ev.clientX||ev.touches?.[0]?.clientX)-sx; const my=(ev.clientY||ev.touches?.[0]?.clientY)-sy; const newAng=Math.atan2(my,mx); let delta=newAng-sep; // keep within +/- 75% of current slice const thisW=arc.sym.weight,nextW=nextArc.sym.weight; const thisFracDelta=delta/(Math.PI*2); const transfer=thisFracDelta*total; const newThis=Math.max(1,thisW+transfer); const newNext=Math.max(1,nextW-transfer); arc.sym.weight=Math.round(newThis); nextArc.sym.weight=Math.round(newNext); redraw(); } function onUp(){ window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); } handle.addEventListener("mousedown",ev=>{ ev.preventDefault();start=sep; window.addEventListener("mousemove",onMove); window.addEventListener("mouseup",onUp); }); s.appendChild(handle); }); // center total s.appendChild(svgEl("circle",{cx:CX,cy:CY,r:INNER-2,fill:"#fff"})); s.appendChild(svgText(CX,CY-4,"Total weight","",{"text-anchor":"middle","font-size":"10","fill":"#6b7280"})); s.appendChild(svgText(CX,CY+14,total.toString(),"",{"text-anchor":"middle","font-size":"18","font-weight":"600","fill":"#11131a","font-family":"JetBrains Mono, monospace"})); wheelPanel.appendChild(s); wheelPanel.appendChild(el("div",{style:"margin-top:8px;font-size:11.5px;color:var(--k-muted);font-family:var(--k-mono)"},"Drag the white handles on the ring to rebalance weights.")); // list panel listPanel.appendChild(el("h3",{class:"k-panel-title"},"Symbols")); const t=el("table",{class:"k-table"}); t.innerHTML=`IDNameWeightProbability`; const tb=el("tbody"); rec.symbols.forEach((sym,i)=>{ const tr=el("tr"); tr.append( el("td",{},el("span",{class:"k-tag",style:`background:${palette[i%palette.length]};color:#fff`},sym.id)), editableCell(sym.name,v=>{sym.name=v;redraw()}), editableCell(sym.weight.toString(),v=>{sym.weight=parseInt(v)||0;redraw()}), el("td",{},((sym.weight/total)*100).toFixed(2)+"%") ); tb.appendChild(tr); }); t.appendChild(tb);listPanel.appendChild(t); } function arcPath(cx,cy,rOut,rIn,a0,a1){ const large=(a1-a0)>Math.PI?1:0; const x0=cx+Math.cos(a0)*rOut,y0=cy+Math.sin(a0)*rOut; const x1=cx+Math.cos(a1)*rOut,y1=cy+Math.sin(a1)*rOut; const x2=cx+Math.cos(a1)*rIn,y2=cy+Math.sin(a1)*rIn; const x3=cx+Math.cos(a0)*rIn,y3=cy+Math.sin(a0)*rIn; return `M${x0},${y0} A${rOut},${rOut} 0 ${large} 1 ${x1},${y1} L${x2},${y2} A${rIn},${rIn} 0 ${large} 0 ${x3},${y3} Z`; } redraw(); }; /* ============================================================ 4. Monte Carlo simulator ============================================================ */ views["monte-carlo"] = async (body,actions)=>{ const g=await currentGame(); if(!g){body.appendChild(emptyState("No game selected","",null));return} const tiers=await tiersForCurrent(); if(!tiers.length){body.appendChild(emptyState("No paytables","",null));return} let idx=parseInt(sessionStorage.getItem("variantSel:mc")||"0");idx=Math.min(idx,tiers.length-1); let sessionsN=100,dealsPerSession=200,seed=42; const tabsHost=el("div");body.appendChild(tabsHost); function redrawTabs(){ tabsHost.innerHTML=""; variantTabs(tabsHost,tiers,idx,i=>{idx=i;renderForm()},"mc"); } const formHost=el("div",{class:"k-panel"});body.appendChild(formHost); const resultHost=el("div");body.appendChild(resultHost); function renderForm(){ redrawTabs(); formHost.innerHTML=""; formHost.appendChild(el("h3",{class:"k-panel-title"},"Configuration")); const row=(label,inp)=>{const r=el("div",{class:"k-form-row"});r.append(el("label",{},label),inp);return r}; const inpN=el("input",{type:"number",value:sessionsN,min:"1",max:"10000"}); const inpD=el("input",{type:"number",value:dealsPerSession,min:"1",max:"100000"}); const inpSeed=el("input",{type:"number",value:seed}); formHost.append(row("Sessions (N)",inpN),row("Deals per session",inpD),row("RNG seed",inpSeed)); const go=el("button",{class:"k-btn accent",onClick:()=>{ sessionsN=parseInt(inpN.value)||100; dealsPerSession=parseInt(inpD.value)||200; seed=parseInt(inpSeed.value)||42; run(); }},"▶ Run simulation"); formHost.appendChild(el("div",{style:"margin-top:6px"},go)); } function run(){ const v=tiers[idx]; const pool=buildPool(v); const poolSize=pool.length; const rng=mulberry32(seed); resultHost.innerHTML=""; const progPanel=el("div",{class:"k-panel"}); progPanel.appendChild(el("h3",{class:"k-panel-title"},`Running ${sessionsN} sessions × ${dealsPerSession} deals`)); const pbar=el("div",{style:"height:6px;background:#eee;border-radius:3px;overflow:hidden"}); const pfill=el("div",{style:"height:100%;width:0;background:var(--k-accent);transition:width .08s"}); pbar.appendChild(pfill);progPanel.appendChild(pbar); resultHost.appendChild(progPanel); const sessionRTPs=new Array(sessionsN); const runningRTP=[]; const tierHits=new Array(v.rows.length).fill(0).map(()=>({tier:0,count:0})); v.rows.forEach((r,i)=>tierHits[i].tier=r.tier); let totalWager=0,totalPayout=0; let si=0; function step(){ const batch=Math.min(Math.ceil(sessionsN/50),sessionsN-si); for(let k=0;kt.tier===pick.tier); if(th)th.count++; } const sr=wager>0?payout/wager*100:0; sessionRTPs[si]=sr; totalWager+=wager;totalPayout+=payout; runningRTP.push(totalPayout/totalWager*100); si++; } pfill.style.width=(si/sessionsN*100)+"%"; // setTimeout instead of requestAnimationFrame: RAF is throttled to 0Hz // in hidden tabs (Chrome), which would freeze the sim mid-run. Task #85. if(sia+b,0)/rtps.length; const sorted=rtps.slice().sort((a,b)=>a-b); const std=Math.sqrt(rtps.reduce((a,b)=>a+(b-mean)**2,0)/rtps.length); const theo=tierRTP(v); const m=el("div",{class:"k-grid-4",style:"margin-bottom:14px"}); m.append( metric("Theoretical RTP",theo.toFixed(4)+"%","paytable exact"), metric("Mean session RTP",mean.toFixed(4)+"%","± "+std.toFixed(3)), metric("Min / Max",sorted[0].toFixed(2)+"% / "+sorted[sorted.length-1].toFixed(2)+"%",""), metric("p5 / p95",sorted[Math.floor(rtps.length*0.05)].toFixed(2)+"% / "+sorted[Math.floor(rtps.length*0.95)].toFixed(2)+"%","") ); resultHost.appendChild(m); const two=el("div",{class:"k-grid-2"}); // Running RTP convergence const p1=el("div",{class:"k-panel"}); p1.appendChild(el("h3",{class:"k-panel-title"},"RTP convergence")); p1.appendChild(lineChart(run,{target:theo,yLabel:"%"})); two.appendChild(p1); // Histogram of session RTPs const p2=el("div",{class:"k-panel"}); p2.appendChild(el("h3",{class:"k-panel-title"},"Session RTP distribution")); p2.appendChild(histogram(rtps,{bins:24,target:theo})); two.appendChild(p2); resultHost.appendChild(two); } renderForm(); }; /* ============================================================ 4b. Player session simulator ============================================================ */ /* player-session:v1 */ views["player-session"] = async (body,actions)=>{ const g = await currentGame(); if(!g){body.appendChild(emptyState("No game selected","",null));return} const tiers = await tiersForCurrent(); if(!tiers.length){body.appendChild(emptyState("No paytables","",null));return} let idx = parseInt(sessionStorage.getItem("variantSel:ps")||"0"); idx = Math.min(idx, tiers.length-1); let budget = 20, goalMult = 2, maxTickets = 400, sessionsN = 2000, seed = 42; const tabsHost = el("div"); body.appendChild(tabsHost); function redrawTabs(){ tabsHost.innerHTML = ""; variantTabs(tabsHost, tiers, idx, i=>{idx=i;renderForm()}, "ps"); } const formHost = el("div",{class:"k-panel"}); body.appendChild(formHost); const resultHost = el("div"); body.appendChild(resultHost); function renderForm(){ redrawTabs(); formHost.innerHTML = ""; formHost.appendChild(el("h3",{class:"k-panel-title"},"Player session")); const v = tiers[idx]; const row = (label,inp,hint) => { const r = el("div",{class:"k-form-row"}); r.append(el("label",{},label), inp); if(hint) r.appendChild(el("span",{class:"k-help",style:"margin-left:8px"},hint)); return r; }; const inpBudget = el("input",{type:"number",value:budget, min:"1", step:"1"}); const inpGoal = el("input",{type:"number",value:goalMult, min:"1.01", step:"0.1"}); const inpMax = el("input",{type:"number",value:maxTickets, min:"1"}); const inpN = el("input",{type:"number",value:sessionsN, min:"50", max:"50000"}); const inpSeed = el("input",{type:"number",value:seed}); formHost.append( row("Session budget ($)", inpBudget, `ticket price = $${v.denom.toFixed(2)}`), row("Goal multiplier", inpGoal, "stop if bankroll ≥ budget × this"), row("Max tickets / session", inpMax, "hard cap to avoid infinite loops"), row("Sessions (N)", inpN, "more = smoother distribution"), row("RNG seed", inpSeed, "") ); const go = el("button",{class:"k-btn accent",onClick:()=>{ budget = parseFloat(inpBudget.value) || 20; goalMult = parseFloat(inpGoal.value) || 2; maxTickets = parseInt(inpMax.value) || 400; sessionsN = parseInt(inpN.value) || 2000; seed = parseInt(inpSeed.value) || 42; run(); }},"▶ Run simulation"); formHost.appendChild(el("div",{style:"margin-top:6px"}, go)); } function run(){ const v = tiers[idx]; const pool = buildPool(v); const poolSize = pool.length; const denom = v.denom; const topPrize = Math.max(...v.rows.map(r=>r.prize)); const rng = mulberry32(seed); resultHost.innerHTML = ""; const progPanel = el("div",{class:"k-panel"}); progPanel.appendChild(el("h3",{class:"k-panel-title"}, `Running ${sessionsN.toLocaleString()} player sessions ($${budget} budget, goal ${goalMult}×)`)); const pbar = el("div",{style:"height:6px;background:#eee;border-radius:3px;overflow:hidden"}); const pfill = el("div",{style:"height:100%;width:0;background:var(--k-accent);transition:width .08s"}); pbar.appendChild(pfill); progPanel.appendChild(pbar); resultHost.appendChild(progPanel); // Collected per session const terminal = new Array(sessionsN); const ticketsPlayed = new Array(sessionsN); const sawTop = new Array(sessionsN); const endedBroke = new Array(sessionsN); const endedGoal = new Array(sessionsN); let si = 0; function step(){ const batch = Math.min(Math.ceil(sessionsN/50), sessionsN-si); for (let k=0; k= denom - 1e-9 && plays < maxTickets && bankroll < goalBankroll){ const pick = local[plays % poolSize]; bankroll = Math.round((bankroll - denom + pick.prize) * 100) / 100; if (pick.prize === topPrize && pick.prize > 0) top = true; plays++; } terminal[si] = bankroll; ticketsPlayed[si] = plays; sawTop[si] = top; endedBroke[si] = bankroll < denom - 1e-9; endedGoal[si] = bankroll >= goalBankroll; si++; } pfill.style.width = (si/sessionsN*100) + "%"; // setTimeout instead of requestAnimationFrame: RAF is throttled to 0Hz // in hidden tabs (Chrome), which would freeze the sim mid-run. Task #85. if (si < sessionsN) setTimeout(step, 0); else renderResults(); } function renderResults(){ resultHost.innerHTML = ""; const mean = terminal.reduce((a,b)=>a+b,0)/terminal.length; const sorted = terminal.slice().sort((a,b)=>a-b); const median = sorted[Math.floor(sorted.length/2)]; const pctPositive = terminal.filter(t => t > budget).length / terminal.length * 100; const pctEven = terminal.filter(t => Math.abs(t - budget) < 0.005).length / terminal.length * 100; const pctBroke = endedBroke.filter(Boolean).length / terminal.length * 100; const pctGoal = endedGoal.filter(Boolean).length / terminal.length * 100; const pctTop = sawTop.filter(Boolean).length / terminal.length * 100; const meanPlays = ticketsPlayed.reduce((a,b)=>a+b,0)/ticketsPlayed.length; const m1 = el("div",{class:"k-grid-4",style:"margin-bottom:14px"}); m1.append( metric("Budget","$"+budget.toFixed(2),`goal ${goalMult}×`), metric("Mean terminal","$"+mean.toFixed(2),`median $${median.toFixed(2)}`), metric("Avg. tickets / session", meanPlays.toFixed(1), `cap ${maxTickets}`), metric("Top-prize hit %", pctTop.toFixed(3)+"%", "any session seeing top") ); resultHost.appendChild(m1); const m2 = el("div",{class:"k-grid-4",style:"margin-bottom:14px"}); m2.append( metric("Hit goal %", pctGoal.toFixed(2)+"%", `≥ $${(budget*goalMult).toFixed(2)}`), metric("Went broke %", pctBroke.toFixed(2)+"%", "could not afford another ticket"), metric("Ended positive", pctPositive.toFixed(2)+"%", "> budget"), metric("Ended even", pctEven.toFixed(2)+"%", "= budget") ); resultHost.appendChild(m2); const two = el("div",{class:"k-grid-2"}); const p1 = el("div",{class:"k-panel"}); p1.appendChild(el("h3",{class:"k-panel-title"},"Terminal bankroll distribution")); p1.appendChild(histogram(terminal,{bins:40,target:budget})); p1.appendChild(el("div",{class:"k-help",style:"margin-top:6px"}, `Red line = starting budget ($${budget.toFixed(2)}). Right of line = player came out ahead.`)); two.appendChild(p1); const p2 = el("div",{class:"k-panel"}); p2.appendChild(el("h3",{class:"k-panel-title"},"Tickets played distribution")); p2.appendChild(histogram(ticketsPlayed,{bins:30})); p2.appendChild(el("div",{class:"k-help",style:"margin-top:6px"}, `How long players lasted before hitting goal / running out / cap.`)); two.appendChild(p2); resultHost.appendChild(two); // Audit log entry audit("simulate","player-session", `variant=${v.volatility}/${v.denom} budget=${budget} goal=${goalMult}x N=${sessionsN} ` + `pctBroke=${pctBroke.toFixed(2)} pctGoal=${pctGoal.toFixed(2)} pctTop=${pctTop.toFixed(3)}`); } step(); } renderForm(); }; function lineChart(arr,opts={}){ const W=420,H=240,PL=42,PR=16,PT=14,PB=28; const s=svg({viewBox:`0 0 ${W} ${H}`,style:"width:100%;height:auto"}); const chartW=W-PL-PR,chartH=H-PT-PB; const max=Math.max(...arr),min=Math.min(...arr); const range=max-min||1; const pad=range*0.1; const yMax=max+pad,yMin=Math.max(0,min-pad); const x=i=>PL+(arr.length>1?i*chartW/(arr.length-1):chartW/2); const y=v=>PT+chartH-((v-yMin)/(yMax-yMin))*chartH; // grid for(let i=0;i<=4;i++){ const yy=PT+chartH*i/4; s.appendChild(svgEl("line",{x1:PL,y1:yy,x2:W-PR,y2:yy,stroke:"#f0f0f0"})); const yv=yMax-(yMax-yMin)*i/4; s.appendChild(svgText(PL-4,yy+3,yv.toFixed(2),"",{"text-anchor":"end","font-size":"9","fill":"#6b7280","font-family":"JetBrains Mono, monospace"})); } // target line if(opts.target!=null && opts.target>=yMin && opts.target<=yMax){ const ty=y(opts.target); s.appendChild(svgEl("line",{x1:PL,y1:ty,x2:W-PR,y2:ty,stroke:"#ef4444","stroke-dasharray":"4,3"})); s.appendChild(svgText(W-PR-2,ty-3,opts.target.toFixed(4)+"% target","",{"text-anchor":"end","font-size":"9","fill":"#ef4444"})); } const path=arr.map((v,i)=>(i===0?"M":"L")+x(i)+","+y(v)).join(""); s.appendChild(svgEl("path",{d:path,stroke:"#f97316",fill:"none","stroke-width":"1.5"})); s.appendChild(svgEl("line",{x1:PL,y1:PT+chartH,x2:W-PR,y2:PT+chartH,stroke:"#999"})); return s; } function histogram(arr,opts={}){ const bins=opts.bins||20; const W=420,H=240,PL=42,PR=12,PT=14,PB=32; const min=Math.min(...arr),max=Math.max(...arr); const w=(max-min)/bins||1; const counts=new Array(bins).fill(0); arr.forEach(v=>{ const b=Math.min(bins-1,Math.floor((v-min)/w)); counts[b]++; }); const maxC=Math.max(...counts); const s=svg({viewBox:`0 0 ${W} ${H}`,style:"width:100%;height:auto"}); const chartW=W-PL-PR,chartH=H-PT-PB; const bw=chartW/bins; counts.forEach((c,i)=>{ const bh=chartH*(c/maxC); s.appendChild(svgEl("rect",{x:PL+i*bw,y:PT+chartH-bh,width:bw*0.9,height:bh,fill:"#f97316",opacity:"0.85"})); }); // target line if(opts.target!=null){ const tx=PL+(opts.target-min)/((max-min)||1)*chartW; s.appendChild(svgEl("line",{x1:tx,y1:PT,x2:tx,y2:PT+chartH,stroke:"#ef4444","stroke-dasharray":"4,3"})); } s.appendChild(svgText(PL,PT+chartH+14,min.toFixed(2)+"%","",{"font-size":"10","fill":"#6b7280"})); s.appendChild(svgText(W-PR,PT+chartH+14,max.toFixed(2)+"%","",{"text-anchor":"end","font-size":"10","fill":"#6b7280"})); s.appendChild(svgEl("line",{x1:PL,y1:PT+chartH,x2:W-PR,y2:PT+chartH,stroke:"#999"})); return s; } /* ============================================================ 5. Bonus routing (Sankey-ish) ============================================================ */ views["bonus-routing"] = async (body,actions)=>{ const g=await currentGame(); if(!g){body.appendChild(emptyState("No game selected","",null));return} const tiers=await tiersForCurrent(); if(!tiers.length){body.appendChild(emptyState("No paytables","",null));return} // routing is stored in game metadata (game.bonusRouting) if(!g.bonusRouting)g.bonusRouting={pool:20,split:{}}; const v=tiers.find(x=>x.volatility==="Med")||tiers[0]; // default split: even across WIN tiers const winners=v.rows.filter(r=>r.type==="WIN"); winners.forEach(r=>{ if(g.bonusRouting.split[r.tier]==null)g.bonusRouting.split[r.tier]=Math.round(100/winners.length); }); actions.appendChild(el("button",{class:"k-btn primary",onClick:async()=>{ await dbPut("games",g);await audit("edit","game","Bonus routing saved"); toast("Saved","ok");state.lastSaveAt=Date.now();renderStatusbar(); }},"Save")); const panel=el("div",{class:"k-panel"});body.appendChild(panel); const controls=el("div",{class:"k-panel"});body.appendChild(controls); function draw(){ panel.innerHTML=""; panel.appendChild(el("h3",{class:"k-panel-title"},"Bonus pool routing")); const total=Object.values(g.bonusRouting.split).reduce((a,b)=>a+b,0)||1; const W=820,H=440,LX=60,RX=760,PADY=20; const s=svg({viewBox:`0 0 ${W} ${H}`,style:"width:100%;height:auto"}); // left = bonus pool const poolH=H-2*PADY; s.appendChild(svgEl("rect",{x:LX-36,y:PADY,width:36,height:poolH,fill:"#11131a"})); s.appendChild(svgText(LX-18,H/2,"BONUS POOL","",{"text-anchor":"middle","fill":"#fff","font-size":"10","font-weight":"600","transform":`rotate(-90 ${LX-18} ${H/2})`})); s.appendChild(svgText(LX-18,PADY-4,g.bonusRouting.pool+"%","",{"text-anchor":"middle","font-size":"10","fill":"#6b7280","font-family":"JetBrains Mono, monospace"})); // right = tiers const rows=winners.slice().sort((a,b)=>b.tier-a.tier); const h=(poolH-(rows.length-1)*6)/rows.length; let poolY=PADY; rows.forEach((r,i)=>{ const rY=PADY+i*(h+6); const frac=(g.bonusRouting.split[r.tier]||0)/total; const ribbonH=poolH*frac; // ribbon path from pool slice (poolY..poolY+ribbonH) to tier box (rY..rY+h) const d=`M${LX},${poolY} C${(LX+RX)/2},${poolY} ${(LX+RX)/2},${rY} ${RX-80},${rY}`+ ` L${RX-80},${rY+h} C${(LX+RX)/2},${rY+h} ${(LX+RX)/2},${poolY+ribbonH} ${LX},${poolY+ribbonH} Z`; const color=r.tier>=6?"#f97316":r.tier>=3?"#fb923c":"#fdba74"; s.appendChild(svgEl("path",{d,fill:color,opacity:"0.7"})); // tier box s.appendChild(svgEl("rect",{x:RX-80,y:rY,width:80,height:h,fill:color})); s.appendChild(svgText(RX-40,rY+h/2+4,"T"+r.tier+" · $"+r.prize,"",{"text-anchor":"middle","fill":"#fff","font-size":"11","font-weight":"600"})); s.appendChild(svgText(RX+4,rY+h/2+4,(frac*100).toFixed(1)+"%","",{"font-size":"11","fill":"#11131a","font-weight":"600","font-family":"JetBrains Mono, monospace"})); poolY+=ribbonH; }); panel.appendChild(s); controls.innerHTML=""; controls.appendChild(el("h3",{class:"k-panel-title"},"Adjust routing (% of bonus deals to each tier)")); const poolRow=el("div",{class:"k-form-row"}); poolRow.append( el("label",{},"Bonus share of deals (%)"), el("input",{type:"number",value:g.bonusRouting.pool,onInput:e=>{g.bonusRouting.pool=parseInt(e.target.value)||0;draw()}}) ); controls.appendChild(poolRow); rows.forEach(r=>{ const row=el("div",{class:"k-form-row"}); const inp=el("input",{type:"number",value:g.bonusRouting.split[r.tier]||0,onInput:e=>{ g.bonusRouting.split[r.tier]=parseInt(e.target.value)||0;draw(); }}); row.append(el("label",{},`T${r.tier} ($${r.prize.toFixed(2)})`),inp); controls.appendChild(row); }); const sum=Object.values(g.bonusRouting.split).reduce((a,b)=>a+b,0); controls.appendChild(el("div",{style:"margin-top:8px;font-size:12px;color:"+(sum===100?"var(--k-ok)":"var(--k-warn)")}, `Sum: ${sum}% ${sum===100?"(balanced)":"(should be 100%)"}`)); } draw(); }; /* ============================================================ 6. Near-miss editor ============================================================ */ views["near-miss"] = async (body,actions)=>{ const g=await currentGame(); if(!g){body.appendChild(emptyState("No game selected","",null));return} const tiers=await tiersForCurrent(); if(!tiers.length){body.appendChild(emptyState("No paytables","",null));return} if(!g.nearMiss)g.nearMiss={cap:0.12,rates:{}}; const v=tiers[0]; v.rows.forEach(r=>{if(g.nearMiss.rates[r.tier]==null)g.nearMiss.rates[r.tier]=r.type==="WIN"?0.05:0}); actions.appendChild(el("button",{class:"k-btn primary",onClick:async()=>{ await dbPut("games",g);await audit("edit","game","Near-miss saved"); toast("Saved","ok");state.lastSaveAt=Date.now();renderStatusbar(); }},"Save")); const panel=el("div",{class:"k-panel"});body.appendChild(panel); const capPanel=el("div",{class:"k-panel"});body.appendChild(capPanel); capPanel.appendChild(el("h3",{class:"k-panel-title"},"Cap")); const capRow=el("div",{class:"k-form-row"}); capRow.append(el("label",{},"Max near-miss rate"), el("input",{type:"number",step:"0.01",value:g.nearMiss.cap,onInput:e=>{g.nearMiss.cap=parseFloat(e.target.value)||0;draw()}}) ); capPanel.appendChild(capRow); function draw(){ panel.innerHTML=""; panel.appendChild(el("h3",{class:"k-panel-title"},"Near-miss rate by tier")); const rows=v.rows.slice().sort((a,b)=>b.tier-a.tier); const W=820,H=360,PL=48,PR=40,PT=20,PB=40; const s=svg({viewBox:`0 0 ${W} ${H}`,style:"width:100%;height:auto;user-select:none"}); const chartW=W-PL-PR,chartH=H-PT-PB; const maxY=Math.max(g.nearMiss.cap*1.5,...Object.values(g.nearMiss.rates)); const x=i=>PL+i*chartW/(rows.length-1||1); const y=v=>PT+chartH-(v/maxY)*chartH; // grid for(let i=0;i<=4;i++){ const yy=PT+chartH*i/4; s.appendChild(svgEl("line",{x1:PL,y1:yy,x2:W-PR,y2:yy,stroke:"#f0f0f0"})); const yv=maxY*(1-i/4); s.appendChild(svgText(PL-4,yy+3,(yv*100).toFixed(1)+"%","",{"text-anchor":"end","font-size":"9","fill":"#6b7280"})); } // cap line const cy=y(g.nearMiss.cap); s.appendChild(svgEl("line",{x1:PL,y1:cy,x2:W-PR,y2:cy,stroke:"#ef4444","stroke-dasharray":"4,3"})); s.appendChild(svgText(W-PR-2,cy-3,"cap "+(g.nearMiss.cap*100).toFixed(2)+"%","",{"text-anchor":"end","font-size":"10","fill":"#ef4444"})); // line const pts=rows.map((r,i)=>({r,i,x:x(i),y:y(g.nearMiss.rates[r.tier]||0)})); const path=pts.map((p,i)=>(i===0?"M":"L")+p.x+","+p.y).join(""); s.appendChild(svgEl("path",{d:path,stroke:"#f97316",fill:"none","stroke-width":"1.75"})); // points (draggable) pts.forEach(p=>{ const c=svgEl("circle",{cx:p.x,cy:p.y,r:7,fill:"#fff",stroke:"#f97316","stroke-width":"2",style:"cursor:ns-resize"}); function onMove(ev){ const rect=s.getBoundingClientRect(); const mY=ev.clientY-rect.top; const svgY=mY/rect.height*H; const ratio=Math.max(0,Math.min(1,(PT+chartH-svgY)/chartH)); const newV=Math.min(g.nearMiss.cap,ratio*maxY); g.nearMiss.rates[p.r.tier]=parseFloat(newV.toFixed(4)); draw(); } function onUp(){window.removeEventListener("mousemove",onMove);window.removeEventListener("mouseup",onUp)} c.addEventListener("mousedown",ev=>{ev.preventDefault();window.addEventListener("mousemove",onMove);window.addEventListener("mouseup",onUp)}); s.appendChild(c); s.appendChild(svgText(p.x,PT+chartH+14,"T"+p.r.tier,"",{"text-anchor":"middle","font-size":"10","fill":"#6b7280"})); s.appendChild(svgText(p.x,Math.max(p.y-10,PT+10),(g.nearMiss.rates[p.r.tier]*100).toFixed(2)+"%","",{"text-anchor":"middle","font-size":"9","fill":"#11131a","font-family":"JetBrains Mono, monospace"})); }); s.appendChild(svgEl("line",{x1:PL,y1:PT+chartH,x2:W-PR,y2:PT+chartH,stroke:"#999"})); panel.appendChild(s); panel.appendChild(el("div",{style:"margin-top:8px;font-size:11.5px;color:var(--k-muted);font-family:var(--k-mono)"},"Drag the dots vertically. Red dashed line is the regulatory cap.")); } draw(); }; /* ============================================================ 7. Prize ladder designer ============================================================ */ views["prize-ladder"] = async (body,actions)=>{ const g=await currentGame(); if(!g){body.appendChild(emptyState("No game selected","",null));return} const tiers=await tiersForCurrent(); if(!tiers.length){body.appendChild(emptyState("No paytables","",null));return} let idx=parseInt(sessionStorage.getItem("variantSel:pl")||"0");idx=Math.min(idx,tiers.length-1); let v=structuredClone(tiers[idx]); const tabsHost=el("div");body.appendChild(tabsHost); const panel=el("div",{class:"k-panel"});body.appendChild(panel); const presetPanel=el("div",{class:"k-panel"});body.appendChild(presetPanel); actions.appendChild(el("button",{class:"k-btn",onClick:()=>{v=structuredClone(tiers[idx]);draw()}},"Reset")); actions.appendChild(el("button",{class:"k-btn primary",onClick:async()=>{ await dbPut("tiers",{...tiers[idx],rows:v.rows}); tiers[idx].rows=structuredClone(v.rows); await audit("edit","tiers","Prize ladder save"); state.lastSaveAt=Date.now();toast("Saved","ok");renderStatusbar(); }},"Save")); function applyPreset(kind){ const winners=v.rows.filter(r=>r.type==="WIN"); const top=Math.max(...winners.map(r=>r.prize)); winners.sort((a,b)=>a.tier-b.tier).forEach((r,i)=>{ const n=winners.length; const t=i/(n-1||1); if(kind==="linear")r.prize=+(v.denom + (top-v.denom)*t).toFixed(2); else if(kind==="exp")r.prize=+(v.denom*Math.pow(top/v.denom,t)).toFixed(2); else if(kind==="power3")r.prize=+(v.denom + (top-v.denom)*Math.pow(t,3)).toFixed(2); else if(kind==="power05")r.prize=+(v.denom + (top-v.denom)*Math.pow(t,0.5)).toFixed(2); }); draw(); } function draw(){ tabsHost.innerHTML=""; variantTabs(tabsHost,tiers,idx,i=>{idx=i;v=structuredClone(tiers[i]);draw()},"pl"); panel.innerHTML=""; panel.appendChild(el("h3",{class:"k-panel-title"},"Prize per tier")); const winners=v.rows.filter(r=>r.type==="WIN").sort((a,b)=>a.tier-b.tier); const W=820,H=360,PL=56,PR=24,PT=18,PB=42; const s=svg({viewBox:`0 0 ${W} ${H}`,style:"width:100%;height:auto;user-select:none"}); const chartW=W-PL-PR,chartH=H-PT-PB; const maxY=Math.max(...winners.map(r=>r.prize))*1.1; const x=i=>PL+i*chartW/(winners.length-1||1); const y=val=>{ if(val<=0)return PT+chartH; // log scale when range is >50x const min=Math.max(0.01,Math.min(...winners.map(r=>r.prize))); if(maxY/min>50){ const lv=Math.log10(val),lmin=Math.log10(min),lmax=Math.log10(maxY); return PT+chartH-((lv-lmin)/(lmax-lmin))*chartH; } return PT+chartH-(val/maxY)*chartH; }; for(let i=0;i<=4;i++){ const yy=PT+chartH*i/4; s.appendChild(svgEl("line",{x1:PL,y1:yy,x2:W-PR,y2:yy,stroke:"#f0f0f0"})); } // path const pts=winners.map((r,i)=>({r,i,x:x(i),y:y(r.prize)})); const path=pts.map((p,i)=>(i===0?"M":"L")+p.x+","+p.y).join(""); s.appendChild(svgEl("path",{d:path,stroke:"#f97316",fill:"none","stroke-width":"2"})); // area const area=path+` L${x(winners.length-1)},${PT+chartH} L${x(0)},${PT+chartH} Z`; s.appendChild(svgEl("path",{d:area,fill:"#f97316",opacity:"0.08"})); // draggable points pts.forEach(p=>{ const c=svgEl("circle",{cx:p.x,cy:p.y,r:7,fill:"#fff",stroke:"#f97316","stroke-width":"2",style:"cursor:ns-resize"}); function onMove(ev){ const rect=s.getBoundingClientRect(); const svgY=(ev.clientY-rect.top)/rect.height*H; const ratio=Math.max(0,Math.min(1,(PT+chartH-svgY)/chartH)); const newV=Math.max(0.01,ratio*maxY); p.r.prize=+newV.toFixed(2); draw(); } function onUp(){window.removeEventListener("mousemove",onMove);window.removeEventListener("mouseup",onUp)} c.addEventListener("mousedown",ev=>{ev.preventDefault();window.addEventListener("mousemove",onMove);window.addEventListener("mouseup",onUp)}); s.appendChild(c); s.appendChild(svgText(p.x,Math.max(p.y-12,PT+12),"$"+p.r.prize.toFixed(2),"",{"text-anchor":"middle","font-size":"10","fill":"#11131a","font-family":"JetBrains Mono, monospace"})); s.appendChild(svgText(p.x,PT+chartH+16,"T"+p.r.tier,"",{"text-anchor":"middle","font-size":"10","fill":"#6b7280"})); }); s.appendChild(svgEl("line",{x1:PL,y1:PT+chartH,x2:W-PR,y2:PT+chartH,stroke:"#999"})); const rtp=tierRTP(v); panel.appendChild(s); panel.appendChild(el("div",{style:"margin-top:8px;display:flex;gap:14px;font-family:var(--k-mono);font-size:12px"}, el("span",{},"RTP "+rtp.toFixed(4)+"%"), el("span",{style:"color:var(--k-muted)"},"· Top prize $"+Math.max(...winners.map(r=>r.prize)).toFixed(2)) )); presetPanel.innerHTML=""; presetPanel.appendChild(el("h3",{class:"k-panel-title"},"Curve presets")); const btns=el("div",{style:"display:flex;gap:8px;flex-wrap:wrap"}); [["linear","Linear"],["exp","Exponential"],["power3","Power (t³) — weighted to top"],["power05","Power (√t) — weighted to bottom"]].forEach(([k,lbl])=>{ btns.appendChild(el("button",{class:"k-btn",onClick:()=>applyPreset(k)},lbl)); }); presetPanel.appendChild(btns); } draw(); }; /* ============================================================ 8. Session replay ============================================================ */ views["session-replay"] = async (body,actions)=>{ const g=await currentGame(); if(!g){body.appendChild(emptyState("No game selected","",null));return} const tiers=await tiersForCurrent(); if(!tiers.length){body.appendChild(emptyState("No paytables","",null));return} let idx=parseInt(sessionStorage.getItem("variantSel:sr")||"0");idx=Math.min(idx,tiers.length-1); let seed=7,speed=30,playing=false,step=0,draws=null,v=null,rtpSeries=[]; const tabsHost=el("div");body.appendChild(tabsHost); const formHost=el("div",{class:"k-panel"});body.appendChild(formHost); const stage=el("div",{class:"k-panel"});body.appendChild(stage); const chartHost=el("div",{class:"k-panel"});body.appendChild(chartHost); function init(){ v=tiers[idx]; const pool=buildPool(v); const rng=mulberry32(seed); shuffleInPlace(pool,rng); draws=pool; step=0;rtpSeries=[]; render(); } function redrawTabs(){ tabsHost.innerHTML=""; variantTabs(tabsHost,tiers,idx,i=>{idx=i;init()},"sr"); } function render(){ redrawTabs(); formHost.innerHTML=""; formHost.appendChild(el("h3",{class:"k-panel-title"},"Playback")); const f=el("div",{style:"display:flex;gap:10px;flex-wrap:wrap;align-items:center"}); f.append( el("button",{class:"k-btn accent",onClick:()=>{playing=!playing;if(playing)tick()}},playing?"⏸ Pause":"▶ Play"), el("button",{class:"k-btn",onClick:()=>{playing=false;init()}},"⟲ Reset"), el("button",{class:"k-btn",onClick:()=>{playing=false;step=draws.length;updateSeries();renderStage();renderChart()}},"⏭ Jump to end"), el("span",{style:"color:var(--k-muted);font-size:12px;margin-left:auto"},`Step ${step}/${draws.length} · Seed ${seed}`) ); formHost.appendChild(f); const f2=el("div",{style:"display:flex;gap:16px;margin-top:10px;align-items:center"}); f2.append( el("label",{style:"font-size:12px;color:var(--k-muted)"},"Speed "), el("input",{type:"range",min:"1",max:"200",value:speed,onInput:e=>{speed=parseInt(e.target.value)}}), el("label",{style:"font-size:12px;color:var(--k-muted);margin-left:16px"},"Seed "), el("input",{type:"number",value:seed,style:"width:80px",onInput:e=>{seed=parseInt(e.target.value)||1;init()}}) ); formHost.appendChild(f2); renderStage();renderChart(); } function tick(){ if(!playing)return; step=Math.min(draws.length,step+speed); updateSeries(); renderStage();renderChart(); if(steptierCounts[r.tier]={count:0,max:r.deals,type:r.type,prize:r.prize}); for(let i=0;i({tier:parseInt(tier),...o})).sort((a,b)=>b.tier-a.tier); const t=el("table",{class:"k-table"}); t.innerHTML=`TierPrizeDrawn / totalProgress`; const tb=el("tbody"); rowsArr.forEach(r=>{ const prog=r.max>0?(r.count/r.max*100):0; const tr=el("tr"); const bar=el("div",{style:"width:100%;height:8px;background:#eee;border-radius:3px;overflow:hidden"}, el("div",{style:`height:100%;width:${prog}%;background:${r.type==="WIN"?"var(--k-accent)":"#cbd5e1"};transition:width .08s`}) ); tr.append( el("td",{},"T"+r.tier), el("td",{},"$"+r.prize.toFixed(2)), el("td",{},`${r.count.toLocaleString()} / ${r.max.toLocaleString()}`), el("td",{style:"min-width:200px"},bar) ); tb.appendChild(tr); }); t.appendChild(tb); stage.appendChild(t); const totalPayout=rtpSeries.length?draws.slice(0,step).reduce((a,d)=>a+d.prize,0):0; const totalWager=step*v.denom; const curRTP=totalWager>0?totalPayout/totalWager*100:0; const m=el("div",{class:"k-grid-4",style:"margin-top:12px"}); m.append( metric("Draws",step.toString(),`of ${draws.length}`), metric("Running RTP",curRTP.toFixed(4)+"%",""), metric("Payout","$"+totalPayout.toFixed(2),""), metric("Wager","$"+totalWager.toFixed(2),"") ); stage.appendChild(m); } function renderChart(){ chartHost.innerHTML=""; chartHost.appendChild(el("h3",{class:"k-panel-title"},"Running RTP")); if(rtpSeries.length<2){ chartHost.appendChild(el("p",{style:"color:var(--k-muted);font-size:12.5px"},"Play to populate the RTP curve.")); return; } const theo=tierRTP(v); // downsample for performance const max=200; const step2=Math.max(1,Math.floor(rtpSeries.length/max)); const sampled=[]; for(let i=0;i { /* kobow-method-v1 Implements the R1-R4 quality framework from cross_file_validation_v2.9.1.md: R1 Session RTP Robustness — theoretical (closed-form) + Monte Carlo histogram R2 Bonus Gap Control — inter-top-prize gap distribution R3 Prize Mass Distribution — Lorenz curve + Gini + entropy R4 Coherent Volatility — cross-variant shape-correlation heatmap Plus: integrated 5-layer validator (L1-L5) and SHA-256 determinism lock. */ const g = await currentGame(); if (!g) { body.appendChild(emptyState("No game selected","Create a game first.",null)); return; } const tiers = await tiersForCurrent(); if (!tiers.length) { body.appendChild(emptyState("No paytables","Add at least one variant to compute Kobow Method scores.",null)); return; } const syms = await symbolsForCurrent(); const feats = await featuresForCurrent(); // ---------- stats helpers ---------- const mean = a => a.length ? a.reduce((x,y)=>x+y,0)/a.length : 0; const variance = a => { const m=mean(a); return a.length ? a.reduce((x,y)=>x+(y-m)*(y-m),0)/a.length : 0; }; const stdev = a => Math.sqrt(variance(a)); function pcorr(a,b){ const ma = mean(a), mb = mean(b); let num=0, da=0, db=0; for (let i=0;i0&&db>0) ? num/Math.sqrt(da*db) : 1; } // ---------- R1 ---------- function r1Theo(variant, sessionN){ const totalDeals = variant.rows.reduce((a,r)=>a+r.deals,0); const pm = variant.rows.map(r => ({p: r.deals/totalDeals, rtpPerDeal: r.prize/variant.denom})); const mu = pm.reduce((a,x)=>a + x.p*x.rtpPerDeal, 0); const sig2 = pm.reduce((a,x)=>a + x.p*(x.rtpPerDeal-mu)*(x.rtpPerDeal-mu), 0); const n = Math.min(sessionN, totalDeals); const fpc = totalDeals>1 ? (totalDeals - n)/(totalDeals - 1) : 1; const sessionVar = (sig2/n) * fpc; return { mean: mu*100, stdev: Math.sqrt(sessionVar)*100, totalDeals, sessionN: n }; } function r1MC(variant, sessionN, runs, seed){ const pool = buildPool(variant); const totalDeals = pool.length; const n = Math.min(sessionN, totalDeals); const rng = mulberry32(seed>>>0); const sessionRTPs = []; const idx = new Array(totalDeals); for (let i = 0; i < totalDeals; i++) idx[i] = i; for (let r = 0; r < runs; r++){ for (let i = 0; i < n; i++){ const j = i + Math.floor(rng()*(totalDeals - i)); const t = idx[i]; idx[i] = idx[j]; idx[j] = t; } let wager = 0, payout = 0; for (let i = 0; i < n; i++){ wager += variant.denom; payout += pool[idx[i]].prize; } sessionRTPs.push(wager>0 ? payout/wager*100 : 0); } return sessionRTPs; } // ---------- R2 ---------- function r2Gaps(variant, seed){ const pool = buildPool(variant); if (!pool.length) return { gaps:[], mean:0, stdev:0, cv:0, trigger:0, triggerCount:0, totalDeals:0 }; let top = -Infinity; for (const _p of pool) if (_p.prize > top) top = _p.prize; const rng = mulberry32(seed>>>0); const shuffled = pool.slice(); shuffleInPlace(shuffled, rng); const triggerIdx = []; for (let i = 0; i < shuffled.length; i++){ if (shuffled[i].prize === top) triggerIdx.push(i); } const gaps = []; for (let i = 1; i < triggerIdx.length; i++) gaps.push(triggerIdx[i] - triggerIdx[i-1]); const m = mean(gaps), s = stdev(gaps); return { gaps, mean:m, stdev:s, cv: m>0 ? s/m : 0, trigger: top, triggerCount: triggerIdx.length, totalDeals: shuffled.length }; } // ---------- R3 ---------- function r3Prize(variant){ const totalDeals = variant.rows.reduce((a,r)=>a+r.deals,0); const totalPayout = variant.rows.reduce((a,r)=>a+r.prize*r.deals,0); const rows = variant.rows.slice().sort((a,b)=>a.prize-b.prize); let cumPop = 0, cumMass = 0; const lorenz = [[0,0]]; for (const r of rows){ cumPop += r.deals/totalDeals; cumMass += totalPayout>0 ? (r.prize*r.deals)/totalPayout : 0; lorenz.push([cumPop, cumMass]); } let area = 0; for (let i = 1; i < lorenz.length; i++){ area += (lorenz[i][0]-lorenz[i-1][0]) * (lorenz[i][1]+lorenz[i-1][1])/2; } const gini = 1 - 2*area; const probs = rows.map(r => r.deals/totalDeals).filter(p=>p>0); const H = -probs.reduce((a,p)=>a+p*Math.log2(p),0); const Hmax = Math.log2(Math.max(1,probs.length)); const entropyNorm = Hmax>0 ? H/Hmax : 0; const sortedDesc = rows.slice().sort((a,b)=>b.prize-a.prize); let sdeals = 0, sMass = 0; for (const r of sortedDesc){ sdeals += r.deals; sMass += r.prize*r.deals; if (sdeals/totalDeals >= 0.10) break; } const top10Mass = totalPayout>0 ? sMass/totalPayout : 0; return { gini, entropyNorm, top10Mass, lorenz }; } // ---------- R4 ---------- function r4Shapes(variants){ return variants.map(v => { const rows = v.rows.slice().sort((a,b)=>b.prize-a.prize); const totalDeals = rows.reduce((a,r)=>a+r.deals,0); const totalPayout = rows.reduce((a,r)=>a+r.prize*r.deals,0); const K = 20; const curve = new Array(K).fill(0); let cumDeals = 0, cumMass = 0, ri = 0; for (let k = 0; k < K; k++){ const targetDeals = totalDeals * (k+1)/K; while (ri < rows.length && cumDeals < targetDeals){ cumDeals += rows[ri].deals; cumMass += rows[ri].prize*rows[ri].deals; ri++; } curve[k] = totalPayout>0 ? cumMass/totalPayout : 0; } return curve; }); } function r4Coherence(variants){ if (variants.length < 2) return { shapes: [], minR: 1, meanR: 1 }; const shapes = r4Shapes(variants); const rs = []; for (let i = 0; i < variants.length; i++){ for (let j = i+1; j < variants.length; j++){ rs.push(pcorr(shapes[i], shapes[j])); } } return { shapes, minR: Math.min(...rs), meanR: mean(rs) }; } // ---------- SVG helpers ---------- const svgNS = "http://www.w3.org/2000/svg"; const mkSvg = (w,h) => { const s = document.createElementNS(svgNS,"svg"); s.setAttribute("width",w); s.setAttribute("height",h); s.setAttribute("viewBox","0 0 "+w+" "+h); return s; }; const mkEl = (p, tag, attrs) => { const n = document.createElementNS(svgNS,tag); for (const k in attrs) n.setAttribute(k, attrs[k]); p.appendChild(n); return n; }; const mkText = (p, x, y, t, opts) => { const o = mkEl(p,"text",{x,y,"font-size":(opts&&opts.size)||10,fill:(opts&&opts.fill)||"#555","text-anchor":(opts&&opts.anchor)||"middle"}); o.textContent = t; return o; }; function svgHist(values, opts){ const o = opts||{}; const w = o.w||520, h = o.h||140, bins = o.bins||20, color = o.color||"#4c5fd5"; if (!values.length) return el("div",{class:"k-panel-sub"},"No data."); const lo = Math.min(...values), hi = Math.max(...values); const span = (hi-lo) || 1; const counts = new Array(bins).fill(0); for (const v of values){ const b = Math.min(bins-1, Math.floor((v-lo)/span*bins)); counts[b]++; } const maxC = Math.max(...counts, 1); const pad = 30; const iw = w - pad*2, ih = h - pad*2; const svg = mkSvg(w, h); mkEl(svg, "line", {x1:pad, y1:h-pad, x2:w-pad, y2:h-pad, stroke:"#ccc"}); mkEl(svg, "line", {x1:pad, y1:pad, x2:pad, y2:h-pad, stroke:"#ccc"}); const bw = iw/bins; for (let i = 0; i < bins; i++){ const bh = counts[i]/maxC * ih; mkEl(svg, "rect", {x:pad + i*bw + 1, y:h - pad - bh, width:Math.max(0,bw-2), height:bh, fill:color}); } mkText(svg, pad, h-8, lo.toFixed(2), {anchor:"start"}); mkText(svg, w-pad, h-8, hi.toFixed(2), {anchor:"end"}); if (o.xLabel) mkText(svg, w/2, h-2, o.xLabel, {size:9, fill:"#888"}); if (o.highlight != null && isFinite(o.highlight)){ const x = pad + (o.highlight-lo)/span*iw; mkEl(svg, "line", {x1:x, y1:pad, x2:x, y2:h-pad, stroke:"#b44", "stroke-width":"1.5"}); mkText(svg, x, pad+9, o.highlight.toFixed(2), {anchor:"middle", size:9, fill:"#b44"}); } return svg; } function svgLorenz(lorenz){ const w = 280, h = 220, pad = 28; const iw = w - pad*2, ih = h - pad*2; const svg = mkSvg(w, h); mkEl(svg,"line",{x1:pad, y1:pad, x2:pad, y2:h-pad, stroke:"#ccc"}); mkEl(svg,"line",{x1:pad, y1:h-pad, x2:w-pad, y2:h-pad, stroke:"#ccc"}); mkEl(svg,"line",{x1:pad, y1:h-pad, x2:w-pad, y2:pad, stroke:"#ddd", "stroke-dasharray":"3,3"}); const pts = lorenz.map(p => (pad + p[0]*iw)+","+(h-pad - p[1]*ih)).join(" "); mkEl(svg,"polyline",{points:pts, fill:"none", stroke:"#4c5fd5", "stroke-width":"2"}); mkText(svg, w/2, h-6, "cum. deals", {size:9, fill:"#888"}); mkText(svg, 6, h/2, "cum. payout", {anchor:"start", size:9, fill:"#888"}); return svg; } function svgHeatmap(M, labels){ const n = M.length, cell = 40, pad = 72; const w = pad + cell*n + 14, h = pad + cell*n + 14; const svg = mkSvg(w, h); const colorFor = r => { if (r >= 0.9) return "#3d4ea3"; if (r >= 0.7) return "#6676c4"; if (r >= 0.5) return "#9aa4d6"; if (r >= 0) return "#d9dceb"; return "#e6b5b5"; }; for (let i = 0; i < n; i++){ for (let j = 0; j < n; j++){ mkEl(svg,"rect",{x:pad+j*cell, y:pad+i*cell, width:cell-1, height:cell-1, fill:colorFor(M[i][j])}); mkText(svg, pad+j*cell+cell/2, pad+i*cell+cell/2+3, M[i][j].toFixed(2), {size:10, fill:"#222"}); } mkText(svg, pad-6, pad+i*cell+cell/2+3, labels[i], {anchor:"end", size:10, fill:"#555"}); mkText(svg, pad+i*cell+cell/2, pad-8, labels[i], {anchor:"middle", size:10, fill:"#555"}); } return svg; } // ---------- compute ---------- const seed = hashSeed((g.id||"")+"|"+(g.currentVersion||"")); const primary = tiers.find(t=>t.volatility==="Med") || tiers[0]; const sessionN = 200; const r1T = r1Theo(primary, sessionN); const r1M = r1MC(primary, sessionN, 500, seed); const r2 = r2Gaps(primary, (seed ^ 0x9E3779B1) >>> 0); const r3 = r3Prize(primary); const r4 = r4Coherence(tiers); const r1Score = Math.max(0, Math.min(100, 100 - Math.abs(r1T.stdev)*3)); const r2Score = r2.gaps.length ? Math.max(0, Math.min(100, 100 - r2.cv*40)) : 50; const gHealthy = r3.gini > 0.5 && r3.gini < 0.85; const r3Score = gHealthy ? 90 : Math.max(0, 100 - Math.abs(r3.gini-0.68)*120); const r4Score = tiers.length < 2 ? 50 : Math.round(50 + r4.meanR*50); // ---------- actions ---------- actions.appendChild(el("button",{class:"k-btn",onClick:()=>{renderMain();toast("Re-running Kobow Method","ok");}},"↻ Re-run")); actions.appendChild(el("button",{class:"k-btn",onClick:()=>{ const fp = window.__kobowFingerprint; if (!fp) { toast("Fingerprint not ready","warn"); return; } navigator.clipboard.writeText(fp).then(()=>toast("Fingerprint copied","ok")); }},"⎘ Copy fingerprint")); /* kobow-method-export-v1 — regulator-ready one-pager (print-to-PDF) */ const openKobowOnePager = () => { const fp = window.__kobowFingerprint || "computing…"; const gen = new Date().toISOString(); const esc = s => String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[c])); const tagHtml = (sc) => { const kind = sc>=80?"ok":sc>=60?"warn":"err"; const label = sc>=80?"PASS":sc>=60?"WARN":"FAIL"; const color = kind==="ok"?"#0a6b33":kind==="warn"?"#8b6a10":"#8b1a1a"; return `${label}`; }; const lfRows = ["schema","regulatory","math","game_logic","cross_file"].map((id,i)=>{ const label = ["L1 Schema","L2 Regulatory","L3 Math","L4 Game-logic","L5 Cross-file"][i]; const f = lf[id]||[]; const hasErr = f.some(x=>x.severity==="error"); const hasWarn = f.some(x=>x.severity==="warn"); const status = hasErr?"FAIL":hasWarn?"WARN":"PASS"; const color = hasErr?"#8b1a1a":hasWarn?"#8b6a10":"#0a6b33"; const notes = f.length ? esc(f.map(x=>x.message).join(" · ")) : "—"; return `${label}${status}${f.length}${notes}`; }).join(""); const r1Band95 = (r1T.mean-1.96*r1T.stdev).toFixed(3)+"% – "+(r1T.mean+1.96*r1T.stdev).toFixed(3)+"%"; const srt = r1M.slice().sort((a,b)=>a-b); const r1pct = srt[Math.floor(srt.length*0.05)].toFixed(3)+"% – "+srt[Math.floor(srt.length*0.95)].toFixed(3)+"%"; const mcMeanVal = r1M.reduce((x,y)=>x+y,0)/r1M.length; const mcVar = r1M.reduce((x,y)=>x+(y-mcMeanVal)*(y-mcMeanVal),0)/r1M.length; const mcMean = mcMeanVal.toFixed(4); const mcStd = Math.sqrt(mcVar).toFixed(4); const expGap = r2.triggerCount>0 ? (r2.totalDeals/r2.triggerCount).toFixed(1) : "—"; const html = ` Kobow Method — ${esc(g.name)} ${esc(g.currentVersion||"")}

Kobow Method — ${esc(g.name)}

${esc(g.code||"")} · version ${esc(g.currentVersion||"—")} · primary variant ${esc(primary.volatility)} / $${primary.denom.toFixed(2)} · ${tiers.length} variant${tiers.length===1?"":"s"} · session ${sessionN} deals

R1–R4 scorecard

R1 Session RTP
${Math.round(r1Score)}/100
${tagHtml(r1Score)} · σ ${r1T.stdev.toFixed(2)}%
R2 Bonus Gap
${Math.round(r2Score)}/100
${tagHtml(r2Score)} · CV ${(r2.cv*100).toFixed(1)}% · ${r2.triggerCount} triggers
R3 Prize Mass
${Math.round(r3Score)}/100
${tagHtml(r3Score)} · Gini ${r3.gini.toFixed(3)}
R4 Coherence
${Math.round(r4Score)}/100
${tagHtml(r4Score)} · ${tiers.length<2?"single variant":"min r "+r4.minR.toFixed(2)}

Detail

R1 · Session RTP Robustness

Theoretical mean${r1T.mean.toFixed(4)}%
Theoretical σ${r1T.stdev.toFixed(4)}%
95% band (±1.96σ)${r1Band95}
MC mean / σ (500 × ${sessionN})${mcMean}% / ${mcStd}%
MC 5th–95th pct${r1pct}

R2 · Bonus Gap Control

Trigger value$${r2.trigger.toFixed(2)}
Trigger count${r2.triggerCount} / ${(r2.totalDeals||0).toLocaleString()} deals
Expected gap${expGap} deals
Mean gap${r2.mean.toFixed(1)} deals
σ gap · CV${r2.stdev.toFixed(1)} · ${(r2.cv*100).toFixed(1)}%

R3 · Prize Mass Distribution

Gini coefficient${r3.gini.toFixed(4)}
Normalized entropy${r3.entropyNorm.toFixed(4)}
Top-10% deals capture${(r3.top10Mass*100).toFixed(2)}% of payout
Health band0.50 ≤ Gini ≤ 0.85
Status${gHealthy?"in band":"outside band"}

R4 · Coherent Volatility

Variants${tiers.length}
Min pair r${tiers.length<2?"—":r4.minR.toFixed(3)}
Mean pair r${tiers.length<2?"—":r4.meanR.toFixed(3)}
Coherence score${r4Score}/100
Rating${tiers.length<2?"single variant":r4.minR>=0.9?"excellent":r4.minR>=0.7?"good":"weak"}

5-layer validator · integrated lock

${lfRows}
LayerStatusCountNotes

Determinism lock · SHA-256 fingerprint

Canonical input size${canonical.length.toLocaleString()} bytes
Variants hashed${tiers.length}
Generated at${esc(gen)}
${esc(fp)}
Kobow Method · R1 RTP · R2 Gap · R3 Mass · R4 Coherence · 5-layer lock Generated by ${esc((state.user&&state.user.name)||"Juliano Machado")} · kobowlotto.com/mathforge
`; const w = window.open("", "_blank"); if (!w){ toast("Pop-up blocked — allow pop-ups to open the one-pager","warn"); return; } w.document.open(); w.document.write(html); w.document.close(); toast("Kobow Method one-pager opened — use Print / Save as PDF","ok"); }; actions.appendChild(el("button",{class:"k-btn",onClick:()=>{ try { openKobowOnePager(); } catch(e){ toast("Export failed: "+e.message,"err"); } }},"📄 Export one-pager")); // ---------- scoreboard ---------- const scoreGrid = el("div",{class:"k-grid-4",style:"margin-bottom:14px"}); const tagKind = v => v >= 80 ? "ok" : v >= 60 ? "warn" : "err"; const scoreTile = (lbl, sc, hint) => el("div",{class:"k-metric"}, el("div",{class:"k-metric-label"}, lbl), el("div",{class:"k-metric-val"}, Math.round(sc).toString()+"/100"), el("div",{class:"k-metric-hint"}, el("span",{class:"k-tag "+tagKind(sc)}, sc>=80?"PASS":sc>=60?"WARN":"FAIL"), " · "+hint) ); scoreGrid.appendChild(scoreTile("R1 Session RTP", r1Score, "σ "+r1T.stdev.toFixed(2)+"% / "+sessionN+"-deal")); scoreGrid.appendChild(scoreTile("R2 Bonus Gap", r2Score, "CV "+(r2.cv*100).toFixed(1)+"% · "+r2.triggerCount+" triggers")); scoreGrid.appendChild(scoreTile("R3 Prize Mass", r3Score, "Gini "+r3.gini.toFixed(3))); scoreGrid.appendChild(scoreTile("R4 Coherence", r4Score, tiers.length<2 ? "single variant" : "min r "+r4.minR.toFixed(2)+" · "+tiers.length+" variants")); body.appendChild(scoreGrid); // ---------- R1 panel ---------- const p1 = el("div",{class:"k-panel"}); p1.appendChild(el("h3",{class:"k-panel-title"},"R1 · Session RTP Robustness")); p1.appendChild(el("div",{class:"k-panel-sub"}, "How tightly does a "+sessionN+"-deal session RTP hug the target for "+primary.volatility+" · $"+primary.denom.toFixed(2)+"?")); const p1grid = el("div",{class:"k-grid-2"}); const p1theo = el("div"); p1theo.appendChild(el("h4",{style:"margin:6px 0 4px;font-size:13px;color:#444"},"Theoretical (closed-form)")); p1theo.appendChild(kv("Target RTP", r1T.mean.toFixed(4)+"%")); p1theo.appendChild(kv("σ per session", r1T.stdev.toFixed(4)+"%")); p1theo.appendChild(kv("95% band (±1.96σ)", (r1T.mean-1.96*r1T.stdev).toFixed(3)+"% – "+(r1T.mean+1.96*r1T.stdev).toFixed(3)+"%")); p1theo.appendChild(kv("Deals / pool size", sessionN+" of "+r1T.totalDeals.toLocaleString())); p1grid.appendChild(p1theo); const p1mc = el("div"); p1mc.appendChild(el("h4",{style:"margin:6px 0 4px;font-size:13px;color:#444"},"Monte Carlo · 500 × "+sessionN+" deals")); p1mc.appendChild(kv("Empirical mean", mean(r1M).toFixed(4)+"%")); p1mc.appendChild(kv("Empirical σ", stdev(r1M).toFixed(4)+"%")); const srt = r1M.slice().sort((a,b)=>a-b); p1mc.appendChild(kv("5th / 95th pct", srt[Math.floor(srt.length*0.05)].toFixed(3)+"% – "+srt[Math.floor(srt.length*0.95)].toFixed(3)+"%")); p1mc.appendChild(kv("Min / max", Math.min(...r1M).toFixed(3)+"% – "+Math.max(...r1M).toFixed(3)+"%")); p1grid.appendChild(p1mc); p1.appendChild(p1grid); const histHost = el("div",{style:"margin-top:10px;overflow:auto"}); histHost.appendChild(svgHist(r1M, {w:720, h:170, bins:30, highlight:r1T.mean, xLabel:"Session RTP (%)"})); p1.appendChild(histHost); body.appendChild(p1); // ---------- R2 panel ---------- const p2 = el("div",{class:"k-panel"}); p2.appendChild(el("h3",{class:"k-panel-title"},"R2 · Bonus Gap Control")); p2.appendChild(el("div",{class:"k-panel-sub"}, "Top-prize trigger ($"+r2.trigger.toFixed(2)+") — distribution of deals between consecutive triggers in the shuffled finite pool.")); const p2grid = el("div",{class:"k-grid-4"}); const expGap = r2.triggerCount>0 ? r2.totalDeals/r2.triggerCount : 0; p2grid.appendChild(metric("Trigger count", r2.triggerCount.toString(), "of "+(r2.totalDeals||0).toLocaleString()+" deals")); p2grid.appendChild(metric("Mean gap", r2.mean.toFixed(1)+" deals", "expected "+expGap.toFixed(1))); p2grid.appendChild(metric("σ gap", r2.stdev.toFixed(1)+" deals", r2.stdev>0?"spread":"—")); p2grid.appendChild(metric("CV", (r2.cv*100).toFixed(1)+"%", r2.cv<0.8?"tight":r2.cv<1.3?"normal":"loose")); p2.appendChild(p2grid); const gapHost = el("div",{style:"margin-top:10px;overflow:auto"}); if (r2.gaps.length) gapHost.appendChild(svgHist(r2.gaps, {w:720, h:150, bins:20, color:"#6aa66a", xLabel:"deals between triggers"})); else gapHost.appendChild(el("div",{class:"k-panel-sub"}, "Fewer than 2 triggers in pool — cannot compute gap distribution.")); p2.appendChild(gapHost); body.appendChild(p2); // ---------- R3 panel ---------- const p3 = el("div",{class:"k-panel"}); p3.appendChild(el("h3",{class:"k-panel-title"},"R3 · Prize Mass Distribution")); p3.appendChild(el("div",{class:"k-panel-sub"}, "Lorenz curve + Gini show how concentrated the payout is across prize tiers.")); const p3grid = el("div",{class:"k-grid-2"}); const p3stats = el("div"); p3stats.appendChild(kv("Gini coefficient", r3.gini.toFixed(4))); p3stats.appendChild(kv("Normalized entropy", r3.entropyNorm.toFixed(4))); p3stats.appendChild(kv("Top-10% deals capture", (r3.top10Mass*100).toFixed(2)+"% of payout")); p3stats.appendChild(kv("Health band", "0.50 ≤ Gini ≤ 0.85 typical")); p3stats.appendChild(kv("Current status", gHealthy ? "in band" : "outside band")); p3grid.appendChild(p3stats); p3grid.appendChild(svgLorenz(r3.lorenz)); p3.appendChild(p3grid); body.appendChild(p3); // ---------- R4 panel ---------- const p4 = el("div",{class:"k-panel"}); p4.appendChild(el("h3",{class:"k-panel-title"},"R4 · Coherent Volatility")); p4.appendChild(el("div",{class:"k-panel-sub"}, "Do Low/Med/High variants share a consistent payout shape? Correlation matrix of normalized contribution curves.")); if (tiers.length < 2){ p4.appendChild(el("div",{class:"k-panel-sub",style:"margin-top:8px"},"Add ≥2 variants (e.g. Low + Med + High) to measure cross-variant shape coherence.")); } else { const p4grid = el("div",{class:"k-grid-4"}); p4grid.appendChild(metric("Variants", tiers.length.toString(), "")); p4grid.appendChild(metric("Min pair r", r4.minR.toFixed(3), r4.minR>=0.9?"excellent":r4.minR>=0.7?"good":"weak")); p4grid.appendChild(metric("Mean pair r", r4.meanR.toFixed(3), "")); p4grid.appendChild(metric("Coherence score", r4Score.toString()+"/100", "")); p4.appendChild(p4grid); const labels = tiers.map(t => t.volatility+"·$"+t.denom.toFixed(2)); const N = tiers.length; const M = Array.from({length:N}, ()=>new Array(N).fill(1)); const shapes = r4.shapes; for (let i=0;i95) lf.math.push({severity:"warn",message:t.volatility+"/$"+t.denom+": RTP "+rtp.toFixed(3)+"% outside typical band"}); if (t.rtpBand && (rtp < t.rtpBand.min || rtp > t.rtpBand.max)) lf.math.push({severity:"error",message:t.volatility+"/$"+t.denom+": RTP "+rtp.toFixed(3)+"% outside declared band "+t.rtpBand.min+"-"+t.rtpBand.max+"%"}); } if (!feats.length) lf.game_logic.push({severity:"info",message:"No features record for this version"}); if (!syms.length) lf.game_logic.push({severity:"info",message:"No symbols record for this version"}); if (tiers.length >= 2){ const rtps = tiers.map(tierRTP); const rng = Math.max(...rtps) - Math.min(...rtps); if (rng > 5) lf.cross_file.push({severity:"warn",message:"Variant RTP spread "+rng.toFixed(2)+"% > 5% — cross-variant drift"}); } const tagFor = k => el("span",{class:"k-tag "+k}, k==="ok"?"PASS":k==="warn"?"WARN":"FAIL"); const row = (id,label) => { const f = lf[id]; const hasErr = f.some(x=>x.severity==="error"); const hasWarn = f.some(x=>x.severity==="warn"); const kind = hasErr?"err":hasWarn?"warn":"ok"; const tr = el("tr"); tr.append( el("td",{},label), el("td",{}, tagFor(kind)), el("td",{}, f.length.toString()+" finding"+(f.length===1?"":"s")), el("td",{}, f.length ? f.map(x=>x.message).join(" · ") : "—") ); return tr; }; const t5 = el("table",{class:"k-table"}); t5.innerHTML = "LayerStatusCountNotes"; const tb5 = el("tbody"); tb5.appendChild(row("schema","L1 Schema")); tb5.appendChild(row("regulatory","L2 Regulatory")); tb5.appendChild(row("math","L3 Math")); tb5.appendChild(row("game_logic","L4 Game-logic")); tb5.appendChild(row("cross_file","L5 Cross-file")); t5.appendChild(tb5); p5.appendChild(t5); body.appendChild(p5); // ---------- determinism lock ---------- const p6 = el("div",{class:"k-panel"}); p6.appendChild(el("h3",{class:"k-panel-title"},"Determinism lock · SHA-256 fingerprint")); p6.appendChild(el("div",{class:"k-panel-sub"}, "Canonical JSON hash of the current variant set. Same inputs → same hash; any edit invalidates the audit anchor.")); const canonical = JSON.stringify({ gameId: g.id, name: g.name, code: g.code, version: g.currentVersion, variants: tiers.slice().sort((a,b)=>(a.volatility+a.denom).localeCompare(b.volatility+b.denom)).map(t=>({ id: t.id||null, volatility: t.volatility, denom: t.denom, rows: t.rows.slice().sort((a,b)=>a.tier-b.tier).map(r=>({tier:r.tier,type:r.type,prize:r.prize,deals:r.deals})), rtpBand: t.rtpBand||null, rtpLock: t.rtpLock||null })) }); p6.appendChild(kv("Input size", fmtBytes(canonical.length))); p6.appendChild(kv("Variants hashed", tiers.length.toString())); p6.appendChild(kv("Generated at", fmtDate(new Date().toISOString()))); const fpBox = el("div",{class:"k-kv"}, el("span",{class:"k-kv-k"},"SHA-256"), el("span",{class:"k-kv-v",id:"km-fingerprint",style:"font-family:monospace;font-size:12px;word-break:break-all"},"computing…")); p6.appendChild(fpBox); body.appendChild(p6); (async () => { try { const enc = new TextEncoder().encode(canonical); const buf = await crypto.subtle.digest("SHA-256", enc); const hex = Array.from(new Uint8Array(buf)).map(b=>b.toString(16).padStart(2,"0")).join(""); const out = document.getElementById("km-fingerprint"); if (out) out.textContent = hex; window.__kobowFingerprint = hex; } catch(err){ const out = document.getElementById("km-fingerprint"); if (out) out.textContent = "sha256 unavailable: "+err.message; } })(); }; /* --- Shared small pieces --- */ function metric(label,val,hint){ return el("div",{class:"k-metric"}, el("div",{class:"k-metric-label"},label), el("div",{class:"k-metric-val"},val), hint?el("div",{class:"k-metric-hint"},hint):null ); } function kv(k,v){ return el("div",{class:"k-kv"}, el("span",{class:"k-kv-k"},k), el("span",{class:"k-kv-v"},v==null?"":v.toString()) ); } function emptyState(title,body,action){ const n=el("div",{class:"k-empty"}, el("h3",{},title), el("p",{},body||"") ); if(action)n.appendChild(el("button",{class:"k-btn primary",onClick:action},"Get started")); return n; } function fmtDate(iso){ if(!iso)return "—"; const d=new Date(iso); return d.toLocaleString(undefined,{dateStyle:"medium",timeStyle:"short"}); } function fmtBytes(n){ if(n<1024)return n+" B"; if(n<1024*1024)return (n/1024).toFixed(1)+" KB"; return (n/1024/1024).toFixed(1)+" MB"; } /* --- New game modal / paytable modal --- */ function newGameModal(){ const body=el("div"); body.appendChild(el("label",{},"Name")); const name=el("input",{type:"text",placeholder:"e.g. Sample Instant Game"});body.appendChild(name); body.appendChild(el("label",{},"Code")); const code=el("input",{type:"text",placeholder:"e.g. SAMPLE-02"});body.appendChild(code); body.appendChild(el("label",{},"Theme")); const theme=el("input",{type:"text"});body.appendChild(theme); body.appendChild(el("label",{},"Regulatory profile")); const prof=el("select"); ["instant-pulltab","charitable-pulltab","instant-scratch","mobile-instant"].forEach(p=>{ prof.appendChild(el("option",{value:p},p)); }); body.appendChild(prof); modal({title:"New game",body,okLabel:"Create",cancelLabel:"Cancel",onOk:async()=>{ if(!name.value)return toast("Name required","err"); const now=new Date().toISOString(); const id=await dbAdd("games",{ orgId:state.org.id, name:name.value,code:code.value||name.value.toUpperCase().replace(/\s+/g,"-"), theme:theme.value, market:"", regulatoryProfile:prof.value, createdAt:now,updatedAt:now,currentVersion:"v0.1" }); state.currentGameId=id; await audit("create","game","Created "+name.value); await refreshGames(); renderMain(); toast("Game created","ok"); }}); } function addPaytableModal(game){ const body=el("div"); body.appendChild(el("label",{},"Volatility")); const v=el("select");["Low","Med","High"].forEach(x=>v.appendChild(el("option",{value:x},x))); body.appendChild(v); body.appendChild(el("label",{},"Denomination ($)")); const d=el("input",{type:"number",step:"0.25",value:"1.00"});body.appendChild(d); modal({title:"Add paytable variant",body,okLabel:"Create",cancelLabel:"Cancel",onOk:async()=>{ await dbAdd("tiers",{ gameId:game.id,version:game.currentVersion, volatility:v.value,denom:parseFloat(d.value)||1, rows:[ {tier:9,type:"WIN",prize:500,multiplier:500,deals:1,pool:0,desc:"500× wager"}, {tier:0,type:"LOSE",prize:0,multiplier:0,deals:999,pool:0,desc:"Loser"} ] }); await audit("create","tiers","New variant "+v.value+"/"+d.value); toast("Variant added","ok"); renderMain(); }}); } /* --- Export / Import --- */ async function exportAll(){ const data={version:1,exportedAt:new Date().toISOString()}; for(const s of STORES){data[s]=await dbAll(s)} const blob=new Blob([JSON.stringify(data,null,2)],{type:"application/json"}); const url=URL.createObjectURL(blob); const a=document.createElement("a");a.href=url;a.download="kobow-studio-export-"+Date.now()+".json";a.click(); await audit("export","vault","Full export"); toast("Vault exported","ok"); } async function importFile(file){ const txt=await file.text(); try{ const data=JSON.parse(txt); if(!data.version)throw new Error("Not a Kobow export file"); if(!confirm("Import will merge into current data. Continue?"))return; for(const s of STORES){ if(!Array.isArray(data[s]))continue; for(const row of data[s]){ const {id, ...rest}=row; await dbAdd(s,rest); } } await audit("import","vault",file.name); toast("Imported","ok"); await refreshGames();renderMain(); }catch(e){toast("Import failed: "+e.message,"err")} } /* ---------- Init ---------- */ (async function init(){ try{ await openDB(); await ensureUser(); await seedIfEmpty(); await refreshGames(); renderUserChip(); const h=(location.hash||"#overview").slice(1); state.currentView=h || "overview"; renderSidebar(); await renderMain(); await renderStatusbar(); // wire top-nav buttons $("#btn-new-game").onclick=newGameModal; $("#btn-import").onclick=()=>{ const inp=document.createElement("input");inp.type="file";inp.accept=".json"; inp.onchange=e=>{if(e.target.files[0])importFile(e.target.files[0])}; inp.click(); }; $("#btn-export").onclick=exportAll; $("#btn-toggle-autosave").onclick=()=>{ state.autosave=!state.autosave; $("#autosave-label").textContent=state.autosave?"Autosave on":"Autosave paused"; $("#autosave-dot").className="k-status-dot"+(state.autosave?"":" warn"); }; window.addEventListener("hashchange",()=>{ state.currentView=(location.hash||"#overview").slice(1); renderSidebar();renderMain(); }); $("#autosave-dot").className="k-status-dot"; }catch(e){ console.error(e); document.body.innerHTML="
Init error: "+e.message+"
"; } })(); // kobow-sync-wired-v1 /* Wires this studio to https://kobowlotto.com/api via KobowSync helper. getProject returns the live `state` object by closure. applyProject stores the pulled snapshot at window._kobowPulled so the studio can merge in its own terms (manual import UI / reconciliation dialog). */ (function kobowSyncBoot(){ var tries = 0; function go(){ if (!window.KobowSync) { if (++tries < 60) return setTimeout(go, 100); return console.warn("[kobow-sync] helper failed to load"); } try { window.KobowSync.init({ gameCode: 'finite-pool-sample', gameName: 'Finite Pool · Sample', gameKind: 'pull_tab', getProject: function(){ try { return state; } catch(e) { return {}; } }, applyProject: function(snap, meta){ window._kobowPulled = {snap: snap, meta: meta, at: new Date().toISOString()}; console.info("[kobow-sync] pulled snapshot stored at window._kobowPulled", meta); } }); } catch(e) { console.warn("[kobow-sync] init failed:", e); } } go(); })();