diff --git a/aiAssets/GUIDE.md b/aiAssets/GUIDE.md new file mode 100644 index 0000000..6cb5742 --- /dev/null +++ b/aiAssets/GUIDE.md @@ -0,0 +1,232 @@ +# AGENTS.md — Playable Ads + +## Stack +Phaser 3.90 · TypeScript · Vite + vite-plugin-singlefile · WebP · MP3 96kbps +Networks: Applovin (al) · GoogleAds (gg) · Ironsource (is) · Mintegral (mtg) · Facebook (fb) · Unity (un) · Vungle (vu) · Moloco (mo) · TikTok (tt) + +## Porting to a new project +1. Update naming fields (3LC, vendor, concept name) in [scripts/build-all.mjs](scripts/build-all.mjs) — drives the output filename +2. Update store URLs in [constants.ts](src/constants.ts) (iOS + Android) +3. Adjust the Depth Map below to match the project's layer needs +4. Update [iteration.ts](src/iteration.ts) A/B config shape for this project's variants +5. Replace `Assets/` with project assets; update asset manifests in [BootScene.ts](src/scenes/BootScene.ts) +6. Rewrite `game/` modules for the new mechanic(s) — keep the single-responsibility split +7. Update this AGENTS.md's Depth Map and any project-specific pitfalls + +## Structure +``` +src/ + main.ts # Bootstrap, resize, MRAID gating, lifecycle stubs on window + constants.ts # Design coords (1080×1920), store URLs + networks.ts # triggerCTA(), initMraid(), bindLifecycle(), notifyGameX() + analytics.ts # trackEvent() → SDK + iteration.ts # A/B iteration config + utils/ + responsive.ts # sx(), sy(), sd() — coordinate helpers + scenes/ + BootScene.ts # Critical preload + deferred asset manifest + GameScene.ts # Orchestrator only — wires game/ modules, handles lifecycle + cta.ts # End-card layout + redirectToStore() → notifyGameClose() + triggerCTA() + game/ # Single-responsibility modules (no direct ad-SDK calls) + # …one file per distinct mechanic or UI widget +Assets/ +scripts/build-all.mjs # Single build → per-network HTML variants +``` + +### Responsibility split +- **`game/`** modules each own a single concern (one mechanic, one widget, one system). + They receive the Phaser scene as a constructor arg and own their own game objects. + They never call ad-SDK functions (`triggerCTA`, `notifyGameX`, `trackEvent`). +- **`GameScene.ts`** is the wiring layer: creates modules, passes data between them, + and calls SDK helpers at the correct lifecycle moments. +- **`scenes/cta.ts`** owns end-card presentation and the store-redirect sequence. + +## Rules +- 60 FPS target; never below 30 on mid-range Android +- `dist/index.html` < 5 MB; WebP images, short audio +- No hardcoded px — `sx()`, `sy()`, `sd()` only +- No audio autoplay — unmute on first `pointerdown` +- `for` loops in game logic; pool objects; no GC +- `setDisplaySize(sd())` on images — never `setScale` +- Never dim via `body`/page background + +## Phaser Config +```ts +{ type: Phaser.AUTO, transparent: true, + scale: { mode: Phaser.Scale.NONE, width: 1080, height: 1920 }, + render: { antialias: true, pixelArt: false } } +``` +`Scale.NONE` — manual sizing; CSS = viewport px; internal = viewport × DPR. +`antialias: true` — required to avoid jagged edges on scaled WebP assets (end-card logo, CTA, score pill). Never set to `false`. + +## Responsive (1080×1920 ref) +```ts +const s = Math.min(viewW / 1080, viewH / 1920) +const offX = (viewW - 1080 * s) / 2 +const offY = (viewH - 1920 * s) / 2 +// sx/sy/sd apply offX, offY, s +``` +- `update()` polls `visualViewport` per-frame for rotation +- On change: `game.scale.resize(vw*dpr, vh*dpr)` → `relayout()` +- `bindResponsiveResize()`: rAF debounce + 100/300/600ms retries + +## CTA — SDK Priority (triggerCTA fallback chain) +``` +1. ExitApi.exit() → GoogleAds (gg) +2. FbPlayableAd.onCTAClick() → Facebook (fb) +3. Luna.Unity.Playable → Unity (un) +4. playableSDK.openAppStore() → (runtime fallback if SDK present) +5. window.install() → Mintegral (mtg) +6. window.openAppStore() → (runtime fallback) +7. window.clickTag → Moloco (mo) +8. window.__VUNGLE__ → Vungle (vu) via parent.postMessage +9. window.__TIKTOK__ → TikTok (tt) → openAppStore() or window.open fallback +10. mraid.open(url) → Applovin (al) / Ironsource (is) +11. window.open(storeUrl) → Fallback (all others) +``` +`notifyGameClose()` fires before every CTA redirect (all networks, no-op when SDK absent). + +## Analytics — SDK Priority +``` +ALPlayableAnalytics.trackEvent(e) → Applovin (al) +playableSDK.reportEvent(e) → (runtime fallback if SDK present) +console.log('[Analytics]', e) → All other networks +``` +Events: `DISPLAYED` · `CTA_CLICKED` · `ENDCARD_SHOWN` · `CHALLENGE_STARTED` · `CHALLENGE_SOLVED` + +## Game Lifecycle (all networks) +`main.ts` exposes stubs on `window` so every network's preview tool detects them. +Stubs only set if the SDK hasn't already provided its own (`typeof` guard). +``` +gameReady() → stub in main.ts; Mintegral also gets onload body attr from build +gameStart() → notifyGameStart() in GameScene.startPlaying() +gameEnd() → notifyGameEnd() in GameScene.triggerFail() / triggerWin() +gameClose() → notifyGameClose() in cta.ts redirectToStore(), before triggerCTA() +``` + +### Pause / Resume / Mute (bindLifecycle in networks.ts) +| Network | Mechanism | +|---|---| +| Unity | `luna:mute` / `luna:unmute` / `luna:pause` / `luna:resume` events | +| Mintegral | `window.message` → `onPause` / `onResume` | +| Vungle | `ad-event-pause` / `ad-event-resume` events | +| Applovin / Ironsource | MRAID viewable state (gated in initMraid) | +| GoogleAds / Facebook / Moloco | No SDK pause — browser handles visibility | + +## Build +Vite outputs IIFE format (`format: 'iife'`, `modulePreload: false`). +Build script strips `type="module"` and `crossorigin` — ad networks reject ES modules. + +| Network | Tag | Injected | Zipped | Included | +|---|---|---|---|---| +| Applovin | al | `mraid.js` | — | ✓ | +| Google | gg | `exitapi.js` | ✓ | ✓ | +| Ironsource | is | `mraid.js` | — | ✓ | +| Mintegral | mtg | `onload="gameReady()"` | ✓ | ✓ | +| Facebook | fb | — | — | ✓ | +| Unity | un | — | — | ✓ | +| Vungle | vu | `window.__VUNGLE__=true` | ✓ | ✓ | +| Moloco | mo | — | — | ✓ | +| TikTok | tt | `window.__TIKTOK__=true` | — | — | + + +Networks marked **Zipped** ingest zipped creatives — [scripts/build-all.mjs](scripts/build-all.mjs) writes a `.zip` containing the HTML (one zip per variant, containing just that HTML at the root named index.html). Toggled per-network via `zip: true` in the `NETWORKS` array. + +Networks marked **Included** are built by default. Networks not marked are prepared for but not built. Toggle per-network via `included: true` in the `NETWORKS` array. + +Output is grouped by **iteration length** (e.g. `2words/`, `4words/`, `full/`) then by **network** (e.g. `Applovin/`, `Google/`, `Mintegral/`), with all per-network HTML or ZIP variants for that iteration sitting together inside the folder: + +`dist//<3lc>___________` + +Example layout: +``` +dist/ + 2words/ + Applovin/ + ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html + Google/ + ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_gg.zip + index.zip + Ironsource/ + ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_is.html + …one HTML or ZIP per network… + 4words/ + full/ +``` + +| Field | Description | Example | +|---|---|---| +| 3LC | 3-letter project code | `ws` (Wordscapes), `pp` (PeoplePuzzle) | +| Creative Type | Format identifier | `mip` | +| Vendor | Studio / vendor code | `grhpl` | +| Concept Name | Creative concept (append `var1`, `var2`… for variants) | `wbinspiredvar1`, `wbinspiredvar2`, `burglar` | +| Concept # | Iteration number, zero-padded | `01`, `02`, `03` | +| Gameplay | Gameplay style | `real`, `cartoon` | +| UGC | UGC presence | `na`, `ugc`, `nougc` | +| Seasonal | Seasonal tie-in | `noseason`, `xmas` | +| Language | Locale code | `en` | +| Length | Run length | `2words`, `4words`, `7words`, `full` (Wordscapes); `2clk`, `10clk`, `120sec` (other projects) | +| Size | Region / size code | `na` | +| Network | Network tag (last segment) | `al`, `gg`, `is`, `mtg`, `fb`, `un`, `vu`, `mo`, `tt` | + +Examples (this project): +- `ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html` (cta2, default theme) +- `ws_mip_grhpl_wbinspiredvar1_04_real_na_noseason_en_full_na_fb.html` (full, default theme) +- `ws_mip_grhpl_wbinspiredvar2_01_real_na_noseason_en_2words_na_gg.html` (cta2, v1 theme) + +Naming fields are defined in [scripts/build-all.mjs](scripts/build-all.mjs) — update them when porting. + +## Asset Loading +- **Critical** (BootScene): first-frame assets, target < 200 KB +- **Deferred**: audio, overlays, secondary UI — loads during intro +- Gameplay start waits on a deferred-ready flag set when the secondary manifest finishes loading + +## Depth Map (adjust per project) +Define depths as named constants in [constants.ts](src/constants.ts) — never use magic numbers in scene code. + +| Layer | Depth | +|---|---| +| Background | 0 | +| Game objects | 1–10 | +| Logo | 17 | +| Dim overlay | 20 | +| Fail/win UI | 21 | +| EndCard input | 25 | + +## Ship Checklist +- [ ] < 5 MB, all WebP +- [ ] FPS ≥ 55 under CPU throttle +- [ ] Portrait + landscape pass +- [ ] MRAID ready before gameplay +- [ ] Audio muted by default +- [ ] CTA fires in fallback +- [ ] No `console.error` +- [ ] No `type="module"` or `crossorigin` on `
+ + + + \ No newline at end of file diff --git a/aiAssets/exampleHTML/Applovin/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_al.html b/aiAssets/exampleHTML/Applovin/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_al.html new file mode 100644 index 0000000..5f2343b --- /dev/null +++ b/aiAssets/exampleHTML/Applovin/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_al.html @@ -0,0 +1,84 @@ + + + + + + Pop The Ice + + + + + + + diff --git a/aiAssets/exampleHTML/Facebook/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_fb.html b/aiAssets/exampleHTML/Facebook/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_fb.html new file mode 100644 index 0000000..c8f4e51 --- /dev/null +++ b/aiAssets/exampleHTML/Facebook/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_fb.html @@ -0,0 +1,83 @@ + + + + + + Pop The Ice + + + + + + diff --git a/aiAssets/exampleHTML/GoogleAds/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_gg.zip b/aiAssets/exampleHTML/GoogleAds/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_gg.zip new file mode 100644 index 0000000..bc6f061 Binary files /dev/null and b/aiAssets/exampleHTML/GoogleAds/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_gg.zip differ diff --git a/aiAssets/exampleHTML/Ironsource/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_is.html b/aiAssets/exampleHTML/Ironsource/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_is.html new file mode 100644 index 0000000..5f2343b --- /dev/null +++ b/aiAssets/exampleHTML/Ironsource/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_is.html @@ -0,0 +1,84 @@ + + + + + + Pop The Ice + + + + + + + diff --git a/aiAssets/exampleHTML/Mintegral/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_mtg.zip b/aiAssets/exampleHTML/Mintegral/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_mtg.zip new file mode 100644 index 0000000..9e129d2 Binary files /dev/null and b/aiAssets/exampleHTML/Mintegral/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_mtg.zip differ diff --git a/aiAssets/exampleHTML/Moloco/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_mo.html b/aiAssets/exampleHTML/Moloco/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_mo.html new file mode 100644 index 0000000..c8f4e51 --- /dev/null +++ b/aiAssets/exampleHTML/Moloco/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_mo.html @@ -0,0 +1,83 @@ + + + + + + Pop The Ice + + + + + + diff --git a/aiAssets/exampleHTML/Unity/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_un.html b/aiAssets/exampleHTML/Unity/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_un.html new file mode 100644 index 0000000..b69ce3a --- /dev/null +++ b/aiAssets/exampleHTML/Unity/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_un.html @@ -0,0 +1,83 @@ + + + + + + Pop The Ice + + + + + + diff --git a/aiAssets/exampleHTML/Vungle/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_vu.zip b/aiAssets/exampleHTML/Vungle/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_vu.zip new file mode 100644 index 0000000..d3af29d Binary files /dev/null and b/aiAssets/exampleHTML/Vungle/bwj_mip_grhpl_poptheicept2_01_real_nougc_noseason_en_full_vu.zip differ diff --git a/aiAssets/testOutput/Playworks_UnityNetwork_al.html b/aiAssets/testOutput/Playworks_UnityNetwork_al.html new file mode 100644 index 0000000..89f7539 --- /dev/null +++ b/aiAssets/testOutput/Playworks_UnityNetwork_al.html @@ -0,0 +1,8 @@ + + +
+ + + + \ No newline at end of file diff --git a/aiAssets/testOutput/Playworks_UnityNetwork_fb.html b/aiAssets/testOutput/Playworks_UnityNetwork_fb.html new file mode 100644 index 0000000..c5e3cf9 --- /dev/null +++ b/aiAssets/testOutput/Playworks_UnityNetwork_fb.html @@ -0,0 +1,8 @@ + + +
+ + + + \ No newline at end of file diff --git a/aiAssets/testOutput/Playworks_UnityNetwork_gg.zip b/aiAssets/testOutput/Playworks_UnityNetwork_gg.zip new file mode 100644 index 0000000..f1e3cab Binary files /dev/null and b/aiAssets/testOutput/Playworks_UnityNetwork_gg.zip differ diff --git a/aiAssets/testOutput/Playworks_UnityNetwork_is.html b/aiAssets/testOutput/Playworks_UnityNetwork_is.html new file mode 100644 index 0000000..89f7539 --- /dev/null +++ b/aiAssets/testOutput/Playworks_UnityNetwork_is.html @@ -0,0 +1,8 @@ + + +
+ + + + \ No newline at end of file diff --git a/aiAssets/testOutput/Playworks_UnityNetwork_mo.html b/aiAssets/testOutput/Playworks_UnityNetwork_mo.html new file mode 100644 index 0000000..950eded --- /dev/null +++ b/aiAssets/testOutput/Playworks_UnityNetwork_mo.html @@ -0,0 +1,8 @@ + + +
+ + + + \ No newline at end of file diff --git a/aiAssets/testOutput/Playworks_UnityNetwork_mtg.zip b/aiAssets/testOutput/Playworks_UnityNetwork_mtg.zip new file mode 100644 index 0000000..90905f4 Binary files /dev/null and b/aiAssets/testOutput/Playworks_UnityNetwork_mtg.zip differ diff --git a/aiAssets/testOutput/Playworks_UnityNetwork_un.html b/aiAssets/testOutput/Playworks_UnityNetwork_un.html new file mode 100644 index 0000000..ccbae8a --- /dev/null +++ b/aiAssets/testOutput/Playworks_UnityNetwork_un.html @@ -0,0 +1,8 @@ + + +
+ + + + \ No newline at end of file diff --git a/aiAssets/testOutput/Playworks_UnityNetwork_vu.zip b/aiAssets/testOutput/Playworks_UnityNetwork_vu.zip new file mode 100644 index 0000000..6c2c52b Binary files /dev/null and b/aiAssets/testOutput/Playworks_UnityNetwork_vu.zip differ diff --git a/package.json b/package.json index 37770b7..1ebb725 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,10 @@ { "command": "hplToolbox.openSendToMobile", "title": "HPL Toolbox: Open Send To Mobile" + }, + { + "command": "hplToolbox.openPlayworksConverter", + "title": "HPL Toolbox: Open Playworks Converter" } ], "viewsContainers": { diff --git a/src/extension.ts b/src/extension.ts index b491e32..dda811a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,6 +6,7 @@ import { openBase64Scanner } from './tools/base64Scanner'; import { openMraidChecker } from './tools/mraidChecker'; import { openDailyUpdate } from './tools/dailyUpdate'; import { openSendToMobile } from './tools/sendToMobile'; +import { openPlayworksConverter } from './tools/playworksConverter'; export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( @@ -15,6 +16,7 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand('hplToolbox.openMraidChecker', () => openMraidChecker(context)), vscode.commands.registerCommand('hplToolbox.openDailyUpdate', () => openDailyUpdate(context)), vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)), + vscode.commands.registerCommand('hplToolbox.openPlayworksConverter', () => openPlayworksConverter(context)), vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context)) ); } diff --git a/src/launcherView.ts b/src/launcherView.ts index 71a76c2..0649706 100644 --- a/src/launcherView.ts +++ b/src/launcherView.ts @@ -41,6 +41,11 @@ const TOOLS: ToolDefinition[] = [ title: 'Send To Mobile', description: 'Share HTML to a phone via LAN + QR', }, + { + command: 'hplToolbox.openPlayworksConverter', + title: 'Playworks Converter', + description: 'Convert Playworks HTML to per-network variants', + }, ]; export class LauncherViewProvider implements vscode.WebviewViewProvider { diff --git a/src/tools/playworksConverter.ts b/src/tools/playworksConverter.ts new file mode 100644 index 0000000..c7a0a4a --- /dev/null +++ b/src/tools/playworksConverter.ts @@ -0,0 +1,504 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as cp from 'child_process'; +import { singletonPanel } from './shared'; + +const store: { panel: vscode.WebviewPanel | null } = { panel: null }; + +interface ConvertOptions { + sourcePath: string; + outputDir: string; + networks: string[]; +} + +interface NetworkResult { + network: string; + file: string; + ok: boolean; + error?: string; +} + +export function openPlayworksConverter(_context: vscode.ExtensionContext) { + const { panel, isNew } = singletonPanel(store, 'hplToolbox.playworksConverter', 'Playworks Converter'); + if (!isNew) return; + + panel.webview.html = getHtml(); + + panel.webview.onDidReceiveMessage(async (msg) => { + switch (msg.type) { + case 'pickSource': { + const picked = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + filters: { HTML: ['html', 'htm'] }, + openLabel: 'Select Playworks HTML', + }); + if (!picked?.[0]) return; + const srcPath = picked[0].fsPath; + const dir = path.dirname(srcPath); + panel.webview.postMessage({ type: 'sourcePicked', path: srcPath, dir }); + break; + } + case 'pickOutput': { + const picked = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: 'Select Output Folder', + }); + if (!picked?.[0]) return; + panel.webview.postMessage({ type: 'outputPicked', path: picked[0].fsPath }); + break; + } + case 'convert': { + const opts: ConvertOptions = msg.opts; + if (!fs.existsSync(opts.sourcePath)) { + panel.webview.postMessage({ type: 'convertDone', results: [], error: 'Source file not found.' }); + return; + } + if (!fs.existsSync(opts.outputDir)) { + try { fs.mkdirSync(opts.outputDir, { recursive: true }); } catch { + panel.webview.postMessage({ type: 'convertDone', results: [], error: 'Could not create output folder.' }); + return; + } + } + + let html: string; + try { + html = fs.readFileSync(opts.sourcePath, 'utf8'); + } catch (e: any) { + panel.webview.postMessage({ type: 'convertDone', results: [], error: `Could not read source: ${e.message}` }); + return; + } + + const results: NetworkResult[] = []; + for (const network of opts.networks) { + try { + const filePath = await convertNetwork(html, opts, network); + results.push({ network, file: filePath, ok: true }); + } catch (e: any) { + results.push({ network, file: '', ok: false, error: e.message }); + } + } + panel.webview.postMessage({ type: 'convertDone', results }); + break; + } + case 'openOutput': { + vscode.env.openExternal(vscode.Uri.file(msg.dir)); + break; + } + } + }); +} + +const ZIPPED_NETWORKS = new Set(['gg', 'vu', 'mtg']); + +function sourceBaseName(sourcePath: string): string { + return path.basename(sourcePath, path.extname(sourcePath)) + .replace(/_(?:un|al|is|fb|gg|mo|vu|mtg|tt)$/, ''); +} + +async function convertNetwork(html: string, opts: ConvertOptions, network: string): Promise { + const transformed = transformHtml(html, network); + const needsZip = ZIPPED_NETWORKS.has(network); + const baseName = sourceBaseName(opts.sourcePath); + const htmlFileName = `${baseName}_${network}.html`; + + if (needsZip) { + const tmpPath = path.join(opts.outputDir, `_tmp_${Date.now()}_${network}.html`); + const zipPath = path.join(opts.outputDir, `${baseName}_${network}.zip`); + fs.writeFileSync(tmpPath, transformed, 'utf8'); + try { + await createZip(tmpPath, 'index.html', zipPath); + } finally { + try { fs.unlinkSync(tmpPath); } catch { /* ignore */ } + } + return zipPath; + } + + const outPath = path.join(opts.outputDir, htmlFileName); + fs.writeFileSync(outPath, transformed, 'utf8'); + return outPath; +} + +function transformHtml(html: string, network: string): string { + let result = html; + + // Inject into : lifecycle stubs only (must register luna:build listener + // BEFORE the Playworks bundle so our handler fires first), then any network SDK script. + // Console restore stays at end-of-body — it must run AFTER the bundle overrides console. + let headInject = buildLifecycleScript(); + if (network === 'al' || network === 'is') { + headInject += ''; + } else if (network === 'gg') { + headInject += ''; + } + result = result.replace('', headInject + '\n'); + + // Body-level flags + if (network === 'vu') { + result = result.replace('', '\n'); + } else if (network === 'mtg') { + result = result.replace('', ''); + } else if (network === 'tt') { + result = result.replace('', '\n'); + } + + // Replace the CTA script + result = replaceCTAScript(result, network); + + // Unity: rewrite window.top → window.self (Luna static scan requirement) + if (network === 'un') { + result = result.split('window.top').join('window.self'); + } + + return result; +} + +function replaceCTAScript(html: string, network: string): string { + const marker = 'Luna.Unity.Playable.InstallFullGame=function'; + const markerIdx = html.lastIndexOf(marker); + if (markerIdx === -1) return html; + + const scriptOpenIdx = html.lastIndexOf('', markerIdx); + if (scriptCloseIdx === -1) return html; + + const before = html.slice(0, scriptOpenIdx); + const after = html.slice(scriptCloseIdx + ''.length); + + // Console restore runs after the bundle (which overrides console), before CTA + return before + buildConsoleRestoreScript() + buildCTAScript(network) + after; +} + +// Restores native console methods overridden by the Playworks bundle, +// using a temporary iframe to get a fresh un-patched console reference. +function buildConsoleRestoreScript(): string { + return [ + '', + ].join(''); +} + +// Exposes gameReady/Start/End/Close stubs on window and wires them to Luna events. +// Registered in so our luna:build listener fires BEFORE the Playworks bundle +// listeners (which synchronously dispatch luna:start inside their luna:build handler). +// One-time guards prevent double-calls: +// - gameReady: luna:build fires it once; mtg's onload="gameReady()" also fires it +// - gameStart: luna:start fires twice on non-MRAID networks (two Playworks listeners) +function buildLifecycleScript(): string { + return [ + '', + ].join(''); +} + +function buildCTAScript(network: string): string { + const urlSetup = `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`; + // gameClose must fire before every redirect + const closeCall = `try{window.gameClose();}catch(e){}`; + + let ctaLogic: string; + switch (network) { + case 'al': + case 'is': + // MRAID — mraid.js injected in + ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){if(typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",function(){mraid.open(o)});}else{mraid.open(o);}}else{window.open(o,"_blank");}`; + break; + case 'un': + // Unity Ads — MRAID-based; window.top already rewritten separately + ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){mraid.open(o);}else{window.open(o,"_blank");}`; + break; + case 'fb': + ctaLogic = `${closeCall}if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`; + break; + case 'gg': + // Google — exitapi.js injected in + ctaLogic = `${closeCall}if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`; + break; + case 'mo': + ctaLogic = `${closeCall}if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`; + break; + case 'vu': + // Vungle — window.__VUNGLE__ flag set in body + ctaLogic = `${closeCall}try{parent.postMessage("download","*");}catch(e){}`; + break; + case 'mtg': + // Mintegral — onload="gameReady()" set on body + ctaLogic = `${closeCall}if(typeof window.install==="function"){window.install();return;}window.open(o,"_blank");`; + break; + case 'tt': + // TikTok — window.__TIKTOK__ flag set in body + ctaLogic = `${closeCall}if(typeof window.openAppStore==="function"){window.openAppStore();return;}window.open(o,"_blank");`; + break; + default: + ctaLogic = `${closeCall}window.open(o,"_blank");`; + } + + return ``; +} + +function createZip(srcFilePath: string, nameInZip: string, destZipPath: string): Promise { + return new Promise((resolve, reject) => { + // Remove existing zip if present (Compress-Archive would fail otherwise) + if (fs.existsSync(destZipPath)) { + try { fs.unlinkSync(destZipPath); } catch { /* ignore */ } + } + + const psScript = [ + 'Add-Type -AssemblyName System.IO.Compression.FileSystem;', + `$zip=[System.IO.Compression.ZipFile]::Open('${destZipPath}','Create');`, + `[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip,'${srcFilePath}','${nameInZip}');`, + '$zip.Dispose()', + ].join(' '); + + const proc = cp.spawn('powershell.exe', ['-NonInteractive', '-Command', psScript], { stdio: ['ignore', 'pipe', 'pipe'] }); + let stderr = ''; + proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); }); + proc.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Zip failed (code ${code}): ${stderr.trim()}`)); + } + }); + proc.on('error', reject); + }); +} + +// ─── Webview UI ──────────────────────────────────────────────────────────────── + +function getHtml(): string { + const networks = [ + { tag: 'al', label: 'Applovin', note: 'HTML + mraid.js in head' }, + { tag: 'is', label: 'Ironsource', note: 'HTML + mraid.js in head' }, + { tag: 'un', label: 'Unity', note: 'HTML (window.top → self)' }, + { tag: 'fb', label: 'Facebook', note: 'HTML' }, + { tag: 'gg', label: 'Google Ads', note: 'ZIP + exitapi.js in head' }, + { tag: 'mo', label: 'Moloco', note: 'HTML' }, + { tag: 'vu', label: 'Vungle', note: 'ZIP + __VUNGLE__ flag' }, + { tag: 'mtg', label: 'Mintegral', note: 'ZIP + onload="gameReady()"' }, + { tag: 'tt', label: 'TikTok', note: 'HTML + __TIKTOK__ flag' }, + ]; + + const checkboxRows = networks.map(n => ` + `).join(''); + + return ` + + + + + + +

Playworks Converter

+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ +
+ + +
+
${checkboxRows}
+
+ +
+ + +
+ +
+
+ + + +`; +}